modules: configurable module defaults (#24367)
Any spec satisfying a default will be symlinked to `default` If multiple specs have modulefiles in the same directory and satisfy configured module defaults, then whichever was written last will be default.
This commit is contained in:
		| @@ -449,6 +449,36 @@ that are already in the LMod hierarchy. | |||||||
|    For hierarchies that are deeper than three layers ``lmod spider`` may have some issues. |    For hierarchies that are deeper than three layers ``lmod spider`` may have some issues. | ||||||
|    See `this discussion on the LMod project <https://github.com/TACC/Lmod/issues/114>`_. |    See `this discussion on the LMod project <https://github.com/TACC/Lmod/issues/114>`_. | ||||||
|  |  | ||||||
|  | """""""""""""""""""""" | ||||||
|  | Select default modules | ||||||
|  | """""""""""""""""""""" | ||||||
|  |  | ||||||
|  | By default, when multiple modules of the same name share a directory, | ||||||
|  | the highest version number will be the default module. This behavior | ||||||
|  | of the ``module`` command can be overridden with a symlink named | ||||||
|  | ``default`` to the desired default module. If you wish to configure | ||||||
|  | default modules with Spack, add a ``defaults`` key to your modules | ||||||
|  | configuration: | ||||||
|  |  | ||||||
|  | .. code-block:: yaml | ||||||
|  |  | ||||||
|  |   modules: | ||||||
|  |     my-module-set: | ||||||
|  |       tcl: | ||||||
|  |         defaults: | ||||||
|  |         - gcc@10.2.1 | ||||||
|  |         - hdf5@1.2.10+mpi+hl%gcc | ||||||
|  |  | ||||||
|  | These defaults may be arbitrarily specific. For any package that | ||||||
|  | satisfies a default, Spack will generate the module file in the | ||||||
|  | appropriate path, and will generate a default symlink to the module | ||||||
|  | file as well. | ||||||
|  |  | ||||||
|  | .. warning::  | ||||||
|  |   If Spack is configured to generate multiple default packages in the | ||||||
|  |   same directory, the last modulefile to be generated will be the | ||||||
|  |   default module. | ||||||
|  |  | ||||||
| .. _customize-env-modifications: | .. _customize-env-modifications: | ||||||
|  |  | ||||||
| """"""""""""""""""""""""""""""""""" | """"""""""""""""""""""""""""""""""" | ||||||
|   | |||||||
| @@ -209,6 +209,10 @@ def merge_config_rules(configuration, spec): | |||||||
|     verbose = module_specific_configuration.get('verbose', False) |     verbose = module_specific_configuration.get('verbose', False) | ||||||
|     spec_configuration['verbose'] = verbose |     spec_configuration['verbose'] = verbose | ||||||
| 
 | 
 | ||||||
|  |     # module defaults per-package | ||||||
|  |     defaults = module_specific_configuration.get('defaults', []) | ||||||
|  |     spec_configuration['defaults'] = defaults | ||||||
|  | 
 | ||||||
|     return spec_configuration |     return spec_configuration | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @@ -453,6 +457,11 @@ def template(self): | |||||||
|         """ |         """ | ||||||
|         return self.conf.get('template', None) |         return self.conf.get('template', None) | ||||||
| 
 | 
 | ||||||
|  |     @property | ||||||
|  |     def defaults(self): | ||||||
|  |         """Returns the specs configured as defaults or [].""" | ||||||
|  |         return self.conf.get('defaults', []) | ||||||
|  | 
 | ||||||
|     @property |     @property | ||||||
|     def env(self): |     def env(self): | ||||||
|         """List of environment modifications that should be done in the |         """List of environment modifications that should be done in the | ||||||
| @@ -891,6 +900,18 @@ def write(self, overwrite=False): | |||||||
|         if os.path.exists(self.layout.filename): |         if os.path.exists(self.layout.filename): | ||||||
|             fp.set_permissions_by_spec(self.layout.filename, self.spec) |             fp.set_permissions_by_spec(self.layout.filename, self.spec) | ||||||
| 
 | 
 | ||||||
|  |         # Symlink defaults if needed | ||||||
|  |         if any(self.spec.satisfies(default) for default in self.conf.defaults): | ||||||
|  |             # This spec matches a default, it needs to be symlinked to default | ||||||
|  |             # Symlink to a tmp location first and move, so that existing | ||||||
|  |             # symlinks do not cause an error. | ||||||
|  |             default_path = os.path.join(os.path.dirname(self.layout.filename), | ||||||
|  |                                         'default') | ||||||
|  |             default_tmp = os.path.join(os.path.dirname(self.layout.filename), | ||||||
|  |                                        '.tmp_spack_default') | ||||||
|  |             os.symlink(self.layout.filename, default_tmp) | ||||||
|  |             os.rename(default_tmp, default_path) | ||||||
|  | 
 | ||||||
|     def remove(self): |     def remove(self): | ||||||
|         """Deletes the module file.""" |         """Deletes the module file.""" | ||||||
|         mod_file = self.layout.filename |         mod_file = self.layout.filename | ||||||
|   | |||||||
| @@ -17,8 +17,8 @@ | |||||||
| #: THIS NEEDS TO BE UPDATED FOR EVERY NEW KEYWORD THAT | #: THIS NEEDS TO BE UPDATED FOR EVERY NEW KEYWORD THAT | ||||||
| #: IS ADDED IMMEDIATELY BELOW THE MODULE TYPE ATTRIBUTE | #: IS ADDED IMMEDIATELY BELOW THE MODULE TYPE ATTRIBUTE | ||||||
| spec_regex = r'(?!hierarchy|core_specs|verbose|hash_length|whitelist|' \ | spec_regex = r'(?!hierarchy|core_specs|verbose|hash_length|whitelist|' \ | ||||||
|              r'blacklist|projections|naming_scheme|core_compilers|all)' \ |              r'blacklist|projections|naming_scheme|core_compilers|all|' \ | ||||||
|              r'(^\w[\w-]*)' |              r'defaults)(^\w[\w-]*)' | ||||||
| 
 | 
 | ||||||
| #: Matches a valid name for a module set | #: Matches a valid name for a module set | ||||||
| # Banned names are valid entries at that level in the previous schema | # Banned names are valid entries at that level in the previous schema | ||||||
| @@ -99,6 +99,7 @@ | |||||||
|                 'type': 'boolean', |                 'type': 'boolean', | ||||||
|                 'default': False |                 'default': False | ||||||
|             }, |             }, | ||||||
|  |             'defaults': array_of_strings, | ||||||
|             'naming_scheme': { |             'naming_scheme': { | ||||||
|                 'type': 'string'  # Can we be more specific here? |                 'type': 'string'  # Can we be more specific here? | ||||||
|             }, |             }, | ||||||
|   | |||||||
| @@ -46,13 +46,28 @@ def test_update_dictionary_extending_list(): | |||||||
| @pytest.fixture() | @pytest.fixture() | ||||||
| def mock_module_filename(monkeypatch, tmpdir): | def mock_module_filename(monkeypatch, tmpdir): | ||||||
|     filename = str(tmpdir.join('module')) |     filename = str(tmpdir.join('module')) | ||||||
|     monkeypatch.setattr(spack.modules.common.BaseFileLayout, |     # Set for both module types so we can test both | ||||||
|  |     monkeypatch.setattr(spack.modules.lmod.LmodFileLayout, | ||||||
|  |                         'filename', | ||||||
|  |                         filename) | ||||||
|  |     monkeypatch.setattr(spack.modules.tcl.TclFileLayout, | ||||||
|                         'filename', |                         'filename', | ||||||
|                         filename) |                         filename) | ||||||
| 
 | 
 | ||||||
