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)