Skip to content

Commit

Permalink
Splitattrs ncsave redo commonmeta (#5538)
Browse files Browse the repository at this point in the history
* Define common-metadata operartions on split attribute dictionaries.

* Tests for split-attributes handling in CubeMetadata operations.

* Small tidy and clarify.

* Common metadata ops support mixed split/unsplit attribute dicts.

* Clarify with better naming, comments, docstrings.

* Remove split-attrs handling to own sourcefile, and implement as a decorator.

* Remove redundant tests duplicated by matrix testcases.

* Newstyle split-attrs matrix testing, with fewer testcases.

* Small improvements to comments + docstrings.

* Fix logic for equals expectation; expand primary/secondary independence test.

* Clarify result testing in metadata operations decorator.
  • Loading branch information
pp-mo authored Nov 15, 2023
1 parent fa7962e commit 0c20608
Show file tree
Hide file tree
Showing 5 changed files with 517 additions and 5 deletions.
2 changes: 1 addition & 1 deletion docs/src/further_topics/metadata.rst
Original file line number Diff line number Diff line change
Expand Up @@ -736,7 +736,7 @@ Let's reinforce this behaviour, but this time by combining metadata where the
>>> metadata != cube.metadata
True
>>> metadata.combine(cube.metadata).attributes
{'Model scenario': 'A1B'}
CubeAttrsDict(globals={}, locals={'Model scenario': 'A1B'})

The combined result for the ``attributes`` member only contains those
**common keys** with **common values**.
Expand Down
125 changes: 125 additions & 0 deletions lib/iris/common/_split_attribute_dicts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
# Copyright Iris contributors
#
# This file is part of Iris and is released under the LGPL license.
# See COPYING and COPYING.LESSER in the root of the repository for full
# licensing details.
"""
Dictionary operations for dealing with the CubeAttrsDict "split"-style attribute
dictionaries.
The idea here is to convert a split-dictionary into a "plain" one for calculations,
whose keys are all pairs of the form ('global', <keyname>) or ('local', <keyname>).
And to convert back again after the operation, if the result is a dictionary.
For "strict" operations this clearly does all that is needed. For lenient ones,
we _might_ want for local+global attributes of the same name to interact.
However, on careful consideration, it seems that this is not actually desirable for
any of the common-metadata operations.
So, we simply treat "global" and "local" attributes of the same name as entirely
independent. Which happily is also the easiest to code, and to explain.
"""

from collections.abc import Mapping, Sequence
from functools import wraps


def _convert_splitattrs_to_pairedkeys_dict(dic):
"""
Convert a split-attributes dictionary to a "normal" dict.
Transform a :class:`~iris.cube.CubeAttributesDict` "split" attributes dictionary
into a 'normal' :class:`dict`, with paired keys of the form ('global', name) or
('local', name).
"""

def _global_then_local_items(dic):
# Routine to produce global, then local 'items' in order, and with all keys
# "labelled" as local or global type, to ensure they are all unique.
for key, value in dic.globals.items():
yield ("global", key), value
for key, value in dic.locals.items():
yield ("local", key), value

return dict(_global_then_local_items(dic))


def _convert_pairedkeys_dict_to_splitattrs(dic):
"""
Convert an input with global/local paired keys back into a split-attrs dict.
For now, this is always and only a :class:`iris.cube.CubeAttrsDict`.
"""
from iris.cube import CubeAttrsDict

result = CubeAttrsDict()
for key, value in dic.items():
keytype, keyname = key
if keytype == "global":
result.globals[keyname] = value
else:
assert keytype == "local"
result.locals[keyname] = value
return result


def adjust_for_split_attribute_dictionaries(operation):
"""
Decorator to make a function of attribute-dictionaries work with split attributes.
The wrapped function of attribute-dictionaries is currently always one of "equals",
"combine" or "difference", with signatures like :
equals(left: dict, right: dict) -> bool
combine(left: dict, right: dict) -> dict
difference(left: dict, right: dict) -> None | (dict, dict)
The results of the wrapped operation are either :
* for "equals" (or "__eq__") : a boolean
* for "combine" : a (converted) attributes-dictionary
* for "difference" : a list of (None or "pair"), where a pair contains two
dictionaries
Before calling the wrapped operation, its inputs (left, right) are modified by
converting any "split" dictionaries to a form where the keys are pairs
of the form ("global", name) or ("local", name).
After calling the wrapped operation, for "combine" or "difference", the result can
contain a dictionary or dictionaries. These are then transformed back from the
'converted' form to split-attribute dictionaries, before returning.
"Split" dictionaries are all of class :class:`~iris.cube.CubeAttrsDict`, since
the only usage of 'split' attribute dictionaries is in Cubes (i.e. they are not
used for cube components).
"""

@wraps(operation)
def _inner_function(*args, **kwargs):
from iris.cube import CubeAttrsDict

# First make all inputs into CubeAttrsDict, if not already.
args = [
arg if isinstance(arg, CubeAttrsDict) else CubeAttrsDict(arg)
for arg in args
]
# Convert all inputs into 'pairedkeys' type dicts
args = [_convert_splitattrs_to_pairedkeys_dict(arg) for arg in args]

result = operation(*args, **kwargs)

# Convert known specific cases of 'pairedkeys' dicts in the result, and convert
# those back into split-attribute dictionaries.
if isinstance(result, Mapping):
# Fix a result which is a single dictionary -- for "combine"
result = _convert_pairedkeys_dict_to_splitattrs(result)
elif isinstance(result, Sequence) and len(result) == 2:
# Fix a result which is a pair of dictionaries -- for "difference"
left, right = result
left, right = (
_convert_pairedkeys_dict_to_splitattrs(left),
_convert_pairedkeys_dict_to_splitattrs(right),
)
result = result.__class__([left, right])
# ELSE: leave other types of result unchanged. E.G. None, bool

return result

return _inner_function
41 changes: 41 additions & 0 deletions lib/iris/common/metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from xxhash import xxh64_hexdigest

from ..config import get_logger
from ._split_attribute_dicts import adjust_for_split_attribute_dictionaries
from .lenient import _LENIENT
from .lenient import _lenient_service as lenient_service
from .lenient import _qualname as qualname
Expand Down Expand Up @@ -1255,6 +1256,46 @@ def _check(item):

return result

#
# Override each of the attribute-dict operations in BaseMetadata, to enable
# them to deal with split-attribute dictionaries correctly.
# There are 6 of these, for (equals/combine/difference) * (lenient/strict).
# Each is overridden with a *wrapped* version of the parent method, using the
# "@adjust_for_split_attribute_dictionaries" decorator, which converts any
# split-attribute dictionaries in the inputs to ordinary dicts, and likewise
# re-converts any dictionaries in the return value.
#

@staticmethod
@adjust_for_split_attribute_dictionaries
def _combine_lenient_attributes(left, right):
return BaseMetadata._combine_lenient_attributes(left, right)

@staticmethod
@adjust_for_split_attribute_dictionaries
def _combine_strict_attributes(left, right):
return BaseMetadata._combine_strict_attributes(left, right)

@staticmethod
@adjust_for_split_attribute_dictionaries
def _compare_lenient_attributes(left, right):
return BaseMetadata._compare_lenient_attributes(left, right)

@staticmethod
@adjust_for_split_attribute_dictionaries
def _compare_strict_attributes(left, right):
return BaseMetadata._compare_strict_attributes(left, right)

@staticmethod
@adjust_for_split_attribute_dictionaries
def _difference_lenient_attributes(left, right):
return BaseMetadata._difference_lenient_attributes(left, right)

@staticmethod
@adjust_for_split_attribute_dictionaries
def _difference_strict_attributes(left, right):
return BaseMetadata._difference_strict_attributes(left, right)


class DimCoordMetadata(CoordMetadata):
"""
Expand Down
3 changes: 2 additions & 1 deletion lib/iris/tests/integration/test_netcdf__loadsaveattrs.py
Original file line number Diff line number Diff line change
Expand Up @@ -565,7 +565,8 @@ def encode_matrix_result(results: List[List[str]]) -> List[str]:
if not isinstance(results[0], list):
results = [results]
assert all(
all(val is None or len(val) == 1 for val in vals) for vals in results
all(val is None or isinstance(val, str) for val in vals)
for vals in results
)

# Translate "None" values to "-"
Expand Down
Loading

0 comments on commit 0c20608

Please sign in to comment.