Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
96e6fe9
New files + modifications for entrypoint features
Crivella Jun 10, 2025
ba12494
Lint and cleanup
Crivella Jun 10, 2025
da942c8
Replace `_log.error` wiht `warning`
Crivella Jun 11, 2025
7738738
Olden typehints to make CI happy and add guard against missing `impor…
Crivella Jun 12, 2025
7836732
Fix for Python<3.10
Crivella Jun 12, 2025
cd5851c
Removed EntryPoint typehints
Crivella Jun 12, 2025
56e544a
More fixes for python versions
Crivella Jun 12, 2025
1c53458
Improved version check logic
Crivella Jun 17, 2025
b9c576c
WIP - adding tests
Crivella Jun 17, 2025
d3efa80
lint
Crivella Jun 18, 2025
71d237d
Fixes undefined `Distribution`
Crivella Jun 18, 2025
86f67e3
Better tearDown
Crivella Jun 18, 2025
ee1f394
- Reworked the way entrypoints are registered and recalled
Crivella Jun 19, 2025
c353fee
lint
Crivella Jun 19, 2025
3021039
Lint and fixes
Crivella Jun 19, 2025
764d0a7
Cleanup and enhanced test-cases to run correct hooks in correct order
Crivella Jun 19, 2025
524e075
lint
Crivella Jun 19, 2025
472b628
- Readded typehints
Crivella Jun 20, 2025
28313bb
Lint and improved validation
Crivella Jun 20, 2025
28aafd3
Comply with new test-suite standards
Crivella Feb 9, 2026
7848daa
Merge branch 'develop' into feature-entrypoints
Crivella Feb 19, 2026
4cdfe85
Should not run the super setup/teardown if the test is being skipped
Crivella Feb 19, 2026
d8f5517
add license header to easybuild/tools/entrypoints.py
boegel Apr 8, 2026
dc43946
remove stray debug print statement
boegel Apr 8, 2026
5c84666
fix year in copyright line in test/framework/entrypoints.py
boegel Apr 8, 2026
f101c14
Merge branch 'develop' into feature-entrypoints
boegel Apr 8, 2026
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
13 changes: 10 additions & 3 deletions easybuild/framework/easyconfig/easyconfig.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
from easybuild.framework.easyconfig.templates import ALTERNATIVE_EASYCONFIG_TEMPLATES, DEPRECATED_EASYCONFIG_TEMPLATES
from easybuild.framework.easyconfig.templates import TEMPLATE_CONSTANTS, TEMPLATE_NAMES_DYNAMIC, template_constant_dict
from easybuild.tools import LooseVersion
from easybuild.tools.entrypoints import EntrypointEasyblock
from easybuild.tools.build_log import EasyBuildError, EasyBuildExit, print_warning, print_msg
from easybuild.tools.config import GENERIC_EASYBLOCK_PKG, LOCAL_VAR_NAMING_CHECK_ERROR, LOCAL_VAR_NAMING_CHECK_LOG
from easybuild.tools.config import LOCAL_VAR_NAMING_CHECK_WARN
Expand Down Expand Up @@ -2053,9 +2054,15 @@ def get_easyblock_class(easyblock, name=None, error_on_failed_import=True, error
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))
eb_from_eps = EntrypointEasyblock.get_loaded_entrypoints(name=easyblock)
if eb_from_eps:
ep = eb_from_eps[0]
cls = ep.wrapped
_log.info("Obtained easyblock class '%s' from entrypoint '%s'", easyblock, str(ep))
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))
else:
# if no easyblock specified, try to find if one exists
if name is None:
Expand Down
9 changes: 9 additions & 0 deletions easybuild/framework/easyconfig/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
from easybuild.framework.easyconfig.easyconfig import process_easyconfig
from easybuild.framework.easyconfig.style import cmdline_easyconfigs_style_check
from easybuild.tools import LooseVersion
from easybuild.tools.entrypoints import EntrypointEasyblock
from easybuild.tools.build_log import EasyBuildError, EasyBuildExit, print_error_and_exit, print_msg, print_warning
from easybuild.tools.config import build_option
from easybuild.tools.environment import restore_env
Expand Down Expand Up @@ -761,6 +762,14 @@ def avail_easyblocks():
else:
raise EasyBuildError("Failed to determine easyblock class name for %s", easyblock_loc)

ept_eb_lst = EntrypointEasyblock.get_loaded_entrypoints()

for ept_eb in ept_eb_lst:
easyblocks[ept_eb.module] = {
'class': ept_eb.name,
'loc': ept_eb.file,
}

return easyblocks


Expand Down
1 change: 1 addition & 0 deletions easybuild/tools/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,7 @@ def mk_full_default_path(name, prefix=DEFAULT_PREFIX):
'upload_test_report',
'update_modules_tool_cache',
'use_ccache',
'use_entrypoints',
'use_existing_modules',
'use_f90cache',
'wait_on_lock_limit',
Expand Down
234 changes: 234 additions & 0 deletions easybuild/tools/entrypoints.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
# #
# Copyright 2009-2026 Ghent University
#
# This file is part of EasyBuild,
# originally created by the HPC team of Ghent University (http://ugent.be/hpc/en),
# with support of Ghent University (http://ugent.be/hpc),
# the Flemish Supercomputer Centre (VSC) (https://www.vscentrum.be),
# Flemish Research Foundation (FWO) (http://www.fwo.be/en)
# and the Department of Economy, Science and Innovation (EWI) (http://www.ewi-vlaanderen.be/en).
#
# https://github.com/easybuilders/easybuild
#
# EasyBuild is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation v2.
#
# EasyBuild is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with EasyBuild. If not, see <http://www.gnu.org/licenses/>.
# #
"""Python module to manage entry points for EasyBuild.

Authors:

* Davide Grassano (CECAM)
"""
import sys
import importlib
from easybuild.tools.config import build_option

from easybuild.base import fancylogger
from easybuild.tools.build_log import EasyBuildError
from typing import TypeVar, List, Set, Any

_T = TypeVar('_T')


HAVE_ENTRY_POINTS = False
HAVE_ENTRY_POINTS_CLS = False
if sys.version_info >= (3, 8):
HAVE_ENTRY_POINTS = True
from importlib.metadata import entry_points, EntryPoint
else:
EntryPoint = Any

if sys.version_info >= (3, 10):
# Python >= 3.10 uses importlib.metadata.EntryPoints as a type for entry_points()
HAVE_ENTRY_POINTS_CLS = True


_log = fancylogger.getLogger('entrypoints', fname=False)


class EasybuildEntrypoint:
group = None
expected_type = None
registered = {}

def __init__(self):
if self.group is None:
raise EasyBuildError(
"Cannot use <EasybuildEntrypoint> drirectly. Please use a subclass that defines `group`",
)

self.wrapped = None
self.module = None
self.name = None
self.file = None

def __repr__(self):
return f"{self.__class__.__name__} <{self.module}:{self.name}>"

def __call__(self, wrap: _T) -> _T:
"""Use an instance of this class as a decorator to register an entrypoint."""
if self.expected_type is not None:
check = False
try:
check = isinstance(wrap, self.expected_type) or issubclass(wrap, self.expected_type)
except Exception:
pass
if not check:
raise EasyBuildError(
"Entrypoint '%s' expected type '%s', got '%s'",
self.name, self.expected_type, type(wrap)
)
self.wrapped = wrap
self.module = getattr(wrap, '__module__', None)
self.name = getattr(wrap, '__name__', None)
if self.module:
mod = importlib.import_module(self.module)
self.file = getattr(mod, '__file__', None)

grp = self.registered.setdefault(self.group, set())

for ep in grp:
if ep.name == self.name and ep.module != self.module:
raise ValueError(
"Entrypoint '%s' already registered in group '%s' by module '%s' vs '%s'",
self.name, self.group, ep.module, self.module
)
grp.add(self)

self.validate()

_log.debug("Registered entrypoint: %s", self)

return wrap

@classmethod
def retrieve_entrypoints(cls) -> Set[EntryPoint]:
""""Get all entrypoints in this group."""
strict_python = True
use_eps = build_option('use_entrypoints', default=None)
if use_eps is None:
# Default True needed to work with commands like --list-toolchains that do not initialize the BuildOptions
use_eps = True
# Needed to work with older Python versions: do not raise errors when entry points are default enabled
strict_python = False
res = set()
if use_eps:
if not HAVE_ENTRY_POINTS:
if strict_python:
msg = "`--use-entrypoints` requires importlib.metadata (Python >= 3.8)"
_log.warning(msg)
raise EasyBuildError(msg)
else:
_log.debug("`get_group_entrypoints` called before BuildOptions initialized, with python < 3.8")
else:
if HAVE_ENTRY_POINTS_CLS:
res = set(entry_points(group=cls.group))
else:
res = set(entry_points().get(cls.group, []))

return res

@classmethod
def load_entrypoints(cls):
"""Load all the entrypoints in this group. This is needed for the modules contining the entrypoints to be
actually imported in order to process the function decorators that will register them in the
`registered` dict."""
for ep in cls.retrieve_entrypoints():
try:
ep.load()
except Exception as e:
msg = f"Error loading entrypoint {ep}: {e}"
_log.warning(msg)
raise EasyBuildError(msg) from e

@classmethod
def get_loaded_entrypoints(cls: _T, name: str = None, **filter_params) -> List[_T]:
"""Get all entrypoints in this group."""
cls.load_entrypoints()

entrypoints = []
for ep in cls.registered.get(cls.group, []):
cond = name is None or ep.name == name
for key, value in filter_params.items():
cond = cond and getattr(ep, key, None) == value
if cond:
entrypoints.append(ep)

return entrypoints

@staticmethod
def clear():
"""Clear the registered entrypoints. Used for testing when the same entrypoint is loaded multiple times
from different temporary directories."""
EasybuildEntrypoint.registered.clear()

def validate(self):
"""Validate the entrypoint."""
if self.module is None or self.name is None:
raise EasyBuildError("Entrypoint `%s` has no module or name associated", self.wrapped)


class EntrypointHook(EasybuildEntrypoint):
"""Class to represent a hook entrypoint."""
group = 'easybuild.hooks'

def __init__(self, step, pre_step=False, post_step=False, priority=0):
"""Initialize the EntrypointHook."""
super().__init__()
self.step = step
self.pre_step = pre_step
self.post_step = post_step
self.priority = priority

def validate(self):
"""Validate the hook entrypoint."""
from easybuild.tools.hooks import KNOWN_HOOKS, HOOK_SUFF, PRE_PREF, POST_PREF
super().validate()

if not callable(self.wrapped):
raise EasyBuildError("Hook entrypoint `%s` is not callable", self.wrapped)

prefix = ''
if self.pre_step:
prefix = PRE_PREF
elif self.post_step:
prefix = POST_PREF

hook_name = f'{prefix}{self.step}{HOOK_SUFF}'

if hook_name not in KNOWN_HOOKS:
msg = f"Attempting to register unknown hook '{hook_name}'"
_log.warning(msg)
raise EasyBuildError(msg)


class EntrypointEasyblock(EasybuildEntrypoint):
"""Class to represent an easyblock entrypoint."""
group = 'easybuild.easyblock'

def __init__(self):
super().__init__()
# Avoid circular imports by importing EasyBlock here
from easybuild.framework.easyblock import EasyBlock
self.expected_type = EasyBlock


class EntrypointToolchain(EasybuildEntrypoint):
"""Class to represent a toolchain entrypoint."""
group = 'easybuild.toolchain'

def __init__(self, prepend=False):
super().__init__()
# Avoid circular imports by importing Toolchain here
from easybuild.tools.toolchain.toolchain import Toolchain
self.expected_type = Toolchain
self.prepend = prepend
31 changes: 26 additions & 5 deletions easybuild/tools/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@
import difflib
import os

from easybuild.tools.entrypoints import EntrypointHook

from easybuild.base import fancylogger
from easybuild.tools.build_log import EasyBuildError, print_msg
from easybuild.tools.config import build_option
Expand Down Expand Up @@ -233,12 +235,9 @@ def run_hook(label, hooks, pre_step_hook=False, post_step_hook=False, args=None,
"""
hook = find_hook(label, hooks, pre_step_hook=pre_step_hook, post_step_hook=post_step_hook)
res = None
args = args or []
kwargs = kwargs or {}
if hook:
if args is None:
args = []
if kwargs is None:
kwargs = {}

if pre_step_hook:
label = 'pre-' + label
elif post_step_hook:
Expand All @@ -251,4 +250,26 @@ def run_hook(label, hooks, pre_step_hook=False, post_step_hook=False, args=None,

_log.info("Running '%s' hook function (args: %s, keyword args: %s)...", hook.__name__, args, kwargs)
res = hook(*args, **kwargs)

entrypoint_hooks = EntrypointHook.get_loaded_entrypoints(
step=label, pre_step=pre_step_hook, post_step=post_step_hook
)
if entrypoint_hooks:
msg = "Running entry point %s hook..." % label
if build_option('debug') and not build_option('silence_hook_trigger'):
print_msg(msg)
entrypoint_hooks.sort(
key=lambda x: (-x.priority, x.name),
)
for hook in entrypoint_hooks:
_log.info(
"Running entry point '%s' hook function (args: %s, keyword args: %s)...",
hook.name, args, kwargs
)
try:
res = hook.wrapped(*args, **kwargs)
except Exception as e:
_log.warning("Error running entry point '%s' hook: %s", hook.name, e)
raise EasyBuildError("Error running entry point '%s' hook: %s", hook.name, e) from e

return res
17 changes: 17 additions & 0 deletions easybuild/tools/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@
from easybuild.tools.systemtools import get_cpu_features, get_gpu_info, get_os_type, get_system_info
from easybuild.tools.utilities import flatten
from easybuild.tools.version import this_is_easybuild
from easybuild.tools.entrypoints import EntrypointHook, EntrypointEasyblock, EntrypointToolchain


try:
Expand Down Expand Up @@ -313,6 +314,9 @@ def basic_options(self):
'stop': ("Stop the installation after certain step",
'choice', 'store_or_None', EXTRACT_STEP, 's', all_stops),
'strict': ("Set strictness level", 'choice', 'store', WARN, strictness_options),
'use-entrypoints': (
"Use entry points for easyblocks, toolchains, and hooks", None, 'store_true', False,
),
})

self.log.debug("basic_options: descr %s opts %s" % (descr, opts))
Expand Down Expand Up @@ -1680,6 +1684,19 @@ def det_location(opt, prefix=''):

pretty_print_opts(opts_dict)

if build_option('use_entrypoints', default=True):
for prefix, cls in [
('Hook', EntrypointHook),
('Easyblock', EntrypointEasyblock),
('Toolchain', EntrypointToolchain),
]:
ept_list = cls.retrieve_entrypoints()
if ept_list:
print()
print("%ss from entrypoints (%d):" % (prefix, len(ept_list)))
for ept in ept_list:
print('-', ept)


def parse_options(args=None, with_include=True):
"""wrapper function for option parsing"""
Expand Down
4 changes: 1 addition & 3 deletions easybuild/tools/toolchain/toolchain.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ class Toolchain:
CLASS_CONSTANTS_TO_RESTORE = None
CLASS_CONSTANT_COPIES = {}

# class method
@classmethod
def _is_toolchain_for(cls, name):
"""see if this class can provide support for toolchain named name"""
# TODO report later in the initialization the found version
Expand All @@ -181,8 +181,6 @@ def _is_toolchain_for(cls, name):
# is no name is supplied, check whether class can be used as a toolchain
return bool(getattr(cls, 'NAME', None))

_is_toolchain_for = classmethod(_is_toolchain_for)

def __init__(self, name=None, version=None, mns=None, class_constants=None, tcdeps=None, modtool=None,
hidden=False):
"""
Expand Down
Loading
Loading