Repository: easybuilders/easybuild-framework

Issue:
--include-easyblocks[-from-pr] fails for multiple easyblocks related by inheritance
I recently updated PythonPackage and TensorFlow  easyblocks in one PR and tried to submit test reports but it failed:
```
File "/home/h3/s3248973/.local/EasyBuildDev/easybuild-framework/easybuild/main.py", line 118, in build_and_install_software
(ec_res['success'], app_log, err) = build_and_install_one(ec, init_env)
File "/home/s3248973/.local/EasyBuildDev/easybuild-framework/easybuild/framework/easyblock.py", line 3697, in build_and_install_one
result = app.run_all_steps(run_test_cases=run_test_cases)
File "/home/s3248973/.local/EasyBuildDev/easybuild-framework/easybuild/framework/easyblock.py", line 3585, in run_all_steps
self.run_step(step_name, step_methods)
File "/home/s3248973/.local/EasyBuildDev/easybuild-framework/easybuild/framework/easyblock.py", line 3438, in run_step
step_method(self)()
File "/home/s3248973/.local/EasyBuildDev/easybuild-easyblocks/easybuild/easyblocks/generic/pythonbundle.py", line 128, in extensions_step
super(PythonBundle, self).extensions_step(*args, **kwargs)
File "/home/s3248973/.local/EasyBuildDev/easybuild-framework/easybuild/framework/easyblock.py", line 2402, in extensions_step
self.init_ext_instances()
File "/home/s3248973/.local/EasyBuildDev/easybuild-framework/easybuild/framework/easyblock.py", line 2332, in init_ext_instances
inst = cls(self, ext)
File "/tmp/easybuild-tmp/included-easyblocks-iBqB1u/easybuild/easyblocks/tensorflow.py", line 239, in init
super(EB_TensorFlow, self).init(*args, **kwargs)
File "/tmp/easybuild-tmp/included-easyblocks-iBqB1u/easybuild/easyblocks/generic/pythonpackage.py", line 278, in init
super(PythonPackage, self).init(*args, **kwargs)
TypeError: must be type, not None
```

It seems that PythonPackage turns out as `None` which I can't really explain. Splitting up the PR into 1 easyblock each seemingly works. See https://github.com/easybuilders/easybuild-easyblocks/pull/2516


Discussion:
@Flamefire I think (or maybe hope) we can narrow this down a bit

- on one hand, the problem doesn't seem to be with *any* multiple easyblocks. For instance, I don't see a problem including all 3 easyblocks from https://github.com/easybuilders/easybuild-easyblocks/pull/2433 (I know that PR is already merged, but the logs show they are all imported from the PR)

- on the other hand, the problem doesn't seem to be specific to the github integration, I get the same error if I try to `--include-easyblocks=*.py` with local copies of `pythonpackage.py` and `tensorflow.py` (can you check?)

