diff --git a/easybuild/cli/__init__.py b/easybuild/cli/__init__.py new file mode 100644 index 0000000000..322ee1ec73 --- /dev/null +++ b/easybuild/cli/__init__.py @@ -0,0 +1,39 @@ +import sys + +try: + import click as original_click +except ImportError: + def eb(): + """Placeholder function to inform the user that `click` is required.""" + 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: + import rich_click as click + except ImportError: + import click + + try: + from rich.traceback import install + except ImportError: + pass + else: + install(suppress=[ + click, original_click + ]) + + from .options import EasyBuildCliOption, EasyconfigParam + + from easybuild.main import main_with_hooks + + @click.command() + @EasyBuildCliOption.apply_options + @click.argument('other_args', nargs=-1, type=EasyconfigParam(), required=False) + def eb(other_args): + """EasyBuild command line interface.""" + # 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() diff --git a/easybuild/cli/options/__init__.py b/easybuild/cli/options/__init__.py new file mode 100644 index 0000000000..e31e3ebfd8 --- /dev/null +++ b/easybuild/cli/options/__init__.py @@ -0,0 +1,299 @@ +import os + +from typing import Callable, Any +from dataclasses import dataclass + +from click.shell_completion import CompletionItem +from easybuild.tools.options import EasyBuildOptions, set_up_configuration +from easybuild.tools.robot import search_easyconfigs + + +opt_group = {} +try: + import rich_click as click +except ImportError: + import click +else: + opt_group = click.rich_click.OPTION_GROUPS + 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', + 'sourcepath', +] + + +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=[]) + + +class DelimitedPathList(click.Path): + """Custom Click parameter type for delimited lists.""" + name = 'pathlist' + + def __init__(self, *args, delimiter=',', **kwargs): + self.resolve_full = kwargs.setdefault('resolve_path', False) + super().__init__(*args, **kwargs) + self.delimiter = delimiter + name = self.name + self.name = f'[{name}[{self.delimiter}{name}]]' + + def convert(self, value, param, ctx): + 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}") + if self.resolve_full: + res = [os.path.abspath(v) for v in res] + return res + + def shell_complete(self, ctx, param, incomplete): + others, last = ([None] + incomplete.rsplit(self.delimiter, 1))[-2:] + 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 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 + + +class DelimitedString(click.ParamType): + """Custom Click parameter type for delimited strings.""" + 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): + 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] + 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): + set_up_configuration(args=["--ignore-index"], silent=True, reconfigure=True) + return [CompletionItem(ec, help='') for ec in search_easyconfigs(fr'^{incomplete}.*\.eb$', filename_only=True)] + + +@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.""" + decl = f"--{self.name}" + other_decls = [] + if self.short: + other_decls.insert(0, f"-{self.short}") + + kwargs = { + 'help': self.description, + 'default': self.default, + 'is_flag': False, + 'show_default': True, + 'type': None + } + + # Manually enforced FILE types + if self.name in KNOWN_FILEPATH_OPTS: + 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(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']: + kwargs['type'] = DelimitedPathList(delimiter=os.pathsep) + # 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=True) + 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 + # 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 + kwargs['type'] = click.BOOL + if self.default is True: + decl = f"--{self.name}/--disable-{self.name}" + elif isinstance(self.default, (list, tuple)): + 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 + + decls = other_decls + [decl] + + return click.option( + *decls, + expose_value=False, + 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] = {} + + @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/easybuild/main.py b/easybuild/main.py index 2dee1c8374..d244ad31a6 100755 --- a/easybuild/main.py +++ b/easybuild/main.py @@ -808,6 +808,9 @@ 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: + # Avoid running double initialization in `main` afterward if `prepare_main` succeeded + args = None hooks = load_hooks(eb_go.options.hooks) diff --git a/setup.py b/setup.py index c305735e13..20d9495c37 100644 --- a/setup.py +++ b/setup.py @@ -101,6 +101,14 @@ def find_rel_test(): # utility scripts 'easybuild/scripts/install_eb_dep.sh', ], + entry_points={ + 'console_scripts': [ + 'eb2 = easybuild.cli:eb', + ] + }, + extras_require={ + 'eb2': ['click', 'rich', 'rich_click'], + }, data_files=[ ('easybuild/scripts', glob.glob('easybuild/scripts/*')), ('etc', glob.glob('etc/*')),