Skip to content

RF: Unify Caret-XML-style metadata structure as dict-like #1091

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Feb 25, 2022
124 changes: 124 additions & 0 deletions nibabel/caret.py
Original file line number Diff line number Diff line change
@@ -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
<CaretMetaData {'key': 'val'}>
>>> dict(md)
{'key': 'val'}
>>> md.to_xml()
b'<MetaData><MD><Name>key</Name><Value>val</Value></MD></MetaData>'

Objects may be constructed like any ``dict``:

>>> md = CaretMetaData(key='val')
>>> md.to_xml()
b'<MetaData><MD><Name>key</Name><Value>val</Value></MD></MetaData>'
"""
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
80 changes: 50 additions & 30 deletions nibabel/cifti2/cifti2.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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
Expand All @@ -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 {}>
>>> Cifti2MetaData([("key", "val")])
<Cifti2MetaData {'key': 'val'}>
>>> Cifti2MetaData(key="val")
<Cifti2MetaData {'key': 'val'}>
>>> with pytest.warns(FutureWarning):
... Cifti2MetaData(None)
<Cifti2MetaData {}>
>>> with pytest.warns(FutureWarning):
... Cifti2MetaData(metadata=None)
<Cifti2MetaData {}>
>>> with pytest.warns(FutureWarning):
... Cifti2MetaData(metadata={'key': 'val'})
<Cifti2MetaData {'key': 'val'}>

Note that "metadata" could be a valid key:

>>> Cifti2MetaData(metadata='val')
<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
Expand All @@ -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
Expand Down
3 changes: 0 additions & 3 deletions nibabel/cifti2/cifti2_axes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
10 changes: 9 additions & 1 deletion nibabel/cifti2/tests/test_cifti2.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)) == []
Expand Down
Loading