modules: use new module function instead of get_module_cmd (#8570)

Use new `module` function instead of `get_module_cmd`

Previously, Spack relied on either examining the bash `module()` function or using the `which` command to find the underlying executable for modules. More complicated module systems do not allow for the sort of simple analysis we were doing (see #6451).

Spack now uses the `module` function directly and copies environment changes from the resulting subprocess back into Spack. This should provide a future-proof implementation for changes to the logic underlying the module system on various HPC systems.
This commit is contained in:
Greg Becker 2019-05-10 07:04:24 +09:00 committed by Todd Gamblin
parent 53ec16c9e5
commit 3d3cea1c87
8 changed files with 116 additions and 246 deletions

View File

@ -9,7 +9,7 @@
import llnl.util.multiproc as mp
from spack.architecture import OperatingSystem
from spack.util.module_cmd import get_module_cmd
from spack.util.module_cmd import module
class Cnl(OperatingSystem):
@ -29,8 +29,7 @@ def __str__(self):
return self.name + str(self.version)
def _detect_crayos_version(self):
modulecmd = get_module_cmd()
output = modulecmd("avail", "PrgEnv-", output=str, error=str)
output = module("avail", "PrgEnv-")
matches = re.findall(r'PrgEnv-\w+/(\d+).\d+.\d+', output)
major_versions = set(matches)
latest_version = max(major_versions)
@ -58,10 +57,7 @@ def find_compiler(self, cmp_cls, *paths):
if not cmp_cls.PrgEnv_compiler:
tty.die('Must supply PrgEnv_compiler with PrgEnv')
modulecmd = get_module_cmd()
output = modulecmd(
'avail', cmp_cls.PrgEnv_compiler, output=str, error=str)
output = module('avail', cmp_cls.PrgEnv_compiler)
version_regex = r'(%s)/([\d\.]+[\d])' % cmp_cls.PrgEnv_compiler
matches = re.findall(version_regex, output)
for name, version in matches:

View File

@ -6,7 +6,7 @@
import os
from spack.operating_systems.linux_distro import LinuxDistro
from spack.util.module_cmd import get_module_cmd
from spack.util.module_cmd import module
class CrayFrontend(LinuxDistro):
@ -41,10 +41,7 @@ def find_compilers(self, *paths):
# into the PATH environment variable (i.e. the following modules:
# 'intel', 'cce', 'gcc', etc.) will also be unloaded since they are
# specified as prerequisites in the PrgEnv-* modulefiles.
modulecmd = get_module_cmd()
exec(compile(
modulecmd('unload', prg_env, output=str, error=os.devnull),
'<string>', 'exec'))
module('unload', prg_env)
# Call the overridden method.
clist = super(CrayFrontend, self).find_compilers(*paths)

View File

@ -11,7 +11,7 @@
from spack.architecture import Platform, Target, NoPlatformError
from spack.operating_systems.cray_frontend import CrayFrontend
from spack.operating_systems.cnl import Cnl
from spack.util.module_cmd import get_module_cmd, unload_module
from spack.util.module_cmd import module
def _get_modules_in_modulecmd_output(output):
@ -90,8 +90,8 @@ def setup_platform_environment(cls, pkg, env):
# Unload these modules to prevent any silent linking or unnecessary
# I/O profiling in the case of darshan.
modules_to_unload = ["cray-mpich", "darshan", "cray-libsci", "altd"]
for module in modules_to_unload:
unload_module(module)
for mod in modules_to_unload:
module('unload', mod)
env.set('CRAYPE_LINK_TYPE', 'dynamic')
cray_wrapper_names = os.path.join(build_env_path, 'cray')
@ -127,8 +127,7 @@ def _default_target_from_env(self):
def _avail_targets(self):
'''Return a list of available CrayPE CPU targets.'''
if getattr(self, '_craype_targets', None) is None:
module = get_module_cmd()
output = module('avail', '-t', 'craype-', output=str, error=str)
output = module('avail', '-t', 'craype-')
craype_modules = _get_modules_in_modulecmd_output(output)
self._craype_targets = targets = []
_fill_craype_targets_from_modules(targets, craype_modules)

View File

@ -205,7 +205,7 @@ def _set_wrong_cc(x):
assert paths.index(spack_path) < paths.index(module_path)
def test_package_inheritance_module_setup(config, mock_packages):
def test_package_inheritance_module_setup(config, mock_packages, working_env):
s = spack.spec.Spec('multimodule-inheritance')
s.concretize()
pkg = s.package
@ -217,8 +217,6 @@ def test_package_inheritance_module_setup(config, mock_packages):
assert pkg.use_module_variable() == 'test_module_variable'
assert os.environ['TEST_MODULE_VAR'] == 'test_module_variable'
os.environ.pop('TEST_MODULE_VAR')
def test_set_build_environment_variables(
config, mock_packages, working_env, monkeypatch,

View File

@ -142,7 +142,11 @@ def remove_whatever_it_is(path):
def working_env():
saved_env = os.environ.copy()
yield
os.environ = saved_env
# os.environ = saved_env doesn't work
# it causes module_parsing::test_module_function to fail
# when it's run after any test using this fixutre
os.environ.clear()
os.environ.update(saved_env)
@pytest.fixture(scope='function', autouse=True)

View File

@ -17,7 +17,8 @@
def temp_env():
old_env = os.environ.copy()
yield
os.environ = old_env
os.environ.clear()
os.environ.update(old_env)
def add_o3_to_build_system_cflags(pkg, name, flags):

View File

@ -4,61 +4,76 @@
# SPDX-License-Identifier: (Apache-2.0 OR MIT)
import pytest
import subprocess
import os
import spack
from spack.util.module_cmd import (
module,
get_path_from_module,
get_path_from_module_contents,
get_path_arg_from_module_line,
get_module_cmd_from_bash,
get_module_cmd,
ModuleError)
get_path_from_module_contents
)
env = os.environ.copy()
env['LC_ALL'] = 'C'
typeset_func = subprocess.Popen('module avail',
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
shell=True)
typeset_func.wait()
typeset = typeset_func.stderr.read()
MODULE_NOT_DEFINED = b'not found' in typeset
test_module_lines = ['prepend-path LD_LIBRARY_PATH /path/to/lib',
'setenv MOD_DIR /path/to',
'setenv LDFLAGS -Wl,-rpath/path/to/lib',
'setenv LDFLAGS -L/path/to/lib',
'prepend-path PATH /path/to/bin']
@pytest.fixture
def save_env():
old_path = os.environ.get('PATH', None)
old_bash_func = os.environ.get('BASH_FUNC_module()', None)
def module_function_test_mode():
old_mode = spack.util.module_cmd._test_mode
spack.util.module_cmd._test_mode = True
yield
if old_path:
os.environ['PATH'] = old_path
if old_bash_func:
os.environ['BASH_FUNC_module()'] = old_bash_func
spack.util.module_cmd._test_mode = old_mode
def test_get_path_from_module(save_env):
lines = ['prepend-path LD_LIBRARY_PATH /path/to/lib',
'prepend-path CRAY_LD_LIBRARY_PATH /path/to/lib',
'setenv MOD_DIR /path/to',
'setenv LDFLAGS -Wl,-rpath/path/to/lib',
'setenv LDFLAGS -L/path/to/lib',
'prepend-path PATH /path/to/bin']
@pytest.fixture
def save_module_func():
old_func = spack.util.module_cmd.module
yield
spack.util.module_cmd.module = old_func
def test_module_function_change_env(tmpdir, working_env,
module_function_test_mode):
src_file = str(tmpdir.join('src_me'))
with open(src_file, 'w') as f:
f.write('export TEST_MODULE_ENV_VAR=TEST_SUCCESS\n')
os.environ['NOT_AFFECTED'] = "NOT_AFFECTED"
module('load', src_file)
assert os.environ['TEST_MODULE_ENV_VAR'] == 'TEST_SUCCESS'
assert os.environ['NOT_AFFECTED'] == "NOT_AFFECTED"
def test_module_function_no_change(tmpdir, module_function_test_mode):
src_file = str(tmpdir.join('src_me'))
with open(src_file, 'w') as f:
f.write('echo TEST_MODULE_FUNCTION_PRINT')
old_env = os.environ.copy()
text = module('show', src_file)
assert text == 'TEST_MODULE_FUNCTION_PRINT\n'
assert os.environ == old_env
def test_get_path_from_module_faked(save_module_func):
for line in test_module_lines:
def fake_module(*args):
return line
spack.util.module_cmd.module = fake_module
for line in lines:
module_func = '() { eval `echo ' + line + ' bash filler`\n}'
os.environ['BASH_FUNC_module()'] = module_func
path = get_path_from_module('mod')
assert path == '/path/to'
os.environ['BASH_FUNC_module()'] = '() { eval $(echo fill bash $*)\n}'
path = get_path_from_module('mod')
assert path is None
def test_get_path_from_module_contents():
# A line with "MODULEPATH" appears early on, and the test confirms that it
@ -106,62 +121,3 @@ def test_get_argument_from_module_line():
for bl in bad_lines:
with pytest.raises(ValueError):
get_path_arg_from_module_line(bl)
@pytest.mark.skipif(MODULE_NOT_DEFINED, reason='Depends on defined module fn')
def test_get_module_cmd_from_bash_using_modules():
module_list_proc = subprocess.Popen(['module list'],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
executable='/bin/bash',
shell=True)
module_list_proc.wait()
module_list = module_list_proc.stdout.read()
module_cmd = get_module_cmd_from_bash()
module_cmd_list = module_cmd('list', output=str, error=str)
# Lmod command reprints some env variables on every invocation.
# Test containment to avoid false failures on lmod systems.
assert module_list in module_cmd_list
def test_get_module_cmd_from_bash_ticks(save_env):
os.environ['BASH_FUNC_module()'] = '() { eval `echo bash $*`\n}'
module_cmd = get_module_cmd()
module_cmd_list = module_cmd('list', output=str, error=str)
assert module_cmd_list == 'python list\n'
def test_get_module_cmd_from_bash_parens(save_env):
os.environ['BASH_FUNC_module()'] = '() { eval $(echo fill sh $*)\n}'
module_cmd = get_module_cmd()
module_cmd_list = module_cmd('list', output=str, error=str)
assert module_cmd_list == 'fill python list\n'
def test_get_module_cmd_fails(save_env):
os.environ.pop('BASH_FUNC_module()')
os.environ.pop('PATH')
with pytest.raises(ModuleError):
module_cmd = get_module_cmd(b'--norc')
module_cmd() # Here to avoid Flake F841 on previous line
def test_get_module_cmd_from_which(tmpdir, save_env):
f = tmpdir.mkdir('bin').join('modulecmd')
f.write('#!/bin/bash\n'
'echo $*')
f.chmod(0o770)
os.environ['PATH'] = str(tmpdir.join('bin')) + ':' + os.environ['PATH']
os.environ.pop('BASH_FUNC_module()')
module_cmd = get_module_cmd(b'--norc')
module_cmd_list = module_cmd('list', output=str, error=str)
assert module_cmd_list == 'python list\n'

View File

@ -8,108 +8,53 @@
parsing environment modules.
"""
import subprocess
import re
import os
import json
import re
import llnl.util.tty as tty
from spack.util.executable import which
# This list is not exhaustive. Currently we only use load and unload
# If we need another option that changes the environment, add it here.
module_change_commands = ['load', 'swap', 'unload', 'purge', 'use', 'unuse']
py_cmd = "'import os\nimport json\nprint(json.dumps(dict(os.environ)))'"
# This is just to enable testing. I hate it but we can't find a better way
_test_mode = False
def get_module_cmd(bashopts=''):
try:
return get_module_cmd_from_bash(bashopts)
except ModuleError:
# Don't catch the exception this time; we have no other way to do it.
tty.warn("Could not detect module function from bash."
" Trying to detect modulecmd from `which`")
try:
return get_module_cmd_from_which()
except ModuleError:
raise ModuleError('Spack requires modulecmd or a defined module'
' function. Make sure modulecmd is in your path'
' or the function "module" is defined in your'
' bash environment.')
def module(*args):
module_cmd = 'module ' + ' '.join(args) + ' 2>&1'
if _test_mode:
tty.warn('module function operating in test mode')
module_cmd = ". %s 2>&1" % args[1]
if args[0] in module_change_commands:
# Do the module manipulation, then output the environment in JSON
# and read the JSON back in the parent process to update os.environ
module_cmd += ' >/dev/null; python -c %s' % py_cmd
module_p = subprocess.Popen(module_cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
shell=True)
# Cray modules spit out warnings that we cannot supress.
# This hack skips to the last output (the environment)
env_output = str(module_p.communicate()[0].decode())
print(env_output)
env = env_output.strip().split('\n')[-1]
def get_module_cmd_from_which():
module_cmd = which('modulecmd')
if not module_cmd:
raise ModuleError('`which` did not find any modulecmd executable')
module_cmd.add_default_arg('python')
# Check that the executable works
module_cmd('list', output=str, error=str, fail_on_error=False)
if module_cmd.returncode != 0:
raise ModuleError('get_module_cmd cannot determine the module command')
return module_cmd
def get_module_cmd_from_bash(bashopts=''):
# Find how the module function is defined in the environment
module_func = os.environ.get('BASH_FUNC_module()', None)
if module_func:
module_func = os.path.expandvars(module_func)
# Update os.environ with new dict
env_dict = json.loads(env)
os.environ.clear()
os.environ.update(env_dict)
else:
module_func_proc = subprocess.Popen(['{0} typeset -f module | '
'envsubst'.format(bashopts)],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
executable='/bin/bash',
shell=True)
module_func_proc.wait()
module_func = module_func_proc.stdout.read()
# Find the portion of the module function that is evaluated
try:
find_exec = re.search(r'.*`(.*(:? bash | sh ).*)`.*', module_func)
exec_line = find_exec.group(1)
except BaseException:
try:
# This will fail with nested parentheses. TODO: expand regex.
find_exec = re.search(r'.*\(([^()]*(:? bash | sh )[^()]*)\).*',
module_func)
exec_line = find_exec.group(1)
except BaseException:
raise ModuleError('get_module_cmd cannot '
'determine the module command from bash')
# Create an executable
args = exec_line.split()
module_cmd = which(args[0])
if module_cmd:
for arg in args[1:]:
if arg in ('bash', 'sh'):
module_cmd.add_default_arg('python')
break
else:
module_cmd.add_default_arg(arg)
else:
raise ModuleError('Could not create executable based on module'
' function.')
# Check that the executable works
module_cmd('list', output=str, error=str, fail_on_error=False)
if module_cmd.returncode != 0:
raise ModuleError('get_module_cmd cannot determine the module command'
'from bash.')
return module_cmd
def unload_module(mod):
"""Takes a module name and unloads the module from the environment. It does
not check whether conflicts arise from the unloaded module"""
tty.debug("Unloading module: {0}".format(mod))
modulecmd = get_module_cmd()
unload_output = modulecmd('unload', mod, output=str, error=str)
try:
exec(compile(unload_output, '<string>', 'exec'))
except Exception:
tty.debug("Module unload output of {0}:\n{1}\n".format(
mod, unload_output))
raise
# Simply execute commands that don't change state and return output
module_p = subprocess.Popen(module_cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
shell=True)
# Decode and str to return a string object in both python 2 and 3
return str(module_p.communicate()[0].decode())
def load_module(mod):
@ -117,37 +62,18 @@ def load_module(mod):
load that module. It then loads the provided module. Depends on the
modulecmd implementation of modules used in cray and lmod.
"""
tty.debug("Loading module: {0}".format(mod))
# Create an executable of the module command that will output python code
modulecmd = get_module_cmd()
# Read the module and remove any conflicting modules
# We do this without checking that they are already installed
# for ease of programming because unloading a module that is not
# loaded does nothing.
module_content = modulecmd('show', mod, output=str, error=str)
text = module_content.split()
try:
for i, word in enumerate(text):
if word == 'conflict':
unload_module(text[i + 1])
except Exception:
tty.debug("Module show output of {0}:\n{1}\n".format(
mod, module_content))
raise
text = module('show', mod).split()
for i, word in enumerate(text):
if word == 'conflict':
module('unload', text[i + 1])
# Load the module now that there are no conflicts
# Some module systems use stdout and some use stderr
load = modulecmd('load', mod, output=str, error='/dev/null')
if not load:
load = modulecmd('load', mod, error=str)
try:
exec(compile(load, '<string>', 'exec'))
except Exception:
tty.debug("Module load output of {0}:\n{1}\n".format(mod, load))
raise
module('load', mod)
def get_path_arg_from_module_line(line):
@ -172,11 +98,8 @@ def get_path_from_module(mod):
"""Inspects a TCL module for entries that indicate the absolute path
at which the library supported by said module can be found.
"""
# Create a modulecmd executable
modulecmd = get_module_cmd()
# Read the module
text = modulecmd('show', mod, output=str, error=str).split('\n')
text = module('show', mod).split('\n')
p = get_path_from_module_contents(text, mod)
if p and not os.path.exists(p):
@ -229,7 +152,3 @@ def get_path_from_module_contents(text, module_name):
# Unable to find module path
return None
class ModuleError(Exception):
"""Raised the the module_cmd utility to indicate errors."""