From 25fc732b6f19e0f155aa00b258edfe3f0a6564c4 Mon Sep 17 00:00:00 2001 From: Wes John-Alder Date: Wed, 30 Sep 2026 18:52:16 -0400 Subject: [PATCH] Let runs carry a template and let ingredient runs own their name MaterialRun, ProcessRun and MeasurementRun accept an optional template that takes precedence over the template of the spec. IngredientRun accepts its own name and labels and falls back to the values on the spec. The version moves from 2.2.5 to 2.3.0. Co-Authored-By: Claude Fable 5.1 --- docs/source/depth/serialization.rst | 5 ++ gemd/__version__.py | 2 +- gemd/entity/object/has_spec.py | 23 +++++- gemd/entity/object/has_template.py | 3 +- gemd/entity/object/ingredient_run.py | 79 +++++++++---------- gemd/entity/object/material_run.py | 16 +++- gemd/entity/object/measurement_run.py | 13 ++++ gemd/entity/object/process_run.py | 13 ++++ tests/entity/object/test_ingredient_run.py | 84 +++++++++++++++++---- tests/entity/object/test_material_run.py | 57 ++++++++++++++ tests/entity/object/test_measurement_run.py | 35 +++++++++ tests/entity/object/test_process_run.py | 41 ++++++++++ tests/util/test_flatten.py | 20 +++++ tests/util/test_substitute_links.py | 29 +++++++ 14 files changed, 358 insertions(+), 62 deletions(-) diff --git a/docs/source/depth/serialization.rst b/docs/source/depth/serialization.rst index 4087fd99..49cb78a1 100644 --- a/docs/source/depth/serialization.rst +++ b/docs/source/depth/serialization.rst @@ -13,6 +13,11 @@ For example, a :class:`~gemd.entity.object.material_run.MaterialRun` links to it Uni-directional links are typically used when the multiplicity of a relationship can be large. For example, a material may be referenced in thousands of ingredients. +Runs may also link directly to a template. +A :class:`~gemd.entity.object.material_run.MaterialRun`, :class:`~gemd.entity.object.process_run.ProcessRun` or :class:`~gemd.entity.object.measurement_run.MeasurementRun` carries its own ``template`` field, serialized alongside ``spec``. +When that field is empty, the run's ``template`` property falls back to the template of its spec. +An :class:`~gemd.entity.object.ingredient_run.IngredientRun` carries its own ``name`` and ``labels`` fields in the same way, falling back to the values on its spec. + In GEMD, bi-directional links are readable but only a single direction is writable. For example, a :class:`~gemd.entity.object.measurement_run.MeasurementRun` can set the :class:`~gemd.entity.object.material_run.MaterialRun` material that it was performed on, but a :class:`~gemd.entity.object.material_run.MaterialRun` cannot set the :class:`~gemd.entity.object.measurement_run.MeasurementRun`s it contains. diff --git a/gemd/__version__.py b/gemd/__version__.py index f1e49f68..55e47090 100644 --- a/gemd/__version__.py +++ b/gemd/__version__.py @@ -1 +1 @@ -__version__ = "2.2.5" +__version__ = "2.3.0" diff --git a/gemd/entity/object/has_spec.py b/gemd/entity/object/has_spec.py index 76c73896..d6ac82c8 100644 --- a/gemd/entity/object/has_spec.py +++ b/gemd/entity/object/has_spec.py @@ -43,12 +43,31 @@ def _spec_type() -> Type: @property def template(self) -> Optional[Union[BaseTemplate, LinkByUID]]: - """Get the template associated with the spec.""" + """The template that bounds this object. + + Objects that also mix in :class:`~gemd.entity.object.has_template.HasTemplate` return + their own template when it is set. Otherwise, this is the template of the spec, if the + spec is an object that has one. + """ + if isinstance(self, HasTemplate): + own = HasTemplate.template.fget(self) + if own is not None: + return own if isinstance(self.spec, HasTemplate): return self.spec.template else: return None + @template.setter + def template(self, template: Optional[Union[BaseTemplate, LinkByUID]]): + """Set the object's own template, if it can carry one.""" + if not isinstance(self, HasTemplate): + raise AttributeError(f"{type(self).__name__} does not carry its own template.") + HasTemplate.template.fset(self, template) + def _local_dependencies(self) -> Set[Union[BaseEntity, LinkByUID]]: """Return a set of all immediate dependencies (no recursion).""" - return {self.spec} if self.spec is not None else set() + result = {self.spec} if self.spec is not None else set() + if isinstance(self, HasTemplate): + result |= HasTemplate._local_dependencies(self) + return result diff --git a/gemd/entity/object/has_template.py b/gemd/entity/object/has_template.py index 8a732546..22d79aef 100644 --- a/gemd/entity/object/has_template.py +++ b/gemd/entity/object/has_template.py @@ -42,4 +42,5 @@ def template(self, template: Optional[Union[BaseTemplate, LinkByUID]]): def _local_dependencies(self) -> Set[Union[BaseEntity, LinkByUID]]: """Return a set of all immediate dependencies (no recursion).""" - return {self.template} if self.template is not None else set() + template = getattr(self, "_template", None) # Only the template this object stores + return {template} if template is not None else set() diff --git a/gemd/entity/object/ingredient_run.py b/gemd/entity/object/ingredient_run.py index f7f068af..94898935 100644 --- a/gemd/entity/object/ingredient_run.py +++ b/gemd/entity/object/ingredient_run.py @@ -1,6 +1,5 @@ -from typing import Any, Iterable, List, Mapping, Optional, Type, Union +from typing import Iterable, List, Mapping, Optional, Type, Union -from gemd.entity.dict_serializable import DictSerializable from gemd.entity.file_link import FileLink from gemd.entity.link_by_uid import LinkByUID from gemd.entity.object.base_object import BaseObject @@ -11,7 +10,7 @@ from gemd.entity.object.ingredient_spec import IngredientSpec from gemd.entity.object.material_run import MaterialRun from gemd.entity.object.process_run import ProcessRun -from gemd.entity.setters import validate_list +from gemd.entity.setters import validate_list, validate_str from gemd.entity.value.continuous_value import ContinuousValue __all__ = ["IngredientRun"] @@ -26,6 +25,12 @@ class IngredientRun( Parameters ---------- + name: str, optional + Name of the ingredient run. + When unset, the name of the spec applies. + labels: List[str], optional + Additional labels on the ingredient that must be unique. + When empty, the labels of the spec apply. uids: Map[str, str], optional A collection of `unique IDs str: - """Get name.""" - from gemd.entity.object.ingredient_spec import IngredientSpec - - if isinstance(self.spec, IngredientSpec): + """The run's own name when it has one, otherwise the name of its spec.""" + if self._name is not None: + return self._name + elif isinstance(self.spec, IngredientSpec): return self.spec.name else: - return super().name + return None + + @name.setter + def name(self, name: str): + """Set the run's own name.""" + self._name = None if name is None else validate_str(name) @property def labels(self) -> List[str]: - """Get labels.""" - from gemd.entity.object.ingredient_spec import IngredientSpec - - if isinstance(self.spec, IngredientSpec): + """The run's own labels when it has any, otherwise the labels of its spec.""" + if len(self._labels) > 0: + return self._labels + elif isinstance(self.spec, IngredientSpec): return self.spec.labels else: return self._labels + @labels.setter + def labels(self, labels: Iterable[str]): + """Set the run's own labels.""" + self._labels = validate_list(labels, str) + @property def material(self) -> Union[MaterialRun, LinkByUID]: """Get the material.""" @@ -156,33 +174,10 @@ def spec(self) -> Union[IngredientSpec, LinkByUID]: @spec.setter def spec(self, spec: Union[IngredientSpec, LinkByUID]): """Set the spec.""" - if isinstance(self.spec, IngredientSpec): # Store values if you had them - self._name = self.spec.name - self._labels = validate_list(self.spec.labels, str) + if isinstance(self.spec, IngredientSpec): # Keep the old spec's values if you have none + if self._name is None: + self._name = self.spec.name + if len(self._labels) == 0: + self._labels = validate_list(self.spec.labels, str) # Note that the super() mechanism does not work properly for overloaded setters getattr(HasSpec, "spec").fset(self, spec) - - @classmethod - def from_dict(cls, d: Mapping[str, Any]) -> DictSerializable: - """Overloaded method from DictSerializable to intercept `name` and `labels` fields. - - Parameters - ---------- - d: dict - The object as a dictionary of key-value pairs that correspond to the object's fields. - - Returns - ------- - DictSerializable - The deserialized object. - - """ - clean = dict(d) - name = clean.pop("name", None) - labels = clean.pop("labels", None) - obj = super().from_dict(clean) - if name is not None: - obj._name = name - if labels is not None: - obj._labels = validate_list(labels, str) - return obj diff --git a/gemd/entity/object/material_run.py b/gemd/entity/object/material_run.py index 44698286..be74fcee 100644 --- a/gemd/entity/object/material_run.py +++ b/gemd/entity/object/material_run.py @@ -5,16 +5,20 @@ from gemd.entity.object.base_object import BaseObject from gemd.entity.object.has_process import HasProcess from gemd.entity.object.has_spec import HasSpec +from gemd.entity.object.has_template import HasTemplate from gemd.entity.object.material_spec import MaterialSpec from gemd.entity.object.process_run import ProcessRun from gemd.entity.setters import validate_list +from gemd.entity.template.material_template import MaterialTemplate from gemd.enumeration import SampleType __all__ = ["MaterialRun"] MeasurementRunType = TypeVar("MeasurementRunType", bound="MeasurementRun") # noqa: F821 -class MaterialRun(BaseObject, HasSpec, HasProcess, typ="material_run", skip={"_measurements"}): +class MaterialRun( + BaseObject, HasSpec, HasTemplate, HasProcess, typ="material_run", skip={"_measurements"} +): """A material run. This includes a link to the originating process and soft links to measurements. @@ -40,6 +44,9 @@ class MaterialRun(BaseObject, HasSpec, HasProcess, typ="material_run", skip={"_m "unknown." Default is "unknown." spec: :class:`~gemd.entity.object.material_spec.MaterialSpec` The material specification of which this is an instance. + template: ~gemd.entity.template.material_template.MaterialTemplate, optional + A template bounding the valid values for this run's attributes. + When unset, the template of the spec applies. file_links: List[~gemd.entity.file_link.FileLink], optional Links to associated files, with resource paths into the files API. @@ -50,6 +57,7 @@ def __init__( name: str, *, spec: Union[MaterialSpec, LinkByUID] = None, + template: Optional[Union[MaterialTemplate, LinkByUID]] = None, process: Union[ProcessRun, LinkByUID] = None, sample_type: Union[SampleType, str] = "unknown", uids: Mapping[str, str] = None, @@ -63,6 +71,7 @@ def __init__( self, name=name, uids=uids, tags=tags, notes=notes, file_links=file_links ) HasSpec.__init__(self, spec=spec) + HasTemplate.__init__(self, template) self._process = None self._measurements = validate_list(None, [MeasurementRun, LinkByUID]) self._sample_type = None @@ -113,6 +122,11 @@ def _spec_type() -> Type: """Required method to satisfy HasTemplates mix-in.""" return MaterialSpec + @staticmethod + def _template_type() -> Type: + """Communicate expected template type to parent class.""" + return MaterialTemplate + def _dict_for_compare(self) -> Mapping[str, Any]: """Support for recursive equals.""" base = super()._dict_for_compare() diff --git a/gemd/entity/object/measurement_run.py b/gemd/entity/object/measurement_run.py index 9e5a22f1..fcefa5a5 100644 --- a/gemd/entity/object/measurement_run.py +++ b/gemd/entity/object/measurement_run.py @@ -12,9 +12,11 @@ from gemd.entity.object.has_properties import HasProperties from gemd.entity.object.has_source import HasSource from gemd.entity.object.has_spec import HasSpec +from gemd.entity.object.has_template import HasTemplate from gemd.entity.object.material_run import MaterialRun from gemd.entity.object.measurement_spec import MeasurementSpec from gemd.entity.source.performed_source import PerformedSource +from gemd.entity.template.measurement_template import MeasurementTemplate __all__ = ["MeasurementRun"] @@ -23,6 +25,7 @@ class MeasurementRun( BaseObject, HasMaterial, HasSpec, + HasTemplate, HasConditions, HasProperties, HasParameters, @@ -56,6 +59,9 @@ class MeasurementRun( Properties that are measured during this measurement run. spec: ~gemd.entity.object.measurement_spec.MeasurementSpec` The measurement specification of which this is an instance. + template: ~gemd.entity.template.measurement_template.MeasurementTemplate, optional + A template bounding the valid values for this run's attributes. + When unset, the template of the spec applies. material: ~gemd.entity.object.material_run.MaterialRun` The material run being measured. spec: ~gemd.entity.object.material_spec.MaterialSpec` @@ -72,6 +78,7 @@ def __init__( name: str, *, spec: Union[MeasurementSpec, LinkByUID] = None, + template: Optional[Union[MeasurementTemplate, LinkByUID]] = None, material: Union[MaterialRun, LinkByUID] = None, properties: Union[Property, Iterable[Property]] = None, conditions: Union[Condition, Iterable[Condition]] = None, @@ -86,6 +93,7 @@ def __init__( self, name=name, uids=uids, tags=tags, notes=notes, file_links=file_links ) HasSpec.__init__(self, spec=spec) + HasTemplate.__init__(self, template) HasProperties.__init__(self, properties) HasConditions.__init__(self, conditions) HasParameters.__init__(self, parameters) @@ -116,3 +124,8 @@ def material(self, value: Union[MaterialRun, LinkByUID]): def _spec_type() -> Type: """Required method to satisfy HasTemplates mix-in.""" return MeasurementSpec + + @staticmethod + def _template_type() -> Type: + """Communicate expected template type to parent class.""" + return MeasurementTemplate diff --git a/gemd/entity/object/process_run.py b/gemd/entity/object/process_run.py index 99a59852..b18c248b 100644 --- a/gemd/entity/object/process_run.py +++ b/gemd/entity/object/process_run.py @@ -9,9 +9,11 @@ from gemd.entity.object.has_parameters import HasParameters from gemd.entity.object.has_source import HasSource from gemd.entity.object.has_spec import HasSpec +from gemd.entity.object.has_template import HasTemplate from gemd.entity.object.process_spec import ProcessSpec from gemd.entity.setters import validate_list from gemd.entity.source.performed_source import PerformedSource +from gemd.entity.template.process_template import ProcessTemplate __all__ = ["ProcessRun"] MaterialRunType = TypeVar("MaterialRunType", bound="MaterialRun") # noqa: F821 @@ -21,6 +23,7 @@ class ProcessRun( BaseObject, HasSpec, + HasTemplate, HasConditions, HasParameters, HasSource, @@ -53,6 +56,9 @@ class ProcessRun( Parameters of this process run. spec: ~gemd.entity.object.process_spec.ProcessSpec Spec for this process run. + template: ~gemd.entity.template.process_template.ProcessTemplate, optional + A template bounding the valid values for this run's attributes. + When unset, the template of the spec applies. file_links: List[~gemd.entity.file_link.FileLink], optional Links to associated files, with resource paths into the files API. source: ~gemd.entity.source.performed_source.PerformedSource, optional @@ -65,6 +71,7 @@ def __init__( name: str, *, spec: Union[ProcessSpec, LinkByUID] = None, + template: Optional[Union[ProcessTemplate, LinkByUID]] = None, conditions: Union[Condition, Iterable[Condition]] = None, parameters: Union[Parameter, Iterable[Parameter]] = None, uids: Mapping[str, str] = None, @@ -79,6 +86,7 @@ def __init__( self, name=name, uids=uids, tags=tags, notes=notes, file_links=file_links ) HasSpec.__init__(self, spec=spec) + HasTemplate.__init__(self, template) HasConditions.__init__(self, conditions) HasParameters.__init__(self, parameters) HasSource.__init__(self, source) @@ -111,6 +119,11 @@ def _spec_type() -> Type: """Required method to satisfy HasTemplates mix-in.""" return ProcessSpec + @staticmethod + def _template_type() -> Type: + """Communicate expected template type to parent class.""" + return ProcessTemplate + def _dict_for_compare(self) -> Dict[str, Any]: """Support for recursive equals.""" base = super()._dict_for_compare() diff --git a/tests/entity/object/test_ingredient_run.py b/tests/entity/object/test_ingredient_run.py index 86d872d1..d0207d97 100644 --- a/tests/entity/object/test_ingredient_run.py +++ b/tests/entity/object/test_ingredient_run.py @@ -37,7 +37,9 @@ def test_invalid_assignment(): with pytest.raises(TypeError): IngredientRun(spec=5) with pytest.raises(TypeError): - IngredientRun(name="Flour") # IngredientRuns don't have their own name + IngredientRun(name=5) + with pytest.raises(TypeError): + IngredientRun(labels=[5]) def test_name_persistence(): @@ -71,25 +73,77 @@ def test_name_persistence(): assert run.name == spec.name assert run.labels == spec.labels - # Test that serialization doesn't get confused after a deser and set + # The stashed values are now the run's own, so a later spec does not replace them spec_too = IngredientSpec(name="Jorge", labels=[], process=ps_link, material=ms_link) run.spec = spec_too assert run == je.copy(run) - assert run.name == spec_too.name - assert run.labels == spec_too.labels + assert run.name == spec.name + assert run.labels == spec.labels -def test_implicit_fields(): - """These test that users can't directly set names and labels.""" - name = "name" - labels = ["label", "also"] - with pytest.raises(TypeError): - IngredientRun(name=name) - with pytest.raises(TypeError): - IngredientRun(labels=labels) +def test_own_name_and_labels(): + """The run's own name and labels take precedence over the spec's.""" + from gemd.entity.link_by_uid import LinkByUID + from gemd.entity.object import IngredientSpec + + spec = IngredientSpec(name="Spec name", labels=["spec label"]) + # No values of its own and no spec run = IngredientRun() + assert run.name is None + assert run.labels == [] + + # No values of its own: the spec's values show through + run = IngredientRun(spec=spec) + assert run.name == spec.name + assert run.labels == spec.labels + + # Own values win, whether set in the constructor or later + run = IngredientRun(name="Run name", labels=["run label"], spec=spec) + assert run.name == "Run name" + assert run.labels == ["run label"] + + run = IngredientRun(spec=spec) + run.name = "Set later" + run.labels = ["later label"] + assert run.name == "Set later" + assert run.labels == ["later label"] + + # Own values stay when the spec goes away + run.spec = LinkByUID(scope="local", id="spec") + assert run.name == "Set later" + assert run.labels == ["later label"] + + # Clearing the own values restores the fallback + run.spec = spec + run.name = None + run.labels = [] + assert run.name == spec.name + assert run.labels == spec.labels + + +def test_name_and_labels_round_trip(): + """Own name and labels survive dict and json round trips.""" + from gemd.entity.object import IngredientSpec + from gemd.json import dumps, loads + + spec = IngredientSpec(name="Spec name", labels=["spec label"]) + run = IngredientRun(name="Run name", labels=["run label"], spec=spec) + + as_dict = run.as_dict() + assert as_dict["name"] == "Run name" + assert as_dict["labels"] == ["run label"] + assert "template" not in as_dict + + rebuilt = IngredientRun.from_dict(as_dict) + assert rebuilt.name == "Run name" + assert rebuilt.labels == ["run label"] + + copied = loads(dumps(run)) + assert copied.name == "Run name" + assert copied.labels == ["run label"] + assert copied.spec.name == spec.name + + # A run with no template link of its own with pytest.raises(AttributeError): - run.name = name - with pytest.raises(AttributeError): - run.labels = labels + run.template = None diff --git a/tests/entity/object/test_material_run.py b/tests/entity/object/test_material_run.py index 9ce25aff..ebc02423 100644 --- a/tests/entity/object/test_material_run.py +++ b/tests/entity/object/test_material_run.py @@ -167,3 +167,60 @@ def test_dependencies(): assert ps not in mr.all_dependencies() assert pr in mr.all_dependencies() assert ms in mr.all_dependencies() + + +def test_own_template(): + """A run's own template wins over its spec's template.""" + spec_template = MaterialTemplate("spec template", uids={"id": str(uuid4())}) + run_template = MaterialTemplate("run template", uids={"id": str(uuid4())}) + spec = MaterialSpec("A spec", uids={"id": str(uuid4())}, template=spec_template) + + # Template only + run = MaterialRun("A run", template=run_template) + assert run.template == run_template + assert run_template in run.all_dependencies() + + # Spec only + run = MaterialRun("A run", spec=spec) + assert run.template == spec_template + assert spec_template not in run.all_dependencies() + + # Both, agreeing + run = MaterialRun("A run", spec=spec, template=spec_template) + assert run.template == spec_template + assert spec_template in run.all_dependencies() + + # Both, disagreeing: the run's own template wins, and clearing it restores the fallback + run = MaterialRun("A run", spec=spec, template=run_template) + assert run.template == run_template + run.template = None + assert run.template == spec_template + run.template = LinkByUID.from_entity(run_template) + assert run.template == LinkByUID.from_entity(run_template) + + # Neither + assert MaterialRun("A run").template is None + + with pytest.raises(TypeError): + MaterialRun("A run", template=spec) + with pytest.raises(TypeError): + run.template = ProcessSpec("Not a template") + + +def test_template_round_trip(): + """A run's own template survives serialization.""" + template = MaterialTemplate("run template", uids={"id": str(uuid4())}) + run = MaterialRun("A run", uids={"id": str(uuid4())}, template=template) + + assert run.as_dict()["template"] == template + assert MaterialRun.from_dict(run.as_dict()).template == template + + copied = gemd_json.loads(gemd_json.dumps(run)) + assert copied.template == template + assert copied == run + + # A run without a template of its own serializes the one it reaches through its spec + spec = MaterialSpec("A spec", uids={"id": str(uuid4())}, template=template) + run = MaterialRun("A run", uids={"id": str(uuid4())}, spec=spec) + assert run.as_dict()["template"] == template + assert gemd_json.loads(gemd_json.dumps(run)) == run diff --git a/tests/entity/object/test_measurement_run.py b/tests/entity/object/test_measurement_run.py index 15743697..da6e77dd 100644 --- a/tests/entity/object/test_measurement_run.py +++ b/tests/entity/object/test_measurement_run.py @@ -202,3 +202,38 @@ def test_dependencies(): assert prop in meas.all_dependencies() assert cond in meas.all_dependencies() assert param in meas.all_dependencies() + + +def test_own_template(): + """A run's own template wins over its spec's template.""" + spec_template = MeasurementTemplate("spec template", uids={"id": str(uuid4())}) + run_template = MeasurementTemplate("run template", uids={"id": str(uuid4())}) + spec = MeasurementSpec("A spec", uids={"id": str(uuid4())}, template=spec_template) + + assert MeasurementRun("A run", template=run_template).template == run_template + assert MeasurementRun("A run", spec=spec).template == spec_template + assert MeasurementRun("A run", spec=spec, template=spec_template).template == spec_template + assert MeasurementRun("A run", spec=spec, template=run_template).template == run_template + assert MeasurementRun("A run").template is None + + with pytest.raises(TypeError): + MeasurementRun("A run", template=spec) + + run = MeasurementRun("A run", uids={"id": str(uuid4())}, template=run_template) + assert loads(dumps(run)).template == run_template + + +def test_own_template_bounds_check(): + """A run with a template and no spec checks its properties against the template.""" + prop_template = PropertyTemplate("prop", bounds=IntegerBounds(0, 10)) + template = MeasurementTemplate( + "run template", properties=[(prop_template, IntegerBounds(0, 5))] + ) + too_big = Property("prop", template=prop_template, value=NominalInteger(7)) + + with validation_level(WarningLevel.IGNORE): + MeasurementRun("A run", template=template, properties=[too_big]) + with validation_level(WarningLevel.FATAL): + MeasurementRun("A run", properties=[too_big]) # No template, so nothing to check + with pytest.raises(ValueError): + MeasurementRun("A run", template=template, properties=[too_big]) diff --git a/tests/entity/object/test_process_run.py b/tests/entity/object/test_process_run.py index 45a1218d..ff0665c1 100644 --- a/tests/entity/object/test_process_run.py +++ b/tests/entity/object/test_process_run.py @@ -80,3 +80,44 @@ def test_equality(): run4 = next(x for x in flatten(run3, "test-scope") if isinstance(x, ProcessRun)) assert run4 == run3, "Flattening removes measurement references, but that's okay" + + +def test_own_template(): + """A run's own template wins over its spec's template.""" + spec_template = ProcessTemplate("spec template", uids={"id": str(uuid4())}) + run_template = ProcessTemplate("run template", uids={"id": str(uuid4())}) + spec = ProcessSpec("A spec", uids={"id": str(uuid4())}, template=spec_template) + + assert ProcessRun("A run", template=run_template).template == run_template + assert ProcessRun("A run", spec=spec).template == spec_template + assert ProcessRun("A run", spec=spec, template=spec_template).template == spec_template + assert ProcessRun("A run", spec=spec, template=run_template).template == run_template + assert ProcessRun("A run").template is None + + with pytest.raises(TypeError): + ProcessRun("A run", template=spec) + + run = ProcessRun("A run", uids={"id": str(uuid4())}, template=run_template) + assert loads(dumps(run)).template == run_template + + +def test_own_template_bounds_check(): + """A run with a template and no spec checks its attributes against the template.""" + from gemd.entity.bounds import IntegerBounds + from gemd.entity.bounds_validation import WarningLevel, validation_level + from gemd.entity.template import ConditionTemplate + from gemd.entity.value import NominalInteger + + cond_template = ConditionTemplate("cond", bounds=IntegerBounds(0, 10)) + template = ProcessTemplate("run template", conditions=[(cond_template, IntegerBounds(0, 5))]) + too_big = Condition("cond", template=cond_template, value=NominalInteger(7)) + + with validation_level(WarningLevel.IGNORE): + ProcessRun("A run", template=template, conditions=[too_big]) + with validation_level(WarningLevel.FATAL): + ProcessRun("A run", conditions=[too_big]) # No template, so nothing to check + with pytest.raises(ValueError): + ProcessRun("A run", template=template, conditions=[too_big]) + run = ProcessRun("A run", template=template) + with pytest.raises(ValueError): + run.conditions.append(too_big) diff --git a/tests/util/test_flatten.py b/tests/util/test_flatten.py index 8a00db8c..bc34325c 100644 --- a/tests/util/test_flatten.py +++ b/tests/util/test_flatten.py @@ -121,3 +121,23 @@ def test_more_iterable_types(): res = recursive_flatmap(dct.values(), lambda x: [x.tags.pop(0)]) assert "3" in res assert "3" not in obj.tags + + +def test_flatten_run_template_without_spec(): + """A run that carries a template and no spec flattens with the template first.""" + from gemd.util import writable_sort_order + + bounds = CategoricalBounds(categories=["foo", "bar"]) + cond_template = ConditionTemplate(name="eggs", bounds=bounds) + template = ProcessTemplate("spam", conditions=[(cond_template, bounds)]) + run = ProcessRun(name="run", template=template) + + flat = flatten(run, "test-scope") + # 3 objects: 1 Process Template, 1 Condition Template and 1 Process Run + assert len(flat) == 3, "Expected 3 flattened objects" + assert [type(x) for x in flat] == [ConditionTemplate, ProcessTemplate, ProcessRun] + assert [writable_sort_order(x) for x in flat] == sorted(writable_sort_order(x) for x in flat) + + flat_run = next(x for x in flat if isinstance(x, ProcessRun)) + assert flat_run.template == template.to_link("test-scope") + assert flat_run.spec is None diff --git a/tests/util/test_substitute_links.py b/tests/util/test_substitute_links.py index f990f1e2..f9fa8328 100644 --- a/tests/util/test_substitute_links.py +++ b/tests/util/test_substitute_links.py @@ -115,3 +115,32 @@ def test_inplace_v_not(): assert subbed != process_dict # This is true because the hashes change, even if objects equal substitute_links(process_dict, inplace=True) assert subbed == process_dict + + +def test_setter_by_attribute(): + """Setters are found for plain attributes, read-only properties and full properties.""" + from gemd.util.impl import _setter_by_attribute + + class Thing: + def __init__(self): + self.plain = 1 + self._read_only = 2 + self._full = 3 + + @property + def read_only(self): + return self._read_only + + @property + def full(self): + return self._full + + @full.setter + def full(self, value): + self._full = value + + thing = Thing() + _setter_by_attribute(Thing, "plain")(thing, 10) + _setter_by_attribute(Thing, "read_only")(thing, 20) + _setter_by_attribute(Thing, "full")(thing, 30) + assert (thing.plain, thing.read_only, thing.full) == (10, 20, 30)