Skip to content

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

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

Merged
merged 19 commits into from
Feb 12, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
cffc2b8
add option module-search-path-headers to control how modules set sear…
lexming Sep 24, 2024
3159de0
integrate --module-search-path-headers into ModuleLoadEnvironment
lexming Jan 27, 2025
1fcf567
add unit tests for cpp headers functionality in ModuleLoadEnvironment
lexming Jan 27, 2025
a63bd4a
add unit test for option module-search-path-headers
lexming Sep 25, 2024
beb4aa5
make test_make_module_req aware of cpp_headers in ModuleLoadEnvironment
lexming Jan 27, 2025
13d8611
fix codestyle in tools.modules
lexming Jan 27, 2025
4e13fe8
Merge branch '5.0.x' into cpath-mod-opt
lexming Feb 5, 2025
f2c32ba
Merge branch '5.0.x' into cpath-mod-opt
lexming Feb 9, 2025
9ea0e6e
replace cpp_headers methods from ModuleLoadEnvironment with a generic…
lexming Feb 10, 2025
4cede72
define aliases in ModuleLoadEnvironment for --module-search-path-head…
lexming Feb 10, 2025
dca5a52
test appending of paths to ModuleLoadEnvironment aliases to test_modu…
lexming Feb 10, 2025
014cbc1
Merge branch '5.0.x' into cpath-mod-opt
lexming Feb 10, 2025
8cf9189
add remove method to ModuleLoadEnvironment
lexming Feb 11, 2025
476c5b0
introduce more constants for MOD_SEARCH_PATH_HEADERS*
boegel Feb 12, 2025
ca64b96
use 'cpath' as default for module_search_path_headers easyconfig para…
boegel Feb 12, 2025
f8afe45
simplify logic to determine mod_load_cpp_headers
boegel Feb 12, 2025
f908b8d
add constant for 'HEADERS' magic string in ModuleLoadEnvironment
boegel Feb 12, 2025
10525cd
fix default for module_search_path_headers build option, remove 'none…
boegel Feb 12, 2025
bebf495
Merge pull request #9 from boegel/cpath-mod-opt
lexming Feb 12, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions easybuild/framework/easyblock.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@
from easybuild.tools.config import CHECKSUM_PRIORITY_JSON, DEFAULT_ENVVAR_USERS_MODULES
from easybuild.tools.config import EASYBUILD_SOURCES_URL, EBPYTHONPREFIXES # noqa
from easybuild.tools.config import FORCE_DOWNLOAD_ALL, FORCE_DOWNLOAD_PATCHES, FORCE_DOWNLOAD_SOURCES
from easybuild.tools.config import PYTHONPATH, SEARCH_PATH_BIN_DIRS, SEARCH_PATH_LIB_DIRS
from easybuild.tools.config import MOD_SEARCH_PATH_HEADERS, PYTHONPATH, SEARCH_PATH_BIN_DIRS, SEARCH_PATH_LIB_DIRS
from easybuild.tools.config import build_option, build_path, get_log_filename, get_repository, get_repositorypath
from easybuild.tools.config import install_path, log_path, package_path, source_paths
from easybuild.tools.environment import restore_env, sanitize_env
Expand All @@ -100,7 +100,7 @@
from easybuild.tools.module_generator import ModuleGeneratorLua, ModuleGeneratorTcl, module_generator, dependencies_for
from easybuild.tools.module_naming_scheme.utilities import det_full_ec_version
from easybuild.tools.modules import ROOT_ENV_VAR_NAME_PREFIX, VERSION_ENV_VAR_NAME_PREFIX, DEVEL_ENV_VAR_NAME_PREFIX
from easybuild.tools.modules import Lmod, ModEnvVarType, ModuleLoadEnvironment
from easybuild.tools.modules import Lmod, ModEnvVarType, ModuleLoadEnvironment, MODULE_LOAD_ENV_HEADERS
from easybuild.tools.modules import curr_module_paths, invalidate_module_caches_for, get_software_root
from easybuild.tools.modules import get_software_root_env_var_name, get_software_version_env_var_name
from easybuild.tools.output import PROGRESS_BAR_DOWNLOAD_ALL, PROGRESS_BAR_EASYCONFIG, PROGRESS_BAR_EXTENSIONS
Expand Down Expand Up @@ -221,7 +221,19 @@ def __init__(self, ec, logfile=None):
self.modules_header = read_file(modules_header_path)

# environment variables on module load
self.module_load_environment = ModuleLoadEnvironment()
mod_load_aliases = {}
# apply --module-search-path-headers: easyconfig parameter has precedence
mod_load_cpp_headers = self.cfg['module_search_path_headers'] or build_option('module_search_path_headers')

try:
mod_load_aliases[MODULE_LOAD_ENV_HEADERS] = MOD_SEARCH_PATH_HEADERS[mod_load_cpp_headers]
except KeyError as err:
raise EasyBuildError(
f"Unknown value selected for option module-search-path-headers: {mod_load_cpp_headers}. "
f"Choose one of: {', '.join(MOD_SEARCH_PATH_HEADERS)}"
) from err

