Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
1ca7bb7
Fix ORM pydantic schemas
edan-bainglass Oct 8, 2025
5035601
Add Kpoints constructor
edan-bainglass Oct 8, 2025
f042534
Fix `Node.from_model`
edan-bainglass Oct 8, 2025
fbeb32f
Missed cast
edan-bainglass Oct 8, 2025
02fed45
Discard direct `orm` import from `cmdline` package
edan-bainglass Oct 8, 2025
4713222
Allow unstored entity (de)serialization
edan-bainglass Oct 8, 2025
646ce4d
Update tests
edan-bainglass Oct 8, 2025
acb6aec
Fix computer type
edan-bainglass Oct 8, 2025
511ac53
Fix some typing issues
edan-bainglass Oct 9, 2025
adc9c19
Allow (de)serialization of unstored nodes
edan-bainglass Oct 10, 2025
79b1363
Add guard for unhandled attributes at parent `Data` constructor
edan-bainglass Oct 10, 2025
b6d0898
Implement constructors for `KpointsData` and `BandsData`
edan-bainglass Oct 10, 2025
bd728a7
Fix tests
edan-bainglass Oct 10, 2025
71bfac0
Serialize/validate arrays as numpy arrays, not bytes
edan-bainglass Oct 10, 2025
98bafad
Fix typing
edan-bainglass Oct 10, 2025
950813b
Fix unhandled attributes check
edan-bainglass Oct 10, 2025
7af6acf
Add `array_labels` back to `BandsData.Model` without constructor hand…
edan-bainglass Oct 10, 2025
94e1726
Nitpick some classes
edan-bainglass Oct 10, 2025
e253132
Fix docstring
edan-bainglass Oct 10, 2025
b735511
Remove user/ctime/mtime default factories from read-only fields
edan-bainglass Oct 11, 2025
281cfa5
Include attributes in node input model
edan-bainglass Oct 11, 2025
08ef433
Remove some unrelated changes
edan-bainglass Oct 12, 2025
a036678
Make repo content serialization opt-in
edan-bainglass Nov 4, 2025
c7b9aeb
Restore array base64 serialization
edan-bainglass Nov 4, 2025
3be2297
Discard explicit quotation for annotations
edan-bainglass Nov 4, 2025
f703612
Implement repo methods to extract object size and objects as a zipfil…
edan-bainglass Nov 4, 2025
d5e0002
Add field validator to transform computer pk to label
edan-bainglass Nov 4, 2025
09c3615
Fix `orm_to_model` type
edan-bainglass Nov 4, 2025
289228b
Fix types
edan-bainglass Nov 4, 2025
086f14a
Mark `InstalledCode.Model.computer` as an attribute
edan-bainglass Nov 5, 2025
26e4e91
Fix `_prepare_yaml`
edan-bainglass Nov 5, 2025
3435796
Update regression tests
edan-bainglass Nov 5, 2025
f8e724c
Centralize model field collection
edan-bainglass Nov 5, 2025
67073e4
Move unhandled node attributes gate to `Node` class
edan-bainglass Nov 6, 2025
2ae4717
Add `attributes` to `Node` constructor
edan-bainglass Nov 6, 2025
36da6f6
Fix code fields
edan-bainglass Nov 6, 2025
1b5a634
Fix centralized model field collection
edan-bainglass Nov 6, 2025
fb47977
Fix tests
edan-bainglass Nov 6, 2025
cec3766
Enable `ArrayNode` numpy array POST payloads via model validator
edan-bainglass Nov 6, 2025
aa2ab44
Allow arguments in `orm_to_model`
edan-bainglass Nov 6, 2025
899ec5a
Fix formatting
edan-bainglass Nov 6, 2025
ad356fb
Fix `repository_path` default in portable code
edan-bainglass Nov 6, 2025
7c4c7b6
Fix iterable array posting
edan-bainglass Nov 7, 2025
5d1563a
Fix model inheritance
edan-bainglass Nov 7, 2025
23397ff
Fix `from_serialized`
edan-bainglass Nov 7, 2025
88aa0cd
Rename `InputModel` and `as_input_model` to `CreateModel` and `as_cre…
edan-bainglass Nov 8, 2025
dfd2203
Discard `exclude_from_cli`
edan-bainglass Nov 11, 2025
60d4654
Exclude `attributes` from ORM
edan-bainglass Nov 11, 2025
a82c6b7
Discard `unstored` from `from_serialized`
edan-bainglass Nov 12, 2025
afabd93
Discard `unstored` serialization parameter
edan-bainglass Nov 12, 2025
e17a9a0
Always pass `kwargs` to `orm_to_model`
edan-bainglass Nov 12, 2025
36b4d59
Discard redundant class method in favor of correct model selection
edan-bainglass Nov 12, 2025
9bb780c
Use `is_stored` for model selection
edan-bainglass Nov 12, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/source/nitpick-exceptions
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ py:class json.encoder.JSONEncoder
py:class EXPOSED_TYPE
py:class EVENT_CALLBACK_TYPE
py:class datetime
py:class UUID
py:class types.LambdaType
py:meth tempfile.TemporaryDirectory

Expand Down Expand Up @@ -68,6 +69,8 @@ py:class aiida.orm.groups.SelfType
py:class aiida.orm.implementation.entitites.EntityType
py:class aiida.engine.processes.functions.FunctionType
py:class aiida.engine.processes.workchains.workchain.MethodType
py:class aiida.orm.entities.EntityInputModel
py:class aiida.orm.entities.EntityModelType
py:class aiida.orm.entities.EntityType
py:class aiida.orm.entities.BackendEntityType
py:class aiida.orm.entities.CollectionType
Expand Down
38 changes: 13 additions & 25 deletions src/aiida/cmdline/commands/cmd_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@
###########################################################################
"""`verdi code` command."""

from __future__ import annotations

import pathlib
import warnings
from collections import defaultdict
from functools import partial
from typing import Any
from typing import TYPE_CHECKING, Any

import click

Expand All @@ -26,16 +28,20 @@
from aiida.cmdline.utils.decorators import with_dbenv
from aiida.common import exceptions

if TYPE_CHECKING:
from aiida.orm import Code


@verdi.group('code')
def verdi_code():
"""Setup and manage codes."""


def create_code(ctx: click.Context, cls, **kwargs) -> None:
def create_code(ctx: click.Context, cls: Code, **kwargs) -> None:
"""Create a new `Code` instance."""
try:
instance = cls._from_model(cls.Model(**kwargs))
model = cls.CreateModel(**kwargs)
instance = cls.from_model(model) # type: ignore[arg-type]
except (TypeError, ValueError) as exception:
echo.echo_critical(f'Failed to create instance `{cls}`: {exception}')

Expand Down Expand Up @@ -236,37 +242,19 @@ def show(code):
table.append(['Type', code.entry_point.name])

for field_name, field_info in code.Model.model_fields.items():
# Skip fields excluded from CLI
if get_metadata(
field_info,
key='exclude_from_cli',
default=False,
):
if get_metadata(field_info, key='exclude_to_orm') or field_name == 'extras':
continue

# Skip fields that are not stored in the attributes column
# NOTE: this also catches e.g., filepath_files for PortableCode, which is actually a "misuse"
# of the is_attribute metadata flag, as there it is flagging that the field is not stored at all!
# TODO (edan-bainglass) consider improving this by introducing a new metadata flag or reworking PortableCode
# TODO see also Dict and InstalledCode for other potential misuses of is_attribute
if not get_metadata(
field_info,
key='is_attribute',
default=True,
):
# FIXME resolve this hardcoded special case properly
if field_name == 'filepath_files':
continue
Comment on lines +248 to 250
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I prefer here we explicitly hardcode 'filepath_files', as it is the intended exclusion here. is_attribute is used for other purposes, not for marking exclusion from the CLI show command. Reminding @GeigerJ2 to open an issue (optionally reply here with a link to the issue).

Copy link
Contributor

@GeigerJ2 GeigerJ2 Nov 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I agree, better to do it like this! I just created #7099 as a sub-issue of #7050, thanks for the reminder!


value = getattr(code, field_name)

# Special handling for computer field to show additional info
if field_name == 'computer':
value = f'{value.label} ({value.hostname}), pk: {value.pk}'

# Use the field's title as display name.
# This allows for custom titles (class-cased by default from Pydantic).
display_name = field_info.title

table.append([display_name, value])
table.append([field_info.title, value])

if is_verbose():
table.append(['Calculations', len(code.base.links.get_outgoing().all())])
Expand Down
9 changes: 6 additions & 3 deletions src/aiida/cmdline/groups/dynamic.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,9 @@ def call_command(self, ctx: click.Context, cls: t.Any, non_interactive: bool, **

if hasattr(cls, 'Model'):
# The plugin defines a pydantic model: use it to validate the provided arguments
Model = cls.CreateModel if hasattr(cls, 'CreateModel') else cls.Model # noqa: N806
try:
cls.Model(**kwargs)
Model(**kwargs)
except ValidationError as exception:
param_hint = [
f'--{loc.replace("_", "-")}' # type: ignore[union-attr]
Expand Down Expand Up @@ -168,10 +169,12 @@ def list_options(self, entry_point: str) -> list[t.Callable[[FC], FC]]:
options_spec = self.factory(entry_point).get_cli_options() # type: ignore[union-attr]
return [self.create_option(*item) for item in options_spec]

Model = cls.CreateModel if hasattr(cls, 'CreateModel') else cls.Model # noqa: N806

options_spec = {}

for key, field_info in cls.Model.model_fields.items():
if get_metadata(field_info, 'exclude_from_cli'):
for key, field_info in Model.model_fields.items():
if get_metadata(field_info, 'exclude_to_orm') or key == 'extras':
continue

default = field_info.default_factory if field_info.default is PydanticUndefined else field_info.default
Expand Down
4 changes: 2 additions & 2 deletions src/aiida/common/datastructures.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,8 +167,8 @@ class CalcInfo(DefaultFieldsAttributeDict):
max_wallclock_seconds: None | int
max_memory_kb: None | int
rerunnable: bool
retrieve_list: None | list[str | tuple[str, str, str]]
retrieve_temporary_list: None | list[str | tuple[str, str, str]]
retrieve_list: None | list[str | tuple[str, str, int]]
retrieve_temporary_list: None | list[str | tuple[str, str, int]]
Comment on lines +170 to +171
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The last argument of the tuple is the depth, which "can be used to control what level of nesting of the source folder hierarchy should be maintained". It is an integer.

local_copy_list: None | list[tuple[str, str, str]]
remote_copy_list: None | list[tuple[str, str, str]]
remote_symlink_list: None | list[tuple[str, str, str]]
Expand Down
20 changes: 10 additions & 10 deletions src/aiida/common/pydantic.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
from __future__ import annotations

import typing as t
from pathlib import Path

from pydantic import Field
from pydantic_core import PydanticUndefined
Expand Down Expand Up @@ -35,10 +34,9 @@ def MetadataField( # noqa: N802
short_name: str | None = None,
option_cls: t.Any | None = None,
orm_class: type[Entity[t.Any, t.Any]] | str | None = None,
orm_to_model: t.Callable[[Entity[t.Any, t.Any], Path], t.Any] | None = None,
model_to_orm: t.Callable[['BaseModel'], t.Any] | None = None,
orm_to_model: t.Callable[[Entity[t.Any, t.Any], dict[str, t.Any]], t.Any] | None = None,
model_to_orm: t.Callable[[BaseModel], t.Any] | None = None,
exclude_to_orm: bool = False,
exclude_from_cli: bool = False,
is_attribute: bool = True,
is_subscriptable: bool = False,
**kwargs: t.Any,
Expand Down Expand Up @@ -66,7 +64,7 @@ class Model(BaseModel):
: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.
:param orm_class: The class, or entry point name thereof, to which the field should be converted. If this field is
defined, the value of this field should acccept an integer which will automatically be converted to an instance
defined, the value of this field should accept an integer which will automatically be converted to an instance
of said ORM class using ``orm_class.collection.get(id={field_value})``. This is useful, for example, where a
field represents an instance of a different entity, such as an instance of ``User``. The serialized data would
store the ``pk`` of the user, but the ORM entity instance would receive the actual ``User`` instance with that
Expand All @@ -75,10 +73,8 @@ class Model(BaseModel):
:param model_to_orm: Optional callable to convert the value of a field from a model instance to an ORM instance.
:param exclude_to_orm: When set to ``True``, this field value will not be passed to the ORM entity constructor
through ``Entity.from_model``.
:param exclude_from_cli: When set to ``True``, this field value will not be exposed on the CLI command that is
dynamically generated to create a new instance.
:param is_attribute: Whether the field is stored as an attribute.
:param is_subscriptable: Whether the field can be indexed like a list or dictionary.
:param is_attribute: Whether the field is stored as an attribute. Used by `QbFields`.
:param is_subscriptable: Whether the field can be indexed like a list or dictionary. Used by `QbFields`.
"""
field_info = Field(default, **kwargs)

Expand All @@ -90,11 +86,15 @@ class Model(BaseModel):
('orm_to_model', orm_to_model),
('model_to_orm', model_to_orm),
('exclude_to_orm', exclude_to_orm),
('exclude_from_cli', exclude_from_cli),
('is_attribute', is_attribute),
('is_subscriptable', is_subscriptable),
):
if value is not None:
field_info.metadata.append({key: value})

if exclude_to_orm:
extra = getattr(field_info, 'json_schema_extra', None) or {}
extra.update({'readOnly': True})
field_info.json_schema_extra = extra
Comment on lines +95 to +98
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Marking readOnly in the JSON Schema for clarity/correctness.


return field_info
8 changes: 4 additions & 4 deletions src/aiida/orm/authinfos.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

from __future__ import annotations

from typing import TYPE_CHECKING, Any, Dict, Optional, Type
from typing import TYPE_CHECKING, Any, Dict, Optional, Type, cast

from aiida.common import exceptions
from aiida.common.pydantic import MetadataField
Expand All @@ -33,7 +33,7 @@ class AuthInfoCollection(entities.Collection['AuthInfo']):
"""The collection of `AuthInfo` entries."""

@staticmethod
def _entity_base_cls() -> Type['AuthInfo']:
def _entity_base_cls() -> Type[AuthInfo]:
return AuthInfo

def delete(self, pk: int) -> None:
Expand All @@ -55,13 +55,13 @@ class Model(entities.Entity.Model):
description='The PK of the computer',
is_attribute=False,
orm_class=Computer,
orm_to_model=lambda auth_info, _: auth_info.computer.pk, # type: ignore[attr-defined]
orm_to_model=lambda auth_info, _: cast(AuthInfo, auth_info).computer.pk,
)
user: int = MetadataField(
description='The PK of the user',
is_attribute=False,
orm_class=User,
orm_to_model=lambda auth_info, _: auth_info.user.pk, # type: ignore[attr-defined]
orm_to_model=lambda auth_info, _: cast(AuthInfo, auth_info).user.pk,
)
enabled: bool = MetadataField(
True,
Expand Down
32 changes: 22 additions & 10 deletions src/aiida/orm/comments.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,11 @@
###########################################################################
"""Comment objects and functions"""

from __future__ import annotations

from datetime import datetime
from typing import TYPE_CHECKING, List, Optional, Type, cast
from uuid import UUID

from aiida.common.pydantic import MetadataField
from aiida.manage import get_manager
Expand All @@ -29,7 +32,7 @@ class CommentCollection(entities.Collection['Comment']):
"""The collection of Comment entries."""

@staticmethod
def _entity_base_cls() -> Type['Comment']:
def _entity_base_cls() -> Type[Comment]:
return Comment

def delete(self, pk: int) -> None:
Expand Down Expand Up @@ -68,28 +71,37 @@ class Comment(entities.Entity['BackendComment', CommentCollection]):
_CLS_COLLECTION = CommentCollection

class Model(entities.Entity.Model):
uuid: Optional[str] = MetadataField(
description='The UUID of the comment', is_attribute=False, exclude_to_orm=True
uuid: UUID = MetadataField(
description='The UUID of the comment',
is_attribute=False,
exclude_to_orm=True,
)
ctime: Optional[datetime] = MetadataField(
description='Creation time of the comment', is_attribute=False, exclude_to_orm=True
ctime: datetime = MetadataField(
description='Creation time of the comment',
is_attribute=False,
exclude_to_orm=True,
)
mtime: Optional[datetime] = MetadataField(
description='Modified time of the comment', is_attribute=False, exclude_to_orm=True
mtime: datetime = MetadataField(
description='Modified time of the comment',
is_attribute=False,
exclude_to_orm=True,
)
node: int = MetadataField(
description='Node PK that the comment is attached to',
is_attribute=False,
orm_class='core.node',
orm_to_model=lambda comment, _: cast('Comment', comment).node.pk,
orm_to_model=lambda comment, _: cast(Comment, comment).node.pk,
)
user: int = MetadataField(
description='User PK that created the comment',
is_attribute=False,
orm_class='core.user',
orm_to_model=lambda comment, _: cast('Comment', comment).user.pk,
orm_to_model=lambda comment, _: cast(Comment, comment).user.pk,
)
content: str = MetadataField(
description='Content of the comment',
is_attribute=False,
)
content: str = MetadataField(description='Content of the comment', is_attribute=False)

def __init__(
self, node: 'Node', user: 'User', content: Optional[str] = None, backend: Optional['StorageBackend'] = None
Expand Down
49 changes: 38 additions & 11 deletions src/aiida/orm/computers.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,11 @@
###########################################################################
"""Module for Computer entities"""

from __future__ import annotations

import os
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Type, Union
from uuid import UUID

from aiida.common import exceptions
from aiida.common.log import AIIDA_LOGGER, AiidaLoggerType
Expand All @@ -32,10 +35,10 @@ class ComputerCollection(entities.Collection['Computer']):
"""The collection of Computer entries."""

@staticmethod
def _entity_base_cls() -> Type['Computer']:
def _entity_base_cls() -> Type[Computer]:
return Computer

def get_or_create(self, label: str, **kwargs: Any) -> Tuple[bool, 'Computer']:
def get_or_create(self, label: str, **kwargs: Any) -> Tuple[bool, Computer]:
"""Try to retrieve a Computer from the DB with the given arguments;
create (and store) a new Computer if such a Computer was not present yet.

Expand Down Expand Up @@ -74,13 +77,37 @@ class Computer(entities.Entity['BackendComputer', ComputerCollection]):
_CLS_COLLECTION = ComputerCollection

class Model(entities.Entity.Model):
uuid: str = MetadataField(description='The UUID of the computer', is_attribute=False, exclude_to_orm=True)
label: str = MetadataField(description='Label for the computer', is_attribute=False)
description: str = MetadataField(description='Description of the computer', is_attribute=False)
hostname: str = MetadataField(description='Hostname of the computer', is_attribute=False)
transport_type: str = MetadataField(description='Transport type of the computer', is_attribute=False)
scheduler_type: str = MetadataField(description='Scheduler type of the computer', is_attribute=False)
metadata: Dict[str, Any] = MetadataField(description='Metadata of the computer', is_attribute=False)
uuid: UUID = MetadataField(
description='The UUID of the computer',
is_attribute=False,
exclude_to_orm=True,
)
label: str = MetadataField(
description='Label for the computer',
is_attribute=False,
)
description: str = MetadataField(
'',
description='Description of the computer',
is_attribute=False,
)
hostname: str = MetadataField(
description='Hostname of the computer',
is_attribute=False,
)
transport_type: str = MetadataField(
description='Transport type of the computer',
is_attribute=False,
)
scheduler_type: str = MetadataField(
description='Scheduler type of the computer',
is_attribute=False,
)
metadata: Dict[str, Any] = MetadataField(
default_factory=dict,
description='Metadata of the computer',
is_attribute=False,
)

def __init__(
self,
Expand Down Expand Up @@ -261,11 +288,11 @@ def default_memory_per_machine_validator(cls, def_memory_per_machine: Optional[i
f'Invalid value for def_memory_per_machine, must be a positive int, got: {def_memory_per_machine}'
)

def copy(self) -> 'Computer':
def copy(self) -> Computer:
"""Return a copy of the current object to work with, not stored yet."""
return entities.from_backend_entity(Computer, self._backend_entity.copy())

def store(self) -> 'Computer':
def store(self) -> Computer:
"""Store the computer in the DB.

Differently from Nodes, a computer can be re-stored if its properties
Expand Down
Loading
Loading