Repository: easybuilders/easybuild-framework

Issue:
--set-default-module does not symlink .version files in moduleclass-specific locations
**Environment**

- Tcl modules
- Modulefiles are generated in `EASYBUILD_INSTALLPATH_MODULES/all`, links are generated in `EASYBUILD_INSTALLPATH_MODULES/<classname>` (I believe this is default EasyBuild behaviour? Or did we configure this somehow?)
- Our modulepath contains `EASYBUILD_INSTALLPATH_MODULES/<moduleclass>`, not `EASYBUILD_INSTALLPATH_MODULES/all`.

**Issue**

After an `eb GCCcore-7.3.0.eb --set-default-module` a `module av GCCcore` doesn't show 7.3.0 as the default version.

**(Probable) cause**
The `--set-default-module` argument causes easybuild to generate a `.version` file in the `EASYBUILD_INSTALLPATH_MODULES/all/<softwarename>/` prefix, but not in the `EASYBUILD_INSTALLPATH_MODULES/<moduleclass>/<softwarename>/` prefix. Since only the latter is in our modulepath, the module command doesn't see nor respect the `.version` file.

So... I'm not entirely sure if this is somehow intended behavior (maybe we configured it wrong/unusual?). In any case, considering EasyBuild creates links for the modulefiles from the `.../<moduleclass>/...` to the `.../all/...` prefixes, shouldn't it do the same for the `.version` file, and create a link to the corresponding `.version` file in the `.../all/...` prefix?


Discussion:
@casparvl Thanks for the bug report. You're right, the `.version` file should also be symlinked, so we should fix that.

Resolving patch:
diff --git a/easybuild/framework/easyblock.py b/easybuild/framework/easyblock.py
--- a/easybuild/framework/easyblock.py
+++ b/easybuild/framework/easyblock.py
@@ -2603,8 +2603,12 @@ def _set_module_as_default(self, fake=False):
         if self.dry_run:
             dry_run_msg("Marked %s v%s as default version" % (self.name, version))
         else:
-            mod_folderpath = os.path.dirname(self.module_generator.get_module_filepath(fake=fake))
-            self.module_generator.set_as_default(mod_folderpath, version)
+            mod_dir_path = os.path.dirname(self.module_generator.get_module_filepath(fake=fake))
+            if fake:
+                mod_symlink_paths = []
+            else:
+                mod_symlink_paths = ActiveMNS().det_module_symlink_paths(self.cfg)
+            self.module_generator.set_as_default(mod_dir_path, version, mod_symlink_paths=mod_symlink_paths)
 
     def cleanup_step(self):
         """
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
@@ -415,12 +415,13 @@ def set_alias(self, key, value):
         """
         raise NotImplementedError
 
-    def set_as_default(self, module_folder_path, module_version):
+    def set_as_default(self, module_dir_path, module_version, mod_symlink_paths=None):
         """
         Set generated module as default module
 
-        :param module_folder_path: module folder path, e.g. $HOME/easybuild/modules/all/Bison
+        :param module_dir_path: module directory path, e.g. $HOME/easybuild/modules/all/Bison
         :param module_version: module version, e.g. 3.0.4
+        :param mod_symlink_paths: list of paths in which symlinks to module files must be created
         """
         raise NotImplementedError
 
@@ -872,18 +873,36 @@ def set_alias(self, key, value):
         # quotes are needed, to ensure smooth working of EBDEVEL* modulefiles
         return 'set-alias\t%s\t\t%s\n' % (key, quote_str(value, tcl=True))
 
-    def set_as_default(self, module_folder_path, module_version):
+    def set_as_default(self, module_dir_path, module_version, mod_symlink_paths=None):
         """
         Create a .version file inside the package module folder in order to set the default version for TMod
 
-        :param module_folder_path: module folder path, e.g. $HOME/easybuild/modules/all/Bison
+        :param module_dir_path: module directory path, e.g. $HOME/easybuild/modules/all/Bison
         :param module_version: module version, e.g. 3.0.4
+        :param mod_symlink_paths: list of paths in which symlinks to module files must be created
         """
         txt = self.MODULE_SHEBANG + '\n'
         txt += 'set ModulesVersion %s\n' % module_version
 
         # write the file no matter what
-        write_file(os.path.join(module_folder_path, '.version'), txt)
+        dot_version_path = os.path.join(module_dir_path, '.version')
+        write_file(dot_version_path, txt)
+
+        # create symlink to .version file in class module folders
+        if mod_symlink_paths is None:
+            mod_symlink_paths = []
+
+        module_dir_name = os.path.basename(module_dir_path)
+        for mod_symlink_path in mod_symlink_paths:
+            mod_symlink_dir = os.path.join(install_path('mod'), mod_symlink_path, module_dir_name)
+            dot_version_link_path = os.path.join(mod_symlink_dir, '.version')
+            if os.path.islink(dot_version_link_path):
+                link_target = resolve_path(dot_version_link_path)
+                remove_file(dot_version_link_path)
+                self.log.info("Removed default version marking from %s.", link_target)
+            elif os.path.exists(dot_version_link_path):
+                raise EasyBuildError('Found an unexpected file named .version in dir %s', mod_symlink_dir)
+            symlink(dot_version_path, dot_version_link_path, use_abspath_source=True)
 
     def set_environment(self, key, value, relpath=False):
         """
@@ -1277,24 +1296,38 @@ def set_alias(self, key, value):
         # quotes are needed, to ensure smooth working of EBDEVEL* modulefiles
         return 'set_alias("%s", %s)\n' % (key, quote_str(value))
 
-    def set_as_default(self, module_folder_path, module_version):
+    def set_as_default(self, module_dir_path, module_version, mod_symlink_paths=None):
         """
         Create a symlink named 'default' inside the package's module folder in order to set the default module version
 
