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:
Greg Becker 2021-10-26 10:34:06 -07:00 committed by GitHub
parent dee75a4945
commit a8a08f66ad
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 86 additions and 3 deletions

View File

@ -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:
""""""""""""""""""""""""""""""""""" """""""""""""""""""""""""""""""""""

View File

@ -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

View File

@ -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?
}, },

View File

@ -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