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 'parse' hook to add support for tweaking 'raw' easyconfig (REVIEW) #2562

Merged
merged 9 commits into from
Sep 5, 2018
11 changes: 6 additions & 5 deletions easybuild/framework/easyblock.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,8 @@
from easybuild.tools.filetools import write_file
from easybuild.tools.hooks import BUILD_STEP, CLEANUP_STEP, CONFIGURE_STEP, EXTENSIONS_STEP, FETCH_STEP, INSTALL_STEP
from easybuild.tools.hooks import MODULE_STEP, PACKAGE_STEP, PATCH_STEP, PERMISSIONS_STEP, POSTPROC_STEP, PREPARE_STEP
from easybuild.tools.hooks import READY_STEP, SANITYCHECK_STEP, SOURCE_STEP, TEST_STEP, TESTCASES_STEP, run_hook
from easybuild.tools.hooks import READY_STEP, SANITYCHECK_STEP, SOURCE_STEP, TEST_STEP, TESTCASES_STEP
from easybuild.tools.hooks import load_hooks, run_hook
from easybuild.tools.run import run_cmd
from easybuild.tools.jenkins import write_to_xml
from easybuild.tools.module_generator import ModuleGeneratorLua, ModuleGeneratorTcl, module_generator, dependencies_for
Expand Down Expand Up @@ -123,7 +124,7 @@ def extra_options(extra=None):
#
# INIT
#
def __init__(self, ec, hooks=None):
def __init__(self, ec):
"""
Initialize the EasyBlock instance.
:param ec: a parsed easyconfig file (EasyConfig instance)
Expand All @@ -133,7 +134,7 @@ def __init__(self, ec, hooks=None):
self.orig_workdir = os.getcwd()

# list of pre- and post-step hooks
self.hooks = hooks or []
self.hooks = load_hooks(build_option('hooks'))

# list of patch/source files, along with checksums
self.patches = []
Expand Down Expand Up @@ -2765,7 +2766,7 @@ def print_dry_run_note(loc, silent=True):
dry_run_msg(msg, silent=silent)


def build_and_install_one(ecdict, init_env, hooks=None):
def build_and_install_one(ecdict, init_env):
"""
Build the software
:param ecdict: dictionary contaning parsed easyconfig + metadata
Expand Down Expand Up @@ -2803,7 +2804,7 @@ def build_and_install_one(ecdict, init_env, hooks=None):
try:
app_class = get_easyblock_class(easyblock, name=name)

