Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 7 additions & 0 deletions packages/essinstruments/tests/loki/spec_digests.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"loki-beam-centre/v1": "8ecc60fbdaca2a91",
"loki-calibrated-detector/v1": "31b35013cb34f95c",
"loki-create-mask/v1": "f5fc5f6545f3b74d",
"loki-iofq/v1": "49a8fc1845f68d66",
"loki-masks/v1": "81f547b6fb3bcaea"
}
13 changes: 13 additions & 0 deletions packages/essinstruments/tests/loki/specs_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,18 @@
# Copyright (c) 2026 Scipp contributors (https://github.com/scipp)
import subprocess
import sys
from pathlib import Path

import pytest
from ess.instruments.loki import specs
from ess.instruments.loki.specs import AngleRange, BeamCentre
from pydantic import ValidationError

from ess.spec import WorkflowSpec
from ess.spec.testing import check_digests

DIGESTS = Path(__file__).with_name('spec_digests.json')


def test_specs_load_without_the_workflow_package() -> None:
code = (
Expand All @@ -18,6 +25,12 @@ def test_specs_load_without_the_workflow_package() -> None:
assert result.returncode == 0


def test_each_spec_has_the_digest_kept_for_its_version() -> None:
check_digests(
[s for s in vars(specs).values() if isinstance(s, WorkflowSpec)], DIGESTS
)


@pytest.mark.parametrize('z', [0.0, -1.0])
def test_a_beam_centre_without_a_distance_from_the_sample_is_refused(z: float) -> None:
with pytest.raises(ValidationError, match='greater than 0'):
Expand Down
51 changes: 50 additions & 1 deletion packages/essspec/docs/params.md
Original file line number Diff line number Diff line change
Expand Up @@ -330,14 +330,62 @@ A validator raises `ValueError` for a value it does not take; any other exceptio
pydantic tries the members of a union in order, so a validator of a member written inline, such as a `model_validator(mode='before')` of `GivenCentre`, also sees a reference to an output, and refuses it with `ValueError`.

The record stores a value written inline in JSON form, and the workflow gets it validated from that form.
So the JSON form of a group is the object of its fields: a field has no alias and no `exclude`, and a group has no serializers and no computed fields.
So the JSON form of a group is the object of its fields: a field has no alias, no `exclude`, and no serializer in its type, such as `Annotated[float, PlainSerializer(...)]`, and a group has no serializers and no computed fields.
A group may have field validators and model validators of any mode.
A value written inline must round-trip through JSON: a validator that changes an already valid value, such as converting millimetres to metres, is refused.

Values that a check compares, such as the start and stop of a range, are fields of one group, which a request always gives whole.
A check across parameters, such as a Q range the wavelength band can reach, belongs to the workflow and fails when the request runs.
Since each parameter is validated alone, and a stage and a driver never get a whole params instance, the params model has no decorators, neither validators of any mode nor serializers nor computed fields, and no `__init__` of its own.

## The digest of a spec

`spec.digest` is a short hex string computed from the name, type, and requiredness of each field of the params and outputs models, and of each group inside them, at any depth.
It tells a record made with other models under the same name and version from one of the bound spec ([ADR 0009](../../../docs/developer/adr/0009-a-record-of-another-spec-stays-readable.md)).

| Changes the digest | Does not |
|---|---|
| a field renamed, added, or removed | a title, a description, a docstring |
| a field's type, such as `float` to `int`, `X` to `X \| None`, `X` to `Referenced[X]` | a default |
| a field made required or optional | a constraint, such as `Field(gt=0)`, or a validator |
| the values of a `Literal` or an enum | the order of fields, or of the members of a union |
| the metadata of a `File(...)` marker | the name or module of a group's class |
| the `extra` setting of a group | the names of an enum's members, or an enum replaced by a `Literal` of its values |
| the root type of a `RootModel` | |
| the module or name of a class that is not a group | |

A type is described so:

```python
class Centre(BaseModel): # a group: by its fields, and by `extra` unless default
x: float # a builtin, a time, Path, a scipp type: by its name
unit: Literal['mm', 'm'] # a Literal or an enum: by its values, {'values': ['m', 'mm']}

class Masks(RootModel[dict[str, sc.Variable]]): ... # by its root type, dict[str, Variable]

class Shapes(RootModel[list[Polygon]]): ... # any other class, such as Polygon: by module and name, 'mypackage.shapes.Polygon'
```

Changing a file field's metadata, including the role of a raw file, changes the digest.
`Referenced[T]` is described by the fields of `T`, since a reference is checked by class when it is used: `T` replaced by another class with the same fields keeps the digest, and a reference made with the old class is refused when it is checked.
Requiredness counts, although a record stores its params with the defaults filled in, since a parameter that becomes required breaks callers.
Serialization settings of a group's `model_config` other than `extra`, such as `ser_json_inf_nan`, are not covered.
A type that is not resolved, such as a forward reference before `model_rebuild()`, has no digest: `spec.digest` raises, naming the field.
The digest does not see a change of what the workflow computes, so the version stays the author's statement of any change a consumer can observe.

A package keeps the digests of its specs in a file and checks them in its tests, so that a model changed without a new version fails there:

```python
from ess.spec.testing import check_digests, write_digests

check_digests([IOFQ, BEAM_CENTRE], Path(__file__).with_name('spec_digests.json'))
write_digests([IOFQ, BEAM_CENTRE], path) # once, to make the file: {"sans-iofq/v1": "9f2c...", ...}
```

The check names each spec whose digest differs from the file's, each spec the file lacks, with the entry the file needs, and each entry of the file that no spec given has.
The file holds the given specs and nothing else, so a test that finds no specs fails.
A changed interface gets a new version; the file is updated under the same version only for a change that is meant to keep it, knowing that records of the old models are then of another spec.

## What `WorkflowSpec` refuses

Making a spec raises `pydantic.ValidationError`, which lists each problem with the model and field it concerns.
Expand Down Expand Up @@ -394,4 +442,5 @@ Params model and groups ([When values are validated](#when-values-are-validated)

- A field has no alias (`alias`, `validation_alias`, `serialization_alias`) and no `exclude` or `exclude_if`.
- A group has no serializers and no computed fields.
- A field's type has no serializer written with `Annotated`, such as `PlainSerializer` or `WrapSerializer`.
- The params model has no decorators (validators of any mode, serializers, computed fields) and no `__init__` of its own.
184 changes: 182 additions & 2 deletions packages/essspec/src/ess/spec/_workflow_spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,17 @@

from __future__ import annotations

import hashlib
import json
from collections.abc import Callable, Sequence
from enum import Enum
from pathlib import Path, PurePath
from types import NoneType, UnionType
from typing import (
Annotated,
Any,
ForwardRef,
Literal,
Self,
TypeAliasType,
Union,
Expand All @@ -66,6 +71,9 @@
BaseModel,
ConfigDict,
Field,
PlainSerializer,
RootModel,
WrapSerializer,
model_validator,
)
from pydantic.fields import FieldInfo
Expand Down Expand Up @@ -159,7 +167,8 @@ class WorkflowSpec[P: BaseModel, O: BaseModel](_SpecFields, frozen=True):
version:
Version of the workflow interface, at least 1. Increase it on any
change that a consumer can observe: the params, the outputs, or what
the workflow computes.
the workflow computes. :attr:`digest` detects a change of the models
under one version.
title:
Display title of the workflow, not empty.
description:
Expand Down Expand Up @@ -218,6 +227,64 @@ def _check_models(self) -> Self:
raise ValueError(f'{self.name}: {"; ".join(problems)}')
return self

@property
def digest(self) -> str:
"""
A short hex string that changes when the stored form of the params or
the outputs changes, so that a record of the spec can tell whether the
spec it was made with had the models of this one.

It covers the name, type, and requiredness of each field of the params
and outputs models, and of each group inside them, at any depth.
Requiredness counts although a record stores its params with the
defaults filled in, since a parameter that becomes required breaks
callers. A type is described so:

- a group, a pydantic model that is not a ``RootModel``, by its fields
and by its ``extra`` setting unless that is the default, not by its
class name or module, so that a group may be renamed or moved;
- a ``RootModel`` by the type of its root;
- a ``Literal`` and an enum by their values, which the stored form
holds, so that the two are the same and an enum member may be
renamed;
- ``Referenced[T]`` as a reference to ``T`` and its fields, and a file
field by the metadata of its ``File(...)`` marker, the role of a raw
file included, which decide which files fit;
- a union by its members, a list, a dict, or a tuple by its type
arguments, in order;
- a builtin, such as ``float`` or ``list``, a time, ``Path``, and a
scipp type, such as ``DataArray``, by its name;
- any other class by its module and qualified name, such as
``mypackage.masks.MaskSet``, so that moving or renaming it changes
the digest.

The order of fields, and of the members of a union, does not count.
A reference is checked by class when it is used, so ``T`` replaced by
another class of the same fields keeps the digest, and a reference
made with the old class is refused when it is checked.

Titles, descriptions, defaults, constraints such as ``Field(gt=0)``,
validators, discriminators, and the spec's name and version are not
part of it: they do not change the stored form of a value. Settings
of a group's ``model_config`` that change its JSON form, other than
``extra``, such as ``ser_json_inf_nan``, are not covered. The digest
does not see a change of what the workflow computes, so the version
stays the author's statement of any change a consumer can observe.
``ess.spec.testing.check_digests`` checks that a changed digest comes
with a new version.

Raises
------
ValueError
If the type of a field is not resolved, such as a forward
reference before ``model_rebuild()``, or is not made of classes.
"""
interface = {
'params': _interface(self.params, ()),
'outputs': _interface(self.outputs, ()),
}
return hashlib.sha256(_canonical(interface).encode()).hexdigest()[:16]

def serialize(self) -> SerializedWorkflowSpec:
"""
Project to the plain-data form.
Expand Down Expand Up @@ -344,7 +411,8 @@ def _fields_problems(
The problems of each field of ``model``, the params model or a group,
inside the groups ``stack``, the params model first. Each field is typed
as :func:`_type_problems` checks, has no alias, is not excluded from the
JSON form, and has a default that is a value (see
JSON form, has no serializer in its type (see
:func:`_annotated_serializers`), and has a default that is a value (see
:func:`_default_problem`).
"""
kind = 'a parameter' if len(stack) == 1 else 'a field of a group'
Expand All @@ -361,6 +429,9 @@ def _fields_problems(
)
continue
metadata = field_types.field_metadata(field)
if found := _annotated_serializers(field.annotation, metadata):
problems.append(f'{where}: no serializers, {found}')
continue
if found := _type_problems(field.annotation, metadata, stack):
problems.extend(f'{where}: {problem}' for problem in found)
elif field.default_factory_takes_validated_data:
Expand All @@ -370,6 +441,25 @@ def _fields_problems(
return problems


def _annotated_serializers(annotation: Any, metadata: tuple[Any, ...]) -> list[str]:
"""
The serializers written with ``Annotated``, such as ``PlainSerializer``,
in ``metadata`` or in ``annotation``, not inside a class in it: each makes
the JSON form of a value differ from that of its type, as a decorator
serializer of a group does.
"""
found = [type(m).__name__ for m in metadata if isinstance(m, _SERIALIZER_MARKERS)]
if get_origin(annotation) is Annotated:
inner = get_args(annotation)[0]
return [*found, *_annotated_serializers(inner, annotation.__metadata__)]
for arg in get_args(annotation):
found.extend(_annotated_serializers(arg, ()))
return found


_SERIALIZER_MARKERS = (PlainSerializer, WrapSerializer)


def _type_problems(
annotation: Any, metadata: tuple[Any, ...], stack: tuple[type[BaseModel], ...]
) -> list[str]:
Expand Down Expand Up @@ -804,3 +894,93 @@ def _output_type_schema(annotation: Any) -> dict[str, Any]:
else:
schema = {'type': 'object', 'additionalProperties': _output_type_schema(item)}
return {'anyOf': [schema, {'type': 'null'}]} if is_optional else schema


def _interface(model: type[BaseModel], stack: tuple[type[BaseModel], ...]) -> Any:
"""
The name, type, and requiredness of each field of ``model``, a group
inside the groups ``stack``, as plain data for :attr:`WorkflowSpec.digest`.

Raises
------
ValueError
If the type of a field cannot be described, naming the field.
"""
if model in stack:
# A group that contains itself, which only an output may hold: how far up.
return {'recursive': len(stack) - stack.index(model)}
described = {}
for name, info in model.model_fields.items():
try:
tp = _type_interface(field_types.field_type(info), (*stack, model))
except _Undescribed as error:
raise ValueError(
f'{model.__name__}.{name}: {error}; the digest describes a field '
'by its type, so each type in it is a class, resolved'
) from None
described[name] = {'type': tp, 'required': info.is_required()}
return described


class _Undescribed(Exception):
"""A type that :func:`_type_interface` cannot describe."""


_SHORT_NAMED = (Path, *field_types.TIMES)
"""
Classes outside ``builtins`` and scipp described by their name alone, which no
other class of a spec takes.
"""


def _type_interface(tp: Any, stack: tuple[type[BaseModel], ...]) -> Any:
"""The type ``tp`` as plain data, by the rules of :attr:`WorkflowSpec.digest`."""
origin, args = get_origin(tp), get_args(tp)
if origin is Annotated:
if REFERENCED in tp.__metadata__:
return {'referenced': _type_interface(args[0], stack)}
if (marker := field_types.file_marker(args[0], *tp.__metadata__)) is not None:
return {'file': marker.metadata}
return _type_interface(args[0], stack)
if origin in (Union, UnionType):
members = (_type_interface(arg, stack) for arg in args)
return {'union': sorted(members, key=_canonical)}
# A Literal and an enum of the same values store the same JSON.
if origin is Literal:
values = [v.value if isinstance(v, Enum) else v for v in args]
return {'values': sorted(values, key=_canonical)}
if isinstance(tp, type) and issubclass(tp, Enum):
return {'values': sorted((m.value for m in tp), key=_canonical)}
if field_types.is_group(tp):
group = {'group': _interface(tp, stack)}
if (extra := tp.model_config.get('extra')) not in (None, 'ignore'):
group['extra'] = extra
return group
if isinstance(tp, type) and issubclass(tp, RootModel):
root = tp.model_fields['root']
return {'root': _type_interface(field_types.field_type(root), stack)}
if origin is not None:
return {_class_name(origin): [_type_interface(a, stack) for a in args]}
if tp is Ellipsis:
return '...'
if isinstance(tp, ForwardRef | str):
raise _Undescribed(f'{tp!r} is not resolved; call model_rebuild() first')
if not isinstance(tp, type):
raise _Undescribed(f'{tp!r} is not a class')
return _class_name(tp)


def _class_name(cls: type) -> str:
"""
The name of ``cls`` in a digest: its name alone for a builtin, a time,
``Path``, or a scipp type, else its module and qualified name.
"""
module = cls.__module__.partition('.')[0]
if cls in _SHORT_NAMED or module in ('builtins', 'scipp'):
return field_types.type_name(cls)
return f'{cls.__module__}.{cls.__qualname__}'


def _canonical(data: Any) -> str:
"""The JSON form of ``data``, the same for equal data in any process."""
return json.dumps(data, sort_keys=True, separators=(',', ':'))
Loading
Loading