-        :param module_folder_path: module folder path, e.g. $HOME/easybuild/modules/all/Bison
+        :param module_dir_path: module directory path, e.g. $HOME/easybuild/modules/all/Bison
         :param module_version: module version, e.g. 3.0.4
+        :param mod_symlink_paths: list of paths in which symlinks to module files must be created
         """
-        default_filepath = os.path.join(module_folder_path, 'default')
+        def create_default_symlink(path):
+            """Helper function to create 'default' symlink in specified directory."""
+            default_filepath = os.path.join(path, 'default')
 
-        if os.path.islink(default_filepath):
-            link_target = resolve_path(default_filepath)
-            remove_file(default_filepath)
-            self.log.info("Removed default version marking from %s.", link_target)
-        elif os.path.exists(default_filepath):
-            raise EasyBuildError('Found an unexpected file named default in dir %s' % module_folder_path)
+            if os.path.islink(default_filepath):
+                link_target = resolve_path(default_filepath)
+                remove_file(default_filepath)
+                self.log.info("Removed default version marking from %s.", link_target)
+            elif os.path.exists(default_filepath):
+                raise EasyBuildError('Found an unexpected file named default in dir %s', module_dir_path)
 
-        symlink(module_version + self.MODULE_FILE_EXTENSION, default_filepath, use_abspath_source=False)
-        self.log.info("Module default version file written to point to %s", default_filepath)
+            symlink(module_version + self.MODULE_FILE_EXTENSION, default_filepath, use_abspath_source=False)
+            self.log.info("Module default version file written to point to %s", default_filepath)
+
+        create_default_symlink(module_dir_path)
+
+        # also create symlinks in class module folders
+        if mod_symlink_paths is None:
+            mod_symlink_paths = []
+
+        for mod_symlink_path in mod_symlink_paths:
+            mod_dir_name = os.path.basename(module_dir_path)
+            mod_symlink_dir = os.path.join(install_path('mod'), mod_symlink_path, mod_dir_name)
+            create_default_symlink(mod_symlink_dir)
 
     def set_environment(self, key, value, relpath=False):
         """


Test patch:
diff --git a/test/framework/options.py b/test/framework/options.py
--- a/test/framework/options.py
+++ b/test/framework/options.py
@@ -3956,6 +3956,30 @@ def test_set_default_module(self):
         else:
             self.assertTrue(False, "Uknown module syntax: %s" % get_module_syntax())
 
+        # make sure default is also set for moduleclass symlink
+        toy_mod_symlink_dir = os.path.join(self.test_installpath, 'modules', 'tools', 'toy')
+        if get_module_syntax() == 'Lua':
+            self.assertEqual(sorted(os.listdir(toy_mod_symlink_dir)), ['0.0-deps.lua', 'default'])
+            default_symlink = os.path.join(toy_mod_symlink_dir, 'default')
+            mod_symlink = os.path.join(toy_mod_symlink_dir, '0.0-deps.lua')
+            self.assertTrue(os.path.islink(default_symlink))
+            self.assertTrue(os.path.islink(mod_symlink))
+            self.assertEqual(os.readlink(default_symlink), '0.0-deps.lua')
+            modfile_path = os.path.join(toy_mod_dir, '0.0-deps.lua')
+            self.assertTrue(os.path.samefile(os.readlink(mod_symlink), modfile_path))
+        elif get_module_syntax() == 'Tcl':
+            self.assertEqual(sorted(os.listdir(toy_mod_symlink_dir)), ['.version', '0.0-deps'])
+            version_symlink = os.path.join(toy_mod_symlink_dir, '.version')
+            mod_symlink = os.path.join(toy_mod_symlink_dir, '0.0-deps')
+            self.assertTrue(os.path.islink(version_symlink))
+            self.assertTrue(os.path.islink(mod_symlink))
+            versionfile_path = os.path.join(toy_mod_dir, '.version')
+            self.assertEqual(os.readlink(version_symlink), versionfile_path)
+            modfile_path = os.path.join(toy_mod_dir, '0.0-deps')
+            self.assertTrue(os.path.samefile(os.readlink(mod_symlink), modfile_path))
+        else:
+            self.assertTrue(False, "Uknown module syntax: %s" % get_module_syntax())
+
     def test_inject_checksums(self):
         """Test for --inject-checksums"""
         topdir = os.path.dirname(os.path.abspath(__file__))