Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 13 additions & 12 deletions docs/src/further_topics/metadata.rst
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ We can easily get all of the associated metadata of the :class:`~iris.cube.Cube`
using the ``metadata`` property:

>>> cube.metadata
CubeMetadata(standard_name='air_temperature', long_name=None, var_name='air_temperature', units=Unit('K'), attributes={'Conventions': 'CF-1.5', 'STASH': STASH(model=1, section=3, item=236), 'Model scenario': 'A1B', 'source': 'Data from Met Office Unified Model 6.05'}, cell_methods=(CellMethod(method='mean', coord_names=('time',), intervals=('6 hour',), comments=()),))
CubeMetadata(standard_name='air_temperature', long_name=None, var_name='air_temperature', units=Unit('K'), attributes=CubeAttrsDict(globals={}, locals={'Conventions': 'CF-1.5', 'STASH': STASH(model=1, section=3, item=236), 'Model scenario': 'A1B', 'source': 'Data from Met Office Unified Model 6.05'}), cell_methods=(CellMethod(method='mean', coord_names=('time',), intervals=('6 hour',), comments=()),))

We can also inspect the ``metadata`` of the ``longitude``
:class:`~iris.coords.DimCoord` attached to the :class:`~iris.cube.Cube` in the same way:
Expand Down Expand Up @@ -675,8 +675,8 @@ For example, consider the following :class:`~iris.common.metadata.CubeMetadata`,

.. doctest:: metadata-combine

>>> cube.metadata # doctest: +SKIP
CubeMetadata(standard_name='air_temperature', long_name=None, var_name='air_temperature', units=Unit('K'), attributes={'Conventions': 'CF-1.5', 'STASH': STASH(model=1, section=3, item=236), 'Model scenario': 'A1B', 'source': 'Data from Met Office Unified Model 6.05'}, cell_methods=(CellMethod(method='mean', coord_names=('time',), intervals=('6 hour',), comments=()),))
>>> cube.metadata
CubeMetadata(standard_name='air_temperature', long_name=None, var_name='air_temperature', units=Unit('K'), attributes=CubeAttrsDict(globals={}, locals={'Conventions': 'CF-1.5', 'STASH': STASH(model=1, section=3, item=236), 'Model scenario': 'A1B', 'source': 'Data from Met Office Unified Model 6.05'}), cell_methods=(CellMethod(method='mean', coord_names=('time',), intervals=('6 hour',), comments=()),))

We can perform the **identity function** by comparing the metadata with itself,

Expand All @@ -701,7 +701,7 @@ which is replaced with a **different value**,
>>> metadata != cube.metadata
True
>>> metadata.combine(cube.metadata) # doctest: +SKIP
CubeMetadata(standard_name=None, long_name=None, var_name='air_temperature', units=Unit('K'), attributes={'STASH': STASH(model=1, section=3, item=236), 'source': 'Data from Met Office Unified Model 6.05', 'Model scenario': 'A1B', 'Conventions': 'CF-1.5'}, cell_methods=(CellMethod(method='mean', coord_names=('time',), intervals=('6 hour',), comments=()),))
CubeMetadata(standard_name=None, long_name=None, var_name='air_temperature', units=Unit('K'), attributes={'Conventions': 'CF-1.5', 'Model scenario': 'A1B', 'STASH': STASH(model=1, section=3, item=236), 'source': 'Data from Met Office Unified Model 6.05'}, cell_methods=(CellMethod(method='mean', coord_names=('time',), intervals=('6 hour',), comments=()),))

The ``combine`` method combines metadata by performing a **strict** comparison
between each of the associated metadata member values,
Expand Down Expand Up @@ -810,16 +810,17 @@ the ``from_metadata`` class method. For example, given the following

.. doctest:: metadata-convert

