Skip to content

Commit 5d843e1

Browse files
committed
New files + modifications for entrypoint features
1 parent 87b3d9d commit 5d843e1

File tree

7 files changed

+318
-5
lines changed

7 files changed

+318
-5
lines changed

easybuild/framework/easyconfig/easyconfig.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@
6565
from easybuild.framework.easyconfig.templates import ALTERNATIVE_EASYCONFIG_TEMPLATES, DEPRECATED_EASYCONFIG_TEMPLATES
6666
from easybuild.framework.easyconfig.templates import TEMPLATE_CONSTANTS, TEMPLATE_NAMES_DYNAMIC, template_constant_dict
6767
from easybuild.tools import LooseVersion
68+
from easybuild.tools.entrypoints import get_easyblock_entrypoints, validate_easyblock_entrypoints
6869
from easybuild.tools.build_log import EasyBuildError, EasyBuildExit, print_warning, print_msg
6970
from easybuild.tools.config import GENERIC_EASYBLOCK_PKG, LOCAL_VAR_NAMING_CHECK_ERROR, LOCAL_VAR_NAMING_CHECK_LOG
7071
from easybuild.tools.config import LOCAL_VAR_NAMING_CHECK_WARN
@@ -2105,6 +2106,14 @@ def get_module_path(name, generic=None, decode=True):
21052106
if generic is None:
21062107
generic = filetools.is_generic_easyblock(name)
21072108

2109+
invalid_eps = validate_easyblock_entrypoints()
2110+
if invalid_eps:
2111+
_log.error("Invalid easyblock entrypoints found: %s", invalid_eps)
2112+
raise EasyBuildError("Invalid easyblock entrypoints found: %s", invalid_eps)
2113+
eb_from_eps = get_easyblock_entrypoints(name)
2114+
if eb_from_eps:
2115+
return list(eb_from_eps.keys())[0]
2116+
21082117
# example: 'EB_VSC_minus_tools' should result in 'vsc_tools'
21092118
if decode:
21102119
name = decode_class_name(name)

easybuild/framework/easyconfig/tools.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
from easybuild.framework.easyconfig.easyconfig import process_easyconfig
5555
from easybuild.framework.easyconfig.style import cmdline_easyconfigs_style_check
5656
from easybuild.tools import LooseVersion
57+
from easybuild.tools.entrypoints import validate_easyblock_entrypoints, get_easyblock_entrypoints
5758
from easybuild.tools.build_log import EasyBuildError, EasyBuildExit, print_error, print_msg, print_warning
5859
from easybuild.tools.config import build_option
5960
from easybuild.tools.environment import restore_env
@@ -799,6 +800,12 @@ def avail_easyblocks():
799800
else:
800801
raise EasyBuildError("Failed to determine easyblock class name for %s", easyblock_loc)
801802

803+
invalid_eps = validate_easyblock_entrypoints()
804+
if invalid_eps:
805+
_log.error("Found invalid easyblock entry points: %s", invalid_eps)
806+
raise EasyBuildError("Found invalid easyblock entry points: %s", invalid_eps)
807+
easyblocks.update(get_easyblock_entrypoints())
808+
802809
return easyblocks
803810

804811

easybuild/tools/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,7 @@ def mk_full_default_path(name, prefix=DEFAULT_PREFIX):
351351
'upload_test_report',
352352
'update_modules_tool_cache',
353353
'use_ccache',
354+
'use_entrypoints',
354355
'use_existing_modules',
355356
'use_f90cache',
356357
'wait_on_lock_limit',

easybuild/tools/entrypoints.py

Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
"""Python module to manage entry points for EasyBuild.
2+
3+
Authors:
4+
5+
* Davide Grassano (CECAM)
6+
"""
7+
8+
import importlib
9+
from importlib.metadata import EntryPoint, entry_points
10+
from easybuild.tools.config import build_option
11+
from typing import Callable
12+
13+
from easybuild.base import fancylogger
14+
from easybuild.tools.build_log import EasyBuildError
15+
16+
_log = fancylogger.getLogger('entrypoints', fname=False)
17+
18+
def get_group_entrypoints(group: str) -> set[EntryPoint]:
19+
"""Get all entrypoints for a group"""
20+
# print(f"--- Getting entry points for group: {group}")
21+
# Default True needed to work with commands like --list-toolchains that do not initialize the BuildOptions
22+
if not build_option('use_entrypoints', default=True):
23+
return set()
24+
return set(ep for ep in entry_points(group=group))
25+
26+
# EASYCONFIG_ENTRYPOINT = "easybuild.easyconfig"
27+
28+
EASYBLOCK_ENTRYPOINT = "easybuild.easyblock"
29+
EASYBLOCK_ENTRYPOINT_MARK = "_is_easybuild_easyblock"
30+
31+
TOOLCHAIN_ENTRYPOINT = "easybuild.toolchain"
32+
TOOLCHAIN_ENTRYPOINT_MARK = "_is_easybuild_toolchain"
33+
TOOLCHAIN_ENTRYPOINT_PREPEND = "_prepend"
34+
35+
HOOKS_ENTRYPOINT = "easybuild.hooks"
36+
HOOKS_ENTRYPOINT_STEP = "_step"
37+
HOOKS_ENTRYPOINT_PRE_STEP = "_pre_step"
38+
HOOKS_ENTRYPOINT_POST_STEP = "_post_step"
39+
HOOKS_ENTRYPOINT_MARK = "_is_easybuild_hook"
40+
HOOKS_ENTRYPOINT_PRIORITY = "_priority"
41+
42+
#########################################################################################3
43+
# Easyblock entrypoints
44+
def register_easyblock_entrypoint():
45+
"""Decorator to register an easyblock entrypoint."""
46+
def decorator(cls: type) -> type:
47+
if not isinstance(cls, type):
48+
raise EasyBuildError("Easyblock entrypoint `%s` is not a class", cls.__name__)
49+
setattr(cls, EASYBLOCK_ENTRYPOINT_MARK, True)
50+
_log.debug("Registering easyblock entrypoint: %s", cls.__name__)
51+
return cls
52+
53+
return decorator
54+
55+
56+
def validate_easyblock_entrypoints() -> list[str]:
57+
"""Validate all easyblock entrypoints.
58+
59+
Returns:
60+
List of invalid easyblocks.
61+
"""
62+
invalid_easyblocks = []
63+
for ep in get_group_entrypoints(EASYBLOCK_ENTRYPOINT):
64+
full_name = f'{ep.name} <{ep.value}>'
65+
66+
eb = ep.load()
67+
if not hasattr(eb, EASYBLOCK_ENTRYPOINT_MARK):
68+
invalid_easyblocks.append(full_name)
69+
_log.warning(f"Easyblock {ep.name} <{ep.value}> is not a valid EasyBuild easyblock")
70+
continue
71+
72+
if not isinstance(eb, type):
73+
_log.warning(f"Easyblock {ep.name} <{ep.value}> is not a class")
74+
invalid_easyblocks.append(full_name)
75+
continue
76+
77+
return invalid_easyblocks
78+
79+
80+
def get_easyblock_entrypoints(name = None) -> dict:
81+
"""Get all easyblock entrypoints.
82+
83+
Returns:
84+
List of easyblocks.
85+
"""
86+
easyblocks = {}
87+
for ep in get_group_entrypoints(EASYBLOCK_ENTRYPOINT):
88+
try:
89+
eb = ep.load()
90+
except Exception as e:
91+
_log.error(f"Error loading easyblock entry point {ep.name}: {e}")
92+
raise EasyBuildError(f"Error loading easyblock entry point {ep.name}: {e}")
93+
mod = importlib.import_module(eb.__module__)
94+
95+
ptr = {
96+
'class': eb.__name__,
97+
'loc': mod.__file__,
98+
}
99+
easyblocks[f'{ep.module}'] = ptr
100+
# print('--' * 80)
101+
# print(easyblocks)
102+
# print('--' * 80)
103+
if name is not None:
104+
for key, value in easyblocks.items():
105+
if value['class'] == name:
106+
return {key: value}
107+
if key == name:
108+
return {key: value}
109+
return {}
110+
111+
return easyblocks
112+
113+
#########################################################################################
114+
# Hooks entrypoints
115+
def register_entrypoint_hooks(step, pre_step=False, post_step=False, priority=0):
116+
"""Decorator to add metadata on functions to be used as hooks.
117+
118+
priority: integer, the priority of the hook, higher value means higher priority
119+
"""
120+
def decorator(func):
121+
setattr(func, HOOKS_ENTRYPOINT_MARK, True)
122+
setattr(func, HOOKS_ENTRYPOINT_STEP, step)
123+
setattr(func, HOOKS_ENTRYPOINT_PRE_STEP, pre_step)
124+
setattr(func, HOOKS_ENTRYPOINT_POST_STEP, post_step)
125+
setattr(func, HOOKS_ENTRYPOINT_PRIORITY, priority)
126+
127+
# Register the function as an entry point
128+
_log.info(
129+
"Registering entry point hook '%s' 'pre=%s' 'post=%s' with priority %d",
130+
func.__name__, pre_step, post_step, priority
131+
)
132+
return func
133+
return decorator
134+
135+
136+
def validate_entrypoint_hooks(known_hooks: list[str], pre_prefix: str, post_prefix: str, suffix: str) -> list[str]:
137+
"""Validate all entrypoints hooks.
138+
139+
Args:
140+
known_hooks: List of known hooks.
141+
pre_prefix: Prefix for pre hooks.
142+
post_prefix: Prefix for post hooks.
143+
suffix: Suffix for hooks.
144+
145+
Returns:
146+
List of invalid hooks.
147+
"""
148+
invalid_hooks = []
149+
for ep in get_group_entrypoints(HOOKS_ENTRYPOINT):
150+
full_name = f'{ep.name} <{ep.value}>'
151+
152+
hook = ep.load()
153+
if not hasattr(hook, HOOKS_ENTRYPOINT_MARK):
154+
invalid_hooks.append(f"{ep.name} <{ep.value}>")
155+
_log.warning(f"Hook {ep.name} <{ep.value}> is not a valid EasyBuild hook")
156+
continue
157+
158+
if not callable(hook):
159+
_log.warning(f"Hook {ep.name} <{ep.value}> is not callable")
160+
invalid_hooks.append(full_name)
161+
continue
162+
163+
label = getattr(hook, HOOKS_ENTRYPOINT_STEP)
164+
pre_cond = getattr(hook, HOOKS_ENTRYPOINT_PRE_STEP)
165+
post_cond = getattr(hook, HOOKS_ENTRYPOINT_POST_STEP)
166+
167+
prefix = ''
168+
if pre_cond:
169+
prefix = pre_prefix
170+
elif post_cond:
171+
prefix = post_prefix
172+
173+
hook_name = prefix + label + suffix
174+
175+
if hook_name not in known_hooks:
176+
_log.warning(f"Hook {full_name} does not match known hooks patterns")
177+
invalid_hooks.append(full_name)
178+
continue
179+
180+
return invalid_hooks
181+
182+
183+
def find_entrypoint_hooks(label, pre_step_hook=False, post_step_hook=False) -> list[Callable]:
184+
"""Get all hooks defined in entry points."""
185+
hooks = []
186+
# print(f"--- Searching for entry point hooks with label: {label}, pre_step_hook: {pre_step_hook}, post_step_hook: {post_step_hook}")
187+
for ep in get_group_entrypoints(HOOKS_ENTRYPOINT):
188+
# print(f"--- Processing entry point: {ep.name}")
189+
try:
190+
hook = ep.load()
191+
except Exception as e:
192+
_log.error(f"Error loading entry point {ep.name}: {e}")
193+
raise EasyBuildError(f"Error loading entry point {ep.name}: {e}")
194+
195+
cond = all([
196+
getattr(hook, HOOKS_ENTRYPOINT_STEP) == label,
197+
getattr(hook, HOOKS_ENTRYPOINT_PRE_STEP) == pre_step_hook,
198+
getattr(hook, HOOKS_ENTRYPOINT_POST_STEP) == post_step_hook,
199+
])
200+
if cond:
201+
hooks.append(hook)
202+
203+
return hooks
204+
205+
#########################################################################################
206+
# Toolchain entrypoints
207+
def register_toolchain_entrypoint(prepend=False):
208+
def decorator(cls):
209+
from easybuild.tools.toolchain.toolchain import Toolchain
210+
if not isinstance(cls, type) or not issubclass(cls, Toolchain):
211+
raise EasyBuildError("Toolchain entrypoint `%s` is not a subclass of `Toolchain`", cls.__name__)
212+
setattr(cls, TOOLCHAIN_ENTRYPOINT_MARK, True)
213+
setattr(cls, TOOLCHAIN_ENTRYPOINT_PREPEND, prepend)
214+
215+
_log.debug("Registering toolchain entrypoint: %s", cls.__name__)
216+
return cls
217+
218+
return decorator
219+
220+
221+
def get_toolchain_entrypoints() -> set[EntryPoint]:
222+
"""Get all toolchain entrypoints."""
223+
toolchains = []
224+
for ep in get_group_entrypoints(TOOLCHAIN_ENTRYPOINT):
225+
try:
226+
tc = ep.load()
227+
except Exception as e:
228+
_log.error(f"Error loading toolchain entry point {ep.name}: {e}")
229+
raise EasyBuildError(f"Error loading toolchain entry point {ep.name}: {e}")
230+
toolchains.append(tc)
231+
# print(f"Found {len(toolchains)} toolchain entry points")
232+
# print(f"Toolchain entry points: {toolchains}")
233+
return toolchains
234+
235+
236+
def validate_toolchain_entrypoints() -> list[str]:
237+
"""Validate all toolchain entrypoints."""
238+
invalid_toolchains = []
239+
for ep in get_group_entrypoints(TOOLCHAIN_ENTRYPOINT):
240+
full_name = f'{ep.name} <{ep.value}>'
241+
242+
tc = ep.load()
243+
if not hasattr(tc, TOOLCHAIN_ENTRYPOINT_MARK):
244+
invalid_toolchains.append(full_name)
245+
_log.warning(f"Toolchain {ep.name} <{ep.value}> is not a valid EasyBuild toolchain")
246+
continue
247+
248+
return invalid_toolchains

easybuild/tools/hooks.py

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,13 @@
3131
"""
3232
import difflib
3333
import os
34+
from functools import wraps
35+
36+
from easybuild.tools.entrypoints import (
37+
find_entrypoint_hooks, validate_entrypoint_hooks,
38+
HOOKS_ENTRYPOINT_MARK, HOOKS_ENTRYPOINT_STEP, HOOKS_ENTRYPOINT_PRE_STEP, HOOKS_ENTRYPOINT_POST_STEP,
39+
HOOKS_ENTRYPOINT_PRIORITY
40+
)
3441

3542
from easybuild.base import fancylogger
3643
from easybuild.tools.build_log import EasyBuildError, print_msg
@@ -75,6 +82,7 @@
7582
PRE_PREF = 'pre_'
7683
POST_PREF = 'post_'
7784
HOOK_SUFF = '_hook'
85+
ENTRYPOINT_PRE = 'entrypoint_'
7886

7987
# list of names for steps in installation procedure (in order of execution)
8088
STEP_NAMES = [FETCH_STEP, READY_STEP, EXTRACT_STEP, PATCH_STEP, PREPARE_STEP, CONFIGURE_STEP, BUILD_STEP, TEST_STEP,
@@ -173,6 +181,8 @@ def verify_hooks(hooks):
173181
"""Check whether obtained hooks only includes known hooks."""
174182
unknown_hooks = [key for key in sorted(hooks) if key not in KNOWN_HOOKS]
175183

184+
unknown_hooks.extend(validate_entrypoint_hooks(KNOWN_HOOKS, PRE_PREF, POST_PREF, HOOK_SUFF))
185+
176186
if unknown_hooks:
177187
error_lines = ["Found one or more unknown hooks:"]
178188

@@ -228,14 +238,12 @@ def run_hook(label, hooks, pre_step_hook=False, post_step_hook=False, args=None,
228238
:param args: arguments to pass to hook function
229239
:param msg: custom message that is printed when hook is called
230240
"""
241+
# print(f"Running hook '{label}' {pre_step_hook=} {post_step_hook=}")
231242
hook = find_hook(label, hooks, pre_step_hook=pre_step_hook, post_step_hook=post_step_hook)
232243
res = None
244+
args = args or []
245+
kwargs = kwargs or {}
233246
if hook:
234-
if args is None:
235-
args = []
236-
if kwargs is None:
237-
kwargs = {}
238-
239247
if pre_step_hook:
240248
label = 'pre-' + label
241249
elif post_step_hook:
@@ -248,4 +256,21 @@ def run_hook(label, hooks, pre_step_hook=False, post_step_hook=False, args=None,
248256

249257
_log.info("Running '%s' hook function (args: %s, keyword args: %s)...", hook.__name__, args, kwargs)
250258
res = hook(*args, **kwargs)
259+
260+
entrypoint_hooks = find_entrypoint_hooks(label=label, pre_step_hook=pre_step_hook, post_step_hook=post_step_hook)
261+
if entrypoint_hooks:
262+
msg = "Running entry point %s hook..." % label
263+
if build_option('debug') and not build_option('silence_hook_trigger'):
264+
print_msg(msg)
265+
entrypoint_hooks.sort(
266+
key=lambda x: (-getattr(x, HOOKS_ENTRYPOINT_PRIORITY, 0), x.__name__),
267+
)
268+
for hook in entrypoint_hooks:
269+
_log.info("Running entry point '%s' hook function (args: %s, keyword args: %s)...", hook.__name__, args, kwargs)
270+
try:
271+
res = hook(*args, **kwargs)
272+
except Exception as e:
273+
_log.error("Error running entry point '%s' hook: %s", hook.__name__, e)
274+
raise EasyBuildError("Error running entry point '%s' hook: %s", hook.__name__, e) from e
275+
251276
return res

easybuild/tools/options.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,9 @@ def basic_options(self):
303303
'stop': ("Stop the installation after certain step",
304304
'choice', 'store_or_None', EXTRACT_STEP, 's', all_stops),
305305
'strict': ("Set strictness level", 'choice', 'store', WARN, strictness_options),
306+
'use-entrypoints': (
307+
"Use entry points for easyblocks, toolchains, and hooks", None, 'store_true', False,
308+
),
306309
})
307310

308311
self.log.debug("basic_options: descr %s opts %s" % (descr, opts))

0 commit comments

Comments
 (0)