self.module_load_environment = ModuleLoadEnvironment(aliases=mod_load_aliases)

# 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': [None, "Environment variable set by modules on load "
"with search paths to header files (if None, use $CPATH)", 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
12 changes: 12 additions & 0 deletions easybuild/tools/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,15 @@
EBPYTHONPREFIXES = 'EBPYTHONPREFIXES'
PYTHON_SEARCH_PATH_TYPES = [PYTHONPATH, EBPYTHONPREFIXES]

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


class Singleton(ABCMeta):
"""Serves as metaclass for classes that should implement the Singleton pattern.
Expand Down Expand Up @@ -389,6 +398,9 @@ def mk_full_default_path(name, prefix=DEFAULT_PREFIX):
DEFAULT_MINIMAL_BUILD_ENV: [
'minimal_build_env',
],
DEFAULT_MOD_SEARCH_PATH_HEADERS: [
'module_search_path_headers',
],
DEFAULT_PKG_RELEASE: [
'package_release',
],
Expand Down
109 changes: 103 additions & 6 deletions easybuild/tools/modules.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@
from easybuild.tools.utilities import get_subclasses, nub


MODULE_LOAD_ENV_HEADERS = 'HEADERS'

# software root/version environment variable name prefixes
ROOT_ENV_VAR_NAME_PREFIX = "EBROOT"
VERSION_ENV_VAR_NAME_PREFIX = "EBVERSION"
Expand Down Expand Up @@ -241,18 +243,38 @@ def is_path(self):


class ModuleLoadEnvironment:
"""Changes to environment variables that should be made when environment module is loaded"""
"""
Changes to environment variables that should be made when environment module is loaded.
- Environment variables are defined as ModuleEnvironmentVariables instances
with attribute name equal to environment variable name.
- Aliases are arbitrary names that serve to apply changes to lists of
environment variables
- Only environment variables attributes are public. Other attributes like
aliases are private.
"""

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

:aliases: dict defining environment variables aliases
"""
self._aliases = {}
if aliases is not None:
try:
for alias_name, alias_vars in aliases.items():
self.update_alias(alias_name, alias_vars)
except AttributeError as err:
raise EasyBuildError(
"Wrong format for aliases defitions passed to ModuleLoadEnvironment. "
f"Expected a dictionary but got: {type(aliases)}."
) from err

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 @@ -261,11 +283,29 @@ 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']

# environment variables with known aliases
# e.g. search paths to C/C++ headers
for envar_name in self._aliases.get(MODULE_LOAD_ENV_HEADERS, []):
setattr(self, envar_name, 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}'")
Expand All @@ -284,17 +324,24 @@ 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
"""
return self.__dict__.items()
for attr in self.vars:
yield attr, getattr(self, attr)

def update(self, new_env):
"""Update contents of environment from given dictionary"""
Expand All @@ -304,6 +351,14 @@ def update(self, new_env):
except AttributeError as err:
raise EasyBuildError("Cannot update ModuleLoadEnvironment from a non-dict variable") from err

def remove(self, var_name):
"""
Remove ModuleEnvironmentVariable attribute from instance
Silently goes through if attribute is already missing
"""
if var_name in self.vars:
delattr(self, var_name)

@property
def as_dict(self):
"""
Expand All @@ -319,6 +374,48 @@ def environ(self):
"""
return {envar_name: str(envar_contents) for envar_name, envar_contents in self.items()}

def alias(self, alias):
"""
Return iterator to search path variables for given alias
"""
try:
yield from [getattr(self, envar) for envar in self._aliases[alias]]
except KeyError as err:
raise EasyBuildError(f"Unknown search path alias: {alias}") from err
except AttributeError as err:
raise EasyBuildError(f"Missing environment variable in '{alias} alias") from err

def alias_vars(self, alias):
"""
Return list of environment variable names aliased by given alias
"""
try:
return self._aliases[alias]
except KeyError as err:
raise EasyBuildError(f"Unknown search path alias: {alias}") from err

def update_alias(self, alias, value):
"""
Update existing or non-existing alias with given search paths variables
"""
if isinstance(value, str):
value = [value]

try:
self._aliases[alias] = [str(envar) for envar in value]
except TypeError as err:
raise TypeError("ModuleLoadEnvironment aliases must be a list of strings") from err

def set_alias_vars(self, alias, value):
"""
Set value of search paths variables for given alias
"""
try:
for envar_name in self._aliases[alias]:
setattr(self, envar_name, value)
except KeyError as err:
raise EasyBuildError(f"Unknown search path alias: {alias}") from err


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,
sorted(MOD_SEARCH_PATH_HEADERS.keys())),
'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
95 changes: 94 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 @@ -668,6 +668,99 @@ 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 = {
"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():
update_build_option('module_search_path_headers', build_opt)
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:
update_build_option('module_search_path_headers', build_opt)
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
Loading
Loading