diff --git a/docs/source/nitpick-exceptions b/docs/source/nitpick-exceptions index 5187ce401e..2eba23e3ec 100644 --- a/docs/source/nitpick-exceptions +++ b/docs/source/nitpick-exceptions @@ -137,6 +137,7 @@ py:func click.shell_completion._start_of_option py:meth click.Option.get_default py:meth fail +py:class pydantic.fields.Field py:class pydantic.main.BaseModel py:class requests.models.Response diff --git a/src/aiida/cmdline/commands/cmd_code.py b/src/aiida/cmdline/commands/cmd_code.py index 69a5f4e2bd..b36f8e3f8e 100644 --- a/src/aiida/cmdline/commands/cmd_code.py +++ b/src/aiida/cmdline/commands/cmd_code.py @@ -221,7 +221,7 @@ def show(code): table.append(['PK', code.pk]) table.append(['UUID', code.uuid]) table.append(['Type', code.entry_point.name]) - for key in code.get_cli_options().keys(): + for key in code.Configuration.model_fields.keys(): try: table.append([key.capitalize().replace('_', ' '), getattr(code, key)]) except AttributeError: @@ -242,7 +242,7 @@ def export(code, output_file): code_data = {} - for key in code.get_cli_options().keys(): + for key in code.Configuration.model_fields.keys(): if key == 'computer': value = getattr(code, key).label else: diff --git a/src/aiida/cmdline/groups/dynamic.py b/src/aiida/cmdline/groups/dynamic.py index 20677f6b9b..80ca52be89 100644 --- a/src/aiida/cmdline/groups/dynamic.py +++ b/src/aiida/cmdline/groups/dynamic.py @@ -1,10 +1,10 @@ """Subclass of :class:`click.Group` that loads subcommands dynamically from entry points.""" from __future__ import annotations -import copy import functools import re import typing as t +import warnings import click @@ -88,10 +88,35 @@ def get_command(self, ctx: click.Context, cmd_name: str) -> click.Command | None command = super().get_command(ctx, cmd_name) return command + def call_command(self, ctx, cls, **kwargs): + """Call the ``command`` after validating the provided inputs.""" + from pydantic import ValidationError + + if hasattr(cls, 'Configuration'): + # The plugin defines a pydantic model: use it to validate the provided arguments + try: + model = cls.Configuration(**kwargs) + except ValidationError as exception: + param_hint = [ + f'--{loc.replace("_", "-")}' # type: ignore[union-attr] + for loc in exception.errors()[0]['loc'] + ] + message = '\n'.join([str(e['ctx']['error']) for e in exception.errors()]) + raise click.BadParameter( + message, + param_hint=param_hint or 'multiple parameters', # type: ignore[arg-type] + ) from exception + + # Update the arguments with the dictionary representation of the model. This will include any type coercions + # that may have been applied with validators defined for the model. + kwargs.update(**model.model_dump()) + + return self._command(ctx, cls, **kwargs) + def create_command(self, ctx: click.Context, entry_point: str) -> click.Command: """Create a subcommand for the given ``entry_point``.""" cls = self.factory(entry_point) - command = functools.partial(self._command, ctx, cls) + command = functools.partial(self.call_command, ctx, cls) command.__doc__ = cls.__doc__ return click.command(entry_point)(self.create_options(entry_point)(command)) @@ -132,60 +157,70 @@ def list_options(self, entry_point: str) -> list: cls = self.factory(entry_point) if not hasattr(cls, 'Configuration'): - # This should be enabled once the ``Code`` classes are migrated to using pydantic to define their model. - # See https://github.com/aiidateam/aiida-core/pull/6190 - # from aiida.common.warnings import warn_deprecation - # warn_deprecation( - # 'Relying on `_get_cli_options` is deprecated. The options should be defined through a ' - # '`pydantic.BaseModel` that should be assigned to the `Config` class attribute.', - # version=3 - # ) from aiida.common.warnings import warn_deprecation warn_deprecation( 'Relying on `_get_cli_options` is deprecated. The options should be defined through a ' - '`pydantic.BaseModel` that should be assigned to the `Config` class attribute.', + '`pydantic.BaseModel` that should be assigned to the `Configuration` class attribute.', version=3, ) options_spec = self.factory(entry_point).get_cli_options() # type: ignore[union-attr] - else: - options_spec = {} - - for key, field_info in cls.Configuration.model_fields.items(): - default = field_info.default_factory if field_info.default is PydanticUndefined else field_info.default - - # The ``field_info.annotation`` property returns the annotation of the field. This can be a plain type - # or a type from ``typing``, e.g., ``Union[int, float]`` or ``Optional[str]``. In these cases, the type - # that needs to be passed to ``click`` is the arguments of the type, which can be obtained using the - # ``typing.get_args()`` method. If it is not a compound type, this returns an empty tuplem so in that - # case, the type is simply the ``field_info.annotation``. - options_spec[key] = { - 'required': field_info.is_required(), - 'type': t.get_args(field_info.annotation) or field_info.annotation, - 'prompt': field_info.title, - 'default': default, - 'help': field_info.description, - } - - return [self.create_option(*item) for item in options_spec.items()] + return [self.create_option(*item) for item in options_spec] + + options_spec = {} + + for key, field_info in cls.Configuration.model_fields.items(): + default = field_info.default_factory if field_info.default is PydanticUndefined else field_info.default + + # If the annotation has the ``__args__`` attribute it is an instance of a type from ``typing`` and the real + # type can be gotten from the arguments. For example it could be ``typing.Union[str, None]`` calling + # ``typing.Union[str, None].__args__`` will return the tuple ``(str, NoneType)``. So to get the real type, + # we simply remove all ``NoneType`` and the remaining type should be the type of the option. + if hasattr(field_info.annotation, '__args__'): + args = list(filter(lambda e: e != type(None), field_info.annotation.__args__)) + if len(args) > 1: + warnings.warn( + f'field `{key}` defines multiple types, but can take only one, taking the first: `{args[0]}`', + UserWarning, + ) + field_type = args[0] + else: + field_type = field_info.annotation + + options_spec[key] = { + 'required': field_info.is_required(), + 'type': field_type, + 'is_flag': field_type is bool, + 'prompt': field_info.title, + 'default': default, + 'help': field_info.description, + } + for metadata in field_info.metadata: + for metadata_key, metadata_value in metadata.items(): + options_spec[key][metadata_key] = metadata_value + + options_ordered = [] + + for name, spec in sorted(options_spec.items(), key=lambda x: x[1].get('priority', 0), reverse=True): + spec.pop('priority', None) + options_ordered.append(self.create_option(name, spec)) + + return options_ordered @staticmethod def create_option(name, spec: dict) -> t.Callable[[t.Any], t.Any]: """Create a click option from a name and a specification.""" - spec = copy.deepcopy(spec) - is_flag = spec.pop('is_flag', False) - default = spec.get('default') name_dashed = name.replace('_', '-') option_name = f'--{name_dashed}/--no-{name_dashed}' if is_flag else f'--{name_dashed}' option_short_name = spec.pop('short_name', None) option_names = (option_short_name, option_name) if option_short_name else (option_name,) - kwargs = {'cls': spec.pop('cls', InteractiveOption), 'show_default': True, 'is_flag': is_flag, **spec} + kwargs = {'cls': spec.pop('option_cls', InteractiveOption), 'show_default': True, 'is_flag': is_flag, **spec} # If the option is a flag with no default, make sure it is not prompted for, as that will force the user to # specify it to be on or off, but cannot let it unspecified. - if kwargs['cls'] is InteractiveOption and is_flag and default is None: + if kwargs['cls'] is InteractiveOption and is_flag and spec.get('default') is None: kwargs['cls'] = functools.partial(InteractiveOption, prompt_fn=lambda ctx: False) return click.option(*(option_names), **kwargs) diff --git a/src/aiida/common/pydantic.py b/src/aiida/common/pydantic.py new file mode 100644 index 0000000000..997e0cfb32 --- /dev/null +++ b/src/aiida/common/pydantic.py @@ -0,0 +1,46 @@ +"""Utilities related to ``pydantic``.""" +from __future__ import annotations + +import typing as t + +from pydantic import Field + + +def MetadataField( # noqa: N802 + default: t.Any | None = None, + *, + priority: int = 0, + short_name: str | None = None, + option_cls: t.Any | None = None, + **kwargs, +): + """Return a :class:`pydantic.fields.Field` instance with additional metadata. + + .. code-block:: python + + class Model(BaseModel): + + attribute: MetadataField('default', priority=1000, short_name='-A') + + This is a utility function that constructs a ``Field`` instance with an easy interface to add additional metadata. + It is possible to add metadata using ``Annotated``:: + + class Model(BaseModel): + + attribute: Annotated[str, {'metadata': 'value'}] = Field(...) + + However, when requiring multiple metadata, this notation can make the model difficult to read. Since this utility + is only used to automatically build command line interfaces from the model definition, it is possible to restrict + which metadata are accepted. + + :param priority: Used to order the list of all fields in the model. Ordering is done from small to large priority. + :param short_name: Optional short name to use for an option on a command line interface. + :param option_cls: The :class:`click.Option` class to use to construct the option. + """ + field_info = Field(default, **kwargs) + + for key, value in (('priority', priority), ('short_name', short_name), ('option_cls', option_cls)): + if value is not None: + field_info.metadata.append({key: value}) + + return field_info diff --git a/src/aiida/orm/nodes/data/code/abstract.py b/src/aiida/orm/nodes/data/code/abstract.py index 0de0b6ee19..c527a6ed54 100644 --- a/src/aiida/orm/nodes/data/code/abstract.py +++ b/src/aiida/orm/nodes/data/code/abstract.py @@ -10,19 +10,23 @@ from __future__ import annotations import abc -import collections +import functools import pathlib -from typing import TYPE_CHECKING +import typing as t +from pydantic import BaseModel, field_validator + +from aiida.cmdline.params.options.interactive import TemplateInteractiveOption from aiida.common import exceptions from aiida.common.folders import Folder from aiida.common.lang import type_check +from aiida.common.pydantic import MetadataField from aiida.orm import Computer from aiida.plugins import CalculationFactory from ..data import Data -if TYPE_CHECKING: +if t.TYPE_CHECKING: from aiida.engine import ProcessBuilder __all__ = ('AbstractCode',) @@ -40,6 +44,80 @@ class AbstractCode(Data, metaclass=abc.ABCMeta): _KEY_ATTRIBUTE_WRAP_CMDLINE_PARAMS: str = 'wrap_cmdline_params' _KEY_EXTRA_IS_HIDDEN: str = 'hidden' # Should become ``is_hidden`` once ``Code`` is dropped + class Configuration(BaseModel): + """Model describing required information to create an instance.""" + + label: str = MetadataField( + ..., + title='Label', + description='A unique label to identify the code by.', + short_name='-L', + ) + description: str = MetadataField( + '', + title='Description', + description='Human-readable description, ideally including version and compilation environment.', + short_name='-D', + ) + default_calc_job_plugin: t.Optional[str] = MetadataField( + None, + title='Default `CalcJob` plugin', + description='Entry point name of the default plugin (as listed in `verdi plugin list aiida.calculations`).', + short_name='-P', + ) + use_double_quotes: bool = MetadataField( + False, + title='Escape using double quotes', + description='Whether the executable and arguments of the code in the submission script should be escaped ' + 'with single or double quotes.', + ) + with_mpi: t.Optional[bool] = MetadataField( + None, + title='Run with MPI', + description='Whether the executable should be run as an MPI program. This option can be left unspecified ' + 'in which case `None` will be set and it is left up to the calculation job plugin or inputs ' + 'whether to run with MPI.', + ) + prepend_text: str = MetadataField( + '', + title='Prepend script', + description='Bash commands that should be prepended to the run line in all submit scripts for this code.', + option_cls=functools.partial( + TemplateInteractiveOption, + extension='.bash', + header='PREPEND_TEXT: if there is any bash commands that should be prepended to the executable call ' + 'in all submit scripts for this code, type that between the equal signs below and save the file.', + footer='All lines that start with `#=`: will be ignored.', + ), + ) + append_text: str = MetadataField( + '', + title='Append script', + description='Bash commands that should be appended to the run line in all submit scripts for this code.', + option_cls=functools.partial( + TemplateInteractiveOption, + extension='.bash', + header='APPEND_TEXT: if there is any bash commands that should be appended to the executable call ' + 'in all submit scripts for this code, type that between the equal signs below and save the file.', + footer='All lines that start with `#=`: will be ignored.', + ), + ) + + @field_validator('label') + @classmethod + def validate_label_uniqueness(cls, value: str) -> str: + """Validate that the label does not already exist.""" + from aiida.orm import load_code + + try: + load_code(value) + except exceptions.NotExistent: + return value + except exceptions.MultipleObjectsError as exception: + raise ValueError(f'Multiple codes with the label `{value}` already exist.') from exception + else: + raise ValueError(f'A code with the label `{value}` already exists.') + def __init__( self, default_calc_job_plugin: str | None = None, @@ -302,95 +380,3 @@ def get_builder(self) -> 'ProcessBuilder': builder.code = self return builder - - @staticmethod - def cli_validate_label_uniqueness(_, __, value): - """Validate the uniqueness of the label of the code.""" - import click - - from aiida.orm import load_code - - try: - load_code(value) - except exceptions.NotExistent: - pass - except exceptions.MultipleObjectsError: - raise click.BadParameter(f'Multiple codes with the label `{value}` already exist.') - else: - raise click.BadParameter(f'A code with the label `{value}` already exists.') - - return value - - @classmethod - def get_cli_options(cls) -> collections.OrderedDict: - """Return the CLI options that would allow to create an instance of this class.""" - return collections.OrderedDict(cls._get_cli_options()) - - @classmethod - def _get_cli_options(cls) -> dict: - """Return the CLI options that would allow to create an instance of this class.""" - import click - - from aiida.cmdline.params.options.interactive import TemplateInteractiveOption - - return { - 'label': { - 'short_name': '-L', - 'required': True, - 'type': click.STRING, - 'prompt': 'Label', - 'help': 'A unique label to identify the code by.', - 'callback': cls.cli_validate_label_uniqueness, - }, - 'description': { - 'short_name': '-D', - 'type': click.STRING, - 'prompt': 'Description', - 'help': 'Human-readable description, ideally including version and compilation environment.', - }, - 'default_calc_job_plugin': { - 'short_name': '-P', - 'type': click.STRING, - 'prompt': 'Default `CalcJob` plugin', - 'help': 'Entry point name of the default plugin (as listed in `verdi plugin list aiida.calculations`).', - }, - 'use_double_quotes': { - 'is_flag': True, - 'default': False, - 'help': 'Whether the executable and arguments of the code in the submission script should be escaped ' - 'with single or double quotes.', - 'prompt': 'Escape using double quotes', - }, - 'with_mpi': { - 'is_flag': True, - 'default': None, - 'help': ( - 'Whether the executable should be run as an MPI program. This option can be left unspecified ' - 'in which case `None` will be set and it is left up to the calculation job plugin or inputs ' - 'whether to run with MPI.' - ), - 'prompt': 'Run with MPI', - }, - 'prepend_text': { - 'cls': TemplateInteractiveOption, - 'type': click.STRING, - 'default': '', - 'prompt': 'Prepend script', - 'help': 'Bash commands that should be prepended to the run line in all submit scripts for this code.', - 'extension': '.bash', - 'header': 'PREPEND_TEXT: if there is any bash commands that should be prepended to the executable call ' - 'in all submit scripts for this code, type that between the equal signs below and save the file.', - 'footer': 'All lines that start with `#=`: will be ignored.', - }, - 'append_text': { - 'cls': TemplateInteractiveOption, - 'type': click.STRING, - 'default': '', - 'prompt': 'Append script', - 'help': 'Bash commands that should be appended to the run line in all submit scripts for this code.', - 'extension': '.bash', - 'header': 'APPEND_TEXT: if there is any bash commands that should be appended to the executable call ' - 'in all submit scripts for this code, type that between the equal signs below and save the file.', - 'footer': 'All lines that start with `#=`: will be ignored.', - }, - } diff --git a/src/aiida/orm/nodes/data/code/containerized.py b/src/aiida/orm/nodes/data/code/containerized.py index 7f9b79c490..8647f7becc 100644 --- a/src/aiida/orm/nodes/data/code/containerized.py +++ b/src/aiida/orm/nodes/data/code/containerized.py @@ -16,6 +16,7 @@ import pathlib from aiida.common.lang import type_check +from aiida.common.pydantic import MetadataField from .installed import InstalledCode @@ -28,6 +29,32 @@ class ContainerizedCode(InstalledCode): _KEY_ATTRIBUTE_ENGINE_COMMAND: str = 'engine_command' _KEY_ATTRIBUTE_IMAGE_NAME: str = 'image_name' + class Configuration(InstalledCode.Configuration): + """Model describing required information to create an instance.""" + + engine_command: str = MetadataField( + ..., + title='Engine command', + description='The command to run the container. It must contain the placeholder {image_name} that will be ' + 'replaced with the `image_name`.', + short_name='-E', + priority=3, + ) + image_name: str = MetadataField( + ..., + title='Image name', + description='Name of the image container in which to the run the executable.', + short_name='-I', + priority=2, + ) + wrap_cmdline_params: bool = MetadataField( + False, + title='Wrap command line parameters', + description='Whether all command line parameters to be passed to the engine command should be wrapped in ' + 'a double quotes to form a single argument. This should be set to `True` for Docker.', + priority=1, + ) + def __init__(self, engine_command: str, image_name: str, **kwargs): super().__init__(**kwargs) self.engine_command = engine_command @@ -103,36 +130,3 @@ def get_prepend_cmdline_params( engine_cmdline_params = engine_cmdline.split() return (mpi_args or []) + (extra_mpirun_params or []) + engine_cmdline_params - - @classmethod - def _get_cli_options(cls) -> dict: - """Return the CLI options that would allow to create an instance of this class.""" - import click - - options = { - 'engine_command': { - 'short_name': '-E', - 'required': True, - 'prompt': 'Engine command', - 'help': 'The command to run the container. It must contain the placeholder {image_name} that will be ' - 'replaced with the `image_name`.', - 'type': click.STRING, - }, - 'image_name': { - 'short_name': '-I', - 'required': True, - 'type': click.STRING, - 'prompt': 'Image name', - 'help': 'Name of the image container in which to the run the executable.', - }, - 'wrap_cmdline_params': { - 'is_flag': True, - 'default': False, - 'help': 'Whether all command line parameters to be passed to the engine command should be wrapped in ' - 'a double quotes to form a single argument. This should be set to `True` for Docker.', - 'prompt': 'Wrap command line parameters', - }, - } - options.update(**super()._get_cli_options()) - - return options diff --git a/src/aiida/orm/nodes/data/code/installed.py b/src/aiida/orm/nodes/data/code/installed.py index bfb9a44998..7933400c9e 100644 --- a/src/aiida/orm/nodes/data/code/installed.py +++ b/src/aiida/orm/nodes/data/code/installed.py @@ -17,12 +17,16 @@ import pathlib +from pydantic import field_validator, model_validator + from aiida.common import exceptions from aiida.common.lang import type_check from aiida.common.log import override_log_level +from aiida.common.pydantic import MetadataField from aiida.orm import Computer from aiida.orm.entities import from_backend_entity +from .abstract import AbstractCode from .legacy import Code __all__ = ('InstalledCode',) @@ -33,6 +37,57 @@ class InstalledCode(Code): _KEY_ATTRIBUTE_FILEPATH_EXECUTABLE: str = 'filepath_executable' + class Configuration(AbstractCode.Configuration): + """Model describing required information to create an instance.""" + + computer: str = MetadataField( + ..., + title='Computer', + description='The remote computer on which the executable resides.', + short_name='-Y', + priority=2, + ) + filepath_executable: str = MetadataField( + ..., + title='Filepath executable', + description='Filepath of the executable on the remote computer.', + short_name='-X', + priority=1, + ) + + @field_validator('label') + @classmethod + def validate_label_uniqueness(cls, value: str) -> str: + """Override the validator for the ``label`` of the base class since uniqueness is defined on full label.""" + return value + + @field_validator('computer') + @classmethod + def validate_computer(cls, value: str) -> Computer: + """Override the validator for the ``label`` of the base class since uniqueness is defined on full label.""" + from aiida.orm import load_computer + + try: + return load_computer(value) + except exceptions.NotExistent as exception: + raise ValueError(exception) from exception + + @model_validator(mode='after') # type: ignore[misc] + def validate_full_label_uniqueness(self) -> AbstractCode.Configuration: + """Validate that the full label does not already exist.""" + from aiida.orm import load_code + + full_label = f'{self.label}@{self.computer.label}' # type: ignore[attr-defined] + + try: + load_code(full_label) + except exceptions.NotExistent: + return self + except exceptions.MultipleObjectsError as exception: + raise ValueError(f'Multiple codes with the label `{full_label}` already exist.') from exception + else: + raise ValueError(f'A code with the label `{full_label}` already exists.') + def __init__(self, computer: Computer, filepath_executable: str, **kwargs): """Construct a new instance. @@ -148,55 +203,3 @@ def filepath_executable(self, value: str) -> None: """ type_check(value, str) self.base.attributes.set(self._KEY_ATTRIBUTE_FILEPATH_EXECUTABLE, value) - - @staticmethod - def cli_validate_label_uniqueness(ctx, _, value): - """Validate the uniqueness of the label of the code.""" - import click - - from aiida.orm import load_code - - computer = ctx.params.get('computer', None) - - if computer is None: - return value - - full_label = f'{value}@{computer.label}' - - try: - load_code(full_label) - except exceptions.NotExistent: - pass - except exceptions.MultipleObjectsError: - raise click.BadParameter(f'Multiple codes with the label `{full_label}` already exist.') - else: - raise click.BadParameter(f'A code with the label `{full_label}` already exists.') - - return value - - @classmethod - def _get_cli_options(cls) -> dict: - """Return the CLI options that would allow to create an instance of this class.""" - import click - - from aiida.cmdline.params.types import ComputerParamType - - options = { - 'computer': { - 'short_name': '-Y', - 'required': True, - 'prompt': 'Computer', - 'help': 'The remote computer on which the executable resides.', - 'type': ComputerParamType(), - }, - 'filepath_executable': { - 'short_name': '-X', - 'required': True, - 'type': click.Path(exists=False), - 'prompt': 'Absolute filepath executable', - 'help': 'Absolute filepath of the executable on the remote computer.', - }, - } - options.update(**super()._get_cli_options()) - - return options diff --git a/src/aiida/orm/nodes/data/code/portable.py b/src/aiida/orm/nodes/data/code/portable.py index 3d49969a3c..9e9f9ce55b 100644 --- a/src/aiida/orm/nodes/data/code/portable.py +++ b/src/aiida/orm/nodes/data/code/portable.py @@ -20,11 +20,15 @@ import pathlib +from pydantic import field_validator + from aiida.common import exceptions from aiida.common.folders import Folder from aiida.common.lang import type_check +from aiida.common.pydantic import MetadataField from aiida.orm import Computer +from .abstract import AbstractCode from .legacy import Code __all__ = ('PortableCode',) @@ -35,6 +39,35 @@ class PortableCode(Code): _KEY_ATTRIBUTE_FILEPATH_EXECUTABLE: str = 'filepath_executable' + class Configuration(AbstractCode.Configuration): + """Model describing required information to create an instance.""" + + filepath_files: str = MetadataField( + ..., + title='Code directory', + description='Filepath to directory containing code files.', + short_name='-F', + priority=2, + ) + filepath_executable: str = MetadataField( + ..., + title='Filepath executable', + description='Relative filepath of executable with directory of code files.', + short_name='-X', + priority=1, + ) + + @field_validator('filepath_files') + @classmethod + def validate_filepath_files(cls, value: str) -> pathlib.Path: + """Validate that ``filepath_files`` is an existing directory.""" + filepath = pathlib.Path(value) + if not filepath.exists(): + raise ValueError(f'The filepath `{value}` does not exist.') + if not filepath.is_dir(): + raise ValueError(f'The filepath `{value}` is not a directory.') + return filepath + def __init__(self, filepath_executable: str, filepath_files: pathlib.Path, **kwargs): """Construct a new instance. @@ -141,28 +174,3 @@ def filepath_executable(self, value: str) -> None: raise ValueError('The `filepath_executable` should not be absolute.') self.base.attributes.set(self._KEY_ATTRIBUTE_FILEPATH_EXECUTABLE, value) - - @classmethod - def _get_cli_options(cls) -> dict: - """Return the CLI options that would allow to create an instance of this class.""" - import click - - options = { - 'filepath_executable': { - 'short_name': '-X', - 'required': True, - 'type': click.STRING, - 'prompt': 'Relative filepath executable', - 'help': 'Relative filepath of executable with directory of code files.', - }, - 'filepath_files': { - 'short_name': '-F', - 'required': True, - 'type': click.Path(exists=True, file_okay=False, dir_okay=True, path_type=pathlib.Path), - 'prompt': 'Code directory', - 'help': 'Filepath to directory containing code files.', - }, - } - options.update(**super()._get_cli_options()) - - return options