Skip to content

Commit 958bfd0

Browse files
sphuberedan-bainglassagoscinski
authored
ORM: Use pydantic to specify a schema for each ORM entity (aiidateam#6255)
This PR addresses [AEP 010](https://github.com/aiidateam/AEP/blob/983a645c9285ba65c7cf07fe6064c23e7e994c06/010_orm_schema/readme.md) by introducing a schema interface for AiiDA's ORM entities using `pydantic`. The goal is to make the structure and content of ORM classes introspectable and serializable, enabling external applications—such as web APIs or CLIs—to interface with AiiDA in a programmatic, schema-driven way. Each core ORM class now defines a `Model` subclass that exposes its fields using rich metadata annotations. These models support conversion to and from ORM instances (`_from_model`, `_to_model`), as well as a user API yielding JSON-compatible formats (`from_serialized`, `serialize`). The serialization mechanism is experimental and is stated to the user as such on use of the serialization methods. In the process of implementing the original proposal, the `MetadataField` utility was extended with additional metadata such as `exclude_to_orm`, `exclude_from_cli`, `orm_class`, `orm_to_model`, and `model_to_orm`, enabling precise control over how fields behave across layers. A helper function `get_metadata` was added to facilitate metadata retrieval from `FieldInfo` objects. The PR refactors the `AuthInfo`, `Comment`, and `PortableCode` classes to adopt this new schema interface. For `AuthInfo` and `Comment`, equality checks are now defined based on the models to improve testability and object comparison. The `PortableCode` now also supports `PurePath` for the `filepath_executable` on initialization, though the type of its model's `filepath_files` is corrected to `str` only, reflecting how it is stored in the database. The serialization of repository files is unified for the `filepath_files` field and yaml-export functionality. For objects with repository-stored files, on serialization, files are written to a user-specified directory (or a temporary one if not provided) and the paths are stored in the serialized model. The `from_serialized` method can then be used to reconstruct the object, including its files, from the serialized data. Furthemore, CLI support was also expanded: dynamic option generation now respects `exclude_from_cli` and allows prioritizing or customizing CLI arguments using schema metadata. The `create_code` command and dynamic group listing logic were updated accordingly to use the new models for constructing instances. Additionally, entry points were registered for core ORM classes to improve plugin discoverability, and a new `FilePath` typing alias was introduced for shared use across the codebase. --------- Co-authored-by: Edan Bainglass <[email protected]> Co-authored-by: Alexander Goscinski <[email protected]> Co-authored-by: Alexander Goscinski <[email protected]>
1 parent bc25323 commit 958bfd0

File tree

102 files changed

+2168
-1491
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

102 files changed

+2168
-1491
lines changed

docs/source/nitpick-exceptions

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,9 @@ py:meth click.Option.get_default
140140
py:meth fail
141141

142142
py:class ComputedFieldInfo
143+
py:class BaseModel
143144
py:class pydantic.fields.Field
145+
py:class pydantic.fields.FieldInfo
144146
py:class pydantic.main.BaseModel
145147
py:class PluggableSchemaValidator
146148

@@ -157,6 +159,7 @@ py:class frozenset
157159

158160
py:class numpy.bool_
159161
py:class numpy.ndarray
162+
py:class np.ndarray
160163
py:class ndarray
161164

162165
py:class paramiko.proxy.ProxyCommand

pyproject.toml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,17 @@ requires-python = '>=3.9'
139139
'process.workflow.workchain' = 'aiida.orm.nodes.process.workflow.workchain:WorkChainNode'
140140
'process.workflow.workfunction' = 'aiida.orm.nodes.process.workflow.workfunction:WorkFunctionNode'
141141

142+
[project.entry-points.'aiida.orm']
143+
'core.auth_info' = 'aiida.orm.authinfos:AuthInfo'
144+
'core.comment' = 'aiida.orm.comments:Comment'
145+
'core.computer' = 'aiida.orm.computers:Computer'
146+
'core.data' = 'aiida.orm.nodes.data.data:Data'
147+
'core.entity' = 'aiida.orm.entities:Entity'
148+
'core.group' = 'aiida.orm.groups:Group'
149+
'core.log' = 'aiida.orm.logs:Log'
150+
'core.node' = 'aiida.orm.nodes.node:Node'
151+
'core.user' = 'aiida.orm.users:User'
152+
142153
[project.entry-points.'aiida.parsers']
143154
'core.arithmetic.add' = 'aiida.parsers.plugins.arithmetic.add:ArithmeticAddParser'
144155
'core.templatereplacer' = 'aiida.parsers.plugins.templatereplacer.parser:TemplatereplacerParser'

src/aiida/cmdline/commands/cmd_code.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,10 @@ def verdi_code():
3030
"""Setup and manage codes."""
3131

3232

33-
def create_code(ctx: click.Context, cls, non_interactive: bool, **kwargs):
33+
def create_code(ctx: click.Context, cls, **kwargs):
3434
"""Create a new `Code` instance."""
3535
try:
36-
instance = cls(**kwargs)
36+
instance = cls._from_model(cls.Model(**kwargs))
3737
except (TypeError, ValueError) as exception:
3838
echo.echo_critical(f'Failed to create instance `{cls}`: {exception}')
3939

@@ -243,9 +243,7 @@ def show(code):
243243
@with_dbenv()
244244
def export(code, output_file, overwrite, sort):
245245
"""Export code to a yaml file. If no output file is given, default name is created based on the code label."""
246-
247246
other_args = {'sort': sort}
248-
249247
fileformat = 'yaml'
250248

251249
if output_file is None:

src/aiida/cmdline/commands/cmd_profile.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@ def verdi_profile():
2929
def command_create_profile(
3030
ctx: click.Context,
3131
storage_cls,
32-
non_interactive: bool,
3332
profile: Profile,
3433
set_as_default: bool = True,
3534
email: str | None = None,

src/aiida/cmdline/groups/dynamic.py

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -88,29 +88,25 @@ def get_command(self, ctx: click.Context, cmd_name: str) -> click.Command | None
8888
command = super().get_command(ctx, cmd_name)
8989
return command
9090

91-
def call_command(self, ctx, cls, **kwargs):
91+
def call_command(self, ctx, cls, non_interactive, **kwargs):
9292
"""Call the ``command`` after validating the provided inputs."""
9393
from pydantic import ValidationError
9494

9595
if hasattr(cls, 'Model'):
9696
# The plugin defines a pydantic model: use it to validate the provided arguments
9797
try:
98-
model = cls.Model(**kwargs)
98+
cls.Model(**kwargs)
9999
except ValidationError as exception:
100100
param_hint = [
101101
f'--{loc.replace("_", "-")}' # type: ignore[union-attr]
102102
for loc in exception.errors()[0]['loc']
103103
]
104-
message = '\n'.join([str(e['ctx']['error']) for e in exception.errors()])
104+
message = '\n'.join([str(e['msg']) for e in exception.errors()])
105105
raise click.BadParameter(
106106
message,
107-
param_hint=param_hint or 'multiple parameters', # type: ignore[arg-type]
107+
param_hint=param_hint or 'one or more parameters', # type: ignore[arg-type]
108108
) from exception
109109

110-
# Update the arguments with the dictionary representation of the model. This will include any type coercions
111-
# that may have been applied with validators defined for the model.
112-
kwargs.update(**model.model_dump())
113-
114110
return self._command(ctx, cls, **kwargs)
115111

116112
def create_command(self, ctx: click.Context, entry_point: str) -> click.Command:
@@ -154,6 +150,8 @@ def list_options(self, entry_point: str) -> list:
154150
"""
155151
from pydantic_core import PydanticUndefined
156152

153+
from aiida.common.pydantic import get_metadata
154+
157155
cls = self.factory(entry_point)
158156

159157
if not hasattr(cls, 'Model'):
@@ -170,6 +168,9 @@ def list_options(self, entry_point: str) -> list:
170168
options_spec = {}
171169

172170
for key, field_info in cls.Model.model_fields.items():
171+
if get_metadata(field_info, 'exclude_from_cli'):
172+
continue
173+
173174
default = field_info.default_factory if field_info.default is PydanticUndefined else field_info.default
174175

175176
# If the annotation has the ``__args__`` attribute it is an instance of a type from ``typing`` and the real
@@ -194,7 +195,8 @@ def list_options(self, entry_point: str) -> list:
194195
}
195196
for metadata in field_info.metadata:
196197
for metadata_key, metadata_value in metadata.items():
197-
options_spec[key][metadata_key] = metadata_value
198+
if metadata_key in ('priority', 'short_name', 'option_cls'):
199+
options_spec[key][metadata_key] = metadata_value
198200

199201
options_ordered = []
200202

src/aiida/common/pydantic.py

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,44 @@
33
from __future__ import annotations
44

55
import typing as t
6+
from pathlib import Path
67

78
from pydantic import Field
9+
from pydantic_core import PydanticUndefined
10+
11+
if t.TYPE_CHECKING:
12+
from pydantic import BaseModel
13+
14+
from aiida.orm import Entity
15+
16+
17+
def get_metadata(field_info, key: str, default: t.Any | None = None):
18+
"""Return a the metadata of the given field for a particular key.
19+
20+
:param field_info: The field from which to retrieve the metadata.
21+
:param key: The metadata name.
22+
:param default: Optional default value to return in case the metadata is not defined on the field.
23+
:returns: The metadata if defined, otherwise the default.
24+
"""
25+
for element in field_info.metadata:
26+
if key in element:
27+
return element[key]
28+
return default
829

930

1031
def MetadataField( # noqa: N802
11-
default: t.Any | None = None,
32+
default: t.Any = PydanticUndefined,
1233
*,
1334
priority: int = 0,
1435
short_name: str | None = None,
1536
option_cls: t.Any | None = None,
37+
orm_class: type['Entity'] | str | None = None,
38+
orm_to_model: t.Callable[['Entity', Path], t.Any] | None = None,
39+
model_to_orm: t.Callable[['BaseModel'], t.Any] | None = None,
40+
exclude_to_orm: bool = False,
41+
exclude_from_cli: bool = False,
42+
is_attribute: bool = True,
43+
is_subscriptable: bool = False,
1644
**kwargs,
1745
):
1846
"""Return a :class:`pydantic.fields.Field` instance with additional metadata.
@@ -37,11 +65,36 @@ class Model(BaseModel):
3765
:param priority: Used to order the list of all fields in the model. Ordering is done from small to large priority.
3866
:param short_name: Optional short name to use for an option on a command line interface.
3967
:param option_cls: The :class:`click.Option` class to use to construct the option.
68+
:param orm_class: The class, or entry point name thereof, to which the field should be converted. If this field is
69+
defined, the value of this field should acccept an integer which will automatically be converted to an instance
70+
of said ORM class using ``orm_class.collection.get(id={field_value})``. This is useful, for example, where a
71+
field represents an instance of a different entity, such as an instance of ``User``. The serialized data would
72+
store the ``pk`` of the user, but the ORM entity instance would receive the actual ``User`` instance with that
73+
primary key.
74+
:param orm_to_model: Optional callable to convert the value of a field from an ORM instance to a model instance.
75+
:param model_to_orm: Optional callable to convert the value of a field from a model instance to an ORM instance.
76+
:param exclude_to_orm: When set to ``True``, this field value will not be passed to the ORM entity constructor
77+
through ``Entity.from_model``.
78+
:param exclude_to_orm: When set to ``True``, this field value will not be exposed on the CLI command that is
79+
dynamically generated to create a new instance.
80+
:param is_attribute: Whether the field is stored as an attribute.
81+
:param is_subscriptable: Whether the field can be indexed like a list or dictionary.
4082
"""
4183
field_info = Field(default, **kwargs)
4284

43-
for key, value in (('priority', priority), ('short_name', short_name), ('option_cls', option_cls)):
44-
if value is not None and field_info is not None:
85+
for key, value in (
86+
('priority', priority),
87+
('short_name', short_name),
88+
('option_cls', option_cls),
89+
('orm_class', orm_class),
90+
('orm_to_model', orm_to_model),
91+
('model_to_orm', model_to_orm),
92+
('exclude_to_orm', exclude_to_orm),
93+
('exclude_from_cli', exclude_from_cli),
94+
('is_attribute', is_attribute),
95+
('is_subscriptable', is_subscriptable),
96+
):
97+
if value is not None:
4598
field_info.metadata.append({key: value})
4699

47100
return field_info

src/aiida/common/typing.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
###########################################################################
2+
# Copyright (c), The AiiDA team. All rights reserved. #
3+
# This file is part of the AiiDA code. #
4+
# #
5+
# The code is hosted on GitHub at https://github.com/aiidateam/aiida-core #
6+
# For further information on the license, see the LICENSE.txt file #
7+
# For further information please visit http://www.aiida.net #
8+
###########################################################################
9+
"""Module to define commonly used data structures."""
10+
11+
from __future__ import annotations
12+
13+
import pathlib
14+
from typing import Union
15+
16+
__all__ = ('FilePath',)
17+
18+
19+
FilePath = Union[str, pathlib.PurePath]

src/aiida/orm/authinfos.py

Lines changed: 66 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,20 @@
88
###########################################################################
99
"""Module for the `AuthInfo` ORM class."""
1010

11+
from __future__ import annotations
12+
1113
from typing import TYPE_CHECKING, Any, Dict, Optional, Type
1214

1315
from aiida.common import exceptions
16+
from aiida.common.pydantic import MetadataField
1417
from aiida.manage import get_manager
1518
from aiida.plugins import TransportFactory
1619

1720
from . import entities, users
18-
from .fields import add_field
21+
from .computers import Computer
22+
from .users import User
1923

2024
if TYPE_CHECKING:
21-
from aiida.orm import Computer, User
2225
from aiida.orm.implementation import StorageBackend
2326
from aiida.orm.implementation.authinfos import BackendAuthInfo # noqa: F401
2427
from aiida.transports import Transport
@@ -45,51 +48,60 @@ class AuthInfo(entities.Entity['BackendAuthInfo', AuthInfoCollection]):
4548
"""ORM class that models the authorization information that allows a `User` to connect to a `Computer`."""
4649

4750
_CLS_COLLECTION = AuthInfoCollection
51+
PROPERTY_WORKDIR = 'workdir'
4852

49-
__qb_fields__ = [
50-
add_field(
51-
'enabled',
52-
dtype=bool,
53+
class Model(entities.Entity.Model):
54+
computer: int = MetadataField(
55+
description='The PK of the computer',
5356
is_attribute=False,
54-
doc='Whether the instance is enabled',
55-
),
56-
add_field(
57-
'auth_params',
58-
dtype=Dict[str, Any],
57+
orm_class=Computer,
58+
orm_to_model=lambda auth_info, _: auth_info.computer.pk, # type: ignore[attr-defined]
59+
)
60+
user: int = MetadataField(
61+
description='The PK of the user',
5962
is_attribute=False,
60-
doc='Dictionary of authentication parameters',
61-
),
62-
add_field(
63-
'metadata',
64-
dtype=Dict[str, Any],
63+
orm_class=User,
64+
orm_to_model=lambda auth_info, _: auth_info.user.pk, # type: ignore[attr-defined]
65+
)
66+
enabled: bool = MetadataField(
67+
True,
68+
description='Whether the instance is enabled',
6569
is_attribute=False,
66-
doc='Dictionary of metadata',
67-
),
68-
add_field(
69-
'computer_pk',
70-
dtype=int,
70+
)
71+
auth_params: Dict[str, Any] = MetadataField(
72+
default_factory=dict,
73+
description='Dictionary of authentication parameters',
7174
is_attribute=False,
72-
doc='The PK of the computer',
73-
),
74-
add_field(
75-
'user_pk',
76-
dtype=int,
75+
)
76+
metadata: Dict[str, Any] = MetadataField(
77+
default_factory=dict,
78+
description='Dictionary of metadata',
7779
is_attribute=False,
78-
doc='The PK of the user',
79-
),
80-
]
81-
82-
PROPERTY_WORKDIR = 'workdir'
83-
84-
def __init__(self, computer: 'Computer', user: 'User', backend: Optional['StorageBackend'] = None) -> None:
80+
)
81+
82+
def __init__(
83+
self,
84+
computer: 'Computer',
85+
user: 'User',
86+
enabled: bool = True,
87+
auth_params: Dict[str, Any] | None = None,
88+
metadata: Dict[str, Any] | None = None,
89+
backend: Optional['StorageBackend'] = None,
90+
) -> None:
8591
"""Create an `AuthInfo` instance for the given computer and user.
8692
8793
:param computer: a `Computer` instance
8894
:param user: a `User` instance
8995
:param backend: the backend to use for the instance, or use the default backend if None
9096
"""
9197
backend = backend or get_manager().get_profile_storage()
92-
model = backend.authinfos.create(computer=computer.backend_entity, user=user.backend_entity)
98+
model = backend.authinfos.create(
99+
computer=computer.backend_entity,
100+
user=user.backend_entity,
101+
enabled=enabled,
102+
auth_params=auth_params or {},
103+
metadata=metadata or {},
104+
)
93105
super().__init__(model)
94106

95107
def __str__(self) -> str:
@@ -98,6 +110,18 @@ def __str__(self) -> str:
98110

99111
return f'AuthInfo for {self.user.email} on {self.computer.label} [DISABLED]'
100112

113+
def __eq__(self, other) -> bool:
114+
if not isinstance(other, AuthInfo):
115+
return False
116+
117+
return (
118+
self.user.pk == other.user.pk
119+
and self.computer.pk == other.computer.pk
120+
and self.enabled == other.enabled
121+
and self.auth_params == other.auth_params
122+
and self.metadata == other.metadata
123+
)
124+
101125
@property
102126
def enabled(self) -> bool:
103127
"""Return whether this instance is enabled.
@@ -126,6 +150,14 @@ def user(self) -> 'User':
126150
"""Return the user associated with this instance."""
127151
return entities.from_backend_entity(users.User, self._backend_entity.user)
128152

153+
@property
154+
def auth_params(self) -> Dict[str, Any]:
155+
return self._backend_entity.get_auth_params()
156+
157+
@property
158+
def metadata(self) -> Dict[str, Any]:
159+
return self._backend_entity.get_metadata()
160+
129161
def get_auth_params(self) -> Dict[str, Any]:
130162
"""Return the dictionary of authentication parameters
131163

0 commit comments

Comments
 (0)