diff --git a/nibabel/caret.py b/nibabel/caret.py new file mode 100644 index 0000000000..9f05585cb2 --- /dev/null +++ b/nibabel/caret.py @@ -0,0 +1,124 @@ +# emacs: -*- mode: python-mode; py-indent-offset: 4; indent-tabs-mode: nil -*- +# vi: set ft=python sts=4 ts=4 sw=4 et: +### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ## +# +# See COPYING file distributed along with the NiBabel package for the +# copyright and license terms. +# +### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ## +from collections.abc import MutableMapping + +from . import xmlutils as xml + + +class CaretMetaData(xml.XmlSerializable, MutableMapping): + """ A list of name-value pairs used in various Caret-based XML formats + + * Description - Provides a simple method for user-supplied metadata that + associates names with values. + * Attributes: [NA] + * Child Elements + + * MD (0...N) + + * Text Content: [NA] + + MD elements are a single metadata entry consisting of a name and a value. + + Attributes + ---------- + data : mapping of {name: value} pairs + + >>> md = CaretMetaData() + >>> md['key'] = 'val' + >>> md + + >>> dict(md) + {'key': 'val'} + >>> md.to_xml() + b'keyval' + + Objects may be constructed like any ``dict``: + + >>> md = CaretMetaData(key='val') + >>> md.to_xml() + b'keyval' + """ + def __init__(self, *args, **kwargs): + args, kwargs = self._sanitize(args, kwargs) + self._data = dict(*args, **kwargs) + + @staticmethod + def _sanitize(args, kwargs): + """ Override in subclasses to accept and warn on previous invocations + """ + return args, kwargs + + def __getitem__(self, key): + """ Get metadata entry by name + + >>> md = CaretMetaData({'key': 'val'}) + >>> md['key'] + 'val' + """ + return self._data[key] + + def __setitem__(self, key, value): + """ Set metadata entry by name + + >>> md = CaretMetaData({'key': 'val'}) + >>> dict(md) + {'key': 'val'} + >>> md['newkey'] = 'newval' + >>> dict(md) + {'key': 'val', 'newkey': 'newval'} + >>> md['key'] = 'otherval' + >>> dict(md) + {'key': 'otherval', 'newkey': 'newval'} + """ + self._data[key] = value + + def __delitem__(self, key): + """ Delete metadata entry by name + + >>> md = CaretMetaData({'key': 'val'}) + >>> dict(md) + {'key': 'val'} + >>> del md['key'] + >>> dict(md) + {} + """ + del self._data[key] + + def __len__(self): + """ Get length of metadata list + + >>> md = CaretMetaData({'key': 'val'}) + >>> len(md) + 1 + """ + return len(self._data) + + def __iter__(self): + """ Iterate over metadata entries + + >>> md = CaretMetaData({'key': 'val'}) + >>> for key in md: + ... print(key) + key + """ + return iter(self._data) + + def __repr__(self): + return f"<{self.__class__.__name__} {self._data!r}>" + + def _to_xml_element(self): + metadata = xml.Element('MetaData') + + for name_text, value_text in self._data.items(): + md = xml.SubElement(metadata, 'MD') + name = xml.SubElement(md, 'Name') + name.text = str(name_text) + value = xml.SubElement(md, 'Value') + value.text = str(value_text) + return metadata diff --git a/nibabel/cifti2/cifti2.py b/nibabel/cifti2/cifti2.py index 948fe0d0d0..85d93129f3 100644 --- a/nibabel/cifti2/cifti2.py +++ b/nibabel/cifti2/cifti2.py @@ -25,6 +25,7 @@ from ..nifti1 import Nifti1Extensions from ..nifti2 import Nifti2Image, Nifti2Header from ..arrayproxy import reshape_dataobj +from ..caret import CaretMetaData from warnings import warn @@ -102,7 +103,7 @@ def _underscore(string): return re.sub(r'([a-z0-9])([A-Z])', r'\1_\2', string).lower() -class Cifti2MetaData(xml.XmlSerializable, MutableMapping): +class Cifti2MetaData(CaretMetaData): """ A list of name-value pairs * Description - Provides a simple method for user-supplied metadata that @@ -121,25 +122,55 @@ class Cifti2MetaData(xml.XmlSerializable, MutableMapping): ---------- data : list of (name, value) tuples """ - def __init__(self, metadata=None): - self.data = OrderedDict() - if metadata is not None: - self.update(metadata) - - def __getitem__(self, key): - return self.data[key] - - def __setitem__(self, key, value): - self.data[key] = value - - def __delitem__(self, key): - del self.data[key] - - def __len__(self): - return len(self.data) + @staticmethod + def _sanitize(args, kwargs): + """ Sanitize and warn on deprecated arguments + + Accept metadata positional/keyword argument that can take + ``None`` to indicate no initialization. + + >>> import pytest + >>> Cifti2MetaData() + + >>> Cifti2MetaData([("key", "val")]) + + >>> Cifti2MetaData(key="val") + + >>> with pytest.warns(FutureWarning): + ... Cifti2MetaData(None) + + >>> with pytest.warns(FutureWarning): + ... Cifti2MetaData(metadata=None) + + >>> with pytest.warns(FutureWarning): + ... Cifti2MetaData(metadata={'key': 'val'}) + + + Note that "metadata" could be a valid key: + + >>> Cifti2MetaData(metadata='val') + + """ + if not args and list(kwargs) == ["metadata"]: + if not isinstance(kwargs["metadata"], str): + warn("Cifti2MetaData now has a dict-like interface and will " + "no longer accept the ``metadata`` keyword argument in " + "NiBabel 6.0. See ``pydoc dict`` for initialization options.", + FutureWarning, stacklevel=3) + md = kwargs.pop("metadata") + if md is not None: + args = (md,) + if args == (None,): + warn("Cifti2MetaData now has a dict-like interface and will no longer " + "accept the positional argument ``None`` in NiBabel 6.0. " + "See ``pydoc dict`` for initialization options.", + FutureWarning, stacklevel=3) + args = () + return args, kwargs - def __iter__(self): - return iter(self.data) + @property + def data(self): + return self._data def difference_update(self, metadata): """Remove metadata key-value pairs @@ -159,17 +190,6 @@ def difference_update(self, metadata): for k in pairs: del self.data[k] - def _to_xml_element(self): - metadata = xml.Element('MetaData') - - for name_text, value_text in self.data.items(): - md = xml.SubElement(metadata, 'MD') - name = xml.SubElement(md, 'Name') - name.text = str(name_text) - value = xml.SubElement(md, 'Value') - value.text = str(value_text) - return metadata - class Cifti2LabelTable(xml.XmlSerializable, MutableMapping): r""" CIFTI-2 label table: a sequence of ``Cifti2Label``\s diff --git a/nibabel/cifti2/cifti2_axes.py b/nibabel/cifti2/cifti2_axes.py index a3b11630bc..174222e189 100644 --- a/nibabel/cifti2/cifti2_axes.py +++ b/nibabel/cifti2/cifti2_axes.py @@ -1075,7 +1075,6 @@ def to_mapping(self, dim): """ mim = cifti2.Cifti2MatrixIndicesMap([dim], 'CIFTI_INDEX_TYPE_SCALARS') for name, meta in zip(self.name, self.meta): - meta = None if len(meta) == 0 else meta named_map = cifti2.Cifti2NamedMap(name, cifti2.Cifti2MetaData(meta)) mim.append(named_map) return mim @@ -1213,8 +1212,6 @@ def to_mapping(self, dim): label_table = cifti2.Cifti2LabelTable() for key, value in label.items(): label_table[key] = (value[0],) + tuple(value[1]) - if len(meta) == 0: - meta = None named_map = cifti2.Cifti2NamedMap(name, cifti2.Cifti2MetaData(meta), label_table) mim.append(named_map) diff --git a/nibabel/cifti2/tests/test_cifti2.py b/nibabel/cifti2/tests/test_cifti2.py index ea571065de..db65d0f82b 100644 --- a/nibabel/cifti2/tests/test_cifti2.py +++ b/nibabel/cifti2/tests/test_cifti2.py @@ -35,12 +35,20 @@ def test_value_if_klass(): def test_cifti2_metadata(): - md = ci.Cifti2MetaData(metadata={'a': 'aval'}) + md = ci.Cifti2MetaData({'a': 'aval'}) assert len(md) == 1 assert list(iter(md)) == ['a'] assert md['a'] == 'aval' assert md.data == dict([('a', 'aval')]) + with pytest.warns(FutureWarning): + md = ci.Cifti2MetaData(metadata={'a': 'aval'}) + assert md == {'a': 'aval'} + + with pytest.warns(FutureWarning): + md = ci.Cifti2MetaData(None) + assert md == {} + md = ci.Cifti2MetaData() assert len(md) == 0 assert list(iter(md)) == [] diff --git a/nibabel/gifti/gifti.py b/nibabel/gifti/gifti.py index bd8b521661..6cd4e73c4f 100644 --- a/nibabel/gifti/gifti.py +++ b/nibabel/gifti/gifti.py @@ -15,62 +15,97 @@ import sys import numpy as np import base64 +import warnings from .. import xmlutils as xml from ..filebasedimages import SerializableImage from ..nifti1 import data_type_codes, xform_codes, intent_codes +from ..caret import CaretMetaData from .util import (array_index_order_codes, gifti_encoding_codes, gifti_endian_codes, KIND2FMT) from ..deprecated import deprecate_with_version -class GiftiMetaData(xml.XmlSerializable): +class GiftiMetaData(CaretMetaData): """ A sequence of GiftiNVPairs containing metadata for a gifti data array """ - def __init__(self, nvpair=None): - self.data = [] - if nvpair is not None: - self.data.append(nvpair) + @staticmethod + def _sanitize(args, kwargs): + """ Sanitize and warn on deprecated arguments + + Accept nvpair positional/keyword argument that is a single + ``GiftiNVPairs`` object. + + >>> import pytest + >>> GiftiMetaData() + + >>> GiftiMetaData([("key", "val")]) + + >>> GiftiMetaData(key="val") + + >>> GiftiMetaData({"key": "val"}) + + >>> nvpairs = GiftiNVPairs(name='key', value='val') + >>> with pytest.warns(FutureWarning): + ... GiftiMetaData(nvpairs) + + >>> with pytest.warns(FutureWarning): + ... GiftiMetaData(nvpair=nvpairs) + + """ + dep_init = False + # Positional arg + dep_init |= not kwargs and len(args) == 1 and isinstance(args[0], GiftiNVPairs) + # Keyword arg + dep_init |= not args and list(kwargs) == ["nvpair"] + if not dep_init: + return args, kwargs + + warnings.warn( + "GiftiMetaData now has a dict-like interface. " + "See ``pydoc dict`` for initialization options. " + "Passing ``GiftiNVPairs()`` or using the ``nvpair`` " + "keyword will fail or behave unexpectedly in NiBabel 6.0.", + FutureWarning, stacklevel=3) + pair = args[0] if args else kwargs.get("nvpair") + return (), {pair.name: pair.value} + + @property + def data(self): + warnings.warn( + "GiftiMetaData.data will be a dict in NiBabel 6.0.", + FutureWarning, stacklevel=2) + return [GiftiNVPairs(k, v) for k, v in self._data.items()] @classmethod + @deprecate_with_version( + 'from_dict class method deprecated. Use GiftiMetaData directly.', + '4.0', '6.0') def from_dict(klass, data_dict): - meda = klass() - for k, v in data_dict.items(): - nv = GiftiNVPairs(k, v) - meda.data.append(nv) - return meda + return klass(data_dict) @deprecate_with_version( 'get_metadata method deprecated. ' "Use the metadata property instead.", '2.1', '4.0') def get_metadata(self): - return self.metadata + return dict(self) @property + @deprecate_with_version( + 'metadata property deprecated. Use GiftiMetadata object ' + 'as dict or pass to dict() for a standard dictionary.', + '4.0', '6.0') def metadata(self): """ Returns metadata as dictionary """ - self.data_as_dict = {} - for ele in self.data: - self.data_as_dict[ele.name] = ele.value - return self.data_as_dict - - def _to_xml_element(self): - metadata = xml.Element('MetaData') - for ele in self.data: - md = xml.SubElement(metadata, 'MD') - name = xml.SubElement(md, 'Name') - value = xml.SubElement(md, 'Value') - name.text = ele.name - value.text = ele.value - return metadata + return dict(self) def print_summary(self): - print(self.metadata) + print(dict(self)) -class GiftiNVPairs(object): +class GiftiNVPairs: """ Gifti name / value pairs Attributes @@ -385,7 +420,7 @@ def __init__(self, self.ind_ord = array_index_order_codes.code[ordering] self.meta = (GiftiMetaData() if meta is None else meta if isinstance(meta, GiftiMetaData) else - GiftiMetaData.from_dict(meta)) + GiftiMetaData(meta)) self.ext_fname = ext_fname self.ext_offset = ext_offset self.dims = [] if self.data is None else list(self.data.shape) @@ -544,12 +579,12 @@ def print_summary(self): "Use the metadata property instead.", '2.1', '4.0') def get_metadata(self): - return self.meta.metadata + return dict(self.meta) @property def metadata(self): """ Returns metadata as dictionary """ - return self.meta.metadata + return dict(self.meta) class GiftiImage(xml.XmlSerializable, SerializableImage): diff --git a/nibabel/gifti/parse_gifti_fast.py b/nibabel/gifti/parse_gifti_fast.py index 49f783662f..17ae695e55 100644 --- a/nibabel/gifti/parse_gifti_fast.py +++ b/nibabel/gifti/parse_gifti_fast.py @@ -18,7 +18,7 @@ import numpy as np from .gifti import (GiftiMetaData, GiftiImage, GiftiLabel, - GiftiLabelTable, GiftiNVPairs, GiftiDataArray, + GiftiLabelTable, GiftiDataArray, GiftiCoordSystem) from .util import (array_index_order_codes, gifti_encoding_codes, gifti_endian_codes) @@ -202,7 +202,7 @@ def StartElementHandler(self, name, attrs): self.meta_da = GiftiMetaData() elif name == 'MD': - self.nvpair = GiftiNVPairs() + self.nvpair = ['', ''] self.fsm_state.append('MD') elif name == 'Name': @@ -313,10 +313,11 @@ def EndElementHandler(self, name): elif name == 'MD': self.fsm_state.pop() + key, val = self.nvpair if self.meta_global is not None and self.meta_da is None: - self.meta_global.data.append(self.nvpair) + self.meta_global[key] = val elif self.meta_da is not None and self.meta_global is None: - self.meta_da.data.append(self.nvpair) + self.meta_da[key] = val # remove reference self.nvpair = None @@ -374,11 +375,11 @@ def flush_chardata(self): # Process data if self.write_to == 'Name': data = data.strip() - self.nvpair.name = data + self.nvpair[0] = data elif self.write_to == 'Value': data = data.strip() - self.nvpair.value = data + self.nvpair[1] = data elif self.write_to == 'DataSpace': data = data.strip() diff --git a/nibabel/gifti/tests/test_gifti.py b/nibabel/gifti/tests/test_gifti.py index f90cdbd302..4363c2cd0a 100644 --- a/nibabel/gifti/tests/test_gifti.py +++ b/nibabel/gifti/tests/test_gifti.py @@ -57,7 +57,7 @@ def test_gifti_image(): # arguments. gi = GiftiImage() assert gi.darrays == [] - assert gi.meta.metadata == {} + assert gi.meta == {} assert gi.labeltable.labels == [] arr = np.zeros((2, 3)) gi.darrays.append(arr) @@ -125,7 +125,7 @@ def test_dataarray_empty(): assert null_da.coordsys.xformspace == 0 assert_array_equal(null_da.coordsys.xform, np.eye(4)) assert null_da.ind_ord == 1 - assert null_da.meta.metadata == {} + assert null_da.meta == {} assert null_da.ext_fname == '' assert null_da.ext_offset == 0 @@ -174,9 +174,9 @@ def test_dataarray_init(): pytest.raises(KeyError, gda, ordering='not an ordering') # metadata meta_dict=dict(one=1, two=2) - assert gda(meta=GiftiMetaData.from_dict(meta_dict)).meta.metadata == meta_dict - assert gda(meta=meta_dict).meta.metadata == meta_dict - assert gda(meta=None).meta.metadata == {} + assert gda(meta=GiftiMetaData(meta_dict)).meta == meta_dict + assert gda(meta=meta_dict).meta == meta_dict + assert gda(meta=None).meta == {} # ext_fname and ext_offset assert gda(ext_fname='foo').ext_fname == 'foo' assert gda(ext_offset=12).ext_offset == 12 @@ -246,17 +246,29 @@ def test_labeltable(): def test_metadata(): + md = GiftiMetaData(key='value') + # Old initialization methods nvpair = GiftiNVPairs('key', 'value') - md = GiftiMetaData(nvpair=nvpair) - assert md.data[0].name == 'key' - assert md.data[0].value == 'value' + with pytest.warns(FutureWarning) as w: + md2 = GiftiMetaData(nvpair=nvpair) + assert len(w) == 1 + with pytest.warns(DeprecationWarning) as w: + md3 = GiftiMetaData.from_dict({'key': 'value'}) + assert md == md2 == md3 == {'key': 'value'} + # .data as a list of NVPairs is going away + with pytest.warns(FutureWarning) as w: + assert md.data[0].name == 'key' + assert md.data[0].value == 'value' + assert len(w) == 2 # Test deprecation with clear_and_catch_warnings() as w: warnings.filterwarnings('always', category=DeprecationWarning) assert md.get_metadata() == dict(key='value') assert len(w) == 1 - assert len(GiftiDataArray().get_metadata()) == 0 + assert md.metadata == dict(key='value') assert len(w) == 2 + assert len(GiftiDataArray().get_metadata()) == 0 + assert len(w) == 3 def test_gifti_label_rgba(): diff --git a/nibabel/gifti/tests/test_parse_gifti_fast.py b/nibabel/gifti/tests/test_parse_gifti_fast.py index 68a2cdf1e8..c1207d41fb 100644 --- a/nibabel/gifti/tests/test_parse_gifti_fast.py +++ b/nibabel/gifti/tests/test_parse_gifti_fast.py @@ -26,7 +26,7 @@ from numpy.testing import assert_array_almost_equal import pytest -from ...testing import clear_and_catch_warnings +from ...testing import clear_and_catch_warnings, suppress_warnings IO_DATA_PATH = pjoin(dirname(__file__), 'data') @@ -126,11 +126,13 @@ def assert_default_types(loaded): default = loaded.__class__() for attr in dir(default): - defaulttype = type(getattr(default, attr)) + with suppress_warnings(): + defaulttype = type(getattr(default, attr)) # Optional elements may have default of None if defaulttype is type(None): continue - loadedtype = type(getattr(loaded, attr)) + with suppress_warnings(): + loadedtype = type(getattr(loaded, attr)) assert loadedtype == defaulttype, ( f"Type mismatch for attribute: {attr} ({loadedtype} != {defaulttype})") @@ -143,9 +145,10 @@ def test_default_types(): assert_default_types(img) # GiftiMetaData assert_default_types(img.meta) - # GiftiNVPairs - for nvpair in img.meta.data: - assert_default_types(nvpair) + # GiftiNVPairs - Remove in NIB6 + with pytest.warns(FutureWarning): + for nvpair in img.meta.data: + assert_default_types(nvpair) # GiftiLabelTable assert_default_types(img.labeltable) # GiftiLabel elements can be None or float; skip @@ -156,9 +159,10 @@ def test_default_types(): assert_default_types(darray.coordsys) # GiftiMetaData assert_default_types(darray.meta) - # GiftiNVPairs - for nvpair in darray.meta.data: - assert_default_types(nvpair) + # GiftiNVPairs - Remove in NIB6 + with pytest.warns(FutureWarning): + for nvpair in darray.meta.data: + assert_default_types(nvpair) def test_read_ordering(): @@ -204,7 +208,7 @@ def test_load_dataarray1(): for img in (img1, bimg): assert_array_almost_equal(img.darrays[0].data, DATA_FILE1_darr1) assert_array_almost_equal(img.darrays[1].data, DATA_FILE1_darr2) - me = img.darrays[0].meta.metadata + me = img.darrays[0].meta assert 'AnatomicalStructurePrimary' in me assert 'AnatomicalStructureSecondary' in me me['AnatomicalStructurePrimary'] == 'CortexLeft' @@ -301,14 +305,13 @@ def test_modify_darray(): def test_write_newmetadata(): img = gi.GiftiImage() - attr = gi.GiftiNVPairs(name='mykey', value='val1') - newmeta = gi.GiftiMetaData(attr) + newmeta = gi.GiftiMetaData(mykey='val1') img.meta = newmeta - myme = img.meta.metadata + myme = img.meta assert 'mykey' in myme - newmeta = gi.GiftiMetaData.from_dict({'mykey1': 'val2'}) + newmeta = gi.GiftiMetaData({'mykey1': 'val2'}) img.meta = newmeta - myme = img.meta.metadata + myme = img.meta assert 'mykey1' in myme assert 'mykey' not in myme