>>> cube.metadata # doctest: +SKIP
CubeMetadata(standard_name='air_temperature', long_name=None, var_name='air_temperature', units=Unit('K'), attributes={'Conventions': 'CF-1.5', 'STASH': STASH(model=1, section=3, item=236), 'Model scenario': 'A1B', 'source': 'Data from Met Office Unified Model 6.05'}, cell_methods=(CellMethod(method='mean', coord_names=('time',), intervals=('6 hour',), comments=()),))
>>> cube.metadata
CubeMetadata(standard_name='air_temperature', long_name=None, var_name='air_temperature', units=Unit('K'), attributes=CubeAttrsDict(globals={}, locals={'Conventions': 'CF-1.5', 'STASH': STASH(model=1, section=3, item=236), 'Model scenario': 'A1B', 'source': 'Data from Met Office Unified Model 6.05'}), cell_methods=(CellMethod(method='mean', coord_names=('time',), intervals=('6 hour',), comments=()),))

We can easily convert it to a :class:`~iris.common.metadata.DimCoordMetadata` instance
using ``from_metadata``,

.. doctest:: metadata-convert

>>> DimCoordMetadata.from_metadata(cube.metadata) # doctest: +SKIP
DimCoordMetadata(standard_name='air_temperature', long_name=None, var_name='air_temperature', units=Unit('K'), attributes={'Conventions': 'CF-1.5', 'STASH': STASH(model=1, section=3, item=236), 'Model scenario': 'A1B', 'source': 'Data from Met Office Unified Model 6.05'}, coord_system=None, climatological=None, circular=None)
>>> newmeta = DimCoordMetadata.from_metadata(cube.metadata)
>>> print(newmeta)
DimCoordMetadata(standard_name=air_temperature, var_name=air_temperature, units=K, attributes={'Conventions': 'CF-1.5', 'STASH': STASH(model=1, section=3, item=236), 'Model scenario': 'A1B', 'source': 'Data from Met Office Unified Model 6.05'})

By examining :numref:`metadata members table`, we can see that the
:class:`~iris.cube.Cube` and :class:`~iris.coords.DimCoord` container
Expand Down Expand Up @@ -849,9 +850,9 @@ class instance,

.. doctest:: metadata-convert

>>> longitude.metadata.from_metadata(cube.metadata)
DimCoordMetadata(standard_name='air_temperature', long_name=None, var_name='air_temperature', units=Unit('K'), attributes={'Conventions': 'CF-1.5', 'STASH': STASH(model=1, section=3, item=236), 'Model scenario': 'A1B', 'source': 'Data from Met Office Unified Model 6.05'}, coord_system=None, climatological=None, circular=None)

>>> newmeta = longitude.metadata.from_metadata(cube.metadata)
>>> print(newmeta)
DimCoordMetadata(standard_name=air_temperature, var_name=air_temperature, units=K, attributes={'Conventions': 'CF-1.5', 'STASH': STASH(model=1, section=3, item=236), 'Model scenario': 'A1B', 'source': 'Data from Met Office Unified Model 6.05'})

.. _metadata assignment:

Expand Down Expand Up @@ -978,7 +979,7 @@ Indeed, it's also possible to assign to the ``metadata`` property with a
>>> longitude.metadata
DimCoordMetadata(standard_name='longitude', long_name=None, var_name='longitude', units=Unit('degrees'), attributes={}, coord_system=GeogCS(6371229.0), climatological=False, circular=False)
>>> longitude.metadata = cube.metadata
>>> longitude.metadata # doctest: +SKIP
>>> longitude.metadata
DimCoordMetadata(standard_name='air_temperature', long_name=None, var_name='air_temperature', units=Unit('K'), attributes={'Conventions': 'CF-1.5', 'STASH': STASH(model=1, section=3, item=236), 'Model scenario': 'A1B', 'source': 'Data from Met Office Unified Model 6.05'}, coord_system=GeogCS(6371229.0), climatological=False, circular=False)

Note that, only **common** metadata members will be assigned new associated
Expand Down
6 changes: 5 additions & 1 deletion lib/iris/common/metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,11 @@ def __str__(self):
field_strings = []
for field in self._fields:
value = getattr(self, field)
if value is None or isinstance(value, (str, dict)) and not value:
if (
value is None
or isinstance(value, (str, Mapping))
and not value
):
continue
field_strings.append(f"{field}={value}")

Expand Down
252 changes: 251 additions & 1 deletion lib/iris/cube.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,19 @@
"""

from collections import OrderedDict
from collections.abc import Container, Iterable, Iterator, MutableMapping
import copy
from copy import deepcopy
from functools import partial, reduce
import itertools
import operator
from typing import (
Container,
Iterable,
Iterator,
Mapping,
MutableMapping,
Optional,
)
import warnings
from xml.dom.minidom import Document
import zlib
Expand All @@ -33,6 +41,7 @@
import iris.aux_factory
from iris.common import CFVariableMixin, CubeMetadata, metadata_manager_factory
from iris.common.metadata import metadata_filter
from iris.common.mixin import LimitedAttributeDict
import iris.coord_systems
import iris.coords
import iris.exceptions
Expand Down Expand Up @@ -764,6 +773,231 @@ def _is_single_item(testee):
return isinstance(testee, str) or not isinstance(testee, Iterable)


class CubeAttrsDict(MutableMapping):
"""
A dict-like object for "Cube.attributes", which provides unified user access to
the combined cube 'local' and 'global' attributes, mimicking the behaviour of a
simple dict.

This supports all the regular methods of a 'dict',
However, a few things such as the detailed print (repr) are different.

In addition, the 'locals' and 'globals' properties provide specific access to local
and global attributes, as regular :class:`~iris.common.mixin.LimitedAttributeDict`s.

Notes
-----
For type testing, "issubclass(CubeAttrsDict, Mapping)" is True, but
"issubclass(CubeAttrsDict, dict)" is False.

Properties 'locals' and 'globals' are the two sets of cube attributes. These are
both :class:`~iris.common.mixin.LimitedAttributeDict`. The CubeAttrsDict object
contains *no* additional state of its own, but simply acts as a view on these two.

When reading (__getitem__, pop. popitem, keys, values etc), it contains all the
keys + values of both 'locals' and 'globals'. When a key occurs in *both* 'locals'
and 'globals', the result is the local value.

When writing (__setitem__, setdefault, update, etc) to a key already present, the
existing entry in either 'locals' or 'globals' is updated. If both are present,
'locals' is updated.

When writing to a new key, this generally updates 'locals'. However, certain
specific names would never normally be 'data' attributes, and these are created as
'globals' instead. These are determined by Appendix A of the
`_CF Conventions: https://cfconventions.org/` .
At present, these are : 'conventions', 'featureType', 'history', 'title'.

Examples
--------
.. doctest::
:hide:
>>> cube = Cube([0])

>>> cube.attributes.update({'history':'fresh', 'x':3})
>>> print(cube.attributes)
{'history': 'fresh', 'x': 3}
>>> print(repr(cube.attributes))
CubeAttrsDict(globals={'history': 'fresh'}, locals={'x': 3})

>>> cube.attributes['history'] += '+added'
>>> print(repr(cube.attributes))
CubeAttrsDict(globals={'history': 'fresh+added'}, locals={'x': 3})

>>> cube.attributes.locals['history'] = 'per-var'
>>> print(cube.attributes)
{'history': 'per-var', 'x': 3}
>>> print(repr(cube.attributes))
CubeAttrsDict(globals={'history': 'fresh+added'}, locals={'x': 3, 'history': 'per-var'})

"""

def __init__(
self,
combined: Optional[Mapping] = "__unset",
locals: Optional[Mapping] = None,
globals: Optional[Mapping] = None,
):
# Allow initialisation from a generic dictionary, or local/global specific ones.
# First initialise locals + globals, defaulting to empty.
self.locals = locals
self.globals = globals
# Update with combined, if present.
if not isinstance(combined, str) or combined != "__unset":
# Treat a single input with 'locals' and 'globals' properties as an
# existing CubeAttrsDict, and update from its content.
# N.B. enforce deep copying, consistent with general Iris usage.
if hasattr(combined, "globals") and hasattr(combined, "locals"):
# Copy a mapping with globals/locals, like another 'CubeAttrsDict'
self.globals.update(deepcopy(combined.globals))
self.locals.update(deepcopy(combined.locals))
else:
# Treat any arbitrary single input value as a mapping (dict), and
# update from it.
self.update(dict(deepcopy(combined)))

#
# Ensure that the stored local/global dictionaries are "LimitedAttributeDicts".
#
@staticmethod
def _normalise_attrs(
attributes: Optional[Mapping],
) -> LimitedAttributeDict:
# Convert an input attributes arg into a standard form.
# N.B. content is always a LimitedAttributeDict, and a deep copy of input.
# Allow arg of None, etc.
if not attributes:
attributes = {}
else:
attributes = deepcopy(attributes)

# Ensure the expected mapping type.
attributes = LimitedAttributeDict(attributes)
return attributes

@property
def locals(self):
return self._locals

@locals.setter
def locals(self, attributes):
self._locals = self._normalise_attrs(attributes)

@property
def globals(self):
return self._globals

@globals.setter
def globals(self, attributes):
self._globals = self._normalise_attrs(attributes)

#
# Provide a serialisation interface
#
def __getstate__(self):
return (self.locals, self.globals)

def __setstate__(self, state):
self.locals, self.globals = state

#
# Support simple comparison, even when contents are arrays.
#
def __eq__(self, other):
# For equality, require both globals + locals to match
other = CubeAttrsDict(other)
result = self.locals == other.locals and self.globals == other.globals
return result

def __ne__(self, other):
return not self == other

#
# Provide a copy method, as for 'dict', but *not* provided by MutableMapping
#
def copy(self):
"""
Return a copy.

Implemented with deep copying, consistent with general Iris usage.

"""
return CubeAttrsDict(self)

#
# The remaining methods are sufficient to generate a complete standard Mapping
# API -- see docs for :class:`collections.abc.MutableMapping`.
#

def __iter__(self):
# Ordering: all global keys, then all local ones, but omitting duplicates.
# NOTE: this means that in the "summary" view, attributes present in both
# locals+globals are listed first, amongst the globals, even though they appear
# with the *value* from locals.
# Otherwise follows order of insertion, as is normal for dicts.
return itertools.chain(
self.globals.keys(),
(x for x in self.locals.keys() if x not in self.globals),
)

def __len__(self):
# Return the number of keys in the 'combined' view.
return len(list(iter(self)))

def __getitem__(self, key):
# Fetch an item from the "combined attributes".
# If both present, the local setting takes priority.
if key in self.locals:
store = self.locals
else:
store = self.globals
return store[key]

def __setitem__(self, key, value):
# Assign an attribute value.
# Make local or global according to the existing content, or the known set of
# "normally global" attributes given by CF.

# If an attribute of this name is already present, update that
# (the local one having priority).
if key in self.locals:
store = self.locals
elif key in self.globals:
store = self.globals
else:
# If NO existing attribute, create local unless it is a "known global" one.
from iris.fileformats.netcdf.saver import _CF_GLOBAL_ATTRS

# Not in existing
if key in _CF_GLOBAL_ATTRS:
store = self.globals
else:
store = self.locals

store[key] = value

def __delitem__(self, key):
"""
Remove an attribute

Delete from both local + global.

"""
if key in self.locals:
del self.locals[key]
if key in self.globals:
del self.globals[key]

def __str__(self):
# Print it just like a "normal" dictionary.
# Convert to a normal dict to do that.
return str(dict(self))

def __repr__(self):
# Special repr form, showing "real" contents.
return f"CubeAttrsDict(globals={self.globals}, locals={self.locals})"


class Cube(CFVariableMixin):
"""
A single Iris cube of data and metadata.
Expand Down Expand Up @@ -1019,6 +1253,22 @@ def _names(self):
"""
return self._metadata_manager._names

#
# Ensure that .attributes is always a :class:`CubeAttrsDict`.
#
@property
def attributes(self):
return self._metadata_manager.attributes

@attributes.setter
def attributes(self, attributes):
"""
An override to CfVariableMixin.attributes.setter, which ensures that Cube
attributes are stored in a way which distinguishes global + local ones.

"""
self._metadata_manager.attributes = CubeAttrsDict(attributes or {})

def _dimensional_metadata(self, name_or_dimensional_metadata):
"""
Return a single _DimensionalMetadata instance that matches the given
Expand Down
Loading