Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add module-search-path-headers configuration option to control how modules set search paths to header files #4655

Open
wants to merge 6 commits into
base: 5.0.x
Choose a base branch
from
8 changes: 7 additions & 1 deletion easybuild/framework/easyblock.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,13 @@ def __init__(self, ec, logfile=None):
self.modules_header = read_file(modules_header_path)

# environment variables on module load
self.module_load_environment = ModuleLoadEnvironment()
# apply --module-search-path-headers: easyconfig parameter has precedence
mod_load_cpp_headers = build_option('module_search_path_headers')
cfg_cpp_headers = self.cfg['module_search_path_headers']
if cfg_cpp_headers is not False:
mod_load_cpp_headers = cfg_cpp_headers

self.module_load_environment = ModuleLoadEnvironment(cpp_headers=mod_load_cpp_headers)

# determine install subdirectory, based on module name
self.install_subdir = None
Expand Down
7 changes: 4 additions & 3 deletions easybuild/framework/easyconfig/default.py
Original file line number Diff line number Diff line change
Expand Up @@ -208,10 +208,11 @@
'moduleloadnoconflict': [False, "Don't check for conflicts, unload other versions instead ", MODULES],
'module_depends_on': [None, 'Use depends_on (Lmod 7.6.1+) for dependencies in generated module '
'(implies recursive unloading of modules) [DEPRECATED]', MODULES],
'module_search_path_headers': [False, "Environment variable set by modules on load with search paths "
"to header files", MODULES],
'recursive_module_unload': [None, "Recursive unload of all dependencies when unloading module "
"(True/False to hard enable/disable; None implies honoring "
"the --recursive-module-unload EasyBuild configuration setting",
MODULES],
"(True/False to hard enable/disable; None implies honoring the "
"--recursive-module-unload EasyBuild configuration setting", MODULES],

# MODULES documentation easyconfig parameters
# (docurls is part of MANDATORY)
Expand Down
9 changes: 9 additions & 0 deletions easybuild/tools/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,14 @@
EBPYTHONPREFIXES = 'EBPYTHONPREFIXES'
PYTHON_SEARCH_PATH_TYPES = [PYTHONPATH, EBPYTHONPREFIXES]

# options to handle header search paths in environment of modules
MOD_SEARCH_PATH_HEADERS = {
"none": [],
"cpath": ["CPATH"],
"include_paths": ["C_INCLUDE_PATH", "CPLUS_INCLUDE_PATH", "OBJC_INCLUDE_PATH"],
}
DEFAULT_MOD_SEARCH_PATH_HEADERS = "cpath"


class Singleton(ABCMeta):
"""Serves as metaclass for classes that should implement the Singleton pattern.
Expand Down Expand Up @@ -307,6 +315,7 @@ def mk_full_default_path(name, prefix=DEFAULT_PREFIX):
'logtostdout',
'minimal_toolchains',
'module_only',
'module_search_path_headers',
'package',
'parallel_extensions_install',
'read_only_installdir',
Expand Down
70 changes: 64 additions & 6 deletions easybuild/tools/modules.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@
from easybuild.base import fancylogger
from easybuild.tools import LooseVersion
from easybuild.tools.build_log import EasyBuildError, EasyBuildExit, print_warning
from easybuild.tools.config import ERROR, EBROOT_ENV_VAR_ACTIONS, IGNORE, LOADED_MODULES_ACTIONS, PURGE
from easybuild.tools.config import DEFAULT_MOD_SEARCH_PATH_HEADERS, ERROR, EBROOT_ENV_VAR_ACTIONS, IGNORE
from easybuild.tools.config import LOADED_MODULES_ACTIONS, MOD_SEARCH_PATH_HEADERS, PURGE
from easybuild.tools.config import SEARCH_PATH_BIN_DIRS, SEARCH_PATH_HEADER_DIRS, SEARCH_PATH_LIB_DIRS, UNLOAD, UNSET
from easybuild.tools.config import build_option, get_modules_tool, install_path
from easybuild.tools.environment import ORIG_OS_ENVIRON, restore_env, setvar, unset_env_vars
Expand Down Expand Up @@ -246,16 +247,18 @@ def is_path(self):
class ModuleLoadEnvironment:
"""Changes to environment variables that should be made when environment module is loaded"""

def __init__(self):
def __init__(self, cpp_headers=None):
"""
Initialize default environment definition
Paths are relative to root of installation directory