My guess is that this bug is specific to the case where the included easyblocks are related by inheritance and possibly with the fact that we are still (even after https://github.com/easybuilders/easybuild-framework/pull/3544) messing with `sys.modules` at https://github.com/easybuilders/easybuild-framework/blob/f8cc137c3748c062dcbb7de0eed2303b0c9cd47d/easybuild/tools/include.py#L134-L141 (i.e., we reimport `PythonPackage` as a new object, but we deleted the `PythonPackage` object that was the super class of `TensorFlow` (?) )


Resolving patch:
diff --git a/easybuild/tools/include.py b/easybuild/tools/include.py
--- a/easybuild/tools/include.py
+++ b/easybuild/tools/include.py
@@ -128,15 +128,17 @@ def set_up_eb_package(parent_path, eb_pkg_name, subpkgs=None, pkg_init_body=None
 def verify_imports(pymods, pypkg, from_path):
     """Verify that import of specified modules from specified package and expected location works."""
 
-    for pymod in pymods:
-        pymod_spec = '%s.%s' % (pypkg, pymod)
-
+    pymod_specs = ['%s.%s' % (pypkg, pymod) for pymod in pymods]
+    for pymod_spec in pymod_specs:
         # force re-import if the specified modules was already imported;
         # this is required to ensure that an easyblock that is included via --include-easyblocks-from-pr
         # gets preference over one that is included via --include-easyblocks
         if pymod_spec in sys.modules:
             del sys.modules[pymod_spec]
 
+    # After all modules to be reloaded have been removed, import them again
+    # Note that removing them here may delete transitively loaded modules and not import them again
+    for pymod_spec in pymod_specs:
         try:
             pymod = __import__(pymod_spec, fromlist=[pypkg])
         # different types of exceptions may be thrown, not only ImportErrors
@@ -180,8 +182,8 @@ def include_easyblocks(tmpdir, paths):
         if not os.path.exists(target_path):
             symlink(easyblock_module, target_path)
 
-    included_ebs = [x for x in os.listdir(easyblocks_dir) if x not in ['__init__.py', 'generic']]
-    included_generic_ebs = [x for x in os.listdir(os.path.join(easyblocks_dir, 'generic')) if x != '__init__.py']
+    included_ebs = sorted(x for x in os.listdir(easyblocks_dir) if x not in ['__init__.py', 'generic'])
+    included_generic_ebs = sorted(x for x in os.listdir(os.path.join(easyblocks_dir, 'generic')) if x != '__init__.py')
     _log.debug("Included generic easyblocks: %s", included_generic_ebs)
     _log.debug("Included software-specific easyblocks: %s", included_ebs)
 


Test patch:
diff --git a/test/framework/options.py b/test/framework/options.py
--- a/test/framework/options.py
+++ b/test/framework/options.py
@@ -34,6 +34,7 @@
 import stat
 import sys
 import tempfile
+import textwrap
 from distutils.version import LooseVersion
 from unittest import TextTestRunner
 
@@ -3232,13 +3233,29 @@ def test_xxx_include_easyblocks(self):
                     sys.modules[pkg].__path__.remove(path)
 
         # include extra test easyblocks
-        foo_txt = '\n'.join([
-            'from easybuild.framework.easyblock import EasyBlock',
-            'class EB_foo(EasyBlock):',
-            '   pass',
-            ''
-        ])
+        # Make them inherit from each other to trigger a known issue with changed imports, see #3779
+        # Choose naming so that order of naming is different than inheritance order
+        afoo_txt = textwrap.dedent("""
+            from easybuild.framework.easyblock import EasyBlock
+            class EB_afoo(EasyBlock):
+                def __init__(self, *args, **kwargs):
+                    super(EB_afoo, self).__init__(*args, **kwargs)
+        """)
+        write_file(os.path.join(self.test_prefix, 'afoo.py'), afoo_txt)
+        foo_txt = textwrap.dedent("""
+            from easybuild.easyblocks.zfoo import EB_zfoo
+            class EB_foo(EB_zfoo):
+                def __init__(self, *args, **kwargs):
+                    super(EB_foo, self).__init__(*args, **kwargs)
+        """)
         write_file(os.path.join(self.test_prefix, 'foo.py'), foo_txt)
+        zfoo_txt = textwrap.dedent("""
+            from easybuild.easyblocks.afoo import EB_afoo
+            class EB_zfoo(EB_afoo):
+                def __init__(self, *args, **kwargs):
+                    super(EB_zfoo, self).__init__(*args, **kwargs)
+        """)
+        write_file(os.path.join(self.test_prefix, 'zfoo.py'), zfoo_txt)
 
         # clear log
         write_file(self.logfile, '')
@@ -3256,12 +3273,27 @@ def test_xxx_include_easyblocks(self):
         foo_regex = re.compile(r"^\|-- EB_foo \(easybuild.easyblocks.foo @ %s\)" % path_pattern, re.M)
         self.assertTrue(foo_regex.search(logtxt), "Pattern '%s' found in: %s" % (foo_regex.pattern, logtxt))
 
+        ec_txt = '\n'.join([
+            'easyblock = "EB_foo"',
+            'name = "pi"',
+            'version = "3.14"',
+            'homepage = "http://example.com"',
+            'description = "test easyconfig"',
+            'toolchain = SYSTEM',
+        ])
+        ec = EasyConfig(path=None, rawtxt=ec_txt)
+
         # easyblock is found via get_easyblock_class
-        klass = get_easyblock_class('EB_foo')
-        self.assertTrue(issubclass(klass, EasyBlock), "%s is an EasyBlock derivative class" % klass)
+        for name in ('EB_afoo', 'EB_foo', 'EB_zfoo'):
+            klass = get_easyblock_class(name)
+            self.assertTrue(issubclass(klass, EasyBlock), "%s (%s) is an EasyBlock derivative class" % (klass, name))
 
-        # 'undo' import of foo easyblock
-        del sys.modules['easybuild.easyblocks.foo']
+            eb_inst = klass(ec)
+            self.assertTrue(eb_inst is not None, "Instantiating the injected class %s works" % name)
+
+        # 'undo' import of the easyblocks
+        for name in ('afoo', 'foo', 'zfoo'):
+            del sys.modules['easybuild.easyblocks.' + name]
 
     # must be run after test for --list-easyblocks, hence the '_xxx_'
     # cleaning up the imported easyblocks is quite difficult...