app = app_class(ecdict['ec'], hooks=hooks)
app = app_class(ecdict['ec'])
_log.info("Obtained application instance of for %s (easyblock: %s)" % (name, easyblock))
except EasyBuildError, err:
print_error("Failed to get application instance for %s (easyblock: %s): %s" % (name, easyblock, err.msg),
Expand Down
34 changes: 24 additions & 10 deletions easybuild/framework/easyconfig/easyconfig.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
from easybuild.tools.config import build_option, get_module_naming_scheme
from easybuild.tools.filetools import EASYBLOCK_CLASS_PREFIX
from easybuild.tools.filetools import copy_file, decode_class_name, encode_class_name, read_file, write_file
from easybuild.tools.hooks import PARSE, load_hooks, run_hook
from easybuild.tools.module_naming_scheme import DEVEL_MODULE_SUFFIX
from easybuild.tools.module_naming_scheme.utilities import avail_module_naming_schemes, det_full_ec_version
from easybuild.tools.module_naming_scheme.utilities import det_hidden_modname, is_valid_module_name
Expand Down Expand Up @@ -434,22 +435,35 @@ def parse(self):
# we need toolchain to be set when we call _parse_dependency
for key in ['toolchain'] + local_vars.keys():
# validations are skipped, just set in the config
# do not store variables we don't need
if key in self._config.keys():
if key in ['dependencies']:
self[key] = [self._parse_dependency(dep) for dep in local_vars[key]]
elif key in ['builddependencies']:
self[key] = [self._parse_dependency(dep, build_only=True) for dep in local_vars[key]]
elif key in ['hiddendependencies']:
self[key] = [self._parse_dependency(dep, hidden=True) for dep in local_vars[key]]
else:
self[key] = local_vars[key]
self[key] = local_vars[key]
self.log.info("setting config option %s: value %s (type: %s)", key, self[key], type(self[key]))
elif key in REPLACED_PARAMETERS:
_log.nosupport("Easyconfig parameter '%s' is replaced by '%s'" % (key, REPLACED_PARAMETERS[key]), '2.0')

# do not store variables we don't need
else:
self.log.debug("Ignoring unknown config option %s (value: %s)" % (key, local_vars[key]))
self.log.debug("Ignoring unknown easyconfig parameter %s (value: %s)" % (key, local_vars[key]))

# trigger parse hook
# templating is disabled when parse_hook is called to allow for easy updating of mutable easyconfig parameters
# (see also comment in resolve_template)
hooks = load_hooks(build_option('hooks'))
prev_enable_templating = self.enable_templating
self.enable_templating = False

parse_hook_msg = None
if self.path:
parse_hook_msg = "Running %s hook for %s..." % (PARSE, os.path.basename(self.path))

run_hook(PARSE, hooks, args=[self], msg=parse_hook_msg)
self.enable_templating = prev_enable_templating

# parse dependency specifications
self.log.info("Parsing dependency specifications...")
self['builddependencies'] = [self._parse_dependency(dep, build_only=True) for dep in self['builddependencies']]
self['dependencies'] = [self._parse_dependency(dep) for dep in self['dependencies']]
self['hiddendependencies'] = [self._parse_dependency(dep, hidden=True) for dep in self['hiddendependencies']]

# update templating dictionary
self.generate_template_values()
Expand Down
8 changes: 5 additions & 3 deletions easybuild/framework/easyconfig/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,7 @@ def parse_easyconfigs(paths, validate=True):
"""
easyconfigs = []
generated_ecs = False

for (path, generated) in paths:
path = os.path.abspath(path)
# keep track of whether any files were generated
Expand All @@ -378,12 +379,13 @@ def parse_easyconfigs(paths, validate=True):
try:
ec_files = find_easyconfigs(path, ignore_dirs=build_option('ignore_dirs'))
for ec_file in ec_files:
# only pass build specs when not generating easyconfig files
kwargs = {'validate': validate}
# only pass build specs when not generating easyconfig files
if not build_option('try_to_generate'):
kwargs['build_specs'] = build_option('build_specs')
ecs = process_easyconfig(ec_file, **kwargs)
easyconfigs.extend(ecs)

easyconfigs.extend(process_easyconfig(ec_file, **kwargs))

except IOError, err:
raise EasyBuildError("Processing easyconfigs in path %s failed: %s", path, err)

Expand Down
20 changes: 10 additions & 10 deletions easybuild/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,27 +106,24 @@ def find_easyconfigs_by_specs(build_specs, robot_path, try_to_generate, testing=
return [(ec_file, generated)]


def build_and_install_software(ecs, init_session_state, exit_on_failure=True, hooks=None):
def build_and_install_software(ecs, init_session_state, exit_on_failure=True):
"""
Build and install software for all provided parsed easyconfig files.

:param ecs: easyconfig files to install software with
:param init_session_state: initial session state, to use in test reports
:param exit_on_failure: whether or not to exit on installation failure
:param hooks: list of defined pre- and post-step hooks
"""
# obtain a copy of the starting environment so each build can start afresh
# we shouldn't use the environment from init_session_state, since relevant env vars might have been set since
# e.g. via easyconfig.handle_allowed_system_deps
init_env = copy.deepcopy(os.environ)

run_hook(START, hooks)

res = []
for ec in ecs:
ec_res = {}
try:
(ec_res['success'], app_log, err) = build_and_install_one(ec, init_env, hooks=hooks)
(ec_res['success'], app_log, err) = build_and_install_one(ec, init_env)
ec_res['log_file'] = app_log
if not ec_res['success']:
ec_res['err'] = EasyBuildError(err)
Expand Down Expand Up @@ -165,8 +162,6 @@ def build_and_install_software(ecs, init_session_state, exit_on_failure=True, ho

res.append((ec, ec_res))

run_hook(END, hooks)

return res


Expand Down Expand Up @@ -284,6 +279,11 @@ def main(args=None, logfile=None, do_build=None, testing=False, modtool=None):
config.init(options, config_options_dict)
config.init_build_options(build_options=build_options, cmdline_options=options)

# load hook implementations (if any)
hooks = load_hooks(options.hooks)

run_hook(START, hooks)

if modtool is None:
modtool = modules_tool(testing=testing)

Expand Down Expand Up @@ -492,10 +492,8 @@ def main(args=None, logfile=None, do_build=None, testing=False, modtool=None):
# build software, will exit when errors occurs (except when testing)
if not testing or (testing and do_build):
exit_on_failure = not (options.dump_test_report or options.upload_test_report)
hooks = load_hooks(options.hooks)

ecs_with_res = build_and_install_software(ordered_ecs, init_session_state,
exit_on_failure=exit_on_failure, hooks=hooks)
ecs_with_res = build_and_install_software(ordered_ecs, init_session_state, exit_on_failure=exit_on_failure)
else:
ecs_with_res = [(ec, {}) for ec in ordered_ecs]

Expand All @@ -518,6 +516,8 @@ def main(args=None, logfile=None, do_build=None, testing=False, modtool=None):
if 'original_spec' in ec and os.path.isfile(ec['spec']):
os.remove(ec['spec'])

run_hook(END, hooks)

# stop logging and cleanup tmp log file, unless one build failed (individual logs are located in eb_tmpdir)
stop_logging(logfile, logtostdout=options.logtostdout)
if overall_success:
Expand Down
1 change: 1 addition & 0 deletions easybuild/tools/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ def mk_full_default_path(name, prefix=DEFAULT_PREFIX):
'github_user',
'github_org',
'group',
'hooks',
'ignore_dirs',
'job_backend_config',
'job_cores',
Expand Down
75 changes: 46 additions & 29 deletions easybuild/tools/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
TESTCASES_STEP = 'testcases'

START = 'start'
PARSE = 'parse'
END = 'end'

PRE_PREF = 'pre_'
Expand All @@ -67,39 +68,51 @@
INSTALL_STEP, EXTENSIONS_STEP, POSTPROC_STEP, SANITYCHECK_STEP, CLEANUP_STEP, MODULE_STEP,
PERMISSIONS_STEP, PACKAGE_STEP, TESTCASES_STEP]

KNOWN_HOOKS = [h + HOOK_SUFF for h in [START] + [p + s for s in STEP_NAMES for p in [PRE_PREF, POST_PREF]] + [END]]
HOOK_NAMES = [START, PARSE] + [p + s for s in STEP_NAMES for p in [PRE_PREF, POST_PREF]] + [END]
KNOWN_HOOKS = [h + HOOK_SUFF for h in HOOK_NAMES]


# cached version of hooks, to avoid having to load them from file multiple times
_cached_hooks = {}


def load_hooks(hooks_path):
"""Load defined hooks (if any)."""
hooks = {}

if hooks_path:
if not os.path.exists(hooks_path):
raise EasyBuildError("Specified path for hooks implementation does not exist: %s", hooks_path)

(hooks_filename, hooks_file_ext) = os.path.splitext(os.path.split(hooks_path)[1])
if hooks_file_ext == '.py':
_log.info("Importing hooks implementation from %s...", hooks_path)
try:
# import module that defines hooks, and collect all functions of which name ends with '_hook'
imported_hooks = imp.load_source(hooks_filename, hooks_path)
for attr in dir(imported_hooks):
if attr.endswith(HOOK_SUFF):
hook = getattr(imported_hooks, attr)
if callable(hook):
hooks.update({attr: hook})
else:
_log.debug("Skipping non-callable attribute '%s' when loading hooks", attr)
_log.info("Found hooks: %s", sorted(hooks.keys()))
except ImportError as err:
raise EasyBuildError("Failed to import hooks implementation from %s: %s", hooks_path, err)
else:
raise EasyBuildError("Provided path for hooks implementation should be location of a Python file (*.py)")

if hooks_path in _cached_hooks:
hooks = _cached_hooks[hooks_path]

else:
_log.info("No location for hooks implementation provided, no hooks defined")
hooks = {}
if hooks_path:
if not os.path.exists(hooks_path):
raise EasyBuildError("Specified path for hooks implementation does not exist: %s", hooks_path)

(hooks_filename, hooks_file_ext) = os.path.splitext(os.path.split(hooks_path)[1])
if hooks_file_ext == '.py':
_log.info("Importing hooks implementation from %s...", hooks_path)
try:
# import module that defines hooks, and collect all functions of which name ends with '_hook'
imported_hooks = imp.load_source(hooks_filename, hooks_path)
for attr in dir(imported_hooks):
if attr.endswith(HOOK_SUFF):
hook = getattr(imported_hooks, attr)
if callable(hook):
hooks.update({attr: hook})
else:
_log.debug("Skipping non-callable attribute '%s' when loading hooks", attr)
_log.info("Found hooks: %s", sorted(hooks.keys()))
except ImportError as err:
raise EasyBuildError("Failed to import hooks implementation from %s: %s", hooks_path, err)
else:
raise EasyBuildError("Provided path for hooks implementation should be path to a Python file (*.py)")
else:
_log.info("No location for hooks implementation provided, no hooks defined")

verify_hooks(hooks)
verify_hooks(hooks)

# cache loaded hooks, so we don't need to load them from file again
_cached_hooks[hooks_path] = hooks

return hooks

Expand Down Expand Up @@ -157,7 +170,7 @@ def find_hook(label, hooks, pre_step_hook=False, post_step_hook=False):
return res


def run_hook(label, hooks, pre_step_hook=False, post_step_hook=False, args=None):
def run_hook(label, hooks, pre_step_hook=False, post_step_hook=False, args=None, msg=None):
"""
Run hook with specified label.

Expand All @@ -166,6 +179,7 @@ def run_hook(label, hooks, pre_step_hook=False, post_step_hook=False, args=None)
:param pre_step_hook: indicates whether hook to run is a pre-step hook
:param post_step_hook: indicates whether hook to run is a post-step hook
:param args: arguments to pass to hook function
:param msg: custom message that is printed when hook is called
"""
hook = find_hook(label, hooks, pre_step_hook=pre_step_hook, post_step_hook=post_step_hook)
if hook:
Expand All @@ -177,6 +191,9 @@ def run_hook(label, hooks, pre_step_hook=False, post_step_hook=False, args=None)
elif post_step_hook:
label = 'post-' + label

print_msg("Running %s hook..." % label)
if msg is None:
msg = "Running %s hook..." % label
print_msg(msg)

_log.info("Running '%s' hook function (arguments: %s)...", hook.__name__, args)
hook(*args)
32 changes: 29 additions & 3 deletions test/framework/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,9 @@
from test.framework.utilities import EnhancedTestCase, TestLoaderFiltered
from unittest import TextTestRunner

import easybuild.tools.hooks
from easybuild.tools.build_log import EasyBuildError
from easybuild.tools.filetools import write_file
from easybuild.tools.filetools import remove_file, write_file
from easybuild.tools.hooks import find_hook, load_hooks, run_hook, verify_hooks


Expand All @@ -48,6 +49,9 @@ def setUp(self):
'def start_hook():',
' print("this is triggered at the very beginning")',
'',
'def parse_hook(ec):',
' print("Parse hook with argument %s" % ec)',
'',
'def foo():',
' print("running foo helper method")',
'',
Expand All @@ -67,10 +71,29 @@ def test_load_hooks(self):

hooks = load_hooks(self.test_hooks_pymod)

self.assertEqual(len(hooks), 3)
self.assertEqual(sorted(hooks.keys()), ['post_configure_hook', 'pre_install_hook', 'start_hook'])
self.assertEqual(len(hooks), 4)
self.assertEqual(sorted(hooks.keys()), ['parse_hook', 'post_configure_hook', 'pre_install_hook', 'start_hook'])
self.assertTrue(all(callable(h) for h in hooks.values()))

# test caching of hooks
remove_file(self.test_hooks_pymod)
cached_hooks = load_hooks(self.test_hooks_pymod)
self.assertTrue(cached_hooks is hooks)

# hooks file can be empty
empty_hooks_path = os.path.join(self.test_prefix, 'empty_hooks.py')
write_file(empty_hooks_path, '')
empty_hooks = load_hooks(empty_hooks_path)
self.assertEqual(empty_hooks, {})

# loading another hooks file doesn't affect cached hooks
prev_hooks = load_hooks(self.test_hooks_pymod)
self.assertTrue(prev_hooks is hooks)

# clearing cached hooks results in error because hooks file is not found
easybuild.tools.hooks._cached_hooks = {}
self.assertErrorRegex(EasyBuildError, "Specified path .* does not exist.*", load_hooks, self.test_hooks_pymod)

def test_find_hook(self):
"""Test for find_hook function."""

Expand Down Expand Up @@ -104,6 +127,7 @@ def test_run_hook(self):
self.mock_stdout(True)
self.mock_stderr(True)
run_hook('start', hooks)
run_hook('parse', hooks, args=['<EasyConfig instance>'], msg="Running parse hook for example.eb...")
run_hook('configure', hooks, pre_step_hook=True, args=[None])
run_hook('configure', hooks, post_step_hook=True, args=[None])
run_hook('build', hooks, pre_step_hook=True, args=[None])
Expand All @@ -118,6 +142,8 @@ def test_run_hook(self):
expected_stdout = '\n'.join([
"== Running start hook...",
"this is triggered at the very beginning",
"== Running parse hook for example.eb...",
"Parse hook with argument <EasyConfig instance>",
"== Running post-configure hook...",
"this is run after configure step",
"running foo helper method",
Expand Down
Loading