-
Notifications
You must be signed in to change notification settings - Fork 203
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 support for implementing pre- and post-step hooks #2343
Changes from 9 commits
a389a3b
5523e85
1bc7dce
e5477ac
7536b40
ff9bd88
6a7dc72
e4496e3
88a50e2
779ebfe
a9f55d4
3e4b326
fb82002
43dfdcf
7f336e1
c5d093e
3194f4f
ae50e00
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -71,8 +71,7 @@ | |
from easybuild.tools.filetools import adjust_permissions, apply_patch, back_up_file, change_dir, convert_name | ||
from easybuild.tools.filetools import compute_checksum, copy_file, derive_alt_pypi_url, diff_files, download_file | ||
from easybuild.tools.filetools import encode_class_name, extract_file, is_alt_pypi_url, mkdir, move_logs, read_file | ||
from easybuild.tools.filetools import remove_file, rmtree2, write_file | ||
from easybuild.tools.filetools import verify_checksum, weld_paths | ||
from easybuild.tools.filetools import remove_file, rmtree2, run_hook, write_file, verify_checksum, weld_paths | ||
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 | ||
|
@@ -136,7 +135,7 @@ def extra_options(extra=None): | |
# | ||
# INIT | ||
# | ||
def __init__(self, ec): | ||
def __init__(self, ec, hooks=None): | ||
""" | ||
Initialize the EasyBlock instance. | ||
:param ec: a parsed easyconfig file (EasyConfig instance) | ||
|
@@ -145,6 +144,9 @@ def __init__(self, ec): | |
# keep track of original working directory, so we can go back there | ||
self.orig_workdir = os.getcwd() | ||
|
||
# list of pre- and post-step hooks | ||
self.hooks = hooks or [] | ||
|
||
# list of patch/source files, along with checksums | ||
self.patches = [] | ||
self.src = [] | ||
|
@@ -2434,6 +2436,9 @@ def run_step(self, step, step_methods): | |
""" | ||
self.log.info("Starting %s step", step) | ||
self.update_config_template_run_step() | ||
|
||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Calling There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'd rather have the logic for dealing with not having any hooks defined in There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Seems reasonable. |
||
run_hook(step, self.hooks, pre_step_hook=True, args=[self]) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I now understand why There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You mean, test that That's already done in , see There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I mean that There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For now, only the step hooks get passed an argument (i.e. This may change in the future though, if a reason pops up to pass additional arguments.
The idea is that the Hooks should be implemented like this, to be future-proof (i.e. keep working when we may pass down additional (even named) arguments):
I'll clarify this in the yet-to-be-written documentation. |
||
|
||
for step_method in step_methods: | ||
self.log.info("Running method %s part of step %s" % ('_'.join(step_method.func_code.co_names), step)) | ||
|
||
|
@@ -2457,6 +2462,8 @@ def run_step(self, step, step_methods): | |
# and returns the actual method, so use () to execute it | ||
step_method(self)() | ||
|
||
run_hook(step, self.hooks, post_step_hook=True, args=[self]) | ||
|
||
if self.cfg['stop'] == step: | ||
self.log.info("Stopping after %s step.", step) | ||
raise StopException(step) | ||
|
@@ -2601,7 +2608,7 @@ def print_dry_run_note(loc, silent=True): | |
dry_run_msg(msg, silent=silent) | ||
|
||
|
||
def build_and_install_one(ecdict, init_env): | ||
def build_and_install_one(ecdict, init_env, hooks=None): | ||
""" | ||
Build the software | ||
:param ecdict: dictionary contaning parsed easyconfig + metadata | ||
|
@@ -2639,7 +2646,7 @@ def build_and_install_one(ecdict, init_env): | |
try: | ||
app_class = get_easyblock_class(easyblock, name=name) | ||
|
||
app = app_class(ecdict['ec']) | ||
app = app_class(ecdict['ec'], hooks=hooks) | ||
_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), | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -58,7 +58,7 @@ | |
from easybuild.framework.easyconfig.tweak import obtain_ec_for, tweak | ||
from easybuild.tools.config import find_last_log, get_repository, get_repositorypath, build_option | ||
from easybuild.tools.docs import list_software | ||
from easybuild.tools.filetools import adjust_permissions, cleanup, write_file | ||
from easybuild.tools.filetools import adjust_permissions, cleanup, load_hooks, run_hook, write_file | ||
from easybuild.tools.github import check_github, find_easybuild_easyconfig, install_github_token | ||
from easybuild.tools.github import new_pr, merge_pr, update_pr | ||
from easybuild.tools.modules import modules_tool | ||
|
@@ -104,18 +104,27 @@ 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): | ||
"""Build and install software for all provided parsed easyconfig files.""" | ||
def build_and_install_software(ecs, init_session_state, hooks=None, 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 hooks: list of defined pre- and post-step hooks | ||
:param exit_on_failure: whether or not to exit on installation failure | ||
""" | ||
# 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) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The labelling is at the moment "freeform". Nothing wrong with it, but I wonder if it makes sense to restrict it and create a list of valid labels? That way one can also inform the user in case of typos in the hooks list. Think of "why is my There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hmm, good idea. So, if someone includes There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yep |
||
|
||
res = [] | ||
for ec in ecs: | ||
ec_res = {} | ||
try: | ||
(ec_res['success'], app_log, err) = build_and_install_one(ec, init_env) | ||
(ec_res['success'], app_log, err) = build_and_install_one(ec, init_env, hooks=hooks) | ||
ec_res['log_file'] = app_log | ||
if not ec_res['success']: | ||
ec_res['err'] = EasyBuildError(err) | ||
|
@@ -154,6 +163,8 @@ def build_and_install_software(ecs, init_session_state, exit_on_failure=True): | |
|
||
res.append((ec, ec_res)) | ||
|
||
run_hook('end', hooks) | ||
|
||
return res | ||
|
||
|
||
|
@@ -453,9 +464,12 @@ def main(args=None, logfile=None, do_build=None, testing=False, modtool=None): | |
sys.exit(0) | ||
|
||
# build software, will exit when errors occurs (except when testing) | ||
exit_on_failure = not options.dump_test_report and not options.upload_test_report | ||
if not testing or (testing and do_build): | ||
ecs_with_res = build_and_install_software(ordered_ecs, init_session_state, exit_on_failure=exit_on_failure) | ||
exit_on_failure = not options.dump_test_report and not options.upload_test_report | ||
hooks = load_hooks(build_option('hooks')) | ||
|
||
ecs_with_res = build_and_install_software(ordered_ecs, init_session_state, hooks=hooks, | ||
exit_on_failure=exit_on_failure) | ||
else: | ||
ecs_with_res = [(ec, {}) for ec in ordered_ecs] | ||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -42,6 +42,7 @@ | |
import fileinput | ||
import glob | ||
import hashlib | ||
import imp | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No longer needed |
||
import os | ||
import re | ||
import shutil | ||
|
@@ -1651,3 +1652,81 @@ def diff_files(path1, path2): | |
file1_lines = ['%s\n' % l for l in read_file(path1).split('\n')] | ||
file2_lines = ['%s\n' % l for l in read_file(path2).split('\n')] | ||
return ''.join(difflib.unified_diff(file1_lines, file2_lines, fromfile=path1, tofile=path2)) | ||
|
||
|
||
def load_hooks(hooks_path): | ||
"""Load defined hooks (if any).""" | ||
hooks = [] | ||
if hooks_path: | ||
(hooks_dir, hooks_filename) = os.path.split(hooks_path) | ||
(hooks_mod_name, hooks_file_ext) = os.path.splitext(hooks_filename) | ||
if hooks_file_ext == '.py': | ||
_log.info("Importing hooks implementation from %s...", hooks_path) | ||
(fh, pathname, descr) = imp.find_module(hooks_mod_name, [hooks_dir]) | ||
try: | ||
# import module that defines hooks, and collect all functions of which name ends with '_hook' | ||
imported_hooks = imp.load_module(hooks_mod_name, fh, pathname, descr) | ||
for attr in dir(imported_hooks): | ||
if attr.endswith('_hook'): | ||
hook = getattr(imported_hooks, attr) | ||
if callable(hook): | ||
hooks.append(hook) | ||
else: | ||
_log.debug("Skipping non-callable attribute '%s' when loading hooks", attr) | ||
_log.debug("Found hooks: %s", hooks) | ||
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)") | ||
else: | ||
_log.info("No location for hooks implementation provided, no hooks defined") | ||
|
||
return hooks | ||
|
||
|
||
def find_hook(label, known_hooks, pre_step_hook=False, post_step_hook=False): | ||
""" | ||
Find hook with specified label. | ||
|
||
:param label: name of hook | ||
:param known_hooks: list of known hooks | ||
: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 | ||
""" | ||
res = None | ||
|
||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The if below means that all hooks, except There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I feel With your suggestion above in mind, you'd get an error like this when you define
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think that's a reasonable approach. |
||
if pre_step_hook: | ||
hook_prefix = 'pre_' | ||
elif post_step_hook: | ||
hook_prefix = 'post_' | ||
else: | ||
hook_prefix = '' | ||
|
||
hook_name = hook_prefix + label + '_hook' | ||
|
||
for hook in known_hooks: | ||
if hook.__name__ == hook_name: | ||
_log.info("Found %s hook", hook_name) | ||
res = hook | ||
break | ||
|
||
return res | ||
|
||
|
||
def run_hook(label, known_hooks, pre_step_hook=False, post_step_hook=False, args=None): | ||
""" | ||
Run hook with specified label. | ||
|
||
:param label: name of hook | ||
:param known_hooks: list of known hooks | ||
: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 | ||
""" | ||
hook = find_hook(label, known_hooks, pre_step_hook=pre_step_hook, post_step_hook=post_step_hook) | ||
if hook: | ||
if args is None: | ||
args = [] | ||
|
||
_log.info("Running %s hook (arguments: %s)...", hook.__name__, args) | ||
hook(*args) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
write_file
at the end to preserve alphabetical order?