|     yield filename |     yield filename | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | @pytest.fixture() | ||||||
|  | def mock_module_defaults(monkeypatch): | ||||||
|  |     def impl(*args): | ||||||
|  |         # No need to patch both types because neither override base | ||||||
|  |         monkeypatch.setattr(spack.modules.common.BaseConfiguration, | ||||||
|  |                             'defaults', | ||||||
|  |                             [arg for arg in args]) | ||||||
|  | 
 | ||||||
|  |     return impl | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
| @pytest.fixture() | @pytest.fixture() | ||||||
| def mock_package_perms(monkeypatch): | def mock_package_perms(monkeypatch): | ||||||
|     perms = stat.S_IRGRP | stat.S_IWGRP |     perms = stat.S_IRGRP | stat.S_IWGRP | ||||||
| @@ -77,6 +92,22 @@ def test_modules_written_with_proper_permissions(mock_module_filename, | |||||||
|         mock_module_filename).st_mode == mock_package_perms |         mock_module_filename).st_mode == mock_package_perms | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | @pytest.mark.parametrize('module_type', ['tcl', 'lmod']) | ||||||
|  | def test_modules_default_symlink( | ||||||
|  |         module_type, mock_packages, mock_module_filename, mock_module_defaults, config | ||||||
|  | ): | ||||||
|  |     spec = spack.spec.Spec('mpileaks@2.3').concretized() | ||||||
|  |     mock_module_defaults(spec.format('{name}{@version}')) | ||||||
|  | 
 | ||||||
|  |     generator_cls = spack.modules.module_types[module_type] | ||||||
|  |     generator = generator_cls(spec, 'default') | ||||||
|  |     generator.write() | ||||||
|  | 
 | ||||||
|  |     link_path = os.path.join(os.path.dirname(mock_module_filename), 'default') | ||||||
|  |     assert os.path.islink(link_path) | ||||||
|  |     assert os.readlink(link_path) == mock_module_filename | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
| class MockDb(object): | class MockDb(object): | ||||||
|     def __init__(self, db_ids, spec_hash_to_db): |     def __init__(self, db_ids, spec_hash_to_db): | ||||||
|         self.upstream_dbs = db_ids |         self.upstream_dbs = db_ids | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user
	 Greg Becker
					Greg Becker