diff --git a/brukerapi/dataset.py b/brukerapi/dataset.py index fe6c67a..0d87a37 100644 --- a/brukerapi/dataset.py +++ b/brukerapi/dataset.py @@ -261,6 +261,7 @@ def __init__(self, path, **state): if self.subtype: self.subtype = self.subtype[1:] # remove the dot from the suffix self._properties = [] + self._declared_properties = set() if self.type not in DEFAULT_STATES: raise UnsupportedDatasetType(self.type) @@ -531,12 +532,40 @@ def unload_properties(self): if hasattr(self, property): delattr(self, property) self._properties = [] + self._declared_properties = set() self._state["load_properties"] = False def reload_properties(self): self.unload_properties() self.load_properties() + def get(self, name, default=None): + """Value of property `name`, or `default` where it does not resolve. + + Which properties a dataset carries is ParaVision-version dependent: a + recipe whose conditions do not match, or whose parameters the files do + not contain, leaves its property unset. Attribute access reports that as + an :class:`AttributeError`, which a caller can only handle by wrapping + every read in ``try``/``except`` -- and that swallows misspellings and + genuine errors from a recipe along with it. + + A name no configuration declares still raises, so a typo does not + quietly become `default`:: + + dataset.get("TE") # 4.0, or None where there is no echo time + dataset.get("TE", "n/a") # 4.0, or "n/a" + dataset.get("TEE") # AttributeError + + Parameters are a separate layer, read with ``dataset[key]`` or + ``dataset.parameters``. + """ + try: + return getattr(self, name) + except AttributeError: + if name in self._declared_properties: + return default + raise + def add_property_file(self, path): with open(path) as f: for property in json.load(f).items(): @@ -553,6 +582,10 @@ def _add_property(self, property): :param property: tuple containing the name of the property and a list of possible commands to create it """ + # Declared whether or not a recipe resolves, so that `get` can tell an + # unresolved property from a name that was never a property at all. + self._declared_properties.add(property[0]) + if property[0] == "scheme_id" and self._state.get("scheme_id") is not None: scheme_id = self._state["scheme_id"] if scheme_id not in {"CART_2D", "CART_3D", "FIELD_MAP", "RADIAL", "EPI", "dEPI", "SPECTROSCOPY", "CSI", "SPIRAL", "ZTE"}: diff --git a/docs/source/compatibility.rst b/docs/source/compatibility.rst index 169831c..99add34 100644 --- a/docs/source/compatibility.rst +++ b/docs/source/compatibility.rst @@ -77,6 +77,12 @@ Data contract and limitations movie frames), not slices. * d3proc is an optional compatibility source for legacy/minimal 2dseq word type and image-size metadata after Visu and RECO metadata have been tried. +* Which derived properties a dataset carries is version dependent, so a recipe + whose conditions do not match, or whose parameters the files do not contain, + leaves its property unset. ``Dataset.get(name, default=None)`` reads one with + a default; a name no configuration declares still raises ``AttributeError``, + and a property that fails for a reason of its own -- ``affine`` on a scan + with no image geometry -- still raises that reason. * ``Dataset.metadata`` groups the Visu parameters the way the format defines them -- administration, subject, study, series, equipment and acquisition -- and the ``SUBJECT_*`` parameters of the study file, which ``subject`` in diff --git a/docs/source/tutorials/how-to-2dseq.rst b/docs/source/tutorials/how-to-2dseq.rst index cb0d85a..2f61a6b 100644 --- a/docs/source/tutorials/how-to-2dseq.rst +++ b/docs/source/tutorials/how-to-2dseq.rst @@ -87,6 +87,20 @@ It is possible to directly access some of the most wanted measurement parameters >> dataset.flip_angle >> 10.0 +Which of these a dataset carries is ParaVision-version dependent, so a property +whose recipe does not resolve is simply not set. ``get`` reads one with a +default instead of an ``AttributeError``; a name that is not a property at all +still raises, so a misspelling does not quietly become the default. + +.. code-block:: python + + >> dataset.get('TE') + >> 3.0 + >> dataset.get('TE', 'n/a') # where the scan records no echo time + >> 'n/a' + >> dataset.get('TEE') + AttributeError: 'Dataset' object has no attribute 'TEE' + The ``visu_pars`` file is used to construct a 2dseq dataset. ``reco`` and ``d3proc`` are optional compatibility sources for legacy/minimal instances; they supply reconstruction word type and image-size metadata only when Visu diff --git a/test/test_api.py b/test/test_api.py index aec2042..1d1abcf 100644 --- a/test/test_api.py +++ b/test/test_api.py @@ -12,6 +12,7 @@ from brukerapi.cli import report as cli_report from brukerapi.cli import split as cli_split from brukerapi.dataset import LOAD_STAGES, Dataset +from brukerapi.exceptions import UnsupportedDatasetType from brukerapi.folders import Folder from brukerapi.splitters import SlicePackageSplitter from test.synthetic import stacked_positions, write_2dseq, write_jcampdx @@ -81,6 +82,40 @@ def test_metadata_reports_the_same_string_as_the_property_that_reads_it(tmp_path assert dataset.subj_id == dataset.metadata["visu_subject"]["name"] +def test_get_returns_a_default_where_a_property_does_not_resolve(tmp_path): + """Which properties a dataset carries is ParaVision-version dependent. + + A recipe whose parameters the files do not contain leaves its property + unset, which attribute access can only report as an AttributeError -- so a + caller had to wrap every read in try/except, and that swallows typos and + genuine recipe errors along with the absence. + """ + with_echo_time = Dataset(study(tmp_path / "with", extra={"VisuAcqEchoTime": 3.5}), load=LOAD_STAGES["properties"]) + without = Dataset(study(tmp_path / "without"), load=LOAD_STAGES["properties"]) + + assert with_echo_time.get("TE") == with_echo_time.TE == 3.5 + assert not hasattr(without, "TE") + assert without.get("TE") is None + assert without.get("TE", "n/a") == "n/a" + + +def test_get_still_raises_for_a_name_that_is_not_a_property(tmp_path): + """A misspelling must not quietly become the default.""" + dataset = Dataset(study(tmp_path), load=LOAD_STAGES["properties"]) + + with pytest.raises(AttributeError, match="TEE"): + dataset.get("TEE") + + # Nor may `get` turn a property's own diagnosis into an absence: a + # spectroscopy scan has no image geometry, and says so. + spectroscopy = Dataset( + write_2dseq(tmp_path / "spectroscopy" / "9" / "pdata" / "1", dim=1, dim_desc=("spectroscopic",), size=(16,), extent=(1.0,), frame_groups=()), + load=LOAD_STAGES["properties"], + ) + with pytest.raises(UnsupportedDatasetType, match="rather than purely spatial"): + spectroscopy.get("affine") + + def test_metadata_can_be_exported(tmp_path): dataset = Dataset(study(tmp_path, extra=EQUIPMENT), load=LOAD_STAGES["properties"])