:cpp_headers: string defining MOD_SEARCH_PATH_HEADERS setting
"""

self.ACLOCAL_PATH = [os.path.join('share', 'aclocal')]
self.CLASSPATH = ['*.jar']
self.CMAKE_LIBRARY_PATH = ['lib64'] # only needed for installations with standalone lib64
self.CMAKE_PREFIX_PATH = ['']
self.CPATH = SEARCH_PATH_HEADER_DIRS
self.GI_TYPELIB_PATH = [os.path.join(x, 'girepository-*') for x in SEARCH_PATH_LIB_DIRS]
self.LD_LIBRARY_PATH = SEARCH_PATH_LIB_DIRS
self.LIBRARY_PATH = SEARCH_PATH_LIB_DIRS
Expand All @@ -264,14 +267,39 @@ def __init__(self):
self.PKG_CONFIG_PATH = [os.path.join(x, 'pkgconfig') for x in SEARCH_PATH_LIB_DIRS + ['share']]
self.XDG_DATA_DIRS = ['share']

# handle search paths to C/C++ headers
self._cpp_headers_opt = DEFAULT_MOD_SEARCH_PATH_HEADERS
if cpp_headers is not None and cpp_headers is not False:
self._cpp_headers_opt = str(cpp_headers)
if self._cpp_headers_opt not in MOD_SEARCH_PATH_HEADERS:
raise EasyBuildError(
f"Unknown value selected for option module-search-path-headers: {self._cpp_headers_opt}. "
f"Choose one of: {', '.join(MOD_SEARCH_PATH_HEADERS)}"
)
self.set_cpp_headers(SEARCH_PATH_HEADER_DIRS)

def __setattr__(self, name, value):
"""
Specific restrictions for ModuleLoadEnvironment attributes:
- public attributes are instances of ModuleEnvironmentVariable with uppercase names
- private attributes are allowed with any name
"""
if name.startswith('_'):
# do not control protected/private attributes
return super().__setattr__(name, value)

return self.__set_module_environment_variable(name, value)

def __set_module_environment_variable(self, name, value):
"""
Specific restrictions for ModuleEnvironmentVariable attributes:
- attribute names are uppercase
- attributes are instances of ModuleEnvironmentVariable
- dictionaries are unpacked into arguments of ModuleEnvironmentVariable
- controls variables with special types (e.g. PATH, LD_LIBRARY_PATH)
"""
if name != name.upper():
raise EasyBuildError(f"Names of ModuleLoadEnvironment attributes must be uppercase, got '{name}'")

try:
(contents, kwargs) = value
except ValueError:
Expand All @@ -286,17 +314,23 @@ def __setattr__(self, name, value):

return super().__setattr__(name, ModuleEnvironmentVariable(contents, **kwargs))

@property
def vars(self):
"""Return list of public ModuleEnvironmentVariable"""

return [envar for envar in self.__dict__ if not str(envar).startswith('_')]

def __iter__(self):
"""Make the class iterable"""
yield from self.__dict__
yield from self.vars

def items(self):
"""
Return key-value pairs for each attribute that is a ModuleEnvironmentVariable
- key = attribute name
- value = its "contents" attribute
"""
for attr in self.__dict__:
for attr in self.vars:
yield attr, getattr(self, attr)

def update(self, new_env):
Expand Down Expand Up @@ -325,6 +359,30 @@ def environ(self):
mapping.update({envar_name: str(envar_contents)})
return mapping

@property
def name_cpp_headers(self):
"""
Return list of environment variable names holding search paths to CPP headers
According to option --module-search-path-headers
"""
return MOD_SEARCH_PATH_HEADERS[self._cpp_headers_opt]

@property
def cpp_headers(self):
"""
Return dict with search path variables for C/C++ headers
According to option --module-search-path-headers
"""
return {envar_name: getattr(self, envar_name) for envar_name in self.name_cpp_headers}

def set_cpp_headers(self, new_headers):
"""
Set search paths variables for C/C++ headers
According to option --module-search-path-headers
"""
for envar_name in self.name_cpp_headers:
setattr(self, envar_name, new_headers)


class ModulesTool(object):
"""An abstract interface to a tool that deals with modules."""
Expand Down
4 changes: 4 additions & 0 deletions easybuild/tools/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
from easybuild.tools.config import DEFAULT_JOB_EB_CMD, DEFAULT_LOGFILE_FORMAT, DEFAULT_MAX_FAIL_RATIO_PERMS
from easybuild.tools.config import DEFAULT_MINIMAL_BUILD_ENV, DEFAULT_MNS, DEFAULT_MODULE_SYNTAX, DEFAULT_MODULES_TOOL
from easybuild.tools.config import DEFAULT_MODULECLASSES, DEFAULT_PATH_SUBDIRS, DEFAULT_PKG_RELEASE, DEFAULT_PKG_TOOL
from easybuild.tools.config import DEFAULT_MOD_SEARCH_PATH_HEADERS, MOD_SEARCH_PATH_HEADERS
from easybuild.tools.config import DEFAULT_PKG_TYPE, DEFAULT_PNS, DEFAULT_PREFIX, DEFAULT_EXTRA_SOURCE_URLS
from easybuild.tools.config import DEFAULT_REPOSITORY, DEFAULT_WAIT_ON_LOCK_INTERVAL, DEFAULT_WAIT_ON_LOCK_LIMIT
from easybuild.tools.config import DEFAULT_PR_TARGET_ACCOUNT, DEFAULT_FILTER_RPATH_SANITY_LIBS
Expand Down Expand Up @@ -615,6 +616,9 @@ def config_options(self):
'module-extensions': ("Include 'extensions' statement in generated module file (Lua syntax only)",
None, 'store_true', True),
'module-naming-scheme': ("Module naming scheme to use", None, 'store', DEFAULT_MNS),
'module-search-path-headers': ("Environment variable set by modules on load with search paths "
"to header files", 'choice', 'store', DEFAULT_MOD_SEARCH_PATH_HEADERS,
[*MOD_SEARCH_PATH_HEADERS]),
'module-syntax': ("Syntax to be used for module files", 'choice', 'store', DEFAULT_MODULE_SYNTAX,
sorted(avail_module_generators().keys())),
'moduleclasses': (("Extend supported module classes "
Expand Down
96 changes: 95 additions & 1 deletion test/framework/easyblock.py
Original file line number Diff line number Diff line change
Expand Up @@ -532,7 +532,7 @@ def test_make_module_req(self):
for env_var in default_mod_load_vars:
delattr(eb.module_load_environment, env_var)

self.assertEqual(len(vars(eb.module_load_environment)), 0)
self.assertEqual(len(eb.module_load_environment.vars), 0)

# check for behavior when a string value is used as value of module_load_environment
eb.module_load_environment.PATH = 'bin'
Expand Down Expand Up @@ -609,6 +609,100 @@ def test_make_module_req(self):
eb.close_log()
os.remove(eb.logfile)

def test_module_search_path_headers(self):
"""Test functionality of module-search-path-headers option"""
sp_headers_mode = {
"none": [],
"cpath": ["CPATH"],
"include_paths": ["C_INCLUDE_PATH", "CPLUS_INCLUDE_PATH", "OBJC_INCLUDE_PATH"],
}

self.contents = '\n'.join([
'easyblock = "ConfigureMake"',
'name = "pi"',
'version = "3.14"',
'homepage = "http://example.com"',
'description = "test easyconfig"',
'toolchain = SYSTEM',
])
self.writeEC()

for build_opt, sp_headers in sp_headers_mode.items():
init_config(build_options={"module_search_path_headers": build_opt, "silent": True})
eb = EasyBlock(EasyConfig(self.eb_file))
eb.installdir = config.install_path()
try:
os.makedirs(os.path.join(eb.installdir, 'include'))
write_file(os.path.join(eb.installdir, 'include', 'header.h'), 'dummy header file')
except FileExistsError:
pass

with eb.module_generator.start_module_creation():
guess = eb.make_module_req()

if not sp_headers:
# none option adds nothing to module file
if get_module_syntax() == 'Tcl':
tcl_ref_pattern = r"^prepend-path\s+CPATH\s+\$root/include$"
self.assertFalse(re.search(tcl_ref_pattern, guess, re.M))
elif get_module_syntax() == 'Lua':
lua_ref_pattern = r'^prepend_path\("CPATH", pathJoin\(root, "include"\)\)$'
self.assertFalse(re.search(lua_ref_pattern, guess, re.M))
else:
for env_var in sp_headers:
if get_module_syntax() == 'Tcl':
tcl_ref_pattern = rf"^prepend-path\s+{env_var}\s+\$root/include$"
self.assertTrue(re.search(tcl_ref_pattern, guess, re.M))
elif get_module_syntax() == 'Lua':
lua_ref_pattern = rf'^prepend_path\("{env_var}", pathJoin\(root, "include"\)\)$'
self.assertTrue(re.search(lua_ref_pattern, guess, re.M))

# test with easyconfig parameter
for ec_param, sp_headers in sp_headers_mode.items():
self.contents += f'\nmodule_search_path_headers = "{ec_param}"'
self.writeEC()
eb = EasyBlock(EasyConfig(self.eb_file))
eb.installdir = config.install_path()
try:
os.makedirs(os.path.join(eb.installdir, 'include'))
write_file(os.path.join(eb.installdir, 'include', 'header.h'), 'dummy header file')
except FileExistsError:
pass

for build_opt in sp_headers_mode:
init_config(build_options={"module_search_path_headers": build_opt, "silent": True})
with eb.module_generator.start_module_creation():
guess = eb.make_module_req()
if not sp_headers:
# none option adds nothing to module file
if get_module_syntax() == 'Tcl':
tcl_ref_pattern = r"^prepend-path\s+CPATH\s+\$root/include$"
self.assertFalse(re.search(tcl_ref_pattern, guess, re.M))
elif get_module_syntax() == 'Lua':
lua_ref_pattern = r'^prepend_path\("CPATH", pathJoin\(root, "include"\)\)$'
self.assertFalse(re.search(lua_ref_pattern, guess, re.M))
else:
for env_var in sp_headers:
if get_module_syntax() == 'Tcl':
tcl_ref_pattern = rf"^prepend-path\s+{env_var}\s+\$root/include$"
self.assertTrue(re.search(tcl_ref_pattern, guess, re.M))
elif get_module_syntax() == 'Lua':
lua_ref_pattern = rf'^prepend_path\("{env_var}", pathJoin\(root, "include"\)\)$'
self.assertTrue(re.search(lua_ref_pattern, guess, re.M))

# test wrong easyconfig parameter
self.contents += '\nmodule_search_path_headers = "WRONG_OPT"'
self.writeEC()
ec = EasyConfig(self.eb_file)

error_pattern = "Unknown value selected for option module-search-path-headers"
with eb.module_generator.start_module_creation():
self.assertErrorRegex(EasyBuildError, error_pattern, EasyBlock, ec)

# cleanup
eb.close_log()
os.remove(eb.logfile)

def test_make_module_extra(self):
"""Test for make_module_extra."""
init_config(build_options={'silent': True})
Expand Down
40 changes: 39 additions & 1 deletion test/framework/modules.py
Original file line number Diff line number Diff line change
Expand Up @@ -1706,8 +1706,10 @@ def test_module_load_environment(self):
self.assertEqual(mod_load_env.TEST_VARTYPE.type, mod.ModEnvVarType.PATH)
self.assertRaises(TypeError, setattr, mod_load_env, 'TEST_UNKNONW', (test_contents, {'unkown_param': True}))

# test retrieving environment
# test retrieval of environment
# use copy of public attributes as reference
ref_load_env = mod_load_env.__dict__.copy()
ref_load_env = {envar: value for envar, value in ref_load_env.items() if not envar.startswith('_')}
self.assertCountEqual(list(mod_load_env), ref_load_env.keys())

ref_load_env_item_list = list(ref_load_env.items())
Expand Down Expand Up @@ -1738,6 +1740,42 @@ def test_module_load_environment(self):
self.assertTrue(hasattr(mod_load_env, 'TEST_STR'))
self.assertEqual(mod_load_env.TEST_STR.contents, ['some/path'])

# test functionality related to --module-search-path-headers
default_search_path_headers = 'cpath'
self.assertEqual(mod_load_env._cpp_headers_opt, default_search_path_headers)
mod_load_env = mod.ModuleLoadEnvironment(default_search_path_headers)
self.assertEqual(mod_load_env._cpp_headers_opt, default_search_path_headers)
self.assertEqual(mod_load_env.name_cpp_headers, ['CPATH'])
repr_mod_load_env = {k: str(v) for k, v in mod_load_env.cpp_headers.items()}
self.assertDictEqual(repr_mod_load_env, {'CPATH': 'include'})
mod_load_env.set_cpp_headers('new_include')
repr_mod_load_env = {k: str(v) for k, v in mod_load_env.cpp_headers.items()}
self.assertDictEqual(repr_mod_load_env, {'CPATH': 'new_include'})
mod_load_env.set_cpp_headers(["new_include_1", "new_include_2"])
repr_mod_load_env = {k: str(v) for k, v in mod_load_env.cpp_headers.items()}
self.assertDictEqual(repr_mod_load_env, {'CPATH': 'new_include_1:new_include_2'})

mod_load_env = mod.ModuleLoadEnvironment(cpp_headers='include_paths')
self.assertNotEqual(mod_load_env._cpp_headers_opt, default_search_path_headers)
ref_include_vars = ['C_INCLUDE_PATH', 'CPLUS_INCLUDE_PATH', 'OBJC_INCLUDE_PATH']
self.assertEqual(mod_load_env.name_cpp_headers, ref_include_vars)
repr_mod_load_env = {k: str(v) for k, v in mod_load_env.cpp_headers.items()}
ref_cpp_headers = {key: 'include' for key in ref_include_vars}
self.assertDictEqual(repr_mod_load_env, ref_cpp_headers)
mod_load_env.set_cpp_headers('new_include')
repr_mod_load_env = {k: str(v) for k, v in mod_load_env.cpp_headers.items()}
ref_cpp_headers = {key: 'new_include' for key in ref_include_vars}
self.assertDictEqual(repr_mod_load_env, ref_cpp_headers)

mod_load_env = mod.ModuleLoadEnvironment(cpp_headers='none')
self.assertEqual(mod_load_env.name_cpp_headers, [])
self.assertEqual(mod_load_env.cpp_headers, {})
mod_load_env.set_cpp_headers('new_include')
self.assertEqual(mod_load_env.cpp_headers, {})

error_pattern = "Unknown value selected for option module-search-path-headers"
self.assertErrorRegex(EasyBuildError, error_pattern, mod.ModuleLoadEnvironment, cpp_headers='nonexistent')


def suite():
""" returns all the testcases in this module """
Expand Down
Loading