diff --git a/dascore/core/inventory.py b/dascore/core/inventory.py index ca53d7cda..ebc99797a 100644 --- a/dascore/core/inventory.py +++ b/dascore/core/inventory.py @@ -665,6 +665,14 @@ class CouplingCondition(_IntervalModel): ) +def _wanted(field: str, info) -> bool: + """Whether a serialization's own include/exclude asked for a field.""" + if (exclude := getattr(info, "exclude", None)) and field in exclude: + return False + include = getattr(info, "include", None) + return not include or field in include + + class OpticalPathAnnotation(_IntervalModel): """ Key/value annotation attached to an interval of an optical path. @@ -697,6 +705,29 @@ def _reject_empty_string(cls, value): raise ValueError(msg) return value + def _write_object_type(self, handler, info): + """ + Tag the document as every model does, and keep the value with it. + + Overridden rather than added beside: pydantic runs one model + serializer per class, so a second one here would take the base's + place and drop the ``object_type`` every document is dispatched by. + + The value itself needs putting back because ``exclude_defaults`` + compares with ``==`` and ``1 == True``, the default. A group + numbered from one would otherwise lose every ``1`` on the way out + and reload holding a boolean, which then mixes kinds with the + numbers beside it and is refused. Identity is what "still its + default" means for a field admitting both. A caller who asked for + the value to be left out is obeyed: this restores what + exclude_defaults dropped, not what anyone chose to filter. + """ + out = super()._write_object_type(handler, info) + if "value" in out or self.value is True or not _wanted("value", info): + return out + out["value"] = self.value + return out + # The coordinates a DistanceMap may be written in, in preference order. DISTANCE_MAP_AXES = ("channel", "instrument_distance") diff --git a/tests/test_core/test_inventory.py b/tests/test_core/test_inventory.py index bdc6378a5..e2ed18b93 100644 --- a/tests/test_core/test_inventory.py +++ b/tests/test_core/test_inventory.py @@ -2073,6 +2073,65 @@ def test_round_trip_equals(self, name): inventory = SAMPLE_INVENTORIES[name] assert dc.inventory(inventory.to_yaml()) == inventory + def test_an_annotation_value_of_one_survives(self): + """`1 == True`, and the value's default is True, so it was dropped.""" + pytest.importorskip("yaml") + path = inv.OpticalPath( + optical_components=(inv.FiberSegment(optical_length=100.0),), + annotations=( + inv.OpticalPathAnnotation( + start_distance=0.0, end_distance=10.0, group="hole", value=1 + ), + inv.OpticalPathAnnotation( + start_distance=20.0, end_distance=30.0, group="hole", value=2 + ), + ), + ) + array = inv.FiberArray(code="L001", optical_paths=(path,)) + inventory = inv.Inventory( + networks=(inv.Network(code="XX", fiber_arrays=(array,)),) + ) + text = inventory.to_yaml() + assert "value: 1" in text + # Without the value, the group reloads holding a boolean beside a + # number and is refused as mixing two kinds. + assert dc.inventory(text) == inventory + + def test_an_annotation_still_names_its_class(self): + """Restoring the value must not displace the document's tag.""" + annotation = inv.OpticalPathAnnotation( + start_distance=0.0, end_distance=1.0, group="hole", value=2 + ) + dumped = annotation.model_dump(mode="json") + assert dumped["object_type"] == "OpticalPathAnnotation" + + def test_a_deliberately_excluded_value_stays_out(self): + """What a caller filtered is not what exclude_defaults dropped.""" + annotation = inv.OpticalPathAnnotation( + start_distance=0.0, end_distance=1.0, group="hole", value=2 + ) + assert "value" not in annotation.model_dump(mode="json", exclude={"value"}) + assert "value" not in annotation.model_dump(mode="json", include={"group"}) + + def test_a_flag_annotation_stays_terse(self): + """A value which really is the default is still left out.""" + pytest.importorskip("yaml") + path = inv.OpticalPath( + optical_components=(inv.FiberSegment(optical_length=100.0),), + annotations=( + inv.OpticalPathAnnotation( + start_distance=0.0, end_distance=10.0, group="noisy" + ), + ), + ) + array = inv.FiberArray(code="L001", optical_paths=(path,)) + inventory = inv.Inventory( + networks=(inv.Network(code="XX", fiber_arrays=(array,)),) + ) + text = inventory.to_yaml() + assert "value:" not in text + assert dc.inventory(text) == inventory + def test_round_trip_through_file(self, tmp_path): """The writer taking a path writes what the text form holds.""" pytest.importorskip("yaml")