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"