Repository: easybuilders/easybuild-framework

Issue:
Can't generate working Tcl modulefiles with Modules 4.2.3+
Using EasyBuild with newer versions of EnvironmentModules fails as it generates Tcl modules that can't load. [Changes to Modules in 4.2.3](https://modules.readthedocs.io/en/latest/NEWS.html#modules-4-2-3-2019-03-23) mean that modules unloaded as part of a swap/switch operation are now flagged as conflicting with the module, so when an EasyBuild module does e.g. `module swap gcc gcc/9.3.0`, 'gcc' becomes conflicting with the main module, 'gcc/9.3.0' can't be loaded, and the main module fails to load.

The problem appeared for me while setting up a new CrayGNU toolchain on a system with Modules 4.3.1. The modulefile generated has several swap statements to ensure the correct module versions are loaded. Installing the toolchain fails at the sanity check stage when the module can't be loaded.

Modulefiles with the simple following form will fail to load for the same reason:

```
#%Module
module swap some_module some_module/x.y.z
```

[The Modules 4.2.3 release notes](https://modules.readthedocs.io/en/latest/NEWS.html#modules-4-2-3-2019-03-23) also state 'If `module switch` has only one argument, do not define a conflict toward switched-off modulefile.' If EasyBuild generates Tcl modulefiles with swap statements like this, the problem should be resolved. However, it also states 'it is not recommended to use `module switch` command in modulefiles' so there might be more to this.


Discussion:

Resolving patch:
diff --git a/easybuild/tools/module_generator.py b/easybuild/tools/module_generator.py
--- a/easybuild/tools/module_generator.py
+++ b/easybuild/tools/module_generator.py
@@ -962,7 +962,12 @@ def swap_module(self, mod_name_out, mod_name_in, guarded=True):
         :param mod_name_in: name of module to load (swap in)
         :param guarded: guard 'swap' statement, fall back to 'load' if module being swapped out is not loaded
         """
-        body = "module swap %s %s" % (mod_name_out, mod_name_in)
+        # In Modules 4.2.3+ a 2-argument swap 'module swap foo foo/X.Y.Z' will fail as the unloaded 'foo'
+        # means all 'foo' modules conflict and 'foo/X.Y.Z' will not load.  A 1-argument swap like
+        # 'module swap foo/X.Y.Z' will unload any currently loaded 'foo' without it becoming conflicting
+        # and successfully load the new module.
+        # See: https://modules.readthedocs.io/en/latest/NEWS.html#modules-4-2-3-2019-03-23
+        body = "module swap %s" % (mod_name_in)
         if guarded:
             alt_body = self.LOAD_TEMPLATE % {'mod_name': mod_name_in}
             swap_statement = [self.conditional_statement(self.is_loaded(mod_name_out), body, else_body=alt_body)]


Test patch:
diff --git a/test/framework/module_generator.py b/test/framework/module_generator.py
--- a/test/framework/module_generator.py
+++ b/test/framework/module_generator.py
@@ -44,7 +44,7 @@
 from easybuild.framework.easyblock import EasyBlock
 from easybuild.framework.easyconfig.easyconfig import EasyConfig, ActiveMNS
 from easybuild.tools.build_log import EasyBuildError
-from easybuild.tools.modules import EnvironmentModulesC, Lmod
+from easybuild.tools.modules import EnvironmentModulesC, EnvironmentModulesTcl, Lmod
 from easybuild.tools.utilities import quote_str
 from test.framework.utilities import EnhancedTestCase, TestLoaderFiltered, find_full_path, init_config
 
@@ -610,7 +610,7 @@ def test_swap(self):
         if self.MODULE_GENERATOR_CLASS == ModuleGeneratorTcl:
             expected = '\n'.join([
                 '',
-                "module swap foo bar",
+                "module swap bar",
                 '',
             ])
         else:
@@ -627,7 +627,7 @@ def test_swap(self):
             expected = '\n'.join([
                 '',
                 "if { [ is-loaded foo ] } {",
-                "    module swap foo bar",
+                "    module swap bar",
                 '} else {',
                 "    module load bar",
                 '}',
@@ -647,6 +647,24 @@ def test_swap(self):
         self.assertEqual(expected, self.modgen.swap_module('foo', 'bar', guarded=True))
         self.assertEqual(expected, self.modgen.swap_module('foo', 'bar'))
 
+        # create tiny test Tcl module to make sure that tested modules tools support single-argument swap
+        # see https://github.com/easybuilders/easybuild-framework/issues/3396;
+        # this is known to fail with the ancient Tcl-only implementation of environment modules,
+        # but that's considered to be a non-issue (since this is mostly relevant for Cray systems,
+        # which are either using EnvironmentModulesC (3.2.10), EnvironmentModules (4.x) or Lmod...
+        if self.MODULE_GENERATOR_CLASS == ModuleGeneratorTcl and self.modtool.__class__ != EnvironmentModulesTcl:
+            test_mod_txt = "#%Module\nmodule swap GCC/7.3.0-2.30"
+
+            test_mod_fn = 'test_single_arg_swap/1.2.3'
+            write_file(os.path.join(self.test_prefix, test_mod_fn), test_mod_txt)
+
+            self.modtool.load(['GCC/4.6.3'])
+            self.modtool.use(self.test_prefix)
+            self.modtool.load(['test_single_arg_swap/1.2.3'])
+
+            expected = ['GCC/7.3.0-2.30', 'test_single_arg_swap/1.2.3']
+            self.assertEqual(sorted([m['mod_name'] for m in self.modtool.list()]), expected)
+
     def test_append_paths(self):
         """Test generating append-paths statements."""
         # test append_paths