From ad100e77930955e4ef7e0882dfb15846c4bf83ba Mon Sep 17 00:00:00 2001 From: crivella Date: Thu, 9 Apr 2026 17:02:16 +0200 Subject: [PATCH 1/6] Add support for theme and highlighters using entrypoints --- easybuild/tools/build_log.py | 4 +- easybuild/tools/config.py | 5 ++ easybuild/tools/entrypoints.py | 7 ++- easybuild/tools/options.py | 1 + easybuild/tools/output.py | 90 +++++++++++++++++++++++++++++++++- setup.py | 5 ++ 6 files changed, 107 insertions(+), 5 deletions(-) diff --git a/easybuild/tools/build_log.py b/easybuild/tools/build_log.py index e6a5103c27..9f1df4e8a6 100644 --- a/easybuild/tools/build_log.py +++ b/easybuild/tools/build_log.py @@ -314,7 +314,7 @@ def print_msg(msg, *args, **kwargs): :param newline: end message with newline :param stderr: print to stderr rather than stdout """ - from easybuild.tools.output import use_rich # avoid circular import + from easybuild.tools.output import use_rich, get_rich_highlighter, get_rich_theme # avoid circular import if args: msg = msg % args @@ -339,7 +339,7 @@ def print_msg(msg, *args, **kwargs): from rich.markup import escape from rich.console import Console - console = Console() + console = Console(highlighter=get_rich_highlighter(), theme=get_rich_theme()) with console.capture() as capture: console.print(escape(msg), end="") diff --git a/easybuild/tools/config.py b/easybuild/tools/config.py index d6769442d9..72efd7dd1e 100644 --- a/easybuild/tools/config.py +++ b/easybuild/tools/config.py @@ -178,6 +178,8 @@ LOCAL_VAR_NAMING_CHECK_WARN = WARN LOCAL_VAR_NAMING_CHECKS = [LOCAL_VAR_NAMING_CHECK_ERROR, LOCAL_VAR_NAMING_CHECK_LOG, LOCAL_VAR_NAMING_CHECK_WARN] +DEFAULT_THEME_NAME = 'default' + OUTPUT_STYLE_AUTO = 'auto' OUTPUT_STYLE_BASIC = 'basic' OUTPUT_STYLE_NO_COLOR = 'no_color' @@ -437,6 +439,9 @@ def mk_full_default_path(name, prefix=DEFAULT_PREFIX): DEFAULT_PR_TARGET_ACCOUNT: [ 'pr_target_account', ], + DEFAULT_THEME_NAME: [ + 'output_theme', + ], GENERAL_CLASS: [ 'suffix_modules_path', ], diff --git a/easybuild/tools/entrypoints.py b/easybuild/tools/entrypoints.py index 1f7702d295..eaab53af22 100644 --- a/easybuild/tools/entrypoints.py +++ b/easybuild/tools/entrypoints.py @@ -139,7 +139,7 @@ def retrieve_entrypoints(cls) -> Set[EntryPoint]: @classmethod def load_entrypoints(cls): - """Load all the entrypoints in this group. This is needed for the modules contining the entrypoints to be + """Load all the entrypoints in this group. This is needed for the modules containing 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(): @@ -177,6 +177,11 @@ def validate(self): raise EasyBuildError("Entrypoint `%s` has no module or name associated", self.wrapped) +class EntrypointRichTheme(EasybuildEntrypoint): + """Class to represent a rich theme entrypoint.""" + group = 'easybuild.rich_theme' + + class EntrypointHook(EasybuildEntrypoint): """Class to represent a hook entrypoint.""" group = 'easybuild.hooks' diff --git a/easybuild/tools/options.py b/easybuild/tools/options.py index 96bb8a484a..c5c2fc85b9 100644 --- a/easybuild/tools/options.py +++ b/easybuild/tools/options.py @@ -555,6 +555,7 @@ def override_options(self): 'output-style': ("Control output style; auto implies using Rich if available to produce rich output, " "with fallback to basic colored output", 'choice', 'store', OUTPUT_STYLE_AUTO, OUTPUT_STYLES), + 'output-theme': ("Set output theme (when using Rich output style)", None, 'store', 'default'), 'parallel': ("Specify level of parallelism that should be used during build procedure, " "(bypasses auto-detection of number of available cores; " "actual value is determined by this value + 'max_parallel' easyconfig parameter)", diff --git a/easybuild/tools/output.py b/easybuild/tools/output.py index d1cf1810be..6a18cdbb45 100644 --- a/easybuild/tools/output.py +++ b/easybuild/tools/output.py @@ -35,11 +35,14 @@ from collections import OrderedDict import sys -from easybuild.tools.build_log import EasyBuildError -from easybuild.tools.config import OUTPUT_STYLE_RICH, build_option, get_output_style +from easybuild.tools.build_log import EasyBuildError, EB_MSG_PREFIX +from easybuild.tools.entrypoints import EntrypointRichTheme +from easybuild.tools.config import OUTPUT_STYLE_RICH, build_option, get_output_style, DEFAULT_THEME_NAME try: import rich.markup + from rich.theme import Theme + from rich.highlighter import Highlighter, RegexHighlighter, ReprHighlighter from rich.console import Console, Group from rich.live import Live from rich.progress import BarColumn, Progress, SpinnerColumn, TextColumn, TimeElapsedColumn @@ -75,6 +78,23 @@ _progress_bar_cache = {} +DEFAULT_THEME_DCT = { + 'easybuild.success': 'bold green reverse', + 'easybuild.warning': 'bold orange3 reverse', + 'easybuild.error': 'bold red reverse', + 'easybuild.prefix1': 'bold yellow3', + 'easybuild.prefix2': 'dim yellow3', +} +DEFAULT_HIGHLIGHTS = [ + r'(?P(ERROR)|(FAILED)|(FAIL))', + r'(?PWARNING)', + r'(?P(COMPLETED)|(SUCCESS)|(PASSED)|(PASS)|(OK))', + fr'(?P{EB_MSG_PREFIX} )', + fr'(?P >> )', +] +CACHED_THEME = None +CACHED_HIGHLIGHTER = None + def colorize(txt, color): """ @@ -133,6 +153,72 @@ def use_rich(): return get_output_style() == OUTPUT_STYLE_RICH +def get_rich_theme(): + """ + Get Rich theme to use for rich output. + """ + global CACHED_THEME + if CACHED_THEME is not None: + return CACHED_THEME + if not use_rich(): + class DummyTheme: + pass + res = DummyTheme() + else: + + use_entrypoints = build_option('use_entrypoints', default=True) + output_theme = build_option('output_theme', default=DEFAULT_THEME_NAME) + if output_theme == DEFAULT_THEME_NAME: + theme_dct = DEFAULT_THEME_DCT + else: + if not use_entrypoints: + raise EasyBuildError( + "Cannot use custom Rich theme '%s' without entry points support enabled", output_theme + ) + for entrypoint in EntrypointRichTheme.retrieve_entrypoints(): + if entrypoint.name == output_theme: + theme_dct = entrypoint.load() + break + else: + raise EasyBuildError("Unknown specified Rich theme '%s'", output_theme) + res = Theme(theme_dct) + CACHED_THEME = res + return res + + +def get_rich_highlighter(): + """ + Get Rich highlighter to use for rich output. + """ + global CACHED_HIGHLIGHTER + if CACHED_HIGHLIGHTER is not None: + return CACHED_HIGHLIGHTER + if not use_rich(): + class DummyHighlighter: + pass + res = DummyHighlighter() + else: + class EasybuildHighlighter(RegexHighlighter): + """Highlighter for EasyBuild messages, to highlight ERROR, WARNING, SUCCESS and similar lines.""" + highlights = DEFAULT_HIGHLIGHTS + base_style = "easybuild." + + class CombinedHighlighter(Highlighter): + """Combined highlighter that applies both EasybuildHighlighter and ReprHighlighter.""" + def __init__(self): + super().__init__() + self.easybuild_highlighter = EasybuildHighlighter() + self.repr_highlighter = ReprHighlighter() + + def highlight(self, text): + self.easybuild_highlighter.highlight(text) + self.repr_highlighter.highlight(text) + + res = CombinedHighlighter() + CACHED_HIGHLIGHTER = res + return res + + def show_progress_bars(): """ Return whether or not to show progress bars. diff --git a/setup.py b/setup.py index 2f1a798a28..5aa31b8b50 100644 --- a/setup.py +++ b/setup.py @@ -119,6 +119,11 @@ def find_rel_test(): # utility scripts 'easybuild/scripts/install_eb_dep.sh', ], + entry_points={ + 'easybuild.rich_theme': [ + 'default = easybuild.tools.output:DEFAULT_THEME_DCT', + ], + }, data_files=[ ('easybuild/scripts', glob.glob('easybuild/scripts/*')), ('etc', glob.glob('etc/*')), From fcc978882979ec42158cc635727e810d52a035eb Mon Sep 17 00:00:00 2001 From: Samuel Moors Date: Sat, 25 Apr 2026 19:55:34 +0200 Subject: [PATCH 2/6] update default theme --- easybuild/tools/output.py | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/easybuild/tools/output.py b/easybuild/tools/output.py index 6a18cdbb45..c18ecb184f 100644 --- a/easybuild/tools/output.py +++ b/easybuild/tools/output.py @@ -79,18 +79,30 @@ _progress_bar_cache = {} DEFAULT_THEME_DCT = { - 'easybuild.success': 'bold green reverse', - 'easybuild.warning': 'bold orange3 reverse', - 'easybuild.error': 'bold red reverse', - 'easybuild.prefix1': 'bold yellow3', - 'easybuild.prefix2': 'dim yellow3', + 'easybuild.success': 'on green', + 'easybuild.warning': 'on orange3', + 'easybuild.error': 'on red', + 'easybuild.prefix1': 'gray50', + 'easybuild.prefix2': 'gray50', + 'easybuild.installing': 'bold', + 'easybuild.installed': 'bold', + 'easybuild.timing': 'gray50', + 'repr.path': 'bright_blue', + 'repr.number': 'red', + 'repr.ipv6': 'yellow', # time + 'repr.call': 'none', # file(s) + 'repr.ellipsis': 'gray50', + 'repr.brace': 'none', } DEFAULT_HIGHLIGHTS = [ r'(?P(ERROR)|(FAILED)|(FAIL))', r'(?PWARNING)', r'(?P(COMPLETED)|(SUCCESS)|(PASSED)|(PASS)|(OK))', fr'(?P{EB_MSG_PREFIX} )', - fr'(?P >> )', + r'(?P >> )', + r'.* (?P(building and installing|installing extension|installing bundle component).*)\.\.\.', + r'(?P\(took .*\))', + r'\[SUCCESS\] (?P\S+)', ] CACHED_THEME = None CACHED_HIGHLIGHTER = None @@ -211,8 +223,9 @@ def __init__(self): self.repr_highlighter = ReprHighlighter() def highlight(self, text): - self.easybuild_highlighter.highlight(text) self.repr_highlighter.highlight(text) + # easybuild_highlighter comes last to take priority + self.easybuild_highlighter.highlight(text) res = CombinedHighlighter() CACHED_HIGHLIGHTER = res From 54e74df673287b1f6f25806c6b1b79d853ad1297 Mon Sep 17 00:00:00 2001 From: Sam Moors Date: Mon, 27 Apr 2026 10:55:09 +0200 Subject: [PATCH 3/6] use grey instead of gray --- easybuild/tools/output.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/easybuild/tools/output.py b/easybuild/tools/output.py index c18ecb184f..07a63f1b72 100644 --- a/easybuild/tools/output.py +++ b/easybuild/tools/output.py @@ -82,16 +82,16 @@ 'easybuild.success': 'on green', 'easybuild.warning': 'on orange3', 'easybuild.error': 'on red', - 'easybuild.prefix1': 'gray50', - 'easybuild.prefix2': 'gray50', + 'easybuild.prefix1': 'grey50', + 'easybuild.prefix2': 'grey50', 'easybuild.installing': 'bold', 'easybuild.installed': 'bold', - 'easybuild.timing': 'gray50', + 'easybuild.timing': 'grey50', 'repr.path': 'bright_blue', 'repr.number': 'red', 'repr.ipv6': 'yellow', # time 'repr.call': 'none', # file(s) - 'repr.ellipsis': 'gray50', + 'repr.ellipsis': 'grey50', 'repr.brace': 'none', } DEFAULT_HIGHLIGHTS = [ From f47448e908a045a99ad8623e5c1cd74df5be9675 Mon Sep 17 00:00:00 2001 From: crivella Date: Fri, 12 Jun 2026 12:04:17 +0200 Subject: [PATCH 4/6] Allow also custom RichHighlighter + use entrypoints decorators --- easybuild/tools/config.py | 6 +++- easybuild/tools/entrypoints.py | 28 ++++++++++++++++ easybuild/tools/options.py | 6 +++- easybuild/tools/output.py | 61 +++++++++++++++++++++++++--------- setup.py | 5 ++- 5 files changed, 87 insertions(+), 19 deletions(-) diff --git a/easybuild/tools/config.py b/easybuild/tools/config.py index 72efd7dd1e..2e42106b74 100644 --- a/easybuild/tools/config.py +++ b/easybuild/tools/config.py @@ -178,7 +178,8 @@ LOCAL_VAR_NAMING_CHECK_WARN = WARN LOCAL_VAR_NAMING_CHECKS = [LOCAL_VAR_NAMING_CHECK_ERROR, LOCAL_VAR_NAMING_CHECK_LOG, LOCAL_VAR_NAMING_CHECK_WARN] -DEFAULT_THEME_NAME = 'default' +DEFAULT_THEME_NAME = 'default_theme' +DEFAULT_HIGHLIGHTS_NAME = 'default_highlights' OUTPUT_STYLE_AUTO = 'auto' OUTPUT_STYLE_BASIC = 'basic' @@ -442,6 +443,9 @@ def mk_full_default_path(name, prefix=DEFAULT_PREFIX): DEFAULT_THEME_NAME: [ 'output_theme', ], + DEFAULT_HIGHLIGHTS_NAME: [ + 'output_highlights', + ], GENERAL_CLASS: [ 'suffix_modules_path', ], diff --git a/easybuild/tools/entrypoints.py b/easybuild/tools/entrypoints.py index eaab53af22..a37f4eecfd 100644 --- a/easybuild/tools/entrypoints.py +++ b/easybuild/tools/entrypoints.py @@ -59,6 +59,7 @@ class EasybuildEntrypoint: group = None expected_type = None registered = {} + desc = None def __init__(self): if self.group is None: @@ -180,11 +181,36 @@ def validate(self): class EntrypointRichTheme(EasybuildEntrypoint): """Class to represent a rich theme entrypoint.""" group = 'easybuild.rich_theme' + desc = "Rich theme" + def validate(self): + super().validate() + if not callable(self.wrapped): + raise EasyBuildError("Rich theme entrypoint `%s` is not callable", self.wrapped) + res = self.wrapped() + if not isinstance(res, dict): + raise EasyBuildError("Rich theme entrypoint `%s` did not return a dict", self.wrapped) + + +class EntrypointRichHighlighter(EasybuildEntrypoint): + """Class to represent a rich highlighter entrypoint.""" + group = 'easybuild.rich_highlighter' + desc = "Rich highlighter" + + def validate(self): + super().validate() + if not callable(self.wrapped): + raise EasyBuildError("Rich highlighter entrypoint `%s` is not callable", self.wrapped) + res = self.wrapped() + if not isinstance(res, list): + raise EasyBuildError("Rich highlighter entrypoint `%s` is not a list", self.wrapped) + if any(not isinstance(item, str) for item in res): + raise EasyBuildError("Rich highlighter entrypoint `%s` did not return a list of strings", self.wrapped) class EntrypointHook(EasybuildEntrypoint): """Class to represent a hook entrypoint.""" group = 'easybuild.hooks' + desc = "Hook" def __init__(self, step, pre_step=False, post_step=False, priority=0): """Initialize the EntrypointHook.""" @@ -219,6 +245,7 @@ def validate(self): class EntrypointEasyblock(EasybuildEntrypoint): """Class to represent an easyblock entrypoint.""" group = 'easybuild.easyblock' + desc = "Easyblock" def __init__(self): super().__init__() @@ -230,6 +257,7 @@ def __init__(self): class EntrypointToolchain(EasybuildEntrypoint): """Class to represent a toolchain entrypoint.""" group = 'easybuild.toolchain' + desc = "Toolchain" def __init__(self, prepend=False): super().__init__() diff --git a/easybuild/tools/options.py b/easybuild/tools/options.py index c5c2fc85b9..d978d6f55d 100644 --- a/easybuild/tools/options.py +++ b/easybuild/tools/options.py @@ -76,6 +76,7 @@ from easybuild.tools.config import EBROOT_ENV_VAR_ACTIONS, ERROR, FORCE_DOWNLOAD_CHOICES, GENERAL_CLASS, IGNORE from easybuild.tools.config import JOB_DEPS_TYPE_ABORT_ON_ERROR, JOB_DEPS_TYPE_ALWAYS_RUN, LOADED_MODULES_ACTIONS from easybuild.tools.config import LOCAL_VAR_NAMING_CHECK_WARN, LOCAL_VAR_NAMING_CHECKS, MOD_SEARCH_PATH_HEADERS +from easybuild.tools.config import DEFAULT_THEME_NAME, DEFAULT_HIGHLIGHTS_NAME from easybuild.tools.config import OUTPUT_STYLE_AUTO, OUTPUT_STYLES, WARN, build_option from easybuild.tools.config import get_pretend_installpath, init, init_build_options, mk_full_default_path from easybuild.tools.config import BuildOptions, ConfigurationVariables @@ -555,7 +556,10 @@ def override_options(self): 'output-style': ("Control output style; auto implies using Rich if available to produce rich output, " "with fallback to basic colored output", 'choice', 'store', OUTPUT_STYLE_AUTO, OUTPUT_STYLES), - 'output-theme': ("Set output theme (when using Rich output style)", None, 'store', 'default'), + 'output-theme': ("Set output theme (when using Rich output style)", None, 'store', DEFAULT_THEME_NAME), + 'output-highlights': ( + "Set output highlights (when using Rich output style)", None, 'store', DEFAULT_HIGHLIGHTS_NAME + ), 'parallel': ("Specify level of parallelism that should be used during build procedure, " "(bypasses auto-detection of number of available cores; " "actual value is determined by this value + 'max_parallel' easyconfig parameter)", diff --git a/easybuild/tools/output.py b/easybuild/tools/output.py index 07a63f1b72..2dd78dd24f 100644 --- a/easybuild/tools/output.py +++ b/easybuild/tools/output.py @@ -36,8 +36,9 @@ import sys from easybuild.tools.build_log import EasyBuildError, EB_MSG_PREFIX -from easybuild.tools.entrypoints import EntrypointRichTheme -from easybuild.tools.config import OUTPUT_STYLE_RICH, build_option, get_output_style, DEFAULT_THEME_NAME +from easybuild.tools.entrypoints import EntrypointRichTheme, EntrypointRichHighlighter +from easybuild.tools.config import OUTPUT_STYLE_RICH, build_option, get_output_style +from easybuild.tools.config import DEFAULT_THEME_NAME, DEFAULT_HIGHLIGHTS_NAME try: import rich.markup @@ -165,6 +166,22 @@ def use_rich(): return get_output_style() == OUTPUT_STYLE_RICH +@EntrypointRichTheme() +def default_theme(): + """ + Default Rich theme, used if no custom theme is specified or available. + """ + return DEFAULT_THEME_DCT + + +@EntrypointRichHighlighter() +def default_highlights(): + """ + Default Rich highlighter, used if no custom highlighter is specified or available. + """ + return DEFAULT_HIGHLIGHTS + + def get_rich_theme(): """ Get Rich theme to use for rich output. @@ -177,23 +194,20 @@ class DummyTheme: pass res = DummyTheme() else: - use_entrypoints = build_option('use_entrypoints', default=True) output_theme = build_option('output_theme', default=DEFAULT_THEME_NAME) - if output_theme == DEFAULT_THEME_NAME: - theme_dct = DEFAULT_THEME_DCT - else: - if not use_entrypoints: - raise EasyBuildError( - "Cannot use custom Rich theme '%s' without entry points support enabled", output_theme - ) - for entrypoint in EntrypointRichTheme.retrieve_entrypoints(): - if entrypoint.name == output_theme: - theme_dct = entrypoint.load() - break + + entrypoints = EntrypointRichTheme.get_loaded_entrypoints(name=output_theme) + if not entrypoints: + if use_entrypoints: + available_themes = ', '.join([_.name for _ in EntrypointRichTheme.get_loaded_entrypoints()]) + msg = f"Unknown specified Rich theme '{output_theme}' (available: {available_themes})" else: - raise EasyBuildError("Unknown specified Rich theme '%s'", output_theme) + msg = f"Cannot use custom Rich theme '{output_theme}' without entry points support enabled" + raise EasyBuildError(msg) + theme_dct = entrypoints[0].wrapped() res = Theme(theme_dct) + CACHED_THEME = res return res @@ -205,14 +219,28 @@ def get_rich_highlighter(): global CACHED_HIGHLIGHTER if CACHED_HIGHLIGHTER is not None: return CACHED_HIGHLIGHTER + if not use_rich(): class DummyHighlighter: pass res = DummyHighlighter() else: + use_entrypoints = build_option('use_entrypoints', default=True) + output_hl = build_option('output_highlights', default=DEFAULT_HIGHLIGHTS_NAME) + + entrypoints = EntrypointRichHighlighter.get_loaded_entrypoints(name=output_hl) + if not entrypoints: + if use_entrypoints: + available_hls = ', '.join([_.name for _ in EntrypointRichHighlighter.get_loaded_entrypoints()]) + msg = f"Unknown specified Rich highlighter '{output_hl}' (available: {available_hls})" + else: + msg = f"Cannot use custom Rich highlighter '{output_hl}' without entry points support enabled" + raise EasyBuildError(msg) + highlights_dct = entrypoints[0].wrapped() + class EasybuildHighlighter(RegexHighlighter): """Highlighter for EasyBuild messages, to highlight ERROR, WARNING, SUCCESS and similar lines.""" - highlights = DEFAULT_HIGHLIGHTS + highlights = highlights_dct base_style = "easybuild." class CombinedHighlighter(Highlighter): @@ -228,6 +256,7 @@ def highlight(self, text): self.easybuild_highlighter.highlight(text) res = CombinedHighlighter() + CACHED_HIGHLIGHTER = res return res diff --git a/setup.py b/setup.py index 5aa31b8b50..6ec2ef57ef 100644 --- a/setup.py +++ b/setup.py @@ -121,7 +121,10 @@ def find_rel_test(): ], entry_points={ 'easybuild.rich_theme': [ - 'default = easybuild.tools.output:DEFAULT_THEME_DCT', + 'default = easybuild.tools.output:default_theme', + ], + 'easybuild.rich_highlighter': [ + 'default = easybuild.tools.output:default_highlights', ], }, data_files=[ From 9a6656d1d3822f6dc9ad67b028cd434c43ef53da Mon Sep 17 00:00:00 2001 From: crivella Date: Fri, 12 Jun 2026 12:04:40 +0200 Subject: [PATCH 5/6] Generalize printing of entrypoints in `--show-config` --- easybuild/tools/options.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/easybuild/tools/options.py b/easybuild/tools/options.py index d978d6f55d..796074b7fd 100644 --- a/easybuild/tools/options.py +++ b/easybuild/tools/options.py @@ -111,7 +111,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 +from easybuild.tools.entrypoints import EasybuildEntrypoint try: @@ -1690,11 +1690,8 @@ 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), - ]: + for cls in EasybuildEntrypoint.__subclasses__(): + prefix = str(cls.desc) ept_list = cls.retrieve_entrypoints() if ept_list: print() From 453d2e1972ef044a2a5c86e10f37943c208f6d7c Mon Sep 17 00:00:00 2001 From: crivella Date: Fri, 12 Jun 2026 15:12:55 +0200 Subject: [PATCH 6/6] Add tests for Theme/Highlighter entrypoints --- easybuild/base/testing.py | 6 +- easybuild/tools/entrypoints.py | 2 +- test/framework/entrypoints.py | 151 ++++++++++++++++++++++++++++++--- 3 files changed, 142 insertions(+), 17 deletions(-) diff --git a/easybuild/base/testing.py b/easybuild/base/testing.py index 58c60d21df..b7de7033cf 100644 --- a/easybuild/base/testing.py +++ b/easybuild/base/testing.py @@ -227,12 +227,12 @@ def mocked_stderr(self): self.mock_stderr(False) @contextmanager - def mocked_stdout_stderr(self, mock_stdout=True, mock_stderr=True): + def mocked_stdout_stderr(self, mock_stdout=True, mock_stderr=True, force_tty=False): """Context manager to mock stdout and stderr""" if mock_stdout: - self.mock_stdout(True) + self.mock_stdout(True, force_tty=force_tty) if mock_stderr: - self.mock_stderr(True) + self.mock_stderr(True, force_tty=force_tty) try: if mock_stdout and mock_stderr: yield sys.stdout, sys.stderr diff --git a/easybuild/tools/entrypoints.py b/easybuild/tools/entrypoints.py index a37f4eecfd..315b2a2b4c 100644 --- a/easybuild/tools/entrypoints.py +++ b/easybuild/tools/entrypoints.py @@ -203,7 +203,7 @@ def validate(self): raise EasyBuildError("Rich highlighter entrypoint `%s` is not callable", self.wrapped) res = self.wrapped() if not isinstance(res, list): - raise EasyBuildError("Rich highlighter entrypoint `%s` is not a list", self.wrapped) + raise EasyBuildError("Rich highlighter entrypoint does not return a list, got %s", type(res)) if any(not isinstance(item, str) for item in res): raise EasyBuildError("Rich highlighter entrypoint `%s` did not return a list of strings", self.wrapped) diff --git a/test/framework/entrypoints.py b/test/framework/entrypoints.py index f052a6ee47..bf8b77bca9 100644 --- a/test/framework/entrypoints.py +++ b/test/framework/entrypoints.py @@ -37,10 +37,13 @@ from unittest import TextTestRunner import easybuild.tools.options as eboptions +import easybuild.tools.output as eboutput +from easybuild.tools.config import update_build_option from easybuild.tools.build_log import EasyBuildError from easybuild.tools.docs import list_easyblocks, list_toolchains from easybuild.tools.entrypoints import ( - HAVE_ENTRY_POINTS, EntrypointHook, EntrypointEasyblock, EntrypointToolchain, EasybuildEntrypoint + HAVE_ENTRY_POINTS, EntrypointHook, EntrypointEasyblock, EntrypointToolchain, EasybuildEntrypoint, + EntrypointRichTheme, EntrypointRichHighlighter ) from easybuild.tools.filetools import write_file from easybuild.tools.hooks import run_hook, START, CONFIGURE_STEP @@ -57,10 +60,24 @@ MOCK_HOOK_EP_NAME = "mock_hook" MOCK_EASYBLOCK_EP_NAME = "mock_easyblock" MOCK_TOOLCHAIN_EP_NAME = "mock_toolchain" +MOCK_THEME_EP_NAME = "mock_theme" +MOCK_HIGHLIGHTER_EP_NAME = "mock_highlighter" MOCK_HOOK = "hello_world_12412412" MOCK_EASYBLOCK = "TestEasyBlock_1212461" MOCK_TOOLCHAIN = "MockTc_352124671346" +MOCK_THEME = "MockTheme_124124" +MOCK_HIGHLIGHTER = "MockHighlighter_124124" + +MOCK_HIGHLIGHTER_TEXT = "TEST_TEXT" + +EXPECTED = { + EntrypointHook: MOCK_HOOK_EP_NAME, + EntrypointEasyblock: MOCK_EASYBLOCK_EP_NAME, + EntrypointToolchain: MOCK_TOOLCHAIN_EP_NAME, + EntrypointRichTheme: MOCK_THEME_EP_NAME, + EntrypointRichHighlighter: MOCK_HIGHLIGHTER_EP_NAME, +} MOCK_EP_FILE = f""" @@ -115,6 +132,21 @@ class {MOCK_TOOLCHAIN}(MockCompiler): class {MOCK_TOOLCHAIN}_invalid(MockCompiler): pass + +########################################################################## +from easybuild.tools.entrypoints import EntrypointRichTheme, EntrypointRichHighlighter + +@EntrypointRichTheme() +def {MOCK_THEME}(): + return {{ + 'easybuild.test': 'blue', + }} + +@EntrypointRichHighlighter() +def {MOCK_HIGHLIGHTER}(): + return [ + r'(?P{MOCK_HIGHLIGHTER_TEXT})', # Using `TEST` to distinguish this from other highlights + ] """ @@ -130,6 +162,12 @@ class {MOCK_TOOLCHAIN}_invalid(MockCompiler): [{EntrypointToolchain.group}] {MOCK_TOOLCHAIN_EP_NAME} = {{module}}:{MOCK_TOOLCHAIN} {{invalid_toolchain}} + +[{EntrypointRichTheme.group}] +{MOCK_THEME_EP_NAME} = {{module}}:{MOCK_THEME} + +[{EntrypointRichHighlighter.group}] +{MOCK_HIGHLIGHTER_EP_NAME} = {{module}}:{MOCK_HIGHLIGHTER} """ FORMAT_DCT = { @@ -288,29 +326,50 @@ class MOCK(Toolchain): pass decorator(MOCK) + def test_entrypoints_register_rich_theme(self): + """Test registering entry point rich themes with both valid and invalid theme names.""" + decorator = EntrypointRichTheme() + + with self.assertRaisesRegex(EasyBuildError, "has no module or name associated"): + decorator(123) + + with self.assertRaisesRegex(EasyBuildError, "did not return a dict"): + decorator(lambda: None) + + decorator(lambda: {}) + + def test_entrypoints_register_rich_highlighter(self): + """Test registering entry point rich highlighters with both valid and invalid highlighter names.""" + decorator = EntrypointRichHighlighter() + + with self.assertRaisesRegex(EasyBuildError, "has no module or name associated"): + decorator(123) + + with self.assertRaisesRegex(EasyBuildError, "does not return a list, got"): + decorator(lambda: None) + + with self.assertRaisesRegex(EasyBuildError, "did not return a list of strings"): + decorator(lambda: ['abc', 123]) + + decorator(lambda: ['a', 'b']) + def test_entrypoints_get_group(self): """Test retrieving entrypoints for a specific group.""" - expected = { - EntrypointHook: MOCK_HOOK_EP_NAME, - EntrypointEasyblock: MOCK_EASYBLOCK_EP_NAME, - EntrypointToolchain: MOCK_TOOLCHAIN_EP_NAME, - } - for ep_type in [EntrypointHook, EntrypointEasyblock, EntrypointToolchain]: + for ep_type in EXPECTED: group = ep_type.group epts = ep_type.retrieve_entrypoints() self.assertIsInstance(epts, set, f"Expected set for group {group}") self.assertEqual(len(epts), 0, f"Expected non-empty set for group {group}") init_config(build_options={'use_entrypoints': True}) - for ep_type in [EntrypointHook, EntrypointEasyblock, EntrypointToolchain]: + for ep_type, expt in EXPECTED.items(): group = ep_type.group epts = ep_type.retrieve_entrypoints() self.assertIsInstance(epts, set, f"Expected set for group {group}") self.assertGreater(len(epts), 0, f"Expected non-empty set for group {group}") loaded_names = [ep.name for ep in epts] - expt = expected[ep_type] self.assertIn(expt, loaded_names, f"Expected entry point {expt} in group {group}") def test_entrypoints_exclude_invalid(self): @@ -383,15 +442,18 @@ def test_entrypoints_show_config(self): args = ['--show-config'] stdout, stderr = self._run_mock_eb(args, strip=True) - for name in ['Hooks', 'Easyblocks', 'Toolchains']: - pattern = f"{name} from entrypoints (" + # for name in ['Hooks', 'Easyblocks', 'Toolchains']: + for ep_type in EXPECTED: + name = ep_type.desc + pattern = f"{name}s from entrypoints (" self.assertIn(pattern, stdout, f"Expected {name} in configuration output") args = ['--show-full-config'] stdout, stderr = self._run_mock_eb(args, strip=True) - for name in ['Hooks', 'Easyblocks', 'Toolchains']: - pattern = f"{name} from entrypoints (" + for ep_type in EXPECTED: + name = ep_type.desc + pattern = f"{name}s from entrypoints (" self.assertIn(pattern, stdout, f"Expected {name} in configuration output") def test_entrypoints_register_invalid_hook(self): @@ -477,6 +539,69 @@ def func_configure_post(): for key, val in flags.items(): self.assertEqual(val, key == 'post_cfg', "Should only run post-configure hooks") + def test_entrypoints_use_rich_theme(self): + """Test registering entry point rich themes with both valid and invalid theme names.""" + from easybuild.tools.build_log import print_msg + + update_build_option('output_style', 'rich') + + def run_check(msg, theme=None, hl=None, with_ep=False): + reload(eboutput) + update_build_option('output_theme', theme or eboutput.DEFAULT_THEME_NAME) + update_build_option('output_highlights', hl or eboutput.DEFAULT_HIGHLIGHTS_NAME) + update_build_option('use_entrypoints', with_ep) + + print_msg(msg) + + # Test that without entry points enabled, the custom theme and highlighter are not applied to the output + msg = "This is a test message" + with self.mocked_stdout_stderr(force_tty=True) as (stdout, _): + run_check(msg) + stdout_txt = stdout.getvalue().strip() + self.assertTrue(stdout_txt.endswith(f"\x1b[0m{msg}")) + + # Test that without entry points enabled, the custom theme and highlighter ONLY are applied + # The custom theme defined here should NOT be applied hece the TEST_TEXT should not be colored + msg = MOCK_HIGHLIGHTER_TEXT + with self.mocked_stdout_stderr(force_tty=True) as (stdout, _): + run_check(msg) + stdout_txt = stdout.getvalue().strip() + self.assertFalse(stdout_txt.endswith(f"{msg}\x1b[0m")) + + # Test that specifying a theme without entry points enabled raises an error with the expected message + reload(eboutput) # Clear out the cached theme/highlighter to ensure the new ones are picked up + exp = f'Cannot use custom Rich theme \'{MOCK_THEME}\' without entry points support enabled' + with self.assertRaisesRegex(EasyBuildError, exp): + run_check(msg, theme=MOCK_THEME) + + # Test that specifying an highlighter without entry points enabled raises an error with the expected message + exp = f'Cannot use custom Rich highlighter \'{MOCK_HIGHLIGHTER}\' without entry points support enabled' + with self.assertRaisesRegex(EasyBuildError, exp): + run_check(msg, hl=MOCK_HIGHLIGHTER) + + # Test that specifying an invalid highlighter with entry points enabled raises an error with the expected + # message listing the available highlighters + highlighter = f'{MOCK_HIGHLIGHTER}_invalid' + exp = rf'Unknown specified Rich highlighter.*{highlighter}.* \(available: .*\)' + with self.assertRaisesRegex(EasyBuildError, exp): + run_check(msg, hl=highlighter, with_ep=True) + + # Test that specifying an invalid theme with entry points enabled raises an error with the expected message + # listing the available themes + theme = f'{MOCK_THEME}_invalid' + exp = rf'Unknown specified Rich theme.*{theme}.* \(available: .*\)' + with self.assertRaisesRegex(EasyBuildError, exp): + run_check(msg, theme=theme, with_ep=True) + + # Test that specifying a valid highlighter with entry points enabled does not raise an error and is applied + # to the output + highlighter = MOCK_HIGHLIGHTER + exp = rf'Unknown specified Rich highlighter.*{highlighter}.* \(available: .*\)' + with self.mocked_stdout_stderr(force_tty=True) as (stdout, _): + run_check(msg, theme=MOCK_THEME, hl=MOCK_HIGHLIGHTER, with_ep=True) + stdout_txt = stdout.getvalue().strip() + self.assertTrue(stdout_txt.endswith(f"{msg}\x1b[0m")) + def suite(loader=None): """ returns all the testcases in this module """