From 2d0756bf0fb1e6de045cee6e1a9e5cf5d258c1b9 Mon Sep 17 00:00:00 2001 From: Wes John-Alder Date: Wed, 30 Sep 2026 19:03:27 -0400 Subject: [PATCH] Expose a template field on run resources Material, process, and measurement runs carry their own template and fall back to the spec's template when it is unset. Ingredient runs carry their own name and labels with the same fallback. The change is additive and bumps the version to 5.1.0. Co-Authored-By: Claude Fable 5.1 --- docs/source/data_entry.rst | 1 + src/citrine/__version__.py | 2 +- src/citrine/resources/ingredient_run.py | 35 ++++++++++ src/citrine/resources/material_run.py | 17 ++++- src/citrine/resources/measurement_run.py | 23 ++++++- src/citrine/resources/object_runs.py | 52 ++++++++++++++- src/citrine/resources/process_run.py | 20 +++++- tests/resources/test_ingredient_run.py | 37 +++++++++++ tests/resources/test_material_run.py | 72 ++++++++++++++++++++- tests/resources/test_measurement_run.py | 24 +++++++ tests/resources/test_process_run.py | 24 +++++++ tests/serialization/test_ingredient_run.py | 1 + tests/serialization/test_measurement_run.py | 2 + tests/serialization/test_process_run.py | 1 + tests/utils/factories.py | 1 + 15 files changed, 300 insertions(+), 12 deletions(-) diff --git a/docs/source/data_entry.rst b/docs/source/data_entry.rst index 0439cddbc..9c16677dd 100644 --- a/docs/source/data_entry.rst +++ b/docs/source/data_entry.rst @@ -12,6 +12,7 @@ The Citrine Python client implementations are consistent with the GEMD_ model sp The Citrine Python client is built on top of and entirely interoperable with the gemd-python_ package. Any method that accepts the Citrine Python client's implementations of data model objects should also accept those from GEMD. +A run may also reference its template directly through its ``template`` field, and an :class:`~citrine.resources.ingredient_run.IngredientRun` may carry its own ``name`` and ``labels``. Identifying Data Model Objects --------------------------------- diff --git a/src/citrine/__version__.py b/src/citrine/__version__.py index 2fe5fde13..0d72820f3 100644 --- a/src/citrine/__version__.py +++ b/src/citrine/__version__.py @@ -1 +1 @@ -__version__ = "5.0.1" +__version__ = "5.1.0" diff --git a/src/citrine/resources/ingredient_run.py b/src/citrine/resources/ingredient_run.py index c9303f089..481d127d0 100644 --- a/src/citrine/resources/ingredient_run.py +++ b/src/citrine/resources/ingredient_run.py @@ -9,6 +9,7 @@ from gemd.entity.object.ingredient_spec import IngredientSpec as GEMDIngredientSpec from gemd.entity.object.material_run import MaterialRun as GEMDMaterialRun from gemd.entity.object.process_run import ProcessRun as GEMDProcessRun +from gemd.entity.setters import validate_list, validate_str from gemd.entity.value.continuous_value import ContinuousValue from citrine._rest.resource import GEMDResource @@ -54,6 +55,10 @@ class IngredientRun( The absolute quantity of the ingredient in the process. spec: IngredientSpec The specification of which this ingredient is a realization. + name: str, optional + The ingredient's own name. When unset, the name of the spec applies. + labels: list[str], optional + The ingredient's own labels. When unset, the labels of the spec apply. file_links: list[FileLink], optional Links to associated files, with resource paths into the files API. @@ -88,6 +93,8 @@ def __init__( number_fraction: ContinuousValue | None = None, absolute_quantity: ContinuousValue | None = None, spec: GEMDIngredientSpec | None = None, + name: str | None = None, + labels: list[str] | None = None, file_links: list[FileLink] | None = None, ): if uids is None: @@ -107,6 +114,34 @@ def __init__( spec=spec, file_links=file_links, ) + self.name = name + self.labels = labels + + @property + def name(self) -> str | None: + """The ingredient's own name when set, otherwise the name of its spec.""" + if self._name is not None: + return self._name + if isinstance(self.spec, GEMDIngredientSpec): + return self.spec.name + return None + + @name.setter + def name(self, name: str | None): + self._name = None if name is None else validate_str(name) + + @property + def labels(self) -> list[str] | None: + """The ingredient's own labels when set, otherwise the labels of its spec.""" + if self._labels is not None: + return self._labels + if isinstance(self.spec, GEMDIngredientSpec): + return self.spec.labels + return None + + @labels.setter + def labels(self, labels: list[str] | None): + self._labels = None if labels is None else validate_list(labels, str) def __str__(self): return f"" diff --git a/src/citrine/resources/material_run.py b/src/citrine/resources/material_run.py index e94d146b5..e5cc4b125 100644 --- a/src/citrine/resources/material_run.py +++ b/src/citrine/resources/material_run.py @@ -16,11 +16,11 @@ from citrine.resources._default_labels import _inject_default_label_tags from citrine.resources.data_concepts import _make_link_by_uid from citrine.resources.material_spec import MaterialSpecCollection -from citrine.resources.object_runs import ObjectRun, ObjectRunCollection +from citrine.resources.object_runs import ObjectRunCollection, TemplatedObjectRun class MaterialRun( - GEMDResource["MaterialRun"], ObjectRun, GEMDMaterialRun, typ=GEMDMaterialRun.typ + GEMDResource["MaterialRun"], TemplatedObjectRun, GEMDMaterialRun, typ=GEMDMaterialRun.typ ): """ A material run. @@ -46,6 +46,9 @@ class MaterialRun( "unknown." Default is "unknown." spec: MaterialSpec The material specification of which this is an instance. + template: MaterialTemplate, optional + The material template this run is an instance of. + When unset, the template of the spec applies. file_links: list[FileLink], optional Links to associated files, with resource paths into the files API. default_labels: list[str], optional @@ -63,6 +66,7 @@ class MaterialRun( process = Optional(LinkOrElse(GEMDProcessRun), "process", override=True, use_init=True) sample_type = Optional(String, "sample_type", override=True) spec = Optional(LinkOrElse(GEMDMaterialSpec), "spec", override=True, use_init=True) + _template = Optional(LinkOrElse(GEMDMaterialTemplate), "template") def __init__( self, @@ -74,13 +78,14 @@ def __init__( process: GEMDProcessRun | None = None, sample_type: str | None = "unknown", spec: GEMDMaterialSpec | None = None, + template: GEMDMaterialTemplate | LinkByUID | None = None, file_links: list[FileLink] | None = None, default_labels: list[str] | None = None, ): if uids is None: uids = dict() all_tags = _inject_default_label_tags(tags, default_labels) - super(ObjectRun, self).__init__() + super(TemplatedObjectRun, self).__init__() GEMDMaterialRun.__init__( self, name=name, @@ -92,6 +97,12 @@ def __init__( file_links=file_links, notes=notes, ) + self.template = template + + @staticmethod + def _template_type() -> type: + """Return the template type this run accepts.""" + return GEMDMaterialTemplate def __str__(self): return f"" diff --git a/src/citrine/resources/measurement_run.py b/src/citrine/resources/measurement_run.py index ae0c872a1..b5badd2ac 100644 --- a/src/citrine/resources/measurement_run.py +++ b/src/citrine/resources/measurement_run.py @@ -12,14 +12,20 @@ from gemd.entity.object.measurement_run import MeasurementRun as GEMDMeasurementRun from gemd.entity.object.measurement_spec import MeasurementSpec as GEMDMeasurementSpec from gemd.entity.source.performed_source import PerformedSource +from gemd.entity.template.measurement_template import ( + MeasurementTemplate as GEMDMeasurementTemplate, +) from citrine._rest.resource import GEMDResource from citrine._serialization.properties import LinkOrElse, List, Object, Optional, String -from citrine.resources.object_runs import ObjectRun, ObjectRunCollection +from citrine.resources.object_runs import ObjectRunCollection, TemplatedObjectRun class MeasurementRun( - GEMDResource["MeasurementRun"], ObjectRun, GEMDMeasurementRun, typ=GEMDMeasurementRun.typ + GEMDResource["MeasurementRun"], + TemplatedObjectRun, + GEMDMeasurementRun, + typ=GEMDMeasurementRun.typ, ): """ A measurement run. @@ -46,6 +52,9 @@ class MeasurementRun( Properties that are measured during this measurement run. spec: MeasurementSpec The measurement specification of which this is an instance. + template: MeasurementTemplate, optional + A template bounding the valid values for this run's attributes. + When unset, the template of the spec applies. material: MaterialRun The material run being measured. spec: MaterialSpec @@ -64,6 +73,7 @@ class MeasurementRun( parameters = Optional(List(Object(Parameter)), "parameters", override=True) properties = Optional(List(Object(Property)), "properties", override=True) spec = Optional(LinkOrElse(GEMDMeasurementSpec), "spec", override=True, use_init=True) + _template = Optional(LinkOrElse(GEMDMeasurementTemplate), "template") material = Optional(LinkOrElse(GEMDMaterialRun), "material", override=True, use_init=True) source = Optional(Object(PerformedSource), "source", override=True) @@ -78,13 +88,14 @@ def __init__( properties: list[Property] | None = None, parameters: list[Parameter] | None = None, spec: GEMDMeasurementSpec | None = None, + template: GEMDMeasurementTemplate | LinkByUID | None = None, material: GEMDMaterialRun | None = None, file_links: list[FileLink] | None = None, source: PerformedSource | None = None, ): if uids is None: uids = dict() - super(ObjectRun, self).__init__() + super(TemplatedObjectRun, self).__init__() GEMDMeasurementRun.__init__( self, name=name, @@ -99,6 +110,12 @@ def __init__( notes=notes, source=source, ) + self.template = template + + @staticmethod + def _template_type() -> type: + """Return the template type this run accepts.""" + return GEMDMeasurementTemplate def __str__(self): return f"" diff --git a/src/citrine/resources/object_runs.py b/src/citrine/resources/object_runs.py index 56c214187..59d5e9409 100644 --- a/src/citrine/resources/object_runs.py +++ b/src/citrine/resources/object_runs.py @@ -1,9 +1,13 @@ """Top-level class for all object run objects and collections thereof.""" -from abc import ABC +from abc import ABC, abstractmethod from typing import TypeVar +from gemd.entity.base_entity import BaseEntity +from gemd.entity.link_by_uid import LinkByUID from gemd.entity.object.has_spec import HasSpec +from gemd.entity.object.has_template import HasTemplate +from gemd.entity.template.base_template import BaseTemplate from citrine.resources.data_objects import DataObject, DataObjectCollection @@ -16,6 +20,52 @@ class ObjectRun(DataObject, HasSpec, ABC): """ +class TemplatedObjectRun(ObjectRun, ABC): + """ + An abstract object run that can reference its template directly. + + The run's own template takes precedence over the template of its spec. + Only a template set on the run itself is sent to the platform; the template of the spec + is never copied onto the run. + + Subclasses declare the serialized ``_template`` field and the accepted template type. + """ + + @staticmethod + @abstractmethod + def _template_type() -> type: + """Return the template type this run accepts.""" + + @property + def template(self) -> BaseTemplate | LinkByUID | None: + """The run's own template when set, otherwise the template of its spec.""" + if self._template is not None: + return self._template + return super().template + + @template.setter + def template(self, template: BaseTemplate | LinkByUID | None): + if template is not None and not isinstance(template, (self._template_type(), LinkByUID)): + raise TypeError( + f"template must be a {self._template_type().__name__} or LinkByUID, " + f"not {type(template)}" + ) + self._template = template + + def _local_dependencies(self) -> set[BaseEntity | LinkByUID]: + """Return the spec and the run's own template, when set.""" + return {x for x in (self.spec, self._template) if x is not None} + + def _dict_for_compare(self) -> dict: + """Support for recursive equals.""" + result = super()._dict_for_compare() + # A gemd run that mixes in HasTemplate has a template key in its as_dict; one that + # does not has no such key, so the key is dropped to keep the two comparable. + if not isinstance(self, HasTemplate): + result.pop("template", None) + return result + + ObjectRunResourceType = TypeVar("ObjectRunResourceType", bound="ObjectRun") diff --git a/src/citrine/resources/process_run.py b/src/citrine/resources/process_run.py index c8a29dfd1..3076f8545 100644 --- a/src/citrine/resources/process_run.py +++ b/src/citrine/resources/process_run.py @@ -10,13 +10,16 @@ from gemd.entity.object.process_run import ProcessRun as GEMDProcessRun from gemd.entity.object.process_spec import ProcessSpec as GEMDProcessSpec from gemd.entity.source.performed_source import PerformedSource +from gemd.entity.template.process_template import ProcessTemplate as GEMDProcessTemplate from citrine._rest.resource import GEMDResource from citrine._serialization.properties import LinkOrElse, List, Object, Optional, String -from citrine.resources.object_runs import ObjectRun, ObjectRunCollection +from citrine.resources.object_runs import ObjectRunCollection, TemplatedObjectRun -class ProcessRun(GEMDResource["ProcessRun"], ObjectRun, GEMDProcessRun, typ=GEMDProcessRun.typ): +class ProcessRun( + GEMDResource["ProcessRun"], TemplatedObjectRun, GEMDProcessRun, typ=GEMDProcessRun.typ +): """ A process run. @@ -42,6 +45,9 @@ class ProcessRun(GEMDResource["ProcessRun"], ObjectRun, GEMDProcessRun, typ=GEMD Parameters of this process run. spec: ProcessSpec Spec for this process run. + template: ProcessTemplate, optional + A template bounding the valid values for this run's conditions and parameters. + When unset, the template of the spec applies. file_links: list[FileLink], optional Links to associated files, with resource paths into the files API. source: PerformedSource, optional @@ -55,6 +61,7 @@ class ProcessRun(GEMDResource["ProcessRun"], ObjectRun, GEMDProcessRun, typ=GEMD conditions = Optional(List(Object(Condition)), "conditions", override=True) parameters = Optional(List(Object(Parameter)), "parameters", override=True) spec = Optional(LinkOrElse(GEMDProcessSpec), "spec", override=True, use_init=True) + _template = Optional(LinkOrElse(GEMDProcessTemplate), "template") source = Optional(Object(PerformedSource), "source", override=True) def __init__( @@ -67,12 +74,13 @@ def __init__( conditions: list[Condition] | None = None, parameters: list[Parameter] | None = None, spec: GEMDProcessSpec | None = None, + template: GEMDProcessTemplate | LinkByUID | None = None, file_links: list[FileLink] | None = None, source: PerformedSource | None = None, ): if uids is None: uids = dict() - super(ObjectRun, self).__init__() + super(TemplatedObjectRun, self).__init__() GEMDProcessRun.__init__( self, name=name, @@ -85,6 +93,12 @@ def __init__( notes=notes, source=source, ) + self.template = template + + @staticmethod + def _template_type() -> type: + """Return the template type this run accepts.""" + return GEMDProcessTemplate def __str__(self): return f"" diff --git a/tests/resources/test_ingredient_run.py b/tests/resources/test_ingredient_run.py index ca71f2ed8..cf55f9b77 100644 --- a/tests/resources/test_ingredient_run.py +++ b/tests/resources/test_ingredient_run.py @@ -64,3 +64,40 @@ def test_equals(): assert gemd_obj == citrine_obj, "GEMD/Citrine equivalence" citrine_obj.notes = "Something else" assert gemd_obj != citrine_obj, "GEMD/Citrine detects difference" + + +def test_name_and_labels_precedence(): + """An ingredient run's own name and labels take precedence over the spec's.""" + from citrine.resources.ingredient_run import IngredientRun as CitrineIngredientRun + from citrine.resources.ingredient_spec import IngredientSpec + + bare = CitrineIngredientRun() + assert bare.name is None + assert bare.labels is None + + spec = IngredientSpec(name="flour", labels=["dry"], uids={"id": "spec"}) + run = CitrineIngredientRun(spec=spec, uids={"id": "run"}) + assert run.name == "flour" + assert run.labels == ["dry"] + assert run.dump()["name"] is None, "Only the run's own name is serialized" + assert run.dump()["labels"] is None, "Only the run's own labels are serialized" + + run.name = "bleached flour" + run.labels = ["dry", "white"] + assert run.name == "bleached flour" + assert run.labels == ["dry", "white"] + built = CitrineIngredientRun.build(run.dump()) + assert built.name == "bleached flour" + assert built.labels == ["dry", "white"] + + run.name = None + run.labels = None + assert run.name == "flour" + assert run.labels == ["dry"] + + own = CitrineIngredientRun(name="salt", labels=["mineral"], spec=spec) + assert own.name == "salt" + assert own.labels == ["mineral"] + + with pytest.raises(TypeError): + run.name = 5 diff --git a/tests/resources/test_material_run.py b/tests/resources/test_material_run.py index 9809521c1..8df11018c 100644 --- a/tests/resources/test_material_run.py +++ b/tests/resources/test_material_run.py @@ -1,5 +1,5 @@ import json -from uuid import UUID +from uuid import UUID, uuid4 import pytest from gemd.demo.cake import change_scope, make_cake @@ -18,12 +18,14 @@ from citrine.resources.gemd_resource import GEMDResourceCollection from citrine.resources.material_run import MaterialRun as CitrineRun from citrine.resources.material_run import MaterialRunCollection +from citrine.resources.material_template import MaterialTemplate from tests.resources.test_data_concepts import run_noop_gemd_relation_search_test from tests.utils.factories import ( LinkByUIDFactory, MaterialRunDataFactory, MaterialRunFactory, MaterialSpecDataFactory, + MaterialSpecFactory, MaterialTemplateFactory, ProcessTemplateFactory, ) @@ -592,3 +594,71 @@ def test_args_only(collection): not_dry = collection.register_all([obj], dry_run=False) assert obj in not_dry assert obj.spec not in not_dry + + +def test_template_precedence(): + """A run's own template takes precedence over the template of its spec.""" + spec_template = MaterialTemplateFactory() + spec = MaterialSpecFactory(template=spec_template) + run = CitrineRun("run", spec=spec) + assert run.template == spec_template, "Falls back to the spec's template" + assert run.dump()["template"] is None, "Only the run's own template is serialized" + + own_template = MaterialTemplateFactory() + run.template = own_template + assert run.template == own_template + assert run.dump()["template"] == own_template.dump() + + run.template = None + assert run.template == spec_template + + with pytest.raises(TypeError): + run.template = spec + + +def test_template_round_trip(): + """A template link survives dump and build.""" + link = LinkByUIDFactory() + run = CitrineRun("run", uids={"id": str(uuid4())}, template=link) + data = run.dump() + assert data["template"] == link.as_dict() + built = CitrineRun.build(data) + assert built.template == link + assert built == run + + +def test_build_with_context_hydrates_template(): + """A template in the context replaces the link on the run.""" + template = MaterialTemplateFactory() + run = CitrineRun("run", uids={"id": str(uuid4())}, template=template.to_link()) + built = CitrineRun.build({"object": run.dump(), "context": [template.dump()]}) + assert isinstance(built.template, MaterialTemplate) + assert built.template == template + + +def test_register_all_writes_template_before_run(collection, session): + """A run with a template and no spec is written after its template.""" + template = MaterialTemplateFactory() + run = CitrineRun("run", uids={"id": str(uuid4())}, template=template) + + registered = collection.register_all([run, template]) + + assert len(session.calls) == 1 + objects = session.calls[0].json["objects"] + assert [obj["type"] for obj in objects] == ["material_template", "material_run"] + assert objects[1]["template"]["type"] == "link_by_uid" + assert registered[1].template == registered[0] + + +def test_dry_run_register_all_batches_template_with_run(collection, session): + """A dry run keeps a run and its own template in one batch.""" + template = MaterialTemplateFactory() + run = CitrineRun("run", uids={"id": str(uuid4())}, template=template) + + collection.register_all([run, template], dry_run=True) + + assert len(session.calls) == 1 + assert {obj["type"] for obj in session.calls[0].json["objects"]} == { + "material_template", + "material_run", + } diff --git a/tests/resources/test_measurement_run.py b/tests/resources/test_measurement_run.py index cf7768a00..d3c0dd2a8 100644 --- a/tests/resources/test_measurement_run.py +++ b/tests/resources/test_measurement_run.py @@ -50,3 +50,27 @@ def test_equals(): assert gemd_obj == citrine_obj, "GEMD/Citrine equivalence" citrine_obj.notes = "Something else" assert gemd_obj != citrine_obj, "GEMD/Citrine detects difference" + + +def test_template_round_trip(): + """A run's own template takes precedence over the spec's and survives dump and build.""" + from gemd.entity.link_by_uid import LinkByUID + + from citrine.resources.measurement_run import MeasurementRun as CitrineMeasurementRun + from citrine.resources.measurement_spec import MeasurementSpec + from citrine.resources.measurement_template import MeasurementTemplate + + spec_template = MeasurementTemplate("spec template", uids={"id": "spec-template"}) + spec = MeasurementSpec("spec", uids={"id": "spec"}, template=spec_template) + run = CitrineMeasurementRun("run", uids={"id": "run"}, spec=spec) + assert run.template == spec_template + assert run.dump()["template"] is None + + link = LinkByUID("id", "own-template") + run.template = link + assert run.template == link + built = CitrineMeasurementRun.build(run.dump()) + assert built.template == link + + with pytest.raises(TypeError): + run.template = spec diff --git a/tests/resources/test_process_run.py b/tests/resources/test_process_run.py index b03875fbe..42a865a19 100644 --- a/tests/resources/test_process_run.py +++ b/tests/resources/test_process_run.py @@ -41,3 +41,27 @@ def test_equals(): assert gemd_obj == citrine_obj, "GEMD/Citrine equivalence" citrine_obj.notes = "Something else" assert gemd_obj != citrine_obj, "GEMD/Citrine detects difference" + + +def test_template_round_trip(): + """A run's own template takes precedence over the spec's and survives dump and build.""" + from gemd.entity.link_by_uid import LinkByUID + + from citrine.resources.process_run import ProcessRun as CitrineProcessRun + from citrine.resources.process_spec import ProcessSpec + from citrine.resources.process_template import ProcessTemplate + + spec_template = ProcessTemplate("spec template", uids={"id": "spec-template"}) + spec = ProcessSpec("spec", uids={"id": "spec"}, template=spec_template) + run = CitrineProcessRun("run", uids={"id": "run"}, spec=spec) + assert run.template == spec_template + assert run.dump()["template"] is None + + link = LinkByUID("id", "own-template") + run.template = link + assert run.template == link + built = CitrineProcessRun.build(run.dump()) + assert built.template == link + + with pytest.raises(TypeError): + run.template = spec diff --git a/tests/serialization/test_ingredient_run.py b/tests/serialization/test_ingredient_run.py index ee4d1a6d1..58e704cf9 100644 --- a/tests/serialization/test_ingredient_run.py +++ b/tests/serialization/test_ingredient_run.py @@ -27,6 +27,7 @@ def valid_data(): "process": None, "sample_type": "unknown", "spec": None, + "template": None, }, process=None, mass_fraction={"type": "normal_real", "mean": 0.5, "std": 0.1, "units": "dimensionless"}, diff --git a/tests/serialization/test_measurement_run.py b/tests/serialization/test_measurement_run.py index b3d5d9c76..9d32a5cdb 100644 --- a/tests/serialization/test_measurement_run.py +++ b/tests/serialization/test_measurement_run.py @@ -48,6 +48,7 @@ def valid_data(): "process": None, "sample_type": "experimental", "spec": None, + "template": None, "file_links": [], "type": "material_run", "audit_info": { @@ -59,6 +60,7 @@ def valid_data(): "dataset": str(uuid4()), }, spec=None, + template=None, file_links=[], type="measurement_run", source={ diff --git a/tests/serialization/test_process_run.py b/tests/serialization/test_process_run.py index 153b191a2..74ab16cbc 100644 --- a/tests/serialization/test_process_run.py +++ b/tests/serialization/test_process_run.py @@ -31,6 +31,7 @@ def valid_data(): } ], parameters=[], + template=None, spec={ "type": "process_spec", "name": "Spec for proc 1", diff --git a/tests/utils/factories.py b/tests/utils/factories.py index 0ea4c8270..51d4aea6e 100644 --- a/tests/utils/factories.py +++ b/tests/utils/factories.py @@ -783,6 +783,7 @@ class MaterialRunDataFactory(factory.DictFactory): process = factory.SubFactory(LinkByUIDDataFactory) sample_type = MaterialRunFactory.sample_type spec = factory.SubFactory(LinkByUIDDataFactory) + template = None file_links = factory.List([factory.SubFactory(FileLinkDataFactory)]) type = "material_run"