From 2aadb58e61b8ee8427c9054e3fe47a1da8aa8f1a Mon Sep 17 00:00:00 2001 From: crivella Date: Wed, 16 Jul 2025 18:28:23 +0200 Subject: [PATCH 01/18] WIP test click CI wrapper --- easybuild/cli/__init__.py | 37 ++++++++ easybuild/cli/options/__init__.py | 150 ++++++++++++++++++++++++++++++ eb2 | 8 ++ setup.py | 1 + 4 files changed, 196 insertions(+) create mode 100644 easybuild/cli/__init__.py create mode 100644 easybuild/cli/options/__init__.py create mode 100644 eb2 diff --git a/easybuild/cli/__init__.py b/easybuild/cli/__init__.py new file mode 100644 index 0000000000..f759603645 --- /dev/null +++ b/easybuild/cli/__init__.py @@ -0,0 +1,37 @@ +try: + import rich_click as click +except ImportError: + import click + +try: + from rich.traceback import install +except ImportError: + pass +else: + install(suppress=[click]) + +from .options import EasyBuildCliOption + +from easybuild.main import main_with_hooks + +@click.command() +@EasyBuildCliOption.apply_options +@click.pass_context +@click.argument('other_args', nargs=-1, type=click.UNPROCESSED, required=False) +def eb(ctx, other_args): + """EasyBuild command line interface.""" + args = [] + for key, value in ctx.hidden_params.items(): + key = key.replace('_', '-') + if isinstance(value, bool): + if value: + args.append(f"--{key}") + else: + if value and value != EasyBuildCliOption.OPTIONS_MAP[key].default: + if isinstance(value, (list, tuple)): + value = ','.join(value) + args.append(f"--{key}={value}") + + args.extend(other_args) + + main_with_hooks(args=args) diff --git a/easybuild/cli/options/__init__.py b/easybuild/cli/options/__init__.py new file mode 100644 index 0000000000..89577a89bb --- /dev/null +++ b/easybuild/cli/options/__init__.py @@ -0,0 +1,150 @@ +import os + +from typing import Callable, Any +from dataclasses import dataclass + +opt_group = {} +try: + import rich_click as click +except ImportError: + import click +else: + opt_group = click.rich_click.OPTION_GROUPS + +from easybuild.tools.options import EasyBuildOptions + +DEBUG_EASYBUILD_OPTIONS = os.environ.get('DEBUG_EASYBUILD_OPTIONS', '').lower() in ('1', 'true', 'yes', 'y') + +class OptionExtracter(EasyBuildOptions): + def __init__(self, *args, **kwargs): + self._option_dicts = {} + super().__init__(*args, **kwargs) + + def add_group_parser(self, opt_dict, descr, *args, prefix='', **kwargs): + super().add_group_parser(opt_dict, descr, *args, prefix=prefix, **kwargs) + self._option_dicts[descr[0]] = (prefix, opt_dict) + +extracter = OptionExtracter(go_args=[]) + +def register_hidden_param(ctx, param, value): + """Register a hidden parameter in the context.""" + if not hasattr(ctx, 'hidden_params'): + ctx.hidden_params = {} + ctx.hidden_params[param.name] = value + +@dataclass +class OptionData: + name: str + description: str + type: str + action: str + default: Any + group: str = None + short: str = None + meta: dict = None + lst: list = None + + def __post_init__(self): + if self.short is not None and not isinstance(self.short, str): + raise TypeError(f"Short option must be a string, got {type(self.short)}") + if self.meta is not None and not isinstance(self.meta, dict): + raise TypeError(f"Meta must be a dictionary, got {type(self.meta)}") + if self.lst is not None and not isinstance(self.lst, (list, tuple)): + raise TypeError(f"List must be a list or tuple, got {type(self.lst)}") + + def to_click_option_dec(self): + """Convert OptionData to a click.Option.""" + decls = [f"--{self.name}"] + if self.short: + decls.insert(0, f"-{self.short}") + + kwargs = { + 'help': self.description, + # 'help': '123', + 'default': self.default, + 'show_default': True, + } + + if self.default is False or self.default is True: + kwargs['is_flag'] = True + + if isinstance(self.default, (list, tuple)): + kwargs['multiple'] = True + kwargs['type'] = click.STRING + + return click.option( + *decls, + expose_value=False, + callback=register_hidden_param, + **kwargs + ) + +class EasyBuildCliOption(): + OPTIONS: list[OptionData] = [] + OPTIONS_MAP: dict[str, OptionData] = {} + + @classmethod + def apply_options(cls, function: Callable) -> Callable: + """Decorator to apply EasyBuild options to a function.""" + group_data = {} + for opt_obj in cls.OPTIONS: + group_data.setdefault(opt_obj.group, []).append(f'--{opt_obj.name}') + function = opt_obj.to_click_option_dec()(function) + lst = [] + for key, value in group_data.items(): + lst.append({ + 'name': key, + # 'description': f'Options for {key}', + 'options': value + }) + opt_group[function.__name__] = lst + return function + + @classmethod + def register_option(cls, group: str, name: str, data: tuple, prefix: str = '') -> None: + """Register an EasyBuild option.""" + if prefix: + name = f"{prefix}-{name}" + if name == 'help': + return + short = None + meta = None + lst = None + descr, typ, action, default, *others = data + while others: + opt = others.pop(0) + if isinstance(opt, str): + if short is not None: + raise ValueError(f"Short option already set: {short} for {name}") + short = opt + elif isinstance(opt, dict): + if meta is not None: + raise ValueError(f"Meta already set: {meta} for {name}") + meta = opt + elif isinstance(opt, (list, tuple)): + if lst is not None: + raise ValueError(f"List already set: {lst} for {name}") + lst = opt + else: + raise ValueError(f"Unexpected type for others: {type(others[0])} in {others}") + + opt = OptionData( + group=group, + name=name, + description=descr, + type=typ, + action=action, + default=default, + short=short, + meta=meta, + lst=lst + ) + cls.OPTIONS_MAP[name] = opt + cls.OPTIONS.append(opt) + +for grp, dct in extracter._option_dicts.items(): + prefix, dct = dct + if dct is None: + continue + for key, value in dct.items(): + EasyBuildCliOption.register_option(grp, key, value, prefix=prefix) diff --git a/eb2 b/eb2 new file mode 100644 index 0000000000..94cb0927bd --- /dev/null +++ b/eb2 @@ -0,0 +1,8 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +import re +import sys +from easybuild.cli import eb +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(eb()) diff --git a/setup.py b/setup.py index c305735e13..9a49743f6d 100644 --- a/setup.py +++ b/setup.py @@ -93,6 +93,7 @@ def find_rel_test(): package_data={'test.framework': find_rel_test()}, scripts=[ 'eb', + 'eb2', # bash completion 'optcomplete.bash', 'minimal_bash_completion.bash', From 779dd146c064190d7c71fae6f5dc5dcf5d01cd3b Mon Sep 17 00:00:00 2001 From: crivella Date: Thu, 17 Jul 2025 10:45:11 +0200 Subject: [PATCH 02/18] Install as a console script --- eb2 | 8 -------- setup.py | 6 +++++- 2 files changed, 5 insertions(+), 9 deletions(-) delete mode 100644 eb2 diff --git a/eb2 b/eb2 deleted file mode 100644 index 94cb0927bd..0000000000 --- a/eb2 +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -import re -import sys -from easybuild.cli import eb -if __name__ == '__main__': - sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) - sys.exit(eb()) diff --git a/setup.py b/setup.py index 9a49743f6d..b97e7d281f 100644 --- a/setup.py +++ b/setup.py @@ -93,7 +93,6 @@ def find_rel_test(): package_data={'test.framework': find_rel_test()}, scripts=[ 'eb', - 'eb2', # bash completion 'optcomplete.bash', 'minimal_bash_completion.bash', @@ -102,6 +101,11 @@ def find_rel_test(): # utility scripts 'easybuild/scripts/install_eb_dep.sh', ], + entry_points={ + 'console_scripts': [ + 'eb2 = easybuild.cli:eb', + ] + }, data_files=[ ('easybuild/scripts', glob.glob('easybuild/scripts/*')), ('etc', glob.glob('etc/*')), From 1f53bd459d57f5b3a6411ce1136a4c3bada251b6 Mon Sep 17 00:00:00 2001 From: crivella Date: Thu, 17 Jul 2025 10:45:39 +0200 Subject: [PATCH 03/18] Avoid warning of double arg initialization if `prepare_main` did not error --- easybuild/main.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/easybuild/main.py b/easybuild/main.py index 2dee1c8374..93071f5134 100755 --- a/easybuild/main.py +++ b/easybuild/main.py @@ -808,6 +808,8 @@ def main_with_hooks(args=None): init_session_state, eb_go, cfg_settings = prepare_main(args=args) except EasyBuildError as err: print_error(err.msg, exit_code=err.exit_code) + else: + args = None hooks = load_hooks(eb_go.options.hooks) From 2788a5923d73b303507d0880fc855e536cbee3f9 Mon Sep 17 00:00:00 2001 From: crivella Date: Thu, 17 Jul 2025 10:45:50 +0200 Subject: [PATCH 04/18] Move to static method --- easybuild/cli/options/__init__.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/easybuild/cli/options/__init__.py b/easybuild/cli/options/__init__.py index 89577a89bb..2c70c08b4b 100644 --- a/easybuild/cli/options/__init__.py +++ b/easybuild/cli/options/__init__.py @@ -26,11 +26,6 @@ def add_group_parser(self, opt_dict, descr, *args, prefix='', **kwargs): extracter = OptionExtracter(go_args=[]) -def register_hidden_param(ctx, param, value): - """Register a hidden parameter in the context.""" - if not hasattr(ctx, 'hidden_params'): - ctx.hidden_params = {} - ctx.hidden_params[param.name] = value @dataclass class OptionData: @@ -75,10 +70,17 @@ def to_click_option_dec(self): return click.option( *decls, expose_value=False, - callback=register_hidden_param, + callback=self.register_hidden_param, **kwargs ) + @staticmethod + def register_hidden_param(ctx, param, value): + """Register a hidden parameter in the context.""" + if not hasattr(ctx, 'hidden_params'): + ctx.hidden_params = {} + ctx.hidden_params[param.name] = value + class EasyBuildCliOption(): OPTIONS: list[OptionData] = [] OPTIONS_MAP: dict[str, OptionData] = {} From 2ed493be9e6a92b878462f5534129fed7822d595 Mon Sep 17 00:00:00 2001 From: crivella Date: Fri, 18 Jul 2025 13:37:33 +0200 Subject: [PATCH 05/18] Added list paramter conversion and better autocomplete --- easybuild/cli/__init__.py | 27 ++++- easybuild/cli/options/__init__.py | 175 ++++++++++++++++++++++++++++-- 2 files changed, 190 insertions(+), 12 deletions(-) diff --git a/easybuild/cli/__init__.py b/easybuild/cli/__init__.py index f759603645..d05e5f2a95 100644 --- a/easybuild/cli/__init__.py +++ b/easybuild/cli/__init__.py @@ -1,19 +1,26 @@ +import os + try: import rich_click as click + import click as original_click except ImportError: import click + import click as original_click try: from rich.traceback import install except ImportError: pass else: - install(suppress=[click]) + install(suppress=[ + click, original_click + ]) from .options import EasyBuildCliOption from easybuild.main import main_with_hooks + @click.command() @EasyBuildCliOption.apply_options @click.pass_context @@ -27,9 +34,21 @@ def eb(ctx, other_args): if value: args.append(f"--{key}") else: - if value and value != EasyBuildCliOption.OPTIONS_MAP[key].default: - if isinstance(value, (list, tuple)): - value = ','.join(value) + opt = EasyBuildCliOption.OPTIONS_MAP[key] + if value and value != opt.default: + if isinstance(value, (list, tuple)) and value: + if isinstance(value[0], list): + value = sum(value, []) + if 'path' in opt.type: + delim = os.pathsep + elif 'str' in opt.type: + delim = ',' + elif 'url' in opt.type: + delim = '|' + else: + raise ValueError(f"Unsupported type for {key}: {opt.type}") + value = delim.join(value) + print(f"--Adding {key}={value} to args") args.append(f"--{key}={value}") args.extend(other_args) diff --git a/easybuild/cli/options/__init__.py b/easybuild/cli/options/__init__.py index 2c70c08b4b..d9402403ee 100644 --- a/easybuild/cli/options/__init__.py +++ b/easybuild/cli/options/__init__.py @@ -1,8 +1,12 @@ +# import logging import os from typing import Callable, Any from dataclasses import dataclass +from click.shell_completion import CompletionItem +from easybuild.tools.options import EasyBuildOptions + opt_group = {} try: import rich_click as click @@ -10,10 +14,8 @@ import click else: opt_group = click.rich_click.OPTION_GROUPS + opt_group.clear() # Clear existing groups to avoid conflicts -from easybuild.tools.options import EasyBuildOptions - -DEBUG_EASYBUILD_OPTIONS = os.environ.get('DEBUG_EASYBUILD_OPTIONS', '').lower() in ('1', 'true', 'yes', 'y') class OptionExtracter(EasyBuildOptions): def __init__(self, *args, **kwargs): @@ -24,9 +26,69 @@ def add_group_parser(self, opt_dict, descr, *args, prefix='', **kwargs): super().add_group_parser(opt_dict, descr, *args, prefix=prefix, **kwargs) self._option_dicts[descr[0]] = (prefix, opt_dict) + extracter = OptionExtracter(go_args=[]) +class DelimitedPathList(click.Path): + """Custom Click parameter type for delimited lists.""" + name = 'pathlist' + + def __init__(self, *args, delimiter=',', resolve_full: bool = True, **kwargs): + super().__init__(*args, **kwargs) + self.delimiter = delimiter + self.resolve_full = resolve_full + + def convert(self, value, param, ctx): + if not isinstance(value, str): + raise click.BadParameter(f"Expected a comma-separated string, got {value}") + res = value.split(self.delimiter) + if self.resolve_full: + res = [os.path.abspath(v) for v in res] + return res + + def shell_complete(self, ctx, param, incomplete): + others, last = ([''] + incomplete.rsplit(self.delimiter, 1))[-2:] + # logging.warning(f"Shell completion for delimited path list: others={others}, last={last}") + dir_path, prefix = os.path.split(last) + dir_path = dir_path or '.' + # logging.warning(f"Shell completion for delimited path list: dir_path={dir_path}, prefix={prefix}") + possibles = [] + for path in os.listdir(dir_path): + if not path.startswith(prefix): + continue + full_path = os.path.join(dir_path, path) + if os.path.isdir(full_path): + if self.dir_okay: + possibles.append(full_path) + possibles.append(full_path + os.sep) + elif os.path.isfile(full_path): + if self.file_okay: + possibles.append(full_path) + start = f'{others}{self.delimiter}' if others else '' + res = [CompletionItem(f"{start}{path}") for path in possibles] + # logging.warning(f"Shell completion for delimited path list: res={possibles}") + return res + + +class DelimitedString(click.ParamType): + """Custom Click parameter type for delimited strings.""" + name = 'strlist' + + def __init__(self, *args, delimiter=',', **kwargs): + super().__init__(*args, **kwargs) + self.delimiter = delimiter + + def convert(self, value, param, ctx): + if isinstance(value, str): + return value.split(self.delimiter) + raise click.BadParameter(f"Expected a string or a comma-separated string, got {value}") + + def shell_complete(self, ctx, param, incomplete): + last = incomplete.rsplit(self.delimiter, 1)[-1] + return super().shell_complete(ctx, param, last) + + @dataclass class OptionData: name: str @@ -55,17 +117,112 @@ def to_click_option_dec(self): kwargs = { 'help': self.description, - # 'help': '123', 'default': self.default, 'show_default': True, + 'type': None } - if self.default is False or self.default is True: - kwargs['is_flag'] = True - - if isinstance(self.default, (list, tuple)): + if self.type in ['strlist', 'strtuple']: + kwargs['type'] = DelimitedString(delimiter=',') kwargs['multiple'] = True + elif self.type in ['pathlist', 'pathtuple']: + # kwargs['type'] = DelimitedPathList(delimiter=os.pathsep) + kwargs['type'] = DelimitedPathList(delimiter=',') + kwargs['multiple'] = True + elif self.type in ['urllist', 'urltuple']: + kwargs['type'] = DelimitedString(delimiter='|') + kwargs['multiple'] = True + elif self.type == 'choice': + if self.lst is None: + raise ValueError(f"Choice type requires a list of choices for option {self.name}") + kwargs['type'] = click.Choice(self.lst, case_sensitive=False) + elif self.type in ['int', int]: + kwargs['type'] = click.INT + elif self.type in ['float', float]: + kwargs['type'] = click.FLOAT + elif self.type in ['str', str]: kwargs['type'] = click.STRING + elif self.type is None: + if self.default is False or self.default is True: + kwargs['is_flag'] = True + kwargs['type'] = click.BOOL + elif isinstance(self.default, (list, tuple)): + kwargs['multiple'] = True + kwargs['type'] = click.STRING + + # if kwargs['type'] is None: + # print(f"Warning: No type specified for option {self.name}, defaulting to STRING") + + # actions = set() + # for opt in EasyBuildCliOption.OPTIONS: + # actions.add(opt.action) + # print(f"Registered {len(EasyBuildCliOption.OPTIONS)} options with actions: {actions}") + # # Registered 296 options with actions: { + # # 'store_infolog', 'add_flex', 'append', 'add', 'store_true', 'store_debuglog', 'store_or_None', + # # 'store_warninglog', 'store', 'extend', 'regex' + # # } + + # Actions: + # - shorthelp : hook for shortend help messages + # - confighelp : hook for configfile-style help messages + # - store_debuglog : turns on fancylogger debugloglevel + # - also: 'store_infolog', 'store_warninglog' + # - add : add value to default (result is default + value) + # - add_first : add default to value (result is value + default) + # - extend : alias for add with strlist type + # - type must support + (__add__) and one of negate (__neg__) or slicing (__getslice__) + # - add_flex : similar to add / add_first, but replaces the first "empty" element with the default + # - the empty element is dependent of the type + # - for {str,path}{list,tuple} this is the empty string + # - types must support the index method to determine the location of the "empty" element + # - the replacement uses + + # - e.g. a strlist type with value "0,,1"` and default [3,4] and action add_flex will + # use the empty string '' as "empty" element, and will result in [0,3,4,1] (not [0,[3,4],1]) + # (but also a strlist with value "" and default [3,4] will result in [3,4]; + # so you can't set an empty list with add_flex) + # - date : convert into datetime.date + # - datetime : convert into datetime.datetime + # - regex: compile str in regexp + # - store_or_None + # - set default to None if no option passed, + # - set to default if option without value passed, + # - set to value if option with value passed + + # Types: + # - strlist, strtuple : convert comma-separated string in a list resp. tuple of strings + # - pathlist, pathtuple : using os.pathsep, convert pathsep-separated string in a list resp. tuple of strings + # - the path separator is OS-dependent + # - urllist, urltuple: convert string seperated by '|' to a list resp. tuple of strings + + # def take_action(self, action, dest, opt, value, values, parser): + # if action == "store": + # setattr(values, dest, value) + # elif action == "store_const": + # setattr(values, dest, self.const) + # elif action == "store_true": + # setattr(values, dest, True) + # elif action == "store_false": + # setattr(values, dest, False) + # elif action == "append": + # values.ensure_value(dest, []).append(value) + # elif action == "append_const": + # values.ensure_value(dest, []).append(self.const) + # elif action == "count": + # setattr(values, dest, values.ensure_value(dest, 0) + 1) + # elif action == "callback": + # args = self.callback_args or () + # kwargs = self.callback_kwargs or {} + # self.callback(self, opt, value, parser, *args, **kwargs) + # elif action == "help": + # parser.print_help() + # parser.exit() + # elif action == "version": + # parser.print_version() + # parser.exit() + # else: + # raise ValueError("unknown action %r" % self.action) + + # return 1 return click.option( *decls, @@ -81,6 +238,7 @@ def register_hidden_param(ctx, param, value): ctx.hidden_params = {} ctx.hidden_params[param.name] = value + class EasyBuildCliOption(): OPTIONS: list[OptionData] = [] OPTIONS_MAP: dict[str, OptionData] = {} @@ -144,6 +302,7 @@ def register_option(cls, group: str, name: str, data: tuple, prefix: str = '') - cls.OPTIONS_MAP[name] = opt cls.OPTIONS.append(opt) + for grp, dct in extracter._option_dicts.items(): prefix, dct = dct if dct is None: From ba9af0b6e47dacad719b52f58a4b1dc0cc1dd73d Mon Sep 17 00:00:00 2001 From: crivella Date: Fri, 18 Jul 2025 13:38:33 +0200 Subject: [PATCH 06/18] Removed comments --- easybuild/cli/options/__init__.py | 74 ------------------------------- 1 file changed, 74 deletions(-) diff --git a/easybuild/cli/options/__init__.py b/easybuild/cli/options/__init__.py index d9402403ee..935adf2d41 100644 --- a/easybuild/cli/options/__init__.py +++ b/easybuild/cli/options/__init__.py @@ -150,80 +150,6 @@ def to_click_option_dec(self): kwargs['multiple'] = True kwargs['type'] = click.STRING - # if kwargs['type'] is None: - # print(f"Warning: No type specified for option {self.name}, defaulting to STRING") - - # actions = set() - # for opt in EasyBuildCliOption.OPTIONS: - # actions.add(opt.action) - # print(f"Registered {len(EasyBuildCliOption.OPTIONS)} options with actions: {actions}") - # # Registered 296 options with actions: { - # # 'store_infolog', 'add_flex', 'append', 'add', 'store_true', 'store_debuglog', 'store_or_None', - # # 'store_warninglog', 'store', 'extend', 'regex' - # # } - - # Actions: - # - shorthelp : hook for shortend help messages - # - confighelp : hook for configfile-style help messages - # - store_debuglog : turns on fancylogger debugloglevel - # - also: 'store_infolog', 'store_warninglog' - # - add : add value to default (result is default + value) - # - add_first : add default to value (result is value + default) - # - extend : alias for add with strlist type - # - type must support + (__add__) and one of negate (__neg__) or slicing (__getslice__) - # - add_flex : similar to add / add_first, but replaces the first "empty" element with the default - # - the empty element is dependent of the type - # - for {str,path}{list,tuple} this is the empty string - # - types must support the index method to determine the location of the "empty" element - # - the replacement uses + - # - e.g. a strlist type with value "0,,1"` and default [3,4] and action add_flex will - # use the empty string '' as "empty" element, and will result in [0,3,4,1] (not [0,[3,4],1]) - # (but also a strlist with value "" and default [3,4] will result in [3,4]; - # so you can't set an empty list with add_flex) - # - date : convert into datetime.date - # - datetime : convert into datetime.datetime - # - regex: compile str in regexp - # - store_or_None - # - set default to None if no option passed, - # - set to default if option without value passed, - # - set to value if option with value passed - - # Types: - # - strlist, strtuple : convert comma-separated string in a list resp. tuple of strings - # - pathlist, pathtuple : using os.pathsep, convert pathsep-separated string in a list resp. tuple of strings - # - the path separator is OS-dependent - # - urllist, urltuple: convert string seperated by '|' to a list resp. tuple of strings - - # def take_action(self, action, dest, opt, value, values, parser): - # if action == "store": - # setattr(values, dest, value) - # elif action == "store_const": - # setattr(values, dest, self.const) - # elif action == "store_true": - # setattr(values, dest, True) - # elif action == "store_false": - # setattr(values, dest, False) - # elif action == "append": - # values.ensure_value(dest, []).append(value) - # elif action == "append_const": - # values.ensure_value(dest, []).append(self.const) - # elif action == "count": - # setattr(values, dest, values.ensure_value(dest, 0) + 1) - # elif action == "callback": - # args = self.callback_args or () - # kwargs = self.callback_kwargs or {} - # self.callback(self, opt, value, parser, *args, **kwargs) - # elif action == "help": - # parser.print_help() - # parser.exit() - # elif action == "version": - # parser.print_version() - # parser.exit() - # else: - # raise ValueError("unknown action %r" % self.action) - - # return 1 - return click.option( *decls, expose_value=False, From b395a81ad94b3507cdd51baa3f3d179b4763a703 Mon Sep 17 00:00:00 2001 From: crivella Date: Fri, 18 Jul 2025 13:39:39 +0200 Subject: [PATCH 07/18] Fix potential missing attribute --- easybuild/cli/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/easybuild/cli/__init__.py b/easybuild/cli/__init__.py index d05e5f2a95..c8c6c63854 100644 --- a/easybuild/cli/__init__.py +++ b/easybuild/cli/__init__.py @@ -28,7 +28,7 @@ def eb(ctx, other_args): """EasyBuild command line interface.""" args = [] - for key, value in ctx.hidden_params.items(): + for key, value in getattr(ctx, 'hidden_params', {}).items(): key = key.replace('_', '-') if isinstance(value, bool): if value: From c93558dcf402360ad50aded43c8e368b16e04714 Mon Sep 17 00:00:00 2001 From: crivella Date: Fri, 18 Jul 2025 14:30:46 +0200 Subject: [PATCH 08/18] Added autocompletion for EC files --- easybuild/cli/__init__.py | 8 +++++--- easybuild/cli/options/__init__.py | 15 ++++++++++++++- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/easybuild/cli/__init__.py b/easybuild/cli/__init__.py index c8c6c63854..2be53f66f4 100644 --- a/easybuild/cli/__init__.py +++ b/easybuild/cli/__init__.py @@ -16,7 +16,7 @@ click, original_click ]) -from .options import EasyBuildCliOption +from .options import EasyBuildCliOption, EasyconfigParam from easybuild.main import main_with_hooks @@ -24,17 +24,19 @@ @click.command() @EasyBuildCliOption.apply_options @click.pass_context -@click.argument('other_args', nargs=-1, type=click.UNPROCESSED, required=False) +@click.argument('other_args', nargs=-1, type=EasyconfigParam(), required=False) def eb(ctx, other_args): """EasyBuild command line interface.""" args = [] for key, value in getattr(ctx, 'hidden_params', {}).items(): key = key.replace('_', '-') + opt = EasyBuildCliOption.OPTIONS_MAP[key] + if value in ['False', 'True']: + value = value == 'True' if isinstance(value, bool): if value: args.append(f"--{key}") else: - opt = EasyBuildCliOption.OPTIONS_MAP[key] if value and value != opt.default: if isinstance(value, (list, tuple)) and value: if isinstance(value[0], list): diff --git a/easybuild/cli/options/__init__.py b/easybuild/cli/options/__init__.py index 935adf2d41..8a0ea01edb 100644 --- a/easybuild/cli/options/__init__.py +++ b/easybuild/cli/options/__init__.py @@ -5,7 +5,9 @@ from dataclasses import dataclass from click.shell_completion import CompletionItem -from easybuild.tools.options import EasyBuildOptions +from easybuild.tools.options import EasyBuildOptions, set_up_configuration +from easybuild.tools.robot import search_easyconfigs + opt_group = {} try: @@ -89,6 +91,17 @@ def shell_complete(self, ctx, param, incomplete): return super().shell_complete(ctx, param, last) +class EasyconfigParam(click.ParamType): + """Custom Click parameter type for easyconfig parameters.""" + name = 'easyconfig' + + def shell_complete(self, ctx, param, incomplete): + if not incomplete: + return [] + set_up_configuration(args=["--ignore-index"], silent=True, reconfigure=True) + return [CompletionItem(ec) for ec in search_easyconfigs(fr'^{incomplete}.*\.eb$', filename_only=True)] + + @dataclass class OptionData: name: str From fac6436641b992e427fa2527af18c07f31dcda9a Mon Sep 17 00:00:00 2001 From: crivella Date: Fri, 18 Jul 2025 14:44:09 +0200 Subject: [PATCH 09/18] Better checks for default values --- easybuild/cli/__init__.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/easybuild/cli/__init__.py b/easybuild/cli/__init__.py index 2be53f66f4..d3c9d63e81 100644 --- a/easybuild/cli/__init__.py +++ b/easybuild/cli/__init__.py @@ -37,10 +37,15 @@ def eb(ctx, other_args): if value: args.append(f"--{key}") else: + if isinstance(value, (list, tuple)) and value: + # Flatten nested lists if necessary + if isinstance(value[0], list): + value = sum(value, []) + # Match the type of the option with the default to see if we need to add it + if isinstance(value, list) and isinstance(opt.default, tuple): + value = tuple(value) if value and value != opt.default: - if isinstance(value, (list, tuple)) and value: - if isinstance(value[0], list): - value = sum(value, []) + if isinstance(value, (list, tuple)): if 'path' in opt.type: delim = os.pathsep elif 'str' in opt.type: @@ -50,7 +55,8 @@ def eb(ctx, other_args): else: raise ValueError(f"Unsupported type for {key}: {opt.type}") value = delim.join(value) - print(f"--Adding {key}={value} to args") + + # print(f"--Adding {key}={value} to args") args.append(f"--{key}={value}") args.extend(other_args) From fd569800ae5ebabb9a61903e4aa24f031d9f109d Mon Sep 17 00:00:00 2001 From: crivella Date: Fri, 18 Jul 2025 16:34:59 +0200 Subject: [PATCH 10/18] - Fixed behavior of bool --X/--disable-X - Fixed beahvore of defaults with `store_or_None` - Fixed checking defaults vs list/tuple instead of flattened value - Added checks for list values in covert comming from the defaults --- easybuild/cli/__init__.py | 61 ++++++++++++++++++++++--------- easybuild/cli/options/__init__.py | 34 +++++++++++++---- 2 files changed, 70 insertions(+), 25 deletions(-) diff --git a/easybuild/cli/__init__.py b/easybuild/cli/__init__.py index d3c9d63e81..4dfdb74ec3 100644 --- a/easybuild/cli/__init__.py +++ b/easybuild/cli/__init__.py @@ -31,34 +31,61 @@ def eb(ctx, other_args): for key, value in getattr(ctx, 'hidden_params', {}).items(): key = key.replace('_', '-') opt = EasyBuildCliOption.OPTIONS_MAP[key] + # TEST_KEYS = [ + # 'robot', + # 'robot-paths', + # 'map-toolchains', + # 'search-paths', + # ] + # if key in TEST_KEYS: + # print(f'{key.upper()}: `{value=}` `{type(value)=}` `{opt.default=}` `{opt.action=}`') if value in ['False', 'True']: value = value == 'True' if isinstance(value, bool): - if value: - args.append(f"--{key}") + if value != opt.default: + if value: + args.append(f"--{key}") + else: + args.append(f"--disable-{key}") else: + if isinstance(value, (list, tuple)) and value: # Flatten nested lists if necessary if isinstance(value[0], list): value = sum(value, []) # Match the type of the option with the default to see if we need to add it - if isinstance(value, list) and isinstance(opt.default, tuple): + if value and isinstance(value, list) and isinstance(opt.default, tuple): value = tuple(value) - if value and value != opt.default: - if isinstance(value, (list, tuple)): - if 'path' in opt.type: - delim = os.pathsep - elif 'str' in opt.type: - delim = ',' - elif 'url' in opt.type: - delim = '|' - else: - raise ValueError(f"Unsupported type for {key}: {opt.type}") - value = delim.join(value) + if value and isinstance(value, tuple) and isinstance(opt.default, list): + value = list(value) + value_is_default = (value == opt.default) - # print(f"--Adding {key}={value} to args") - args.append(f"--{key}={value}") + value_flattened = value + if isinstance(value, (list, tuple)): + if 'path' in opt.type: + delim = os.pathsep + elif 'str' in opt.type: + delim = ',' + elif 'url' in opt.type: + delim = '|' + else: + raise ValueError(f"Unsupported type for {key}: {opt.type}") + value_flattened = delim.join(value) - args.extend(other_args) + if opt.action == 'store_or_None': + if value is None or value == (): + continue + if value_is_default: + args.append(f"--{key}") + else: + args.append(f"--{key}={value_flattened}") + elif value and not value_is_default: + if value: + args.append(f"--{key}={value_flattened}") + else: + args.append(f"--{key}") + # for arg in args: + # print(f"ARG: {arg}") + args.extend(other_args) main_with_hooks(args=args) diff --git a/easybuild/cli/options/__init__.py b/easybuild/cli/options/__init__.py index 8a0ea01edb..8f974b5102 100644 --- a/easybuild/cli/options/__init__.py +++ b/easybuild/cli/options/__init__.py @@ -42,11 +42,16 @@ def __init__(self, *args, delimiter=',', resolve_full: bool = True, **kwargs): self.resolve_full = resolve_full def convert(self, value, param, ctx): - if not isinstance(value, str): + # logging.warning(f"{param=} convert called with `{value=}`, `{type(value)=}`") + if isinstance(value, str): + res = value.split(self.delimiter) + elif isinstance(value, (list, tuple)): + res = value + else: raise click.BadParameter(f"Expected a comma-separated string, got {value}") - res = value.split(self.delimiter) if self.resolve_full: res = [os.path.abspath(v) for v in res] + # logging.warning(f"{param=} convert returning `{res=}`") return res def shell_complete(self, ctx, param, incomplete): @@ -83,8 +88,12 @@ def __init__(self, *args, delimiter=',', **kwargs): def convert(self, value, param, ctx): if isinstance(value, str): - return value.split(self.delimiter) - raise click.BadParameter(f"Expected a string or a comma-separated string, got {value}") + res = value.split(self.delimiter) + elif isinstance(value, (list, tuple)): + res = value + else: + raise click.BadParameter(f"Expected a string or a comma-separated string, got {value}") + return res def shell_complete(self, ctx, param, incomplete): last = incomplete.rsplit(self.delimiter, 1)[-1] @@ -124,13 +133,15 @@ def __post_init__(self): def to_click_option_dec(self): """Convert OptionData to a click.Option.""" - decls = [f"--{self.name}"] + decl = f"--{self.name}" + other_decls = [] if self.short: - decls.insert(0, f"-{self.short}") + other_decls.insert(0, f"-{self.short}") kwargs = { 'help': self.description, 'default': self.default, + 'is_flag': False, 'show_default': True, 'type': None } @@ -139,7 +150,6 @@ def to_click_option_dec(self): kwargs['type'] = DelimitedString(delimiter=',') kwargs['multiple'] = True elif self.type in ['pathlist', 'pathtuple']: - # kwargs['type'] = DelimitedPathList(delimiter=os.pathsep) kwargs['type'] = DelimitedPathList(delimiter=',') kwargs['multiple'] = True elif self.type in ['urllist', 'urltuple']: @@ -148,7 +158,7 @@ def to_click_option_dec(self): elif self.type == 'choice': if self.lst is None: raise ValueError(f"Choice type requires a list of choices for option {self.name}") - kwargs['type'] = click.Choice(self.lst, case_sensitive=False) + kwargs['type'] = click.Choice(self.lst, case_sensitive=True) elif self.type in ['int', int]: kwargs['type'] = click.INT elif self.type in ['float', float]: @@ -159,10 +169,18 @@ def to_click_option_dec(self): if self.default is False or self.default is True: kwargs['is_flag'] = True kwargs['type'] = click.BOOL + if self.action in ['store_true', 'store_false']: + decl = f"--{self.name}/--disable-{self.name}" elif isinstance(self.default, (list, tuple)): kwargs['multiple'] = True kwargs['type'] = click.STRING + if self.action == 'store_or_None': + kwargs['default'] = None + kwargs['flag_value'] = self.default + + decls = other_decls + [decl] + return click.option( *decls, expose_value=False, From 5dc9dea1f934ead5a24a9d7a8eea226100e767d2 Mon Sep 17 00:00:00 2001 From: crivella Date: Thu, 31 Jul 2025 13:16:24 +0200 Subject: [PATCH 11/18] Do not resolve full path to avoid `:` in `robot-paths` being improperly converted --- easybuild/cli/options/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/easybuild/cli/options/__init__.py b/easybuild/cli/options/__init__.py index 8f974b5102..a1b7c18041 100644 --- a/easybuild/cli/options/__init__.py +++ b/easybuild/cli/options/__init__.py @@ -36,7 +36,7 @@ class DelimitedPathList(click.Path): """Custom Click parameter type for delimited lists.""" name = 'pathlist' - def __init__(self, *args, delimiter=',', resolve_full: bool = True, **kwargs): + def __init__(self, *args, delimiter=',', resolve_full: bool = False, **kwargs): super().__init__(*args, **kwargs) self.delimiter = delimiter self.resolve_full = resolve_full From 09b5735757b666410330c8bf599a7effaf225bf0 Mon Sep 17 00:00:00 2001 From: crivella Date: Thu, 31 Jul 2025 13:54:33 +0200 Subject: [PATCH 12/18] Ensure that if `click` is not present a nicer message is shown --- easybuild/cli/__init__.py | 154 +++++++++++++++++++------------------- 1 file changed, 77 insertions(+), 77 deletions(-) diff --git a/easybuild/cli/__init__.py b/easybuild/cli/__init__.py index 4dfdb74ec3..dd574adb1f 100644 --- a/easybuild/cli/__init__.py +++ b/easybuild/cli/__init__.py @@ -1,91 +1,91 @@ import os +import sys try: - import rich_click as click import click as original_click except ImportError: - import click - import click as original_click - -try: - from rich.traceback import install -except ImportError: - pass + def eb(): + """Placeholder function to inform the user that `click` is required.""" + print("Using `eb2` requires `click` to be installed. Either use `eb` or install `click` with `pip install click`.") + print("`eb2` also uses `rich` and `rich_click` as optional dependencies for enhanced CLI experience.") + print("Exiting...") + sys.exit(0) else: - install(suppress=[ - click, original_click - ]) + try: + import rich_click as click + except ImportError: + import click -from .options import EasyBuildCliOption, EasyconfigParam + try: + from rich.traceback import install + except ImportError: + pass + else: + install(suppress=[ + click, original_click + ]) -from easybuild.main import main_with_hooks + from .options import EasyBuildCliOption, EasyconfigParam + from easybuild.main import main_with_hooks -@click.command() -@EasyBuildCliOption.apply_options -@click.pass_context -@click.argument('other_args', nargs=-1, type=EasyconfigParam(), required=False) -def eb(ctx, other_args): - """EasyBuild command line interface.""" - args = [] - for key, value in getattr(ctx, 'hidden_params', {}).items(): - key = key.replace('_', '-') - opt = EasyBuildCliOption.OPTIONS_MAP[key] - # TEST_KEYS = [ - # 'robot', - # 'robot-paths', - # 'map-toolchains', - # 'search-paths', - # ] - # if key in TEST_KEYS: - # print(f'{key.upper()}: `{value=}` `{type(value)=}` `{opt.default=}` `{opt.action=}`') - if value in ['False', 'True']: - value = value == 'True' - if isinstance(value, bool): - if value != opt.default: - if value: - args.append(f"--{key}") - else: - args.append(f"--disable-{key}") - else: + @click.command() + @EasyBuildCliOption.apply_options + @click.pass_context + @click.argument('other_args', nargs=-1, type=EasyconfigParam(), required=False) + def eb(ctx, other_args): + """EasyBuild command line interface.""" + args = [] + for key, value in getattr(ctx, 'hidden_params', {}).items(): + key = key.replace('_', '-') + opt = EasyBuildCliOption.OPTIONS_MAP[key] + if value in ['False', 'True']: + value = value == 'True' + if isinstance(value, bool): + if value != opt.default: + if value: + args.append(f"--{key}") + else: + args.append(f"--disable-{key}") + else: - if isinstance(value, (list, tuple)) and value: - # Flatten nested lists if necessary - if isinstance(value[0], list): - value = sum(value, []) - # Match the type of the option with the default to see if we need to add it - if value and isinstance(value, list) and isinstance(opt.default, tuple): - value = tuple(value) - if value and isinstance(value, tuple) and isinstance(opt.default, list): - value = list(value) - value_is_default = (value == opt.default) + if isinstance(value, (list, tuple)) and value: + # Flatten nested lists if necessary + if isinstance(value[0], list): + value = sum(value, []) + # Match the type of the option with the default to see if we need to add it + if value and isinstance(value, list) and isinstance(opt.default, tuple): + value = tuple(value) + if value and isinstance(value, tuple) and isinstance(opt.default, list): + value = list(value) + value_is_default = (value == opt.default) - value_flattened = value - if isinstance(value, (list, tuple)): - if 'path' in opt.type: - delim = os.pathsep - elif 'str' in opt.type: - delim = ',' - elif 'url' in opt.type: - delim = '|' - else: - raise ValueError(f"Unsupported type for {key}: {opt.type}") - value_flattened = delim.join(value) + value_flattened = value + if isinstance(value, (list, tuple)): + if 'path' in opt.type: + delim = os.pathsep + elif 'str' in opt.type: + delim = ',' + elif 'url' in opt.type: + delim = '|' + else: + raise ValueError(f"Unsupported type for {key}: {opt.type}") + value_flattened = delim.join(value) - if opt.action == 'store_or_None': - if value is None or value == (): - continue - if value_is_default: - args.append(f"--{key}") - else: - args.append(f"--{key}={value_flattened}") - elif value and not value_is_default: - if value: - args.append(f"--{key}={value_flattened}") - else: - args.append(f"--{key}") - # for arg in args: - # print(f"ARG: {arg}") + if opt.action == 'store_or_None': + if value is None or value == (): + continue + if value_is_default: + args.append(f"--{key}") + else: + args.append(f"--{key}={value_flattened}") + elif value and not value_is_default: + if value: + args.append(f"--{key}={value_flattened}") + else: + args.append(f"--{key}") + for arg in args: + print(f"ARG: {arg}") - args.extend(other_args) - main_with_hooks(args=args) + args.extend(other_args) + main_with_hooks(args=args) From e152b0c7acdb205d20e05a30f5ecf0f041aa3b79 Mon Sep 17 00:00:00 2001 From: crivella Date: Thu, 31 Jul 2025 13:56:43 +0200 Subject: [PATCH 13/18] Use pathsep as delimiter for paths and allow empty initial path in autocomplete --- easybuild/cli/options/__init__.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/easybuild/cli/options/__init__.py b/easybuild/cli/options/__init__.py index a1b7c18041..c67eae8e13 100644 --- a/easybuild/cli/options/__init__.py +++ b/easybuild/cli/options/__init__.py @@ -1,4 +1,3 @@ -# import logging import os from typing import Callable, Any @@ -55,7 +54,7 @@ def convert(self, value, param, ctx): return res def shell_complete(self, ctx, param, incomplete): - others, last = ([''] + incomplete.rsplit(self.delimiter, 1))[-2:] + others, last = ([None] + incomplete.rsplit(self.delimiter, 1))[-2:] # logging.warning(f"Shell completion for delimited path list: others={others}, last={last}") dir_path, prefix = os.path.split(last) dir_path = dir_path or '.' @@ -72,7 +71,7 @@ def shell_complete(self, ctx, param, incomplete): elif os.path.isfile(full_path): if self.file_okay: possibles.append(full_path) - start = f'{others}{self.delimiter}' if others else '' + start = f'{others}{self.delimiter}' if others is not None else '' res = [CompletionItem(f"{start}{path}") for path in possibles] # logging.warning(f"Shell completion for delimited path list: res={possibles}") return res @@ -105,10 +104,8 @@ class EasyconfigParam(click.ParamType): name = 'easyconfig' def shell_complete(self, ctx, param, incomplete): - if not incomplete: - return [] set_up_configuration(args=["--ignore-index"], silent=True, reconfigure=True) - return [CompletionItem(ec) for ec in search_easyconfigs(fr'^{incomplete}.*\.eb$', filename_only=True)] + return [CompletionItem(ec, help='') for ec in search_easyconfigs(fr'^{incomplete}.*\.eb$', filename_only=True)] @dataclass @@ -150,7 +147,7 @@ def to_click_option_dec(self): kwargs['type'] = DelimitedString(delimiter=',') kwargs['multiple'] = True elif self.type in ['pathlist', 'pathtuple']: - kwargs['type'] = DelimitedPathList(delimiter=',') + kwargs['type'] = DelimitedPathList(delimiter=os.pathsep) kwargs['multiple'] = True elif self.type in ['urllist', 'urltuple']: kwargs['type'] = DelimitedString(delimiter='|') From 7e8512ec3c8d4c7d1e6e867cbb604153151bbcd4 Mon Sep 17 00:00:00 2001 From: crivella Date: Thu, 31 Jul 2025 13:58:21 +0200 Subject: [PATCH 14/18] Passthrough the arguments from the CLI to optparse instead of rebuilding the, --- easybuild/cli/__init__.py | 61 +++------------------------------------ 1 file changed, 4 insertions(+), 57 deletions(-) diff --git a/easybuild/cli/__init__.py b/easybuild/cli/__init__.py index dd574adb1f..56770e7866 100644 --- a/easybuild/cli/__init__.py +++ b/easybuild/cli/__init__.py @@ -1,4 +1,3 @@ -import os import sys try: @@ -31,61 +30,9 @@ def eb(): @click.command() @EasyBuildCliOption.apply_options - @click.pass_context @click.argument('other_args', nargs=-1, type=EasyconfigParam(), required=False) - def eb(ctx, other_args): + def eb(other_args): """EasyBuild command line interface.""" - args = [] - for key, value in getattr(ctx, 'hidden_params', {}).items(): - key = key.replace('_', '-') - opt = EasyBuildCliOption.OPTIONS_MAP[key] - if value in ['False', 'True']: - value = value == 'True' - if isinstance(value, bool): - if value != opt.default: - if value: - args.append(f"--{key}") - else: - args.append(f"--disable-{key}") - else: - - if isinstance(value, (list, tuple)) and value: - # Flatten nested lists if necessary - if isinstance(value[0], list): - value = sum(value, []) - # Match the type of the option with the default to see if we need to add it - if value and isinstance(value, list) and isinstance(opt.default, tuple): - value = tuple(value) - if value and isinstance(value, tuple) and isinstance(opt.default, list): - value = list(value) - value_is_default = (value == opt.default) - - value_flattened = value - if isinstance(value, (list, tuple)): - if 'path' in opt.type: - delim = os.pathsep - elif 'str' in opt.type: - delim = ',' - elif 'url' in opt.type: - delim = '|' - else: - raise ValueError(f"Unsupported type for {key}: {opt.type}") - value_flattened = delim.join(value) - - if opt.action == 'store_or_None': - if value is None or value == (): - continue - if value_is_default: - args.append(f"--{key}") - else: - args.append(f"--{key}={value_flattened}") - elif value and not value_is_default: - if value: - args.append(f"--{key}={value_flattened}") - else: - args.append(f"--{key}") - for arg in args: - print(f"ARG: {arg}") - - args.extend(other_args) - main_with_hooks(args=args) + # Really no need to re-build the arguments if we support the exact same syntax we can just let them pass + # through to optparse + main_with_hooks() From c376375c3315d16c1a23dd2fdafe39105f5c251f Mon Sep 17 00:00:00 2001 From: crivella Date: Thu, 31 Jul 2025 14:15:59 +0200 Subject: [PATCH 15/18] Improve autocomplete --- easybuild/cli/options/__init__.py | 39 ++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/easybuild/cli/options/__init__.py b/easybuild/cli/options/__init__.py index c67eae8e13..edba6d4ca6 100644 --- a/easybuild/cli/options/__init__.py +++ b/easybuild/cli/options/__init__.py @@ -18,6 +18,34 @@ opt_group.clear() # Clear existing groups to avoid conflicts +KNOWN_FILEPATH_OPTS = [ + 'hooks', + 'modules-footer', + 'modules-header', +] + +KNOWN_DIRPATH_OPTS = [ + 'locks-dir', + + 'failed-install-build-dirs-path', + 'failed-install-logs-path', + 'installpath-data', + 'installpath-modules', + 'installpath-software', + 'prefix', + 'sourcepath-data', + 'testoutput', + 'tmp-logdir', + 'tmpdir', + + 'buildpath', + 'containerpath', + 'installpath', + 'repositorypath', + 'sourcepath', +] + + class OptionExtracter(EasyBuildOptions): def __init__(self, *args, **kwargs): self._option_dicts = {} @@ -143,7 +171,14 @@ def to_click_option_dec(self): 'type': None } - if self.type in ['strlist', 'strtuple']: + # Manually enforced FILE types + if self.name in KNOWN_FILEPATH_OPTS: + kwargs['type'] = click.Path(exists=True, dir_okay=False, file_okay=True) + # Manually enforced DIRECTORY types + elif self.name in KNOWN_DIRPATH_OPTS: + kwargs['type'] = click.Path(exists=True, dir_okay=True, file_okay=False) + # Convert options from easybuild.tools.options + elif self.type in ['strlist', 'strtuple']: kwargs['type'] = DelimitedString(delimiter=',') kwargs['multiple'] = True elif self.type in ['pathlist', 'pathtuple']: @@ -162,6 +197,7 @@ def to_click_option_dec(self): kwargs['type'] = click.FLOAT elif self.type in ['str', str]: kwargs['type'] = click.STRING + # If type is None assume type based on default value elif self.type is None: if self.default is False or self.default is True: kwargs['is_flag'] = True @@ -172,6 +208,7 @@ def to_click_option_dec(self): kwargs['multiple'] = True kwargs['type'] = click.STRING + # store_or_None implies that the option can be used as a flag with no value if self.action == 'store_or_None': kwargs['default'] = None kwargs['flag_value'] = self.default From ddc0e268522b6eb39ecd1115610c135c063c10a3 Mon Sep 17 00:00:00 2001 From: crivella Date: Thu, 31 Jul 2025 14:36:33 +0200 Subject: [PATCH 16/18] Improve `help` metadata --- easybuild/cli/options/__init__.py | 25 +++++++++++-------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/easybuild/cli/options/__init__.py b/easybuild/cli/options/__init__.py index edba6d4ca6..e31e3ebfd8 100644 --- a/easybuild/cli/options/__init__.py +++ b/easybuild/cli/options/__init__.py @@ -41,7 +41,6 @@ 'buildpath', 'containerpath', 'installpath', - 'repositorypath', 'sourcepath', ] @@ -63,13 +62,14 @@ class DelimitedPathList(click.Path): """Custom Click parameter type for delimited lists.""" name = 'pathlist' - def __init__(self, *args, delimiter=',', resolve_full: bool = False, **kwargs): + def __init__(self, *args, delimiter=',', **kwargs): + self.resolve_full = kwargs.setdefault('resolve_path', False) super().__init__(*args, **kwargs) self.delimiter = delimiter - self.resolve_full = resolve_full + name = self.name + self.name = f'[{name}[{self.delimiter}{name}]]' def convert(self, value, param, ctx): - # logging.warning(f"{param=} convert called with `{value=}`, `{type(value)=}`") if isinstance(value, str): res = value.split(self.delimiter) elif isinstance(value, (list, tuple)): @@ -78,12 +78,10 @@ def convert(self, value, param, ctx): raise click.BadParameter(f"Expected a comma-separated string, got {value}") if self.resolve_full: res = [os.path.abspath(v) for v in res] - # logging.warning(f"{param=} convert returning `{res=}`") return res def shell_complete(self, ctx, param, incomplete): others, last = ([None] + incomplete.rsplit(self.delimiter, 1))[-2:] - # logging.warning(f"Shell completion for delimited path list: others={others}, last={last}") dir_path, prefix = os.path.split(last) dir_path = dir_path or '.' # logging.warning(f"Shell completion for delimited path list: dir_path={dir_path}, prefix={prefix}") @@ -107,11 +105,10 @@ def shell_complete(self, ctx, param, incomplete): class DelimitedString(click.ParamType): """Custom Click parameter type for delimited strings.""" - name = 'strlist' - def __init__(self, *args, delimiter=',', **kwargs): super().__init__(*args, **kwargs) self.delimiter = delimiter + self.name = f'[STR[{self.delimiter}STR]]' def convert(self, value, param, ctx): if isinstance(value, str): @@ -173,20 +170,20 @@ def to_click_option_dec(self): # Manually enforced FILE types if self.name in KNOWN_FILEPATH_OPTS: - kwargs['type'] = click.Path(exists=True, dir_okay=False, file_okay=True) + kwargs['type'] = click.Path(dir_okay=False, file_okay=True) # Manually enforced DIRECTORY types elif self.name in KNOWN_DIRPATH_OPTS: - kwargs['type'] = click.Path(exists=True, dir_okay=True, file_okay=False) + kwargs['type'] = click.Path(dir_okay=True, file_okay=False) # Convert options from easybuild.tools.options elif self.type in ['strlist', 'strtuple']: kwargs['type'] = DelimitedString(delimiter=',') - kwargs['multiple'] = True + # kwargs['multiple'] = True elif self.type in ['pathlist', 'pathtuple']: kwargs['type'] = DelimitedPathList(delimiter=os.pathsep) - kwargs['multiple'] = True + # kwargs['multiple'] = True elif self.type in ['urllist', 'urltuple']: kwargs['type'] = DelimitedString(delimiter='|') - kwargs['multiple'] = True + # kwargs['multiple'] = True elif self.type == 'choice': if self.lst is None: raise ValueError(f"Choice type requires a list of choices for option {self.name}") @@ -202,7 +199,7 @@ def to_click_option_dec(self): if self.default is False or self.default is True: kwargs['is_flag'] = True kwargs['type'] = click.BOOL - if self.action in ['store_true', 'store_false']: + if self.default is True: decl = f"--{self.name}/--disable-{self.name}" elif isinstance(self.default, (list, tuple)): kwargs['multiple'] = True From 01817eb4ffeb842ec00958c0d833b536fe9f3494 Mon Sep 17 00:00:00 2001 From: crivella Date: Thu, 31 Jul 2025 14:43:16 +0200 Subject: [PATCH 17/18] Lint and better comments --- easybuild/cli/__init__.py | 7 ++++--- easybuild/main.py | 1 + 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/easybuild/cli/__init__.py b/easybuild/cli/__init__.py index 56770e7866..322ee1ec73 100644 --- a/easybuild/cli/__init__.py +++ b/easybuild/cli/__init__.py @@ -5,9 +5,10 @@ except ImportError: def eb(): """Placeholder function to inform the user that `click` is required.""" - print("Using `eb2` requires `click` to be installed. Either use `eb` or install `click` with `pip install click`.") - print("`eb2` also uses `rich` and `rich_click` as optional dependencies for enhanced CLI experience.") - print("Exiting...") + print('Using `eb2` requires `click` to be installed.') + print('Either use `eb` or install `click` with `pip install click`.') + print('`eb2` also uses `rich` and `rich_click` as optional dependencies for enhanced CLI experience.') + print('Exiting...') sys.exit(0) else: try: diff --git a/easybuild/main.py b/easybuild/main.py index 93071f5134..d244ad31a6 100755 --- a/easybuild/main.py +++ b/easybuild/main.py @@ -809,6 +809,7 @@ def main_with_hooks(args=None): except EasyBuildError as err: print_error(err.msg, exit_code=err.exit_code) else: + # Avoid running double initialization in `main` afterward if `prepare_main` succeeded args = None hooks = load_hooks(eb_go.options.hooks) From 473d432c6283faf02c085b49776a7fdc38d89c01 Mon Sep 17 00:00:00 2001 From: crivella Date: Thu, 31 Jul 2025 15:05:00 +0200 Subject: [PATCH 18/18] Added optional dependencies `eb2` to install packages required by `eb2` --- setup.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/setup.py b/setup.py index b97e7d281f..20d9495c37 100644 --- a/setup.py +++ b/setup.py @@ -106,6 +106,9 @@ def find_rel_test(): 'eb2 = easybuild.cli:eb', ] }, + extras_require={ + 'eb2': ['click', 'rich', 'rich_click'], + }, data_files=[ ('easybuild/scripts', glob.glob('easybuild/scripts/*')), ('etc', glob.glob('etc/*')),