Skip to content

Make generic EasyBlock usable to install software #4531

New issue

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

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

Already on ? Sign in to your account

Open
wants to merge 30 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
5bf48e4
standarize description of pre-step-opts and step-opts easyconfig para…
lexmingMay 12, 2024
9781455
add test and postinstall easyconfig parameters to group format rules
lexmingMay 12, 2024
6be1579
add new easyconfig parameters to prepend commands to main step commands
lexmingMay 12, 2024
95fed87
add pre-step-cmds easyconfig parameters to group format rules
lexmingMay 12, 2024
b51c586
update test_dump_order with more parameters for each main step
lexmingMay 12, 2024
6233cb4
split easyblock.run_post_intall_commands into a new generic private m…
lexmingMay 13, 2024
32ca373
add new private method to run step action by executing all pre-comman…
lexmingMay 13, 2024
5f370cd
simplify structure of easyconfig.get_easyblock_class method
lexmingMay 13, 2024
7eafeb7
use generic EasyBlock as fallback easyblock for easyconfigs without a…
lexmingMay 13, 2024
37d0edc
add easyconfigs parameters to define the main commands used in each b…
lexmingMay 13, 2024
39be30a
implement default configure, build and install steps in base EasyBlock
lexmingMay 13, 2024
ac2fa7a
refactor test step in base EasyBlock to leverage run_step_main_action…
lexmingMay 13, 2024
9655dfb
fix code style errors
lexmingMay 13, 2024
74bf3a9
Merge branch '5.0.x' into pre-step-cmds
lexmingJun 11, 2024
3834170
use new parameter name for pre_step commands options
lexmingJun 11, 2024
b6d9b3b
fix test_iter_builddeps_templates in test.framework.easyconfig with u…
lexmingJun 11, 2024
cea4bcd
fix test_easyblock in test.framework.easyblock with usable EasyBlock …
lexmingJun 11, 2024
2b39e93
fix codestyle in easybuild.framework.easyblock
lexmingJun 11, 2024
0b73ef5
fix test_gen_easyblocks_overview in test.framework.docs with usable E…
lexmingJun 12, 2024
6add7bc
fix test_xxx_include_generic_easyblocks in test.framework.options wit…
lexmingJun 12, 2024
2564be9
remove unused error_on_failed_import from get_easyblock_class method
lexmingJun 12, 2024
69aa521
fix test_get_easyblock_class in test.framework.easyconfig with usable…
lexmingJun 12, 2024
164e3c6
fix test_skip_test_step in test.framework.options with usable EasyBlo…
lexmingJun 12, 2024
041a397
update tests replacing deprecated runtest with test_cmd
lexmingJun 12, 2024
a1a58c1
update reference error messages in test_xxx_include_generic_easyblocks
lexmingJun 12, 2024
31f9a93
rename method EasyBlock._run_command_stack to EasyBlock._run_cmds
lexmingJun 28, 2024
b879130
update call to renamed EasyBlock._run_cmds
lexmingJul 2, 2024
22d9648
prefer test_cmd over runtest whenever both are defined
lexmingJul 2, 2024
e05a985
replace EasyBlock.run_step_main_action with EasyBlock.run_core_step_c…
lexmingJul 2, 2024
eacd6e3
disable error_on_failed_import argument in Easyconfig.get_easyblock_c…
lexmingJul 2, 2024
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
94 changes: 70 additions & 24 deletions easybuild/framework/easyblock.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -2720,20 +2720,72 @@ def prepare_step(self, start_dir=True, load_tc_deps_modules=True):
if start_dir:
self.guess_start_dir()

def _run_cmds(self, cmds):
"""Execute list of shell commands"""
self.log.debug(f"List of commands to be executed: {cmds}")

# make sure we have a list of commands
if not isinstance(cmds, (list, tuple)):
raise EasyBuildError(
f"Invalid argument for EasyBlock._run_cmds, expected list or tuple of strings: {cmds}"
)

for cmd in cmds:
if not isinstance(cmd, str):
raise EasyBuildError(f"Invalid element in command list, not a string: {cmd}")
run_shell_cmd(cmd)

def run_core_step_cmd(self, step):
"""Construct main command of core step and execute it"""

core_steps = ["configure", "build", "test", "install"]
if step not in core_steps:
raise EasyBuildError(
f"Cannot construct command for step {step}. Supported steps are: {', '.join(core_steps)}."
)

step_cmd_cfgs = {
"configure": ('pre_configure_cmds', 'preconfigopts', 'configure_cmd', 'configopts'),
"build": ('pre_build_cmds', 'prebuildopts', 'build_cmd', 'buildopts'),
"test": ('pre_test_cmds', 'pretestopts', 'test_cmd', 'testopts'),
"install": ('pre_install_cmds', 'preinstallopts', 'install_cmd', 'installopts'),
}

pre_step_cmds = self.cfg[step_cmd_cfgs[step][0]]
if pre_step_cmds:
try:
self._run_cmds(pre_step_cmds)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't we limit the usefulness of pre_configure_cmds by running those commands separately?

Something like export FOO=bar in pre_configure_cmds won't work as expected because it's run in a different shell than configure_cmd, which limits the usefulness of pre_configure_cmds quite a bit I think?

I can see the value though (better errors when one of the commands in pre_configure_cmds fails, for example), but we should try and make it very clear that these commands are running in a different subshell?

except EasyBuildError as err:
raise EasyBuildError(f"Failed to execute pre-step commands for step '{step}'. {err}")

step_cmd = ' '.join(str(self.cfg[cfg_name]) for cfg_name in step_cmd_cfgs[step][1:4])
return run_shell_cmd(step_cmd)

def configure_step(self):
"""Configure build (abstract method)."""
raise NotImplementedError
"""Configure build."""
if self.cfg['configure_cmd'] is not None:
res = self.run_core_step_cmd("configure")
return res.output

def build_step(self):
"""Build software (abstract method)."""
raise NotImplementedError
"""Build software."""
if self.cfg['build_cmd'] is not None:
res = self.run_core_step_cmd("build")
return res.output

def test_step(self):
"""Run unit tests provided by software (if any)."""
unit_test_cmd = self.cfg['runtest']
if unit_test_cmd:
self.log.debug(f"Trying to execute {unit_test_cmd} as a command for running unit tests...")
res = run_shell_cmd(unit_test_cmd)
if self.cfg['runtest'] is not None:
self.log.deprecated("Easyconfig parameter 'runtest' is deprecated, use 'test_cmd' instead.", "6.0")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@lexming This implies updating all easyblocks/easyconfigs to use test_cmd instead of runtest before we release EasyBuild 5.0, so please open an issue on that in both repos and tag it for EasyBuild 5.0

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, what if both test_cmd and runtest are set?

Either we error out, or we let one overrule the other (with logging).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good point, I suggest to not error out and just ignore runtest if both are defined (with a warning ofc). Fixed in 22d9648

if self.cfg['test_cmd'] is None:
self.cfg['test_cmd'] = self.cfg['runtest']
else:
self.log.warning(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't we print a warning here (too), to avoid that this is overlooked easily?

"Both 'runtest' and 'test_cmd' easyconfig parameters are defined. Ignoring deprecated 'runtest'."
)

if self.cfg['test_cmd'] is not None:
res = self.run_core_step_cmd("test")
return res.output

def _test_step(self):
Expand All@@ -2750,8 +2802,10 @@ def stage_install_step(self):
"""Install in a stage directory before actual installation."""

def install_step(self):
"""Install built software (abstract method)."""
raise NotImplementedError
"""Install built software."""
if self.cfg['install_cmd'] is not None:
res = self.run_core_step_cmd("install")
return res.output

def init_ext_instances(self):
"""
Expand DownExpand Up@@ -2810,8 +2864,8 @@ def init_ext_instances(self):
error_on_missing_easyblock=False)

self.log.debug("Obtained class %s for extension %s", cls, ext_name)
if cls is not None:
# make sure that this easyblock can be used to install extensions
if cls not in (None, EasyBlock):
# extensions need a non-default EasyBlock capable of installing extensions
if not issubclass(cls, Extension):
raise EasyBuildError("%s easyblock can not be used to install extensions!", cls.__name__)

Expand DownExpand Up@@ -2992,18 +3046,10 @@ def run_post_install_commands(self, commands=None):
if commands is None:
commands = self.cfg['postinstallcmds']

if commands:
self.log.debug(f"Specified post install commands: {commands}")

# make sure we have a list of commands
if not isinstance(commands, (list, tuple)):
error_msg = f"Invalid value for 'postinstallcmds', should be list or tuple of strings: {commands}"
raise EasyBuildError(error_msg)

for cmd in commands:
if not isinstance(cmd, str):
raise EasyBuildError(f"Invalid element in 'postinstallcmds', not a string: {cmd}")
run_shell_cmd(cmd)
try:
self._run_cmds(commands)
except EasyBuildError as err:
raise EasyBuildError(f"Failed to execute post-install commands. {err}")

def apply_post_install_es(self, es=None):
"""
Expand Down
38 changes: 23 additions & 15 deletions easybuild/framework/easyconfig/default.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -88,9 +88,11 @@
"to be linked in any installed binary/library", BUILD],
'bitbucket_account': ['%(namelower)s', "Bitbucket account name to be used to resolve template values in source"
" URLs", BUILD],
'buildopts': ['', 'Extra options passed to make step (default already has -j X)', BUILD],
'buildopts': ['', 'Extra options appended to build command', BUILD],
'build_cmd': [None, "Main shell command used in the build step", BUILD],
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should also mention something like "(only used by default build step implemented by EasyBlock class)", or something like that?
As soon as a generic/custom easyblock is used, build_cmd will be totally ignored.
Same goes for configure, test, install

'checksums': [[], "Checksums for sources and es", BUILD],
'configopts': ['', 'Extra options passed to configure (default already has --prefix)', BUILD],
'configopts': ['', 'Extra options appended to configure command', BUILD],
'configure_cmd': [None, "Main shell command used in the configure step", BUILD],
'cuda_compute_capabilities': [[], "List of CUDA compute capabilities to build with (if supported)", BUILD],
'download_instructions': ['', "Specify steps to acquire necessary file, if obtaining it is difficult", BUILD],
'easyblock': [None, "EasyBlock to use for building; if set to None, an easyblock is selected "
Expand All@@ -107,18 +109,23 @@
'_account': ['%(namelower)s', " account name to be used to resolve template values in source URLs",
BUILD],
'hidden': [False, "Install module file as 'hidden' by prefixing its version with '.'", BUILD],
'installopts': ['', 'Extra options for installation', BUILD],
'installopts': ['', 'Extra options appended to installation command', BUILD],
'install_cmd': [None, "Main shell command used in the install step", BUILD],
'maxparallel': [None, 'Max degree of parallelism', BUILD],
'parallel': [None, ('Degree of parallelism for e.g. make (default: based on the number of '
'cores, active cpuset and restrictions in ulimit)'), BUILD],
'es': [[], "List of es to apply", BUILD],
'prebuildopts': ['', 'Extra options pre-passed to build command.', BUILD],
'preconfigopts': ['', 'Extra options pre-passed to configure.', BUILD],
'preinstallopts': ['', 'Extra prefix options for installation.', BUILD],
'pretestopts': ['', 'Extra prefix options for test.', BUILD],
'postinstallcmds': [[], 'Commands to run after the install step.', BUILD],
'postinstalles': [[], ' files to apply after running the install step.', BUILD],
'postinstallmsgs': [[], 'Messages to print after running the install step.', BUILD],
'pre_build_cmds': ['', 'Extra commands executed before main build command', BUILD],
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mention that these are run in a different subshell than the build command itself (no sharing of environment), and only when there's no generic/custom easyblock is used

Same for configure, test, install

'prebuildopts': ['', 'Extra options prepended to build command', BUILD],
'pre_configure_cmds': ['', 'Extra commands executed before main configure command', BUILD],
'preconfigopts': ['', 'Extra options prepended to configure command', BUILD],
'pre_install_cmds': ['', 'Extra commands executed before main install command', BUILD],
'preinstallopts': ['', 'Extra options prepended to installation command', BUILD],
'pre_test_cmds': ['', 'Extra commands executed before main test command', BUILD],
'pretestopts': ['', 'Extra options prepended to test command', BUILD],
'postinstallcmds': [[], 'Commands to run after the install step', BUILD],
'postinstalles': [[], ' files to apply after running the install step', BUILD],
'postinstallmsgs': [[], 'Messages to print after running the install step', BUILD],
'required_linked_shared_libs': [[], "List of shared libraries (names, file names, or paths) which must be "
"linked in all installed binaries/libraries", BUILD],
'runtest': [None, ('Indicates if a test should be run after make; should specify argument '
Expand All@@ -134,8 +141,9 @@
'skipsteps': [[], "Skip these steps", BUILD],
'source_urls': [[], "List of URLs for source files", BUILD],
'sources': [[], "List of source files", BUILD],
'stop': [None, 'Keyword to halt the build process after a certain step.', BUILD],
'testopts': ['', 'Extra options for test.', BUILD],
'stop': [None, 'Keyword to halt the build process after a certain step', BUILD],
'testopts': ['', 'Extra options appended to test command', BUILD],
'test_cmd': [None, "Main shell command used in the test step", BUILD],
'tests': [[], ("List of test-scripts to run after install. A test script should return a "
"non-zero exit status to fail"), BUILD],
'unpack_options': ['', "Extra options for unpacking source", BUILD],
Expand All@@ -148,9 +156,9 @@
'buildininstalldir': [False, ('Boolean to build (True) or not build (False) in the installation directory'),
FILEMANAGEMENT],
'cleanupoldbuild': [True, ('Boolean to remove (True) or backup (False) the previous build '
'directory with identical name or not.'), FILEMANAGEMENT],
'directory with identical name or not'), FILEMANAGEMENT],
'cleanupoldinstall': [True, ('Boolean to remove (True) or backup (False) the previous install '
'directory with identical name or not.'), FILEMANAGEMENT],
'directory with identical name or not'), FILEMANAGEMENT],
'dontcreateinstalldir': [False, ('Boolean to create (False) or not create (True) the install directory'),
FILEMANAGEMENT],
'keeppreviousinstall': [False, ('Boolean to keep the previous installation with identical '
Expand All@@ -159,7 +167,7 @@
'or if the content of the files pointed to should be copied'),
FILEMANAGEMENT],
'start_dir': [None, ('Path to start the make in. If the path is absolute, use that path. '
'If not, this is added to the guessed path.'), FILEMANAGEMENT],
'If not, this is added to the guessed path'), FILEMANAGEMENT],

# DEPENDENCIES easyconfig parameters
'allow_system_deps': [[], "Allow listed system dependencies (format: (<name>, <version>))", DEPENDENCIES],
Expand Down
136 changes: 68 additions & 68 deletions easybuild/framework/easyconfig/easyconfig.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -103,8 +103,10 @@
try:
import autopep8
HAVE_AUTOPEP8 = True
except ImportError as err:
_log.warning("Failed to import autopep8, dumping easyconfigs with reformatting enabled will not work: %s", err)
except ImportError as import_err:
_log.warning(
"Failed to import autopep8, dumping easyconfigs with reformatting enabled will not work: %s", import_err
)
HAVE_AUTOPEP8 = False


Expand DownExpand Up@@ -1858,83 +1860,81 @@ def det_installversion(version, toolchain_name, toolchain_version, prefix, suffi
_log.nosupport('Use det_full_ec_version from easybuild.tools.module_generator instead of %s' % old_fn, '2.0')


def get_easyblock_class(easyblock, name=None, error_on_failed_import=True, error_on_missing_easyblock=True, **kwargs):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't remove error_on_failed_import, that's API breakage.

At best deprecate it

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, we can actually break the API between major versions 😉 I re-added it with a deprecation warning nonetheless. Done in eacd6e3

def get_easyblock_class(easyblock, name=None, error_on_failed_import=False, error_on_missing_easyblock=True, **kwargs):
"""
Get class for a particular easyblock (or use default)
"""
cls = None
try:
if easyblock:
# something was specified, lets parse it
es = easyblock.split('.')
class_name = es.pop(-1)
# figure out if full path was specified or not
if es:
modulepath = '.'.join(es)
_log.info("Assuming that full easyblock module path was specified (class: %s, modulepath: %s)",
class_name, modulepath)
cls = get_class_for(modulepath, class_name)
else:
modulepath = get_module_path(easyblock)
cls = get_class_for(modulepath, class_name)
_log.info("Derived full easyblock module path for %s: %s" % (class_name, modulepath))
modulepath, class_name = None, None

if error_on_failed_import:
_log.deprecated(
"Use of error_on_failed_import argument in Easyconfig.get_easyblock_class is deprecated, "
"use 'error_on_missing_easyblock' instead",
"6.0"
)

if easyblock:
# something was specified, lets parse it
easyblock_spec = easyblock.split('.')
class_name = easyblock_spec.pop(-1)
# figure out if full path was specified or not
if easyblock_spec:
modulepath = '.'.join(easyblock_spec)
_log.info(
f"Assuming a full easyblock module path specification (class: {class_name}, modulepath: {modulepath})"
)
else:
# if no easyblock specified, try to find if one exists
if name is None:
name = "UNKNOWN"
# The following is a generic way to calculate unique class names for any funny software title
class_name = encode_class_name(name)
# modulepath will be the namespace + encoded modulename (from the classname)
modulepath = get_module_path(class_name, generic=False)
modulepath_imported = False
try:
__import__(modulepath, globals(), locals(), [''])
modulepath_imported = True
except ImportError as err:
_log.debug("Failed to import module '%s': %s" % (modulepath, err))

# check if determining module path based on software name would have resulted in a different module path
if modulepath_imported:
_log.debug("Module path '%s' found" % modulepath)
else:
_log.debug("No module path '%s' found" % modulepath)
modulepath_bis = get_module_path(name, generic=False, decode=False)
_log.debug("Module path determined based on software name: %s" % modulepath_bis)
if modulepath_bis != modulepath:
_log.nosupport("Determining module path based on software name", '2.0')

# try and find easyblock
try:
_log.debug("getting class for %s.%s" % (modulepath, class_name))
cls = get_class_for(modulepath, class_name)
_log.info("Successfully obtained %s class instance from %s" % (class_name, modulepath))
except ImportError as err:
# when an ImportError occurs, make sure that it's caused by not finding the easyblock module,
# and not because of a broken import statement in the easyblock module
modname = modulepath.replace('easybuild.easyblocks.', '')
error_re = re.compile(r"No module named '?.*/?%s'?" % modname)
_log.debug("error regexp for ImportError on '%s' easyblock: %s", modname, error_re.pattern)
if error_re.match(str(err)):
if error_on_missing_easyblock:
raise EasyBuildError("No software-specific easyblock '%s' found for %s", class_name, name)
elif error_on_failed_import:
raise EasyBuildError("Failed to import %s easyblock: %s", class_name, err)
else:
_log.debug("Failed to import easyblock for %s, but ignoring it: %s" % (class_name, err))
modulepath = get_module_path(easyblock)
_log.info(f"Derived full easyblock module path for {class_name}: {modulepath}")
else:
# if no easyblock specified, try to find if one exists
if name is None:
name = "UNKNOWN"
# The following is a generic way to calculate unique class names for any funny software title
class_name = encode_class_name(name)
# modulepath will be the namespace + encoded modulename (from the classname)
modulepath = get_module_path(class_name, generic=False)

if cls is not None:
_log.info("Successfully obtained class '%s' for easyblock '%s' (software name '%s')",
cls.__name__, easyblock, name)
try:
__import__(modulepath, globals(), locals(), [''])
except ImportError as err:
_log.debug(f"Failed to import easyblock module '{modulepath}' (derived from easyconfig name): {err}")
# fallback to generic EasyBlock for easyconfigs
# without any easyblock specification or no specific easyblock
class_name = "EasyBlock"
modulepath = "easybuild.framework.easyblock"
_log.info(f"Using generic EasyBlock module for software '{name}'")
else:
_log.debug("No class found for easyblock '%s' (software name '%s')", easyblock, name)

return cls
_log.debug(f"Module path '{modulepath}' found (derived from easyconfig name)")

try:
_log.debug(f"Importing easyblock class {class_name} from {modulepath}")
cls = get_class_for(modulepath, class_name)
except ImportError as err:
# when an ImportError occurs, make sure that it's caused by not finding the easyblock module,
# and not because of a broken import statement in the easyblock module
modname = modulepath.replace('easybuild.easyblocks.', '')
error_re = re.compile(rf"No module named '?.*/?{modname}'?")
_log.debug(f"Error regexp for ImportError on '{modname}' easyblock: {error_re.pattern}")
if error_re.match(str(err)):
if error_on_missing_easyblock:
raise EasyBuildError(f"Software-specific easyblock '{class_name}' not found for {name}")
elif error_on_failed_import:
raise EasyBuildError(f"Failed to import '{class_name}' easyblock: {err}")
Comment on lines +1922 to +1923
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't belong here, in this case the easyblock doesn't exist?

else:
_log.debug(f"Easyblock for {class_name} not found, but ignoring it: {err}")
else:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

keep the elif error_on_failed_import here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in eacd6e3

raise EasyBuildError(f"Failed to import {class_name} easyblock: {err}")
except EasyBuildError as err:
# simply reraise rather than wrapping it into another error
raise err
except Exception as err:
raise EasyBuildError("Failed to obtain class for %s easyblock (not available?): %s", easyblock, err)
raise EasyBuildError(f"Failed to obtain class for {easyblock} easyblock (not available?): {err}")
else:
_log.info(
f"Successfully obtained class '{cls.__name__}' for easyblock '{easyblock}' (software name '{name}')"
)
return cls


def get_module_path(name, generic=None, decode=True):
Expand Down
Loading
Loading