From d50cbd971921e4a834db613e94eb5ed8ee7d04e0 Mon Sep 17 00:00:00 2001 From: Sebastiaan Huber Date: Wed, 22 Nov 2023 12:52:43 +0100 Subject: [PATCH] ORM: Switch to `pydantic` for code schema definition The `verdi code create` command dynamically generates a subcommand for each registered entry point that is a subclass of the `AbstractCode` data plugin base class. The options for each subcommand are generated automatically for each plugin using the `DynamicEntryPointCommandGroup`. When first developed, this `click.Group` subclass would rely on the plugin defining the `get_cli_options` method to return a dictionary with a spec for each of the options. This specification used an ad-hoc custom schema making it not very useful for any other applications. Recently, the class added support for using `pydantic` models to define the specification instead. This was already used for plugins of storage backends. Here, the `AbstractCode` and its subclasses are also migrated to use `pydantic` instead to define their model. Most of the data that is required to create `click` options from the pydantic model can be communicated using the default properties of pydantic's `Field` class. However, there was the need for a few additional metadata properties: * `priority`: To control the order in which options are prompted for. This used to be controlled by the `_get_cli_options` of each plugin. It could define the options in the order required and could also determine whether they came before or after the options that could potentially be inherited from a base class. The way the pydantic models work, the fields of a subclass will always come _after_ those of the base class and there is no way to control this. * `short_name`: The short form of the option name. The option name is derived from the `title` attribute of the `Field`. In addition to a full name, options often want to provide a short form option. Since there is no algorithmic method of deducing this from the title, a dedicated metadata keyword is added. * `option_cls`: To customize the class to be used to create the option. This can be used by options that should use a different subclass of the `click.Option` base class. The `aiida.common.pydantic.MetadataField` utility function is added which provides a transparent way to define these metadata arguments when declaring a field in the model. The alternative is to use `Annotated` but this quickly makes the model difficult to read if multiple metadata are provided. The changes introduce _almost_ no difference in behavior of the `verdi code create` command. There is one exception and that is that the callbacks of the options are now replaced by the validators of the models. The downside is that the validators are only called once all options are specified, whereas the callbacks would be called immediately once the respective option was defined. This is not really a problem except for the `label` of the `InstalledCode`. The callback would be called immediately and so if an invalid label was provided during an interactive session, the user would be immediately prompted to provide a new label. It is not clear how this behavior can be reproduced using the pydantic validators. --- docs/source/nitpick-exceptions | 1 + src/aiida/cmdline/commands/cmd_code.py | 4 +- src/aiida/cmdline/groups/dynamic.py | 109 +++++++---- src/aiida/common/pydantic.py | 46 +++++ src/aiida/manage/configuration/__init__.py | 2 +- src/aiida/orm/nodes/data/code/abstract.py | 176 ++++++++---------- .../orm/nodes/data/code/containerized.py | 60 +++--- src/aiida/orm/nodes/data/code/installed.py | 107 +++++------ src/aiida/orm/nodes/data/code/portable.py | 58 +++--- src/aiida/storage/psql_dos/backend.py | 2 +- src/aiida/storage/sqlite_dos/backend.py | 2 +- src/aiida/storage/sqlite_temp/backend.py | 2 +- src/aiida/storage/sqlite_zip/backend.py | 2 +- tests/cmdline/groups/test_dynamic.py | 4 +- tests/storage/sqlite_dos/test_backend.py | 8 +- tests/storage/sqlite_zip/test_backend.py | 10 +- 16 files changed, 333 insertions(+), 260 deletions(-) create mode 100644 src/aiida/common/pydantic.py diff --git a/docs/source/nitpick-exceptions b/docs/source/nitpick-exceptions index 8cd95a9930..6e09890b38 100644 --- a/docs/source/nitpick-exceptions +++ b/docs/source/nitpick-exceptions @@ -138,6 +138,7 @@ py:meth click.Option.get_default py:meth fail py:class ComputedFieldInfo +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..f6365f5e34 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.Model.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.Model.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..413d0951fc 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, 'Model'): + # The plugin defines a pydantic model: use it to validate the provided arguments + try: + model = cls.Model(**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)) @@ -131,61 +156,71 @@ 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 - # ) + if not hasattr(cls, 'Model'): 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 `Model` 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.Model.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/manage/configuration/__init__.py b/src/aiida/manage/configuration/__init__.py index f11f710c57..6e3f376b06 100644 --- a/src/aiida/manage/configuration/__init__.py +++ b/src/aiida/manage/configuration/__init__.py @@ -219,7 +219,7 @@ def create_profile( from aiida.manage import get_manager from aiida.orm import User - storage_config = storage_cls.Configuration(**{k: v for k, v in kwargs.items() if v is not None}).model_dump() + storage_config = storage_cls.Model(**{k: v for k, v in kwargs.items() if v is not None}).model_dump() profile: Profile = config.create_profile(name=name, storage_cls=storage_cls, storage_config=storage_config) with profile_context(profile.name, allow_switch=True): diff --git a/src/aiida/orm/nodes/data/code/abstract.py b/src/aiida/orm/nodes/data/code/abstract.py index 0de0b6ee19..a42c063a5c 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 Model(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..527087f8db 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 Model(InstalledCode.Model): + """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..d56c611723 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 Model(AbstractCode.Model): + """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.Model: + """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..1d0bc48479 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 Model(AbstractCode.Model): + """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 diff --git a/src/aiida/storage/psql_dos/backend.py b/src/aiida/storage/psql_dos/backend.py index fe262b87db..8a38c2d7c8 100644 --- a/src/aiida/storage/psql_dos/backend.py +++ b/src/aiida/storage/psql_dos/backend.py @@ -71,7 +71,7 @@ class PsqlDosBackend(StorageBackend): The `django` backend was removed, to consolidate access to this storage. """ - class Configuration(BaseModel): + class Model(BaseModel): """Model describing required information to configure an instance of the storage.""" database_engine: str = Field( diff --git a/src/aiida/storage/sqlite_dos/backend.py b/src/aiida/storage/sqlite_dos/backend.py index 01c812a152..d738c7c856 100644 --- a/src/aiida/storage/sqlite_dos/backend.py +++ b/src/aiida/storage/sqlite_dos/backend.py @@ -95,7 +95,7 @@ class SqliteDosStorage(PsqlDosBackend): migrator = SqliteDosMigrator - class Configuration(BaseModel): + class Model(BaseModel): """Model describing required information to configure an instance of the storage.""" filepath: str = Field( diff --git a/src/aiida/storage/sqlite_temp/backend.py b/src/aiida/storage/sqlite_temp/backend.py index 25530768ab..398dc01264 100644 --- a/src/aiida/storage/sqlite_temp/backend.py +++ b/src/aiida/storage/sqlite_temp/backend.py @@ -42,7 +42,7 @@ class SqliteTempBackend(StorageBackend): and destroys it when it is garbage collected. """ - class Configuration(BaseModel): + class Model(BaseModel): filepath: str = Field( title='Temporary directory', description='Temporary directory in which to store data for this backend.', diff --git a/src/aiida/storage/sqlite_zip/backend.py b/src/aiida/storage/sqlite_zip/backend.py index 4caf48b663..62e1f080a7 100644 --- a/src/aiida/storage/sqlite_zip/backend.py +++ b/src/aiida/storage/sqlite_zip/backend.py @@ -67,7 +67,7 @@ class SqliteZipBackend(StorageBackend): read_only = True """This plugin is read only and data cannot be created or mutated.""" - class Configuration(BaseModel): + class Model(BaseModel): """Model describing required information to configure an instance of the storage.""" filepath: str = Field(title='Filepath of the archive', description='Filepath of the archive.') diff --git a/tests/cmdline/groups/test_dynamic.py b/tests/cmdline/groups/test_dynamic.py index cbe43f96b5..eb706002f0 100644 --- a/tests/cmdline/groups/test_dynamic.py +++ b/tests/cmdline/groups/test_dynamic.py @@ -9,7 +9,7 @@ class CustomClass: """Test plugin class.""" - class Configuration(BaseModel): + class Model(BaseModel): """Model configuration.""" optional_type: t.Union[int, float] = Field(title='Optional type') @@ -27,6 +27,6 @@ def test_list_options(entry_points): for option_decorators in group.list_options('custom'): option = option_decorators(lambda x: True).__click_params__[0] - field = CustomClass.Configuration.model_fields[option.name] + field = CustomClass.Model.model_fields[option.name] assert option.default == field.default_factory if field.default is PydanticUndefined else field.default assert option.type == t.get_args(field.annotation) or field.annotation diff --git a/tests/storage/sqlite_dos/test_backend.py b/tests/storage/sqlite_dos/test_backend.py index 9f2b231b20..7cc36e38a5 100644 --- a/tests/storage/sqlite_dos/test_backend.py +++ b/tests/storage/sqlite_dos/test_backend.py @@ -6,8 +6,8 @@ @pytest.mark.usefixtures('chdir_tmp_path') -def test_configuration(): - """Test :class:`aiida.storage.sqlite_dos.backend.SqliteDosStorage.Configuration`.""" +def test_model(): + """Test :class:`aiida.storage.sqlite_dos.backend.SqliteDosStorage.Model`.""" filepath = pathlib.Path.cwd() / 'archive.aiida' - configuration = SqliteDosStorage.Configuration(filepath=filepath.name) - assert pathlib.Path(configuration.filepath).is_absolute() + model = SqliteDosStorage.Model(filepath=filepath.name) + assert pathlib.Path(model.filepath).is_absolute() diff --git a/tests/storage/sqlite_zip/test_backend.py b/tests/storage/sqlite_zip/test_backend.py index 997198d529..6636325e41 100644 --- a/tests/storage/sqlite_zip/test_backend.py +++ b/tests/storage/sqlite_zip/test_backend.py @@ -51,13 +51,13 @@ def test_initialise_reset_false(tmp_path, aiida_caplog): @pytest.mark.usefixtures('chdir_tmp_path') -def test_configuration(): - """Test :class:`aiida.storage.sqlite_zip.backend.SqliteZipBackend.Configuration`.""" +def test_model(): + """Test :class:`aiida.storage.sqlite_zip.backend.SqliteZipBackend.Model`.""" with pytest.raises(ValidationError, match=r'.*The archive `non-existent` does not exist.*'): - SqliteZipBackend.Configuration(filepath='non-existent') + SqliteZipBackend.Model(filepath='non-existent') filepath = pathlib.Path.cwd() / 'archive.aiida' filepath.touch() - configuration = SqliteZipBackend.Configuration(filepath=filepath.name) - assert pathlib.Path(configuration.filepath).is_absolute() + model = SqliteZipBackend.Model(filepath=filepath.name) + assert pathlib.Path(model.filepath).is_absolute()