From 556e71e01bbc4efb3f24a25705f6cfcb4e3c3c6f Mon Sep 17 00:00:00 2001 From: Bill Little Date: Fri, 14 Aug 2020 13:40:52 +0100 Subject: [PATCH] [PI-3478] Merge cube arithmetic feature branch (#3785) * PI-3478: Common metadata API (#3583) * common metadata api * rationalise _cube_coord_common into common * move state into metadata * MetadataFactory test coverage * temporarily pin back iris-grib * test coverage for iris.common.metadata._BaseMeta * test coverage for iris.common.mixin.LimitedAttributeDict * remove temporary iris-grib pin * review actions * Update lib/iris/tests/unit/common/metadata/test_BaseMetadata.py Co-Authored-By: lbdreyer * [FB] [PI-3478] Lenient metadata (#3739) * add lenient infra-structure * add metadata lenient __eq__ support * complete __eq__, combine and difference support * explicit inherited lenient_service + support equal convenience * fix attributes difference + lenient kwargs * make lenient public + minor tidy * rename MetadataManagerFactory to metadata_manager_factory * extend lenient_client decorator to support services registration * add lenient test coverage * purge qualname usage in metadata.py * support global enable for lenient services * support partial mapping metadata assignment * purge Lenient.__setattr__ from api * add BaseMetadata compare test coverage * metadata rationalisation * add BaseMetadata difference test coverage * added context manager ephemeral comment clarification * add BaseMetadata __ne__ test coverage * standardise lenient decorator closure names * add BaseMetadata equal test coverage * half dunder context * add AncillaryVariableMetadata test coverage * add additional AncillaryVariableMetadata test coverage * add CellMeasureMetadata test coverage * Clarify lenient_service operation + simplify code. * add CoordMetadata test coverage * add CubeMetadata test coverage * metadata tests use self.cls * fix typo * fix context manager ephemeral services * add logging * Pin pillow to make graphics tests work again. (#3630) * Fixed tests since Numpy 1.18 deprecation of non-int num arguments for linspace. (#3655) * Switched use of datetime.weekday() to datetime.dayofwk. (#3687) * New image hashes for mpl 3x2 (#3682) * New image hash for iris.test.test_plot.TestSymbols.test_cloud_cover with matplotlib 3.2.0. * Further images changes for mpl3x2. * Yet more updated image results. * fix sentinel uniqueness test failure * remove redundant cdm mapping test * difference returns None for no difference * protect Lenient and LENIENT private * privitise lenient framework and add API veneer * add explicit maths feature default * review actions * review actions * trexfeathers review actions * stephenworsley review actions Co-authored-by: Patrick Peglar Co-authored-by: Martin Yeo <40734014+trexfeathers@users.noreply.github.com> * [FB] [PI-3478] Lenient cube arithmetic (#3774) * initial cube arithmetic * support in-place cube resolve * fix non in-place broadcasting * remove temporary resolve scenario test * lenient/strict support for attributes dicts with numpy arrays * lenient/strict treatment of scalar coordinates * strict points/bounds matching * lenient/strict prepare local dim/aux/scalar coordinates * support extended broadcasting * always raise exception on points/bounds mismatch * ignore scalar points/bounds mismatches, lenient only * remove todos * tidy logger debugs * qualify src/tgt cube references in debug * Numpy rounding fix (#3758) ensure rounding is numpy like (maintains type) * avoid unittest.mock.sentinel copy issue * fast load np.int32 * fix cube maths doctest * fix iris.common.resolve logging configuration * fix prepare points/bounds + extra metadata cml * support mapping reversal based on free dims * var_name fix for lenient equality * add support for DimCoordMetadata * fix circular flag + support CoordMetadata and DimCoordMetadata exchange * fix circular issue for concatenate DimCoord->AuxCoord demotion * fix concatenate._CubeSignature sorted * minor tweaks * keep lenient_client private in maths * tidy maths * tidy iris.analysis.maths.IFunc * refactor IFunc test * polish in-place support * tidy metadata_resolve Co-authored-by: stephenworsley <49274989+stephenworsley@users.noreply.github.com> * rebase master fix-up for cube arithmetic * add missing new dependency to readthedocs.yml requirements Co-authored-by: lbdreyer Co-authored-by: Patrick Peglar Co-authored-by: Martin Yeo <40734014+trexfeathers@users.noreply.github.com> Co-authored-by: stephenworsley <49274989+stephenworsley@users.noreply.github.com> --- .flake8 | 6 + ci/requirements/readthedocs.yml | 1 + docs/iris/src/userguide/cube_maths.rst | 4 + lib/iris/_concatenate.py | 19 +- lib/iris/_constraints.py | 2 +- lib/iris/_merge.py | 23 +- lib/iris/analysis/__init__.py | 2 +- lib/iris/analysis/maths.py | 504 ++--- lib/iris/aux_factory.py | 123 +- lib/iris/common/__init__.py | 11 + lib/iris/common/lenient.py | 661 +++++++ lib/iris/common/metadata.py | 1477 +++++++++++++++ .../mixin.py} | 211 +-- lib/iris/common/resolve.py | 1542 ++++++++++++++++ lib/iris/config.py | 11 + lib/iris/coords.py | 283 +-- lib/iris/cube.py | 132 +- lib/iris/etc/logging.yaml | 39 + .../fileformats/_pyke_rules/fc_rules_cf.krb | 6 +- lib/iris/iterate.py | 9 +- lib/iris/plot.py | 4 +- lib/iris/tests/__init__.py | 73 + .../integration/fast_load/test_fast_load.py | 5 +- lib/iris/tests/results/analysis/abs.cml | 3 + lib/iris/tests/results/analysis/addition.cml | 3 + .../results/analysis/addition_coord_x.cml | 3 + .../results/analysis/addition_coord_y.cml | 3 + .../analysis/addition_different_std_name.cml | 3 + .../results/analysis/addition_in_place.cml | 3 + .../analysis/addition_in_place_coord.cml | 3 + .../results/analysis/addition_scalar.cml | 3 + .../tests/results/analysis/apply_ifunc.cml | 3 + .../analysis/apply_ifunc_frompyfunc.cml | 3 + .../tests/results/analysis/apply_ufunc.cml | 3 + .../analysis/apply_ufunc_frompyfunc.cml | 3 + lib/iris/tests/results/analysis/division.cml | 3 + .../results/analysis/division_by_array.cml | 3 + .../results/analysis/division_by_latitude.cml | 3 + .../analysis/division_by_longitude.cml | 3 + .../analysis/division_by_singular_coord.cml | 3 + .../results/analysis/division_scalar.cml | 3 + .../tests/results/analysis/exponentiate.cml | 3 + lib/iris/tests/results/analysis/log.cml | 3 + lib/iris/tests/results/analysis/log10.cml | 3 + lib/iris/tests/results/analysis/log2.cml | 3 + lib/iris/tests/results/analysis/multiply.cml | 3 + .../analysis/multiply_different_std_name.cml | 3 + lib/iris/tests/results/analysis/sqrt.cml | 3 + lib/iris/tests/results/analysis/subtract.cml | 3 + .../tests/results/analysis/subtract_array.cml | 3 + .../results/analysis/subtract_coord_x.cml | 3 + .../results/analysis/subtract_coord_y.cml | 3 + .../results/analysis/subtract_scalar.cml | 3 + .../TestBroadcasting/collapse_all_dims.cml | 3 + .../TestBroadcasting/collapse_last_dims.cml | 3 + .../TestBroadcasting/collapse_middle_dim.cml | 3 + .../TestBroadcasting/collapse_zeroth_dim.cml | 3 + .../maths/add/TestBroadcasting/slice.cml | 3 + .../maths/add/TestBroadcasting/transposed.cml | 3 + .../TestBroadcasting/collapse_all_dims.cml | 3 + .../TestBroadcasting/collapse_last_dims.cml | 3 + .../TestBroadcasting/collapse_middle_dim.cml | 3 + .../TestBroadcasting/collapse_zeroth_dim.cml | 3 + .../maths/divide/TestBroadcasting/slice.cml | 3 + .../divide/TestBroadcasting/transposed.cml | 3 + .../TestBroadcasting/collapse_all_dims.cml | 3 + .../TestBroadcasting/collapse_last_dims.cml | 3 + .../TestBroadcasting/collapse_middle_dim.cml | 3 + .../TestBroadcasting/collapse_zeroth_dim.cml | 3 + .../maths/multiply/TestBroadcasting/slice.cml | 3 + .../multiply/TestBroadcasting/transposed.cml | 3 + .../TestBroadcasting/collapse_all_dims.cml | 3 + .../TestBroadcasting/collapse_last_dims.cml | 3 + .../TestBroadcasting/collapse_middle_dim.cml | 3 + .../TestBroadcasting/collapse_zeroth_dim.cml | 3 + .../maths/subtract/TestBroadcasting/slice.cml | 3 + .../subtract/TestBroadcasting/transposed.cml | 3 + lib/iris/tests/test_basic_maths.py | 23 +- lib/iris/tests/test_cdm.py | 8 - lib/iris/tests/test_coord_api.py | 10 +- .../{cube_coord_common => common}/__init__.py | 2 +- .../tests/unit/common/lenient/__init__.py | 6 + .../tests/unit/common/lenient/test_Lenient.py | 182 ++ .../unit/common/lenient/test__Lenient.py | 835 +++++++++ .../common/lenient/test__lenient_client.py | 182 ++ .../common/lenient/test__lenient_service.py | 116 ++ .../unit/common/lenient/test__qualname.py | 66 + .../tests/unit/common/metadata/__init__.py | 6 + .../test_AncillaryVariableMetadata.py | 494 +++++ .../unit/common/metadata/test_BaseMetadata.py | 1636 +++++++++++++++++ .../metadata/test_CellMeasureMetadata.py | 663 +++++++ .../common/metadata/test_CoordMetadata.py | 724 ++++++++ .../unit/common/metadata/test_CubeMetadata.py | 831 +++++++++ .../common/metadata/test__NamedTupleMeta.py | 148 ++ .../unit/common/metadata/test__hexdigest.py | 179 ++ .../metadata/test_metadata_manager_factory.py | 210 +++ lib/iris/tests/unit/common/mixin/__init__.py | 6 + .../unit/common/mixin/test_CFVariableMixin.py | 364 ++++ .../common/mixin/test_LimitedAttributeDict.py | 69 + .../mixin/test__get_valid_standard_name.py} | 26 +- lib/iris/tests/unit/coords/test_CellMethod.py | 6 +- lib/iris/tests/unit/coords/test_Coord.py | 11 + .../cube_coord_common/test_CFVariableMixin.py | 199 -- .../experimental/stratify/test_relevel.py | 5 +- .../netcdf/test__load_aux_factory.py | 14 +- lib/iris/util.py | 2 +- requirements/core.txt | 1 + 107 files changed, 11329 insertions(+), 1005 deletions(-) create mode 100644 lib/iris/common/__init__.py create mode 100644 lib/iris/common/lenient.py create mode 100644 lib/iris/common/metadata.py rename lib/iris/{_cube_coord_common.py => common/mixin.py} (51%) create mode 100644 lib/iris/common/resolve.py create mode 100644 lib/iris/etc/logging.yaml rename lib/iris/tests/unit/{cube_coord_common => common}/__init__.py (75%) create mode 100644 lib/iris/tests/unit/common/lenient/__init__.py create mode 100644 lib/iris/tests/unit/common/lenient/test_Lenient.py create mode 100644 lib/iris/tests/unit/common/lenient/test__Lenient.py create mode 100644 lib/iris/tests/unit/common/lenient/test__lenient_client.py create mode 100644 lib/iris/tests/unit/common/lenient/test__lenient_service.py create mode 100644 lib/iris/tests/unit/common/lenient/test__qualname.py create mode 100644 lib/iris/tests/unit/common/metadata/__init__.py create mode 100644 lib/iris/tests/unit/common/metadata/test_AncillaryVariableMetadata.py create mode 100644 lib/iris/tests/unit/common/metadata/test_BaseMetadata.py create mode 100644 lib/iris/tests/unit/common/metadata/test_CellMeasureMetadata.py create mode 100644 lib/iris/tests/unit/common/metadata/test_CoordMetadata.py create mode 100644 lib/iris/tests/unit/common/metadata/test_CubeMetadata.py create mode 100644 lib/iris/tests/unit/common/metadata/test__NamedTupleMeta.py create mode 100644 lib/iris/tests/unit/common/metadata/test__hexdigest.py create mode 100644 lib/iris/tests/unit/common/metadata/test_metadata_manager_factory.py create mode 100644 lib/iris/tests/unit/common/mixin/__init__.py create mode 100644 lib/iris/tests/unit/common/mixin/test_CFVariableMixin.py create mode 100644 lib/iris/tests/unit/common/mixin/test_LimitedAttributeDict.py rename lib/iris/tests/unit/{cube_coord_common/test_get_valid_standard_name.py => common/mixin/test__get_valid_standard_name.py} (70%) delete mode 100644 lib/iris/tests/unit/cube_coord_common/test_CFVariableMixin.py diff --git a/.flake8 b/.flake8 index 38cd1d82f7..131b6eb1ff 100644 --- a/.flake8 +++ b/.flake8 @@ -15,6 +15,8 @@ ignore = E402, # E501: line too long E501, + # E731: do not assign a lambda expression, use a def + E731, # W503: line break before binary operator W503, # W504: line break after binary operator @@ -38,3 +40,7 @@ exclude = # ignore third-party files # gitwash_dumper.py, + # + # convenience imports + # + lib/iris/common/__init__.py diff --git a/ci/requirements/readthedocs.yml b/ci/requirements/readthedocs.yml index 5a7e3975f7..4a1df9cc7b 100644 --- a/ci/requirements/readthedocs.yml +++ b/ci/requirements/readthedocs.yml @@ -23,6 +23,7 @@ dependencies: - netcdf4 - numpy>=1.14 - scipy + - python-xxhash # Dependencies needed to run the iris tests #------------------------------------------ diff --git a/docs/iris/src/userguide/cube_maths.rst b/docs/iris/src/userguide/cube_maths.rst index 6af4d5b3a6..0ac2b8da74 100644 --- a/docs/iris/src/userguide/cube_maths.rst +++ b/docs/iris/src/userguide/cube_maths.rst @@ -60,6 +60,10 @@ but with the data representing their difference: Scalar coordinates: forecast_reference_time: 1859-09-01 06:00:00 height: 1.5 m + Attributes: + Conventions: CF-1.5 + Model scenario: E1 + source: Data from Met Office Unified Model 6.05 .. note:: diff --git a/lib/iris/_concatenate.py b/lib/iris/_concatenate.py index 32dc87d65b..6bda3aa274 100644 --- a/lib/iris/_concatenate.py +++ b/lib/iris/_concatenate.py @@ -68,7 +68,7 @@ class _CoordMetaData( Args: * defn: - The :class:`iris.coords.CoordDefn` metadata that represents a + The :class:`iris.common.CoordMetadata` metadata that represents a coordinate. * dims: @@ -85,7 +85,7 @@ class _CoordMetaData( """ - def __new__(cls, coord, dims): + def __new__(mcs, coord, dims): """ Create a new :class:`_CoordMetaData` instance. @@ -101,7 +101,7 @@ def __new__(cls, coord, dims): The new class instance. """ - defn = coord._as_defn() + defn = coord.metadata points_dtype = coord.points.dtype bounds_dtype = coord.bounds.dtype if coord.bounds is not None else None kwargs = {} @@ -120,7 +120,7 @@ def __new__(cls, coord, dims): order = _DECREASING kwargs["order"] = order metadata = super().__new__( - cls, defn, dims, points_dtype, bounds_dtype, kwargs + mcs, defn, dims, points_dtype, bounds_dtype, kwargs ) return metadata @@ -194,7 +194,7 @@ def __new__(cls, ancil, dims): The new class instance. """ - defn = ancil._as_defn() + defn = ancil.metadata metadata = super().__new__(cls, defn, dims) return metadata @@ -403,11 +403,11 @@ def __init__(self, cube): axes = dict(T=0, Z=1, Y=2, X=3) # Coordinate sort function - by guessed coordinate axis, then - # by coordinate definition, then by dimensions, in ascending order. + # by coordinate name, then by dimensions, in ascending order. def key_func(coord): return ( axes.get(guess_coord_axis(coord), len(axes) + 1), - coord._as_defn(), + coord.name(), cube.coord_dims(coord), ) @@ -422,7 +422,7 @@ def key_func(coord): self.scalar_coords.append(coord) def meta_key_func(dm): - return (dm._as_defn(), dm.cube_dims(cube)) + return (dm.metadata, dm.cube_dims(cube)) for cm in sorted(cube.cell_measures(), key=meta_key_func): dims = cube.cell_measure_dims(cm) @@ -990,6 +990,9 @@ def _build_aux_coordinates(self): points, bounds=bnds, **kwargs ) except ValueError: + # Ensure to remove the "circular" kwarg, which may be + # present in the defn of a DimCoord being demoted. + _ = kwargs.pop("circular", None) coord = iris.coords.AuxCoord( points, bounds=bnds, **kwargs ) diff --git a/lib/iris/_constraints.py b/lib/iris/_constraints.py index 4746425bb3..0f6a8ab6c6 100644 --- a/lib/iris/_constraints.py +++ b/lib/iris/_constraints.py @@ -131,7 +131,7 @@ def _coordless_match(self, cube): if self._name: # Require to also check against cube.name() for the fallback # "unknown" default case, when there is no name metadata available. - match = self._name in cube.names or self._name == cube.name() + match = self._name in cube._names or self._name == cube.name() if match and self._cube_func: match = self._cube_func(cube) return match diff --git a/lib/iris/_merge.py b/lib/iris/_merge.py index 9ea07e54b2..ed6dd784f2 100644 --- a/lib/iris/_merge.py +++ b/lib/iris/_merge.py @@ -22,8 +22,9 @@ is_lazy_data, multidim_lazy_stack, ) -import iris.cube import iris.coords +from iris.common import CoordMetadata, CubeMetadata +import iris.cube import iris.exceptions import iris.util @@ -115,7 +116,7 @@ class _ScalarCoordPayload( Args: * defns: - A list of scalar coordinate definitions :class:`iris.coords.CoordDefn` + A list of scalar coordinate metadata :class:`iris.common.CoordMetadata` belonging to a :class:`iris.cube.Cube`. * values: @@ -1478,9 +1479,7 @@ def axis_and_name(name): ) else: bounds = None - kwargs = dict( - zip(iris.coords.CoordDefn._fields, defns[name]) - ) + kwargs = dict(zip(CoordMetadata._fields, defns[name])) kwargs.update(metadata[name].kwargs) def name_in_independents(): @@ -1560,7 +1559,7 @@ def name_in_independents(): if bounds is not None: bounds[index] = name_value.bound - kwargs = dict(zip(iris.coords.CoordDefn._fields, defns[name])) + kwargs = dict(zip(CoordMetadata._fields, defns[name])) self._aux_templates.append( _Template(dims, points, bounds, kwargs) ) @@ -1594,7 +1593,7 @@ def _get_cube(self, data): (deepcopy(coord), dims) for coord, dims in self._aux_coords_and_dims ] - kwargs = dict(zip(iris.cube.CubeMetadata._fields, signature.defn)) + kwargs = dict(zip(CubeMetadata._fields, signature.defn)) cms_and_dims = [ (deepcopy(cm), dims) for cm, dims in self._cell_measures_and_dims @@ -1794,7 +1793,7 @@ def _extract_coord_payload(self, cube): # Coordinate sort function. # NB. This makes use of two properties which don't end up in - # the CoordDefn used by scalar_defns: `coord.points.dtype` and + # the metadata used by scalar_defns: `coord.points.dtype` and # `type(coord)`. def key_func(coord): points_dtype = coord.dtype @@ -1805,14 +1804,14 @@ def key_func(coord): axis_dict.get( iris.util.guess_coord_axis(coord), len(axis_dict) + 1 ), - coord._as_defn(), + coord.metadata, ) # Order the coordinates by hints, axis, and definition. for coord in sorted(coords, key=key_func): if not cube.coord_dims(coord) and coord.shape == (1,): # Extract the scalar coordinate data and metadata. - scalar_defns.append(coord._as_defn()) + scalar_defns.append(coord.metadata) # Because we know there's a single Cell in the # coordinate, it's quicker to roll our own than use # Coord.cell(). @@ -1844,14 +1843,14 @@ def key_func(coord): factory_defns = [] for factory in sorted( - cube.aux_factories, key=lambda factory: factory._as_defn() + cube.aux_factories, key=lambda factory: factory.metadata ): dependency_defns = [] dependencies = factory.dependencies for key in sorted(dependencies): coord = dependencies[key] if coord is not None: - dependency_defns.append((key, coord._as_defn())) + dependency_defns.append((key, coord.metadata)) factory_defn = _FactoryDefn(type(factory), dependency_defns) factory_defns.append(factory_defn) diff --git a/lib/iris/analysis/__init__.py b/lib/iris/analysis/__init__.py index 0d4d3bfdab..12560cefda 100644 --- a/lib/iris/analysis/__init__.py +++ b/lib/iris/analysis/__init__.py @@ -319,7 +319,7 @@ def _dimensional_metadata_comparison(*cubes, object_get=None): eq = ( other_coord is coord or other_coord.name() == coord.name() - and other_coord._as_defn() == coord._as_defn() + and other_coord.metadata == coord.metadata ) if eq: coord_to_add_to_group = other_coord diff --git a/lib/iris/analysis/maths.py b/lib/iris/analysis/maths.py index 0de97b02f3..3a38b3b283 100644 --- a/lib/iris/analysis/maths.py +++ b/lib/iris/analysis/maths.py @@ -10,22 +10,27 @@ from functools import lru_cache import inspect +import logging import math import operator import warnings import cf_units +import dask.array as da import numpy as np from numpy import ma import iris.analysis +from iris.common import SERVICES, Resolve +from iris.common.lenient import _lenient_client import iris.coords import iris.cube import iris.exceptions import iris.util -import dask.array as da -from dask.array.core import broadcast_shapes + +# Configure the logger. +logger = logging.getLogger(__name__) @lru_cache(maxsize=128, typed=True) @@ -115,7 +120,9 @@ def abs(cube, in_place=False): _assert_is_cube(cube) new_dtype = _output_dtype(np.abs, cube.dtype, in_place=in_place) op = da.absolute if cube.has_lazy_data() else np.abs - return _math_op_common(cube, op, cube.units, new_dtype, in_place=in_place) + return _math_op_common( + cube, op, cube.units, new_dtype=new_dtype, in_place=in_place + ) def intersection_of_cubes(cube, other_cube): @@ -179,43 +186,7 @@ def _assert_is_cube(cube): ) -def _assert_compatible(cube, other): - """ - Checks to see if cube.data and another array can be broadcast to - the same shape. - - """ - try: - new_shape = broadcast_shapes(cube.shape, other.shape) - except ValueError as err: - # re-raise - raise ValueError( - "The array was not broadcastable to the cube's data " - "shape. The error message when " - "broadcasting:\n{}\nThe cube's shape was {} and the " - "array's shape was {}".format(err, cube.shape, other.shape) - ) - - if cube.shape != new_shape: - raise ValueError( - "The array operation would increase the size or " - "dimensionality of the cube. The new cube's data " - "would have had to become: {}".format(new_shape) - ) - - -def _assert_matching_units(cube, other, operation_name): - """ - Check that the units of the cube and the other item are the same, or if - the other does not have a unit, skip this test - """ - if cube.units != getattr(other, "units", cube.units): - msg = "Cannot use {!r} with differing units ({} & {})".format( - operation_name, cube.units, other.units - ) - raise iris.exceptions.NotYetImplementedError(msg) - - +@_lenient_client(services=SERVICES) def add(cube, other, dim=None, in_place=False): """ Calculate the sum of two cubes, or the sum of a cube and a @@ -249,7 +220,10 @@ def add(cube, other, dim=None, in_place=False): """ _assert_is_cube(cube) new_dtype = _output_dtype( - operator.add, cube.dtype, _get_dtype(other), in_place=in_place + operator.add, + cube.dtype, + second_dtype=_get_dtype(other), + in_place=in_place, ) if in_place: _inplace_common_checks(cube, other, "addition") @@ -261,6 +235,7 @@ def add(cube, other, dim=None, in_place=False): ) +@_lenient_client(services=SERVICES) def subtract(cube, other, dim=None, in_place=False): """ Calculate the difference between two cubes, or the difference between @@ -294,7 +269,10 @@ def subtract(cube, other, dim=None, in_place=False): """ _assert_is_cube(cube) new_dtype = _output_dtype( - operator.sub, cube.dtype, _get_dtype(other), in_place=in_place + operator.sub, + cube.dtype, + second_dtype=_get_dtype(other), + in_place=in_place, ) if in_place: _inplace_common_checks(cube, other, "subtraction") @@ -335,30 +313,15 @@ def _add_subtract_common( """ _assert_is_cube(cube) - _assert_matching_units(cube, other, operation_name) - - if isinstance(other, iris.cube.Cube): - # get a coordinate comparison of this cube and the cube to do the - # operation with - coord_comp = iris.analysis._dimensional_metadata_comparison( - cube, other - ) - bad_coord_grps = ( - coord_comp["ungroupable_and_dimensioned"] - + coord_comp["resamplable"] + if cube.units != getattr(other, "units", cube.units): + emsg = ( + f"Cannot use {operation_name!r} with differing units " + f"({cube.units} & {other.units})" ) - if bad_coord_grps: - raise ValueError( - "This operation cannot be performed as there are " - "differing coordinates (%s) remaining " - "which cannot be ignored." - % ", ".join({coord_grp.name() for coord_grp in bad_coord_grps}) - ) - else: - coord_comp = None + raise iris.exceptions.NotYetImplementedError(emsg) - new_cube = _binary_op_common( + result = _binary_op_common( operation_function, operation_name, cube, @@ -369,17 +332,10 @@ def _add_subtract_common( in_place=in_place, ) - if coord_comp: - # If a coordinate is to be ignored - remove it - ignore = filter( - None, [coord_grp[0] for coord_grp in coord_comp["ignorable"]] - ) - for coord in ignore: - new_cube.remove_coord(coord) - - return new_cube + return result +@_lenient_client(services=SERVICES) def multiply(cube, other, dim=None, in_place=False): """ Calculate the product of a cube and another cube or coordinate. @@ -403,38 +359,23 @@ def multiply(cube, other, dim=None, in_place=False): """ _assert_is_cube(cube) + new_dtype = _output_dtype( - operator.mul, cube.dtype, _get_dtype(other), in_place=in_place + operator.mul, + cube.dtype, + second_dtype=_get_dtype(other), + in_place=in_place, ) other_unit = getattr(other, "units", "1") new_unit = cube.units * other_unit + if in_place: _inplace_common_checks(cube, other, "multiplication") op = operator.imul else: op = operator.mul - if isinstance(other, iris.cube.Cube): - # get a coordinate comparison of this cube and the cube to do the - # operation with - coord_comp = iris.analysis._dimensional_metadata_comparison( - cube, other - ) - bad_coord_grps = ( - coord_comp["ungroupable_and_dimensioned"] - + coord_comp["resamplable"] - ) - if bad_coord_grps: - raise ValueError( - "This operation cannot be performed as there are " - "differing coordinates (%s) remaining " - "which cannot be ignored." - % ", ".join({coord_grp.name() for coord_grp in bad_coord_grps}) - ) - else: - coord_comp = None - - new_cube = _binary_op_common( + result = _binary_op_common( op, "multiply", cube, @@ -445,15 +386,7 @@ def multiply(cube, other, dim=None, in_place=False): in_place=in_place, ) - if coord_comp: - # If a coordinate is to be ignored - remove it - ignore = filter( - None, [coord_grp[0] for coord_grp in coord_comp["ignorable"]] - ) - for coord in ignore: - new_cube.remove_coord(coord) - - return new_cube + return result def _inplace_common_checks(cube, other, math_op): @@ -475,6 +408,7 @@ def _inplace_common_checks(cube, other, math_op): ) +@_lenient_client(services=SERVICES) def divide(cube, other, dim=None, in_place=False): """ Calculate the division of a cube by a cube or coordinate. @@ -498,44 +432,29 @@ def divide(cube, other, dim=None, in_place=False): """ _assert_is_cube(cube) + new_dtype = _output_dtype( - operator.truediv, cube.dtype, _get_dtype(other), in_place=in_place + operator.truediv, + cube.dtype, + second_dtype=_get_dtype(other), + in_place=in_place, ) other_unit = getattr(other, "units", "1") new_unit = cube.units / other_unit + if in_place: if cube.dtype.kind in "iu": # Cannot coerce float result from inplace division back to int. - aemsg = ( - "Cannot perform inplace division of cube {!r} " + emsg = ( + f"Cannot perform inplace division of cube {cube.name()!r} " "with integer data." ) - raise ArithmeticError(aemsg) + raise ArithmeticError(emsg) op = operator.itruediv else: op = operator.truediv - if isinstance(other, iris.cube.Cube): - # get a coordinate comparison of this cube and the cube to do the - # operation with - coord_comp = iris.analysis._dimensional_metadata_comparison( - cube, other - ) - bad_coord_grps = ( - coord_comp["ungroupable_and_dimensioned"] - + coord_comp["resamplable"] - ) - if bad_coord_grps: - raise ValueError( - "This operation cannot be performed as there are " - "differing coordinates (%s) remaining " - "which cannot be ignored." - % ", ".join({coord_grp.name() for coord_grp in bad_coord_grps}) - ) - else: - coord_comp = None - - new_cube = _binary_op_common( + result = _binary_op_common( op, "divide", cube, @@ -546,15 +465,7 @@ def divide(cube, other, dim=None, in_place=False): in_place=in_place, ) - if coord_comp: - # If a coordinate is to be ignored - remove it - ignore = filter( - None, [coord_grp[0] for coord_grp in coord_comp["ignorable"]] - ) - for coord in ignore: - new_cube.remove_coord(coord) - - return new_cube + return result def exponentiate(cube, exponent, in_place=False): @@ -585,7 +496,10 @@ def exponentiate(cube, exponent, in_place=False): """ _assert_is_cube(cube) new_dtype = _output_dtype( - operator.pow, cube.dtype, _get_dtype(exponent), in_place=in_place + operator.pow, + cube.dtype, + second_dtype=_get_dtype(exponent), + in_place=in_place, ) if cube.has_lazy_data(): @@ -598,7 +512,11 @@ def power(data, out=None): return np.power(data, exponent, out) return _math_op_common( - cube, power, cube.units ** exponent, new_dtype, in_place=in_place + cube, + power, + cube.units ** exponent, + new_dtype=new_dtype, + in_place=in_place, ) @@ -628,7 +546,7 @@ def exp(cube, in_place=False): new_dtype = _output_dtype(np.exp, cube.dtype, in_place=in_place) op = da.exp if cube.has_lazy_data() else np.exp return _math_op_common( - cube, op, cf_units.Unit("1"), new_dtype, in_place=in_place + cube, op, cf_units.Unit("1"), new_dtype=new_dtype, in_place=in_place ) @@ -654,7 +572,11 @@ def log(cube, in_place=False): new_dtype = _output_dtype(np.log, cube.dtype, in_place=in_place) op = da.log if cube.has_lazy_data() else np.log return _math_op_common( - cube, op, cube.units.log(math.e), new_dtype, in_place=in_place + cube, + op, + cube.units.log(math.e), + new_dtype=new_dtype, + in_place=in_place, ) @@ -680,7 +602,7 @@ def log2(cube, in_place=False): new_dtype = _output_dtype(np.log2, cube.dtype, in_place=in_place) op = da.log2 if cube.has_lazy_data() else np.log2 return _math_op_common( - cube, op, cube.units.log(2), new_dtype, in_place=in_place + cube, op, cube.units.log(2), new_dtype=new_dtype, in_place=in_place ) @@ -706,12 +628,12 @@ def log10(cube, in_place=False): new_dtype = _output_dtype(np.log10, cube.dtype, in_place=in_place) op = da.log10 if cube.has_lazy_data() else np.log10 return _math_op_common( - cube, op, cube.units.log(10), new_dtype, in_place=in_place + cube, op, cube.units.log(10), new_dtype=new_dtype, in_place=in_place ) def apply_ufunc( - ufunc, cube, other_cube=None, new_unit=None, new_name=None, in_place=False + ufunc, cube, other=None, new_unit=None, new_name=None, in_place=False ): """ Apply a `numpy universal function @@ -735,7 +657,7 @@ def apply_ufunc( Kwargs: - * other_cube: + * other: An instance of :class:`iris.cube.Cube` to be given as the second argument to :func:`numpy.ufunc`. @@ -758,51 +680,59 @@ def apply_ufunc( """ if not isinstance(ufunc, np.ufunc): - name = getattr(ufunc, "__name__", "function passed to apply_ufunc") - - raise TypeError( - "{} is not recognised (it is not an instance of " - "numpy.ufunc)".format(name) + ufunc_name = getattr( + ufunc, "__name__", "function passed to apply_ufunc" ) + emsg = f"{ufunc_name} is not recognised, it is not an instance of numpy.ufunc" + raise TypeError(emsg) + + ufunc_name = ufunc.__name__ if ufunc.nout != 1: - raise ValueError( - "{} returns {} objects, apply_ufunc currently " - "only supports ufunc functions returning a single " - "object.".format(ufunc.__name__, ufunc.nout) + emsg = ( + f"{ufunc_name} returns {ufunc.nout} objects, apply_ufunc currently " + "only supports numpy.ufunc functions returning a single object." ) + raise ValueError(emsg) - if ufunc.nin == 2: - if other_cube is None: - raise ValueError( - "{} requires two arguments, so other_cube " - "must also be passed to apply_ufunc".format(ufunc.__name__) + if ufunc.nin == 1: + if other is not None: + dmsg = ( + "ignoring surplus 'other' argument to apply_ufunc, " + f"provided ufunc {ufunc_name!r} only requires 1 input" ) + logger.debug(dmsg) - _assert_is_cube(other_cube) + new_dtype = _output_dtype(ufunc, cube.dtype, in_place=in_place) + + new_cube = _math_op_common( + cube, ufunc, new_unit, new_dtype=new_dtype, in_place=in_place + ) + elif ufunc.nin == 2: + if other is None: + emsg = ( + f"{ufunc_name} requires two arguments, another cube " + "must also be passed to apply_ufunc." + ) + raise ValueError(emsg) + + _assert_is_cube(other) new_dtype = _output_dtype( - ufunc, cube.dtype, other_cube.dtype, in_place=in_place + ufunc, cube.dtype, second_dtype=other.dtype, in_place=in_place ) new_cube = _binary_op_common( ufunc, - ufunc.__name__, + ufunc_name, cube, - other_cube, + other, new_unit, new_dtype=new_dtype, in_place=in_place, ) - - elif ufunc.nin == 1: - new_dtype = _output_dtype(ufunc, cube.dtype, in_place=in_place) - - new_cube = _math_op_common( - cube, ufunc, new_unit, new_dtype, in_place=in_place - ) - else: - raise ValueError(ufunc.__name__ + ".nin should be 1 or 2.") + emsg = f"Provided ufunc '{ufunc_name}.nin' must be 1 or 2." + raise ValueError(emsg) new_cube.rename(new_name) @@ -838,39 +768,63 @@ def _binary_op_common( `cube` and `cube.data` """ _assert_is_cube(cube) + + # Flag to notify the _math_op_common function to simply wrap the resultant + # data of the maths operation in a cube with no metadata. + skeleton_cube = False + if isinstance(other, iris.coords.Coord): - other = _broadcast_cube_coord_data(cube, other, operation_name, dim) + # The rhs must be an array. + rhs = _broadcast_cube_coord_data(cube, other, operation_name, dim=dim) elif isinstance(other, iris.cube.Cube): - try: - broadcast_shapes(cube.shape, other.shape) - except ValueError: - other = iris.util.as_compatible_shape(other, cube) - other = other.core_data() - else: - other = np.asanyarray(other) + # Prepare to resolve the cube operands and associated coordinate + # metadata into the resultant cube. + resolver = Resolve(cube, other) + + # Get the broadcast, auto-transposed safe versions of the cube operands. + cube = resolver.lhs_cube_resolved + other = resolver.rhs_cube_resolved - # don't worry about checking for other data types (such as scalars or - # np.ndarrays) because _assert_compatible validates that they are broadcast - # compatible with cube.data - _assert_compatible(cube, other) + # Flag that it's safe to wrap the resultant data of the math operation + # in a cube with no metadata, as all of the metadata of the resultant + # cube is being managed by the resolver. + skeleton_cube = True - def unary_func(x): - ret = operation_function(x, other) - if ret is NotImplemented: - # explicitly raise the TypeError, so it gets raised even if, for + # The rhs must be an array. + rhs = other.core_data() + else: + # The rhs must be an array. + rhs = np.asanyarray(other) + + def unary_func(lhs): + data = operation_function(lhs, rhs) + if data is NotImplemented: + # Explicitly raise the TypeError, so it gets raised even if, for # example, `iris.analysis.maths.multiply(cube, other)` is called - # directly instead of `cube * other` - raise TypeError( - "cannot %s %r and %r objects" - % ( - operation_function.__name__, - type(x).__name__, - type(other).__name__, - ) + # directly instead of `cube * other`. + emsg = ( + f"Cannot {operation_function.__name__} {type(lhs).__name__!r} " + f"and {type(rhs).__name__} objects." ) - return ret + raise TypeError(emsg) + return data + + result = _math_op_common( + cube, + unary_func, + new_unit, + new_dtype=new_dtype, + in_place=in_place, + skeleton_cube=skeleton_cube, + ) - return _math_op_common(cube, unary_func, new_unit, new_dtype, in_place) + if isinstance(other, iris.cube.Cube): + # Insert the resultant data from the maths operation + # within the resolved cube. + result = resolver.cube(result.core_data(), in_place=in_place) + _sanitise_metadata(result, new_unit) + + return result def _broadcast_cube_coord_data(cube, other, operation_name, dim=None): @@ -915,26 +869,64 @@ def _broadcast_cube_coord_data(cube, other, operation_name, dim=None): return points +def _sanitise_metadata(cube, unit): + """ + As part of the maths metadata contract, clear the necessary or + unsupported metadata from the resultant cube of the maths operation. + + """ + # Clear the cube names. + cube.rename(None) + + # Clear the cube cell methods. + cube.cell_methods = None + + # Clear the cell measures. + for cm in cube.cell_measures(): + cube.remove_cell_measure(cm) + + # Clear the ancillary variables. + for av in cube.ancillary_variables(): + cube.remove_ancillary_variable(av) + + # Clear the STASH attribute, if present. + if "STASH" in cube.attributes: + del cube.attributes["STASH"] + + # Set the cube units. + cube.units = unit + + def _math_op_common( - cube, operation_function, new_unit, new_dtype=None, in_place=False + cube, + operation_function, + new_unit, + new_dtype=None, + in_place=False, + skeleton_cube=False, ): _assert_is_cube(cube) - if in_place: - new_cube = cube + if in_place and not skeleton_cube: if cube.has_lazy_data(): - new_cube.data = operation_function(cube.lazy_data()) + cube.data = operation_function(cube.lazy_data()) else: try: operation_function(cube.data, out=cube.data) except TypeError: - # Non ufunc function + # Non-ufunc function operation_function(cube.data) + new_cube = cube else: - new_cube = cube.copy(data=operation_function(cube.core_data())) + data = operation_function(cube.core_data()) + if skeleton_cube: + # Simply wrap the resultant data in a cube, as no + # cube metadata is required by the caller. + new_cube = iris.cube.Cube(data) + else: + new_cube = cube.copy(data) - # If the result of the operation is scalar and masked, we need to fix up - # the dtype + # If the result of the operation is scalar and masked, we need to fix-up the dtype. if ( new_dtype is not None and not new_cube.has_lazy_data() @@ -943,8 +935,8 @@ def _math_op_common( ): new_cube.data = ma.masked_array(0, 1, dtype=new_dtype) - iris.analysis.clear_phenomenon_identity(new_cube) - new_cube.units = new_unit + _sanitise_metadata(new_cube, new_unit) + return new_cube @@ -965,12 +957,12 @@ def __init__(self, data_func, units_func): are given as positional arguments. Should return another data array, with the same shape as the first array. - Can also have keyword arguments. + May also have keyword arguments. * units_func: - Function to calculate the unit of the resulting cube. - Should take the cube(s) as input and return + Function to calculate the units of the resulting cube. + Should take the cube/s as input and return an instance of :class:`cf_units.Unit`. Returns: @@ -1008,6 +1000,22 @@ def ws_units_func(u_cube, v_cube): cs_cube = cs_ifunc(cube, axis=1) """ + self._data_func_name = getattr( + data_func, "__name__", "data_func argument passed to IFunc" + ) + + if not callable(data_func): + emsg = f"{self._data_func_name} is not callable." + raise TypeError(emsg) + + self._unit_func_name = getattr( + units_func, "__name__", "units_func argument passed to IFunc" + ) + + if not callable(units_func): + emsg = f"{self._unit_func_name} is not callable." + raise TypeError(emsg) + if hasattr(data_func, "nin"): self.nin = data_func.nin else: @@ -1023,39 +1031,38 @@ def ws_units_func(u_cube, v_cube): self.nin = len(args) if self.nin not in [1, 2]: - msg = ( - "{} requires {} input data arrays, the IFunc class " - "currently only supports functions requiring 1 or two " - "data arrays as input." + emsg = ( + f"{self._data_func_name} requires {self.nin} input data " + "arrays, the IFunc class currently only supports functions " + "requiring 1 or 2 data arrays as input." ) - raise ValueError(msg.format(data_func.__name__, self.nin)) + raise ValueError(emsg) if hasattr(data_func, "nout"): if data_func.nout != 1: - msg = ( - "{} returns {} objects, the IFunc class currently " - "only supports functions returning a single object." - ) - raise ValueError( - msg.format(data_func.__name__, data_func.nout) + emsg = ( + f"{self._data_func_name} returns {data_func.nout} objects, " + "the IFunc class currently only supports functions " + "returning a single object." ) + raise ValueError(emsg) self.data_func = data_func - self.units_func = units_func def __repr__(self): - return "iris.analysis.maths.IFunc({}, {})".format( - self.data_func.__name__, self.units_func.__name__ + result = ( + f"iris.analysis.maths.IFunc({self._data_func_name}, " + f"{self._unit_func_name})" ) + return result def __str__(self): - return ( - "IFunc constructed from the data function {} " - "and the units function {}".format( - self.data_func.__name__, self.units_func.__name__ - ) + result = ( + f"IFunc constructed from the data function {self._data_func_name} " + f"and the units function {self._unit_func_name}" ) + return result def __call__( self, @@ -1105,11 +1112,27 @@ def wrap_data_func(*args, **kwargs): return self.data_func(*args, **kwargs_combined) - if self.nin == 2: + if self.nin == 1: + if other is not None: + dmsg = ( + "ignoring surplus 'other' argument to IFunc.__call__, " + f"provided data_func {self._data_func_name!r} only requires " + "1 input" + ) + logger.debug(dmsg) + + new_unit = self.units_func(cube) + + new_cube = _math_op_common( + cube, wrap_data_func, new_unit, in_place=in_place + ) + else: if other is None: - raise ValueError( - self.data_func.__name__ + " requires two arguments" + emsg = ( + f"{self._data_func_name} requires two arguments, another " + "cube must also be passed to IFunc.__call__." ) + raise ValueError(emsg) new_unit = self.units_func(cube, other) @@ -1123,21 +1146,6 @@ def wrap_data_func(*args, **kwargs): in_place=in_place, ) - elif self.nin == 1: - if other is not None: - raise ValueError( - self.data_func.__name__ + " requires one argument" - ) - - new_unit = self.units_func(cube) - - new_cube = _math_op_common( - cube, wrap_data_func, new_unit, in_place=in_place - ) - - else: - raise ValueError("self.nin should be 1 or 2.") - if new_name is not None: new_cube.rename(new_name) diff --git a/lib/iris/aux_factory.py b/lib/iris/aux_factory.py index 11148188fa..0cc6bf068f 100644 --- a/lib/iris/aux_factory.py +++ b/lib/iris/aux_factory.py @@ -14,7 +14,11 @@ import dask.array as da import numpy as np -from iris._cube_coord_common import CFVariableMixin +from iris.common import ( + CFVariableMixin, + CoordMetadata, + metadata_manager_factory, +) import iris.coords @@ -33,14 +37,40 @@ class AuxCoordFactory(CFVariableMixin, metaclass=ABCMeta): """ def __init__(self): + # Configure the metadata manager. + if not hasattr(self, "_metadata_manager"): + self._metadata_manager = metadata_manager_factory(CoordMetadata) + #: Descriptive name of the coordinate made by the factory self.long_name = None #: netCDF variable name for the coordinate made by the factory self.var_name = None - #: Coordinate system (if any) of the coordinate made by the factory self.coord_system = None + # See the climatological property getter. + self._metadata_manager.climatological = False + + @property + def coord_system(self): + """ + The coordinate-system (if any) of the coordinate made by the factory. + + """ + return self._metadata_manager.coord_system + + @coord_system.setter + def coord_system(self, value): + self._metadata_manager.coord_system = value + + @property + def climatological(self): + """ + Always returns False, as a factory itself can never have points/bounds + and therefore can never be climatological by definition. + + """ + return self._metadata_manager.climatological @property @abstractmethod @@ -51,20 +81,6 @@ def dependencies(self): """ - def _as_defn(self): - defn = iris.coords.CoordDefn( - self.standard_name, - self.long_name, - self.var_name, - self.units, - self.attributes, - self.coord_system, - # Slot for Coord 'climatological' property, which this - # doesn't have. - False, - ) - return defn - @abstractmethod def make_coord(self, coord_dims_func): """ @@ -372,6 +388,8 @@ def __init__(self, delta=None, sigma=None, orography=None): The coordinate providing the `orog` term. """ + # Configure the metadata manager. + self._metadata_manager = metadata_manager_factory(CoordMetadata) super().__init__() if delta and delta.nbounds not in (0, 2): @@ -395,21 +413,24 @@ def __init__(self, delta=None, sigma=None, orography=None): self.standard_name = "altitude" if delta is None and orography is None: - raise ValueError( - "Unable to determine units: no delta or orography" - " available." + emsg = ( + "Unable to determine units: no delta or orography " + "available." ) + raise ValueError(emsg) if delta and orography and delta.units != orography.units: - raise ValueError( - "Incompatible units: delta and orography must" - " have the same units." + emsg = ( + "Incompatible units: delta and orography must have " + "the same units." ) + raise ValueError(emsg) self.units = (delta and delta.units) or orography.units if not self.units.is_convertible("m"): - raise ValueError( - "Invalid units: delta and/or orography" - " must be expressed in length units." + emsg = ( + "Invalid units: delta and/or orography must be expressed " + "in length units." ) + raise ValueError(emsg) self.attributes = {"positive": "up"} @property @@ -556,10 +577,13 @@ def __init__(self, delta=None, sigma=None, surface_air_pressure=None): The coordinate providing the `ps` term. """ + # Configure the metadata manager. + self._metadata_manager = metadata_manager_factory(CoordMetadata) super().__init__() # Check that provided coords meet necessary conditions. self._check_dependencies(delta, sigma, surface_air_pressure) + self.units = (delta and delta.units) or surface_air_pressure.units self.delta = delta self.sigma = sigma @@ -568,20 +592,12 @@ def __init__(self, delta=None, sigma=None, surface_air_pressure=None): self.standard_name = "air_pressure" self.attributes = {} - @property - def units(self): - if self.delta is not None: - units = self.delta.units - else: - units = self.surface_air_pressure.units - return units - @staticmethod def _check_dependencies(delta, sigma, surface_air_pressure): # Check for sufficient coordinates. if delta is None and (sigma is None or surface_air_pressure is None): msg = ( - "Unable to contruct hybrid pressure coordinate factory " + "Unable to construct hybrid pressure coordinate factory " "due to insufficient source coordinates." ) raise ValueError(msg) @@ -753,7 +769,7 @@ def __init__( zlev=None, ): """ - Creates a ocean sigma over z coordinate factory with the formula: + Creates an ocean sigma over z coordinate factory with the formula: if k < nsigma: z(n, k, j, i) = eta(n, j, i) + sigma(k) * @@ -766,10 +782,13 @@ def __init__( either `eta`, or 'sigma' and `depth` and `depth_c` coordinates. """ + # Configure the metadata manager. + self._metadata_manager = metadata_manager_factory(CoordMetadata) super().__init__() # Check that provided coordinates meet necessary conditions. self._check_dependencies(sigma, eta, depth, depth_c, nsigma, zlev) + self.units = zlev.units self.sigma = sigma self.eta = eta @@ -781,16 +800,12 @@ def __init__( self.standard_name = "sea_surface_height_above_reference_ellipsoid" self.attributes = {"positive": "up"} - @property - def units(self): - return self.zlev.units - @staticmethod def _check_dependencies(sigma, eta, depth, depth_c, nsigma, zlev): # Check for sufficient factory coordinates. if zlev is None: raise ValueError( - "Unable to determine units: " "no zlev coordinate available." + "Unable to determine units: no zlev coordinate available." ) if nsigma is None: raise ValueError("Missing nsigma coordinate.") @@ -1068,10 +1083,13 @@ def __init__(self, sigma=None, eta=None, depth=None): (depth(j, i) + eta(n, j, i)) """ + # Configure the metadata manager. + self._metadata_manager = metadata_manager_factory(CoordMetadata) super().__init__() # Check that provided coordinates meet necessary conditions. self._check_dependencies(sigma, eta, depth) + self.units = depth.units self.sigma = sigma self.eta = eta @@ -1080,10 +1098,6 @@ def __init__(self, sigma=None, eta=None, depth=None): self.standard_name = "sea_surface_height_above_reference_ellipsoid" self.attributes = {"positive": "up"} - @property - def units(self): - return self.depth.units - @staticmethod def _check_dependencies(sigma, eta, depth): # Check for sufficient factory coordinates. @@ -1252,10 +1266,13 @@ def __init__(self, s=None, c=None, eta=None, depth=None, depth_c=None): S(k,j,i) = depth_c * s(k) + (depth(j,i) - depth_c) * C(k) """ + # Configure the metadata manager. + self._metadata_manager = metadata_manager_factory(CoordMetadata) super().__init__() # Check that provided coordinates meet necessary conditions. self._check_dependencies(s, c, eta, depth, depth_c) + self.units = depth.units self.s = s self.c = c @@ -1266,10 +1283,6 @@ def __init__(self, s=None, c=None, eta=None, depth=None, depth_c=None): self.standard_name = "sea_surface_height_above_reference_ellipsoid" self.attributes = {"positive": "up"} - @property - def units(self): - return self.depth.units - @staticmethod def _check_dependencies(s, c, eta, depth, depth_c): # Check for sufficient factory coordinates. @@ -1476,10 +1489,13 @@ def __init__( b * [tanh(a * (s(k) + 0.5)) / (2 * tanh(0.5*a)) - 0.5] """ + # Configure the metadata manager. + self._metadata_manager = metadata_manager_factory(CoordMetadata) super().__init__() # Check that provided coordinates meet necessary conditions. self._check_dependencies(s, eta, depth, a, b, depth_c) + self.units = depth.units self.s = s self.eta = eta @@ -1491,10 +1507,6 @@ def __init__( self.standard_name = "sea_surface_height_above_reference_ellipsoid" self.attributes = {"positive": "up"} - @property - def units(self): - return self.depth.units - @staticmethod def _check_dependencies(s, eta, depth, a, b, depth_c): # Check for sufficient factory coordinates. @@ -1695,10 +1707,13 @@ def __init__(self, s=None, c=None, eta=None, depth=None, depth_c=None): (depth_c + depth(j,i)) """ + # Configure the metadata manager. + self._metadata_manager = metadata_manager_factory(CoordMetadata) super().__init__() # Check that provided coordinates meet necessary conditions. self._check_dependencies(s, c, eta, depth, depth_c) + self.units = depth.units self.s = s self.c = c @@ -1709,10 +1724,6 @@ def __init__(self, s=None, c=None, eta=None, depth=None, depth_c=None): self.standard_name = "sea_surface_height_above_reference_ellipsoid" self.attributes = {"positive": "up"} - @property - def units(self): - return self.depth.units - @staticmethod def _check_dependencies(s, c, eta, depth, depth_c): # Check for sufficient factory coordinates. diff --git a/lib/iris/common/__init__.py b/lib/iris/common/__init__.py new file mode 100644 index 0000000000..c540d81bc0 --- /dev/null +++ b/lib/iris/common/__init__.py @@ -0,0 +1,11 @@ +# 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. + + +from .lenient import * +from .metadata import * +from .mixin import * +from .resolve import * diff --git a/lib/iris/common/lenient.py b/lib/iris/common/lenient.py new file mode 100644 index 0000000000..802d854554 --- /dev/null +++ b/lib/iris/common/lenient.py @@ -0,0 +1,661 @@ +# 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. + +from collections.abc import Iterable +from contextlib import contextmanager +from copy import deepcopy +from functools import wraps +from inspect import getmodule +import threading + + +__all__ = [ + "LENIENT", + "Lenient", +] + + +#: Default _Lenient services global activation state. +_LENIENT_ENABLE_DEFAULT = True + +#: Default Lenient maths feature state. +_LENIENT_MATHS_DEFAULT = True + +#: Protected _Lenient internal non-client, non-service keys. +_LENIENT_PROTECTED = ("active", "enable") + + +def _lenient_client(*dargs, services=None): + """ + Decorator that allows a client function/method to declare at runtime that + it is executing and requires lenient behaviour from a prior registered + lenient service function/method. + + This decorator supports being called with no arguments e.g., + + @_lenient_client() + def func(): + pass + + This is equivalent to using it as a simple naked decorator e.g., + + @_lenient_client + def func() + pass + + Alternatively, this decorator supports the lenient client explicitly + declaring the lenient services that it wishes to use e.g., + + @_lenient_client(services=(service1, service2, ...) + def func(): + pass + + Args: + + * dargs (tuple of callable): + A tuple containing the callable lenient client function/method to be + wrapped by the decorator. This is automatically populated by Python + through the decorator interface. No argument requires to be manually + provided. + + Kwargs: + + * services (callable or str or iterable of callable/str) + Zero or more function/methods, or equivalent fully qualified string names, of + lenient service function/methods. + + Returns: + Closure wrapped function/method. + + """ + ndargs = len(dargs) + + if ndargs: + assert ( + ndargs == 1 + ), f"Invalid lenient client arguments, expecting 1 got {ndargs}." + assert callable( + dargs[0] + ), "Invalid lenient client argument, expecting a callable." + + assert not ( + ndargs and services + ), "Invalid lenient client, got both arguments and keyword arguments." + + if ndargs: + # The decorator has been used as a simple naked decorator. + (func,) = dargs + + @wraps(func) + def lenient_client_inner_naked(*args, **kwargs): + """ + Closure wrapper function to register the wrapped function/method + as active at runtime before executing it. + + """ + with _LENIENT.context(active=_qualname(func)): + result = func(*args, **kwargs) + return result + + result = lenient_client_inner_naked + else: + # The decorator has been called with None, zero or more explicit lenient services. + if services is None: + services = () + + if isinstance(services, str) or not isinstance(services, Iterable): + services = (services,) + + def lenient_client_outer(func): + @wraps(func) + def lenient_client_inner(*args, **kwargs): + """ + Closure wrapper function to register the wrapped function/method + as active at runtime before executing it. + + """ + with _LENIENT.context(*services, active=_qualname(func)): + result = func(*args, **kwargs) + return result + + return lenient_client_inner + + result = lenient_client_outer + + return result + + +def _lenient_service(*dargs): + """ + Decorator that allows a function/method to declare that it supports lenient + behaviour as a service. + + Registration is at Python interpreter parse time. + + The decorator supports being called with no arguments e.g., + + @_lenient_service() + def func(): + pass + + This is equivalent to using it as a simple naked decorator e.g., + + @_lenient_service + def func(): + pass + + Args: + + * dargs (tuple of callable): + A tuple containing the callable lenient service function/method to be + wrapped by the decorator. This is automatically populated by Python + through the decorator interface. No argument requires to be manually + provided. + + Returns: + Closure wrapped function/method. + + """ + ndargs = len(dargs) + + if ndargs: + assert ( + ndargs == 1 + ), f"Invalid lenient service arguments, expecting 1 got {ndargs}." + assert callable( + dargs[0] + ), "Invalid lenient service argument, expecting a callable." + + if ndargs: + # The decorator has been used as a simple naked decorator. + # Thus the (single) argument is a function to be wrapped. + # We just register the argument function as a lenient service, and + # return it unchanged + (func,) = dargs + + _LENIENT.register_service(func) + + # This decorator registers 'func': the func itself is unchanged. + result = func + + else: + # The decorator has been called with no arguments. + # Return a decorator, to apply to 'func' immediately following. + def lenient_service_outer(func): + _LENIENT.register_service(func) + + # Decorator registers 'func', but func itself is unchanged. + return func + + result = lenient_service_outer + + return result + + +def _qualname(func): + """ + Return the fully qualified function/method string name. + + Args: + + * func (callable): + Callable function/method. Non-callable arguments are simply + passed through. + + .. note:: + Inherited methods will be qualified with the base class that + defines the method. + + """ + result = func + if callable(func): + module = getmodule(func) + result = f"{module.__name__}.{func.__qualname__}" + + return result + + +class Lenient(threading.local): + def __init__(self, **kwargs): + """ + A container for managing the run-time lenient features and options. + + Kwargs: + + * kwargs (dict) + Mapping of lenient key/value options to enable/disable. Note that, + only the lenient "maths" options is available, which controls + lenient/strict cube arithmetic. + + For example:: + + Lenient(maths=False) + + Note that, the values of these options are thread-specific. + + """ + # Configure the initial default lenient state. + self._init() + + if not kwargs: + # If not specified, set the default behaviour of the maths lenient feature. + kwargs = dict(maths=_LENIENT_MATHS_DEFAULT) + + # Configure the provided (or default) lenient features. + for feature, state in kwargs.items(): + self[feature] = state + + def __contains__(self, key): + return key in self.__dict__ + + def __getitem__(self, key): + if key not in self.__dict__: + cls = self.__class__.__name__ + emsg = f"Invalid {cls!r} option, got {key!r}." + raise KeyError(emsg) + return self.__dict__[key] + + def __repr__(self): + cls = self.__class__.__name__ + msg = f"{cls}(maths={self.__dict__['maths']!r})" + return msg + + def __setitem__(self, key, value): + cls = self.__class__.__name__ + + if key not in self.__dict__: + emsg = f"Invalid {cls!r} option, got {key!r}." + raise KeyError(emsg) + + if not isinstance(value, bool): + emsg = f"Invalid {cls!r} option {key!r} value, got {value!r}." + raise ValueError(emsg) + + self.__dict__[key] = value + # Toggle the (private) lenient behaviour. + _LENIENT.enable = value + + def _init(self): + """Configure the initial default lenient state.""" + # This is the only public supported lenient feature i.e., cube arithmetic + self.__dict__["maths"] = None + + @contextmanager + def context(self, **kwargs): + """ + Return a context manager which allows temporary modification of the + lenient option state within the scope of the context manager. + + On entry to the context manager, all provided keyword arguments are + applied. On exit from the context manager, the previous lenient + option state is restored. + + For example:: + with iris.common.Lenient.context(maths=False): + pass + + """ + + def configure_state(state): + for feature, value in state.items(): + self[feature] = value + + # Save the original state. + original_state = deepcopy(self.__dict__) + + # Configure the provided lenient features. + configure_state(kwargs) + + try: + yield + finally: + # Restore the original state. + self.__dict__.clear() + self._init() + configure_state(original_state) + + +############################################################################### + + +class _Lenient(threading.local): + def __init__(self, *args, **kwargs): + """ + A container for managing the run-time lenient services and client + options for pre-defined functions/methods. + + Args: + + * args (callable or str or iterable of callable/str) + A function/method or fully qualified string name of the function/method + acting as a lenient service. + + Kwargs: + + * kwargs (dict of callable/str or iterable of callable/str) + Mapping of lenient client function/method, or fully qualified string name + of the function/method, to one or more lenient service + function/methods or fully qualified string name of function/methods. + + For example:: + + _Lenient(service1, service2, client1=service1, client2=(service1, service2)) + + Note that, the values of these options are thread-specific. + + """ + # The executing lenient client at runtime. + self.__dict__["active"] = None + # The global lenient services state activation switch. + self.__dict__["enable"] = _LENIENT_ENABLE_DEFAULT + + for service in args: + self.register_service(service) + + for client, services in kwargs.items(): + self.register_client(client, services) + + def __call__(self, func): + """ + Determine whether it is valid for the function/method to provide a + lenient service at runtime to the actively executing lenient client. + + Args: + + * func (callable or str): + A function/method or fully qualified string name of the function/method. + + Returns: + Boolean. + + """ + result = False + if self.__dict__["enable"]: + service = _qualname(func) + if service in self and self.__dict__[service]: + active = self.__dict__["active"] + if active is not None and active in self: + services = self.__dict__[active] + if isinstance(services, str) or not isinstance( + services, Iterable + ): + services = (services,) + result = service in services + return result + + def __contains__(self, name): + name = _qualname(name) + return name in self.__dict__ + + def __getattr__(self, name): + if name not in self.__dict__: + cls = self.__class__.__name__ + emsg = f"Invalid {cls!r} option, got {name!r}." + raise AttributeError(emsg) + return self.__dict__[name] + + def __getitem__(self, name): + name = _qualname(name) + if name not in self.__dict__: + cls = self.__class__.__name__ + emsg = f"Invalid {cls!r} option, got {name!r}." + raise KeyError(emsg) + return self.__dict__[name] + + def __repr__(self): + cls = self.__class__.__name__ + width = len(cls) + 1 + kwargs = [ + "{}={!r}".format(name, self.__dict__[name]) + for name in sorted(self.__dict__.keys()) + ] + joiner = ",\n{}".format(" " * width) + return "{}({})".format(cls, joiner.join(kwargs)) + + def __setitem__(self, name, value): + name = _qualname(name) + cls = self.__class__.__name__ + + if name not in self.__dict__: + emsg = f"Invalid {cls!r} option, got {name!r}." + raise KeyError(emsg) + + if name == "active": + value = _qualname(value) + if not isinstance(value, str) and value is not None: + emsg = f"Invalid {cls!r} option {name!r}, expected a registered {cls!r} client, got {value!r}." + raise ValueError(emsg) + self.__dict__[name] = value + elif name == "enable": + self.enable = value + else: + if isinstance(value, str) or callable(value): + value = (value,) + if isinstance(value, Iterable): + value = tuple([_qualname(item) for item in value]) + self.__dict__[name] = value + + @contextmanager + def context(self, *args, **kwargs): + """ + Return a context manager which allows temporary modification of + the lenient option state for the active thread. + + On entry to the context manager, all provided keyword arguments are + applied. On exit from the context manager, the previous lenient option + state is restored. + + For example:: + with iris._LENIENT.context(example_lenient_flag=False): + # ... code that expects some non-lenient behaviour + + .. note:: + iris._LENIENT.example_lenient_flag does not exist and is + provided only as an example. + + """ + + def update_client(client, services): + if client in self.__dict__: + existing_services = self.__dict__[client] + else: + existing_services = () + + self.__dict__[client] = tuple(set(existing_services + services)) + + # Save the original state. + original_state = deepcopy(self.__dict__) + + # Temporarily update the state with the kwargs first. + for name, value in kwargs.items(): + self[name] = value + + # Get the active client. + active = self.__dict__["active"] + + if args: + # Update the client with the provided services. + new_services = tuple([_qualname(arg) for arg in args]) + + if active is None: + # Ensure not to use "context" as the ephemeral name + # of the context manager runtime "active" lenient client, + # as this causes a namespace clash with this method + # i.e., _Lenient.context, via _Lenient.__getattr__ + active = "__context" + self.__dict__["active"] = active + self.__dict__[active] = new_services + else: + # Append provided services to any pre-existing services of the active client. + update_client(active, new_services) + else: + # Append previous ephemeral services (for non-specific client) to the active client. + if ( + active is not None + and active != "__context" + and "__context" in self.__dict__ + ): + new_services = self.__dict__["__context"] + update_client(active, new_services) + + try: + yield + finally: + # Restore the original state. + self.__dict__.clear() + self.__dict__.update(original_state) + + @property + def enable(self): + """Return the activation state of the lenient services.""" + return self.__dict__["enable"] + + @enable.setter + def enable(self, state): + """ + Set the activate state of the lenient services. + + Setting the state to `False` disables all lenient services, and + setting the state to `True` enables all lenient services. + + Args: + + * state (bool): + Activate state for lenient services. + + """ + if not isinstance(state, bool): + cls = self.__class__.__name__ + emsg = f"Invalid {cls!r} option 'enable', expected a {type(True)!r}, got {state!r}." + raise ValueError(emsg) + self.__dict__["enable"] = state + + def register_client(self, func, services, append=False): + """ + Add the provided mapping of lenient client function/method to + required lenient service function/methods. + + Args: + + * func (callable or str): + A client function/method or fully qualified string name of the + client function/method. + + * services (callable or str or iterable of callable/str): + One or more service function/methods or fully qualified string names + of the required service function/method. + + Kwargs: + + * append (bool): + If True, append the lenient services to any pre-registered lenient + services for the provided lenient client. Default is False. + + """ + func = _qualname(func) + cls = self.__class__.__name__ + + if func in _LENIENT_PROTECTED: + emsg = ( + f"Cannot register {cls!r} client. " + f"Please rename your client to be something other than {func!r}." + ) + raise ValueError(emsg) + if isinstance(services, str) or not isinstance(services, Iterable): + services = (services,) + if not len(services): + emsg = f"Require at least one {cls!r} client service." + raise ValueError(emsg) + services = tuple([_qualname(service) for service in services]) + if append: + # The original provided service order is not significant. There is + # no requirement to preserve it, so it's safe to sort. + existing = self.__dict__[func] if func in self else () + services = tuple(sorted(set(existing) | set(services))) + self.__dict__[func] = services + + def register_service(self, func): + """ + Add the provided function/method as providing a lenient service and + activate it. + + Args: + + * func (callable or str): + A service function/method or fully qualified string name of the + service function/method. + + """ + func = _qualname(func) + if func in _LENIENT_PROTECTED: + cls = self.__class__.__name__ + emsg = ( + f"Cannot register {cls!r} service. " + f"Please rename your service to be something other than {func!r}." + ) + raise ValueError(emsg) + self.__dict__[func] = True + + def unregister_client(self, func): + """ + Remove the provided function/method as a lenient client using lenient services. + + Args: + + * func (callable or str): + A function/method of fully qualified string name of the function/method. + + """ + func = _qualname(func) + cls = self.__class__.__name__ + + if func in _LENIENT_PROTECTED: + emsg = f"Cannot unregister {cls!r} client, as {func!r} is a protected {cls!r} option." + raise ValueError(emsg) + + if func in self.__dict__: + value = self.__dict__[func] + if isinstance(value, bool): + emsg = f"Cannot unregister {cls!r} client, as {func!r} is not a valid {cls!r} client." + raise ValueError(emsg) + del self.__dict__[func] + else: + emsg = f"Cannot unregister unknown {cls!r} client {func!r}." + raise ValueError(emsg) + + def unregister_service(self, func): + """ + Remove the provided function/method as providing a lenient service. + + Args: + + * func (callable or str): + A function/method or fully qualified string name of the function/method. + + """ + func = _qualname(func) + cls = self.__class__.__name__ + + if func in _LENIENT_PROTECTED: + emsg = f"Cannot unregister {cls!r} service, as {func!r} is a protected {cls!r} option." + raise ValueError(emsg) + + if func in self.__dict__: + value = self.__dict__[func] + if not isinstance(value, bool): + emsg = f"Cannot unregister {cls!r} service, as {func!r} is not a valid {cls!r} service." + raise ValueError(emsg) + del self.__dict__[func] + else: + emsg = f"Cannot unregister unknown {cls!r} service {func!r}." + raise ValueError(emsg) + + +#: (Private) Instance that manages all Iris run-time lenient client and service options. +_LENIENT = _Lenient() + +#: (Public) Instance that manages all Iris run-time lenient features. +LENIENT = Lenient() diff --git a/lib/iris/common/metadata.py b/lib/iris/common/metadata.py new file mode 100644 index 0000000000..af097ab4ec --- /dev/null +++ b/lib/iris/common/metadata.py @@ -0,0 +1,1477 @@ +# 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. + +from abc import ABCMeta +from collections import namedtuple +from collections.abc import Iterable, Mapping +from copy import deepcopy +from functools import wraps +import logging +import re + +import numpy as np +import numpy.ma as ma +from xxhash import xxh64_hexdigest + +from .lenient import _LENIENT +from .lenient import _lenient_service as lenient_service +from .lenient import _qualname as qualname + + +__all__ = [ + "SERVICES_COMBINE", + "SERVICES_DIFFERENCE", + "SERVICES_EQUAL", + "SERVICES", + "AncillaryVariableMetadata", + "BaseMetadata", + "CellMeasureMetadata", + "CoordMetadata", + "CubeMetadata", + "DimCoordMetadata", + "metadata_manager_factory", +] + + +# https://www.unidata.ucar.edu/software/netcdf/docs/netcdf_data_set_components.html#object_name +_TOKEN_PARSE = re.compile(r"""^[a-zA-Z0-9][\w\.\+\-@]*$""") + +# Configure the logger. +logger = logging.getLogger(__name__) + + +def _hexdigest(value): + """ + Return a hexidecimal string hash representation of the provided value. + + Calculates a 64-bit non-cryptographic hash of the provided value, + and returns the hexdigest string representation of the calculated hash. + + """ + # Special case: deal with numpy arrays. + if ma.isMaskedArray(value): + parts = ( + value.shape, + xxh64_hexdigest(value.data), + xxh64_hexdigest(value.mask), + ) + value = str(parts) + elif isinstance(value, np.ndarray): + parts = (value.shape, xxh64_hexdigest(value)) + value = str(parts) + + try: + # Calculate single-shot hash to avoid allocating state on the heap + result = xxh64_hexdigest(value) + except TypeError: + # xxhash expects a bytes-like object, so try hashing the + # string representation of the provided value instead, but + # also fold in the object type... + parts = (type(value), value) + result = xxh64_hexdigest(str(parts)) + + return result + + +class _NamedTupleMeta(ABCMeta): + """ + Meta-class to support the convenience of creating a namedtuple from + names/members of the metadata class hierarchy. + + """ + + def __new__(mcs, name, bases, namespace): + names = [] + + for base in bases: + if hasattr(base, "_fields"): + base_names = getattr(base, "_fields") + is_abstract = getattr( + base_names, "__isabstractmethod__", False + ) + if not is_abstract: + if (not isinstance(base_names, Iterable)) or isinstance( + base_names, str + ): + base_names = (base_names,) + names.extend(base_names) + + if "_members" in namespace and not getattr( + namespace["_members"], "__isabstractmethod__", False + ): + namespace_names = namespace["_members"] + + if (not isinstance(namespace_names, Iterable)) or isinstance( + namespace_names, str + ): + namespace_names = (namespace_names,) + + names.extend(namespace_names) + + if names: + item = namedtuple(f"{name}Namedtuple", names) + bases = list(bases) + # Influence the appropriate MRO. + bases.insert(0, item) + bases = tuple(bases) + + return super().__new__(mcs, name, bases, namespace) + + +class BaseMetadata(metaclass=_NamedTupleMeta): + """ + Container for common metadata. + + """ + + DEFAULT_NAME = "unknown" # the fall-back name for metadata identity + + _members = ( + "standard_name", + "long_name", + "var_name", + "units", + "attributes", + ) + + __slots__ = () + + @lenient_service + def __eq__(self, other): + """ + Determine whether the associated metadata members are equivalent. + + Args: + + * other (metadata): + A metadata instance of the same type. + + Returns: + Boolean. + + """ + result = NotImplemented + # Only perform equivalence with similar class instances. + if hasattr(other, "__class__") and other.__class__ is self.__class__: + if _LENIENT(self.__eq__) or _LENIENT(self.equal): + # Perform "lenient" equality. + logger.debug( + "lenient", extra=dict(cls=self.__class__.__name__) + ) + result = self._compare_lenient(other) + else: + # Perform "strict" equality. + logger.debug("strict", extra=dict(cls=self.__class__.__name__)) + + def func(field): + left = getattr(self, field) + right = getattr(other, field) + if self._is_attributes(field, left, right): + result = self._compare_strict_attributes(left, right) + else: + result = left == right + return result + + # Note that, for strict we use "_fields" not "_members". + # The "circular" member does not participate in strict equivalence. + fields = filter( + lambda field: field != "circular", self._fields + ) + result = all([func(field) for field in fields]) + + return result + + def __lt__(self, other): + # + # Support Python2 behaviour for a "<" operation involving a + # "NoneType" operand. + # + if not isinstance(other, self.__class__): + return NotImplemented + + def _sort_key(item): + keys = [] + for field in item._fields: + if field != "attributes": + value = getattr(item, field) + keys.extend((value is not None, value)) + return tuple(keys) + + return _sort_key(self) < _sort_key(other) + + def __ne__(self, other): + result = self.__eq__(other) + if result is not NotImplemented: + result = not result + + return result + + def _api_common( + self, other, func_service, func_operation, action, lenient=None + ): + """ + Common entry-point for lenient metadata API methods. + + Args: + + * other (metadata): + A metadata instance of the same type. + + * func_service (callable): + The parent service method offering the API entry-point to the service. + + * func_operation (callable): + The parent service method that provides the actual service. + + * action (str): + The verb describing the service operation. + + Kwargs: + + * lenient (boolean): + Enable/disable the lenient service operation. The default is to automatically + detect whether this lenient service operation is enabled. + + Returns: + The result of the service operation to the parent service caller. + + """ + # Ensure that we have similar class instances. + if ( + not hasattr(other, "__class__") + or other.__class__ is not self.__class__ + ): + emsg = "Cannot {} {!r} with {!r}." + raise TypeError( + emsg.format(action, self.__class__.__name__, type(other)) + ) + + if lenient is None: + result = func_operation(other) + else: + if lenient: + # Use qualname to disassociate from the instance bounded method. + args, kwargs = (qualname(func_service),), dict() + else: + # Use qualname to guarantee that the instance bounded method + # is a hashable key. + args, kwargs = (), {qualname(func_service): False} + + with _LENIENT.context(*args, **kwargs): + result = func_operation(other) + + return result + + def _combine(self, other): + """Perform associated metadata member combination.""" + if _LENIENT(self.combine): + # Perform "lenient" combine. + logger.debug("lenient", extra=dict(cls=self.__class__.__name__)) + values = self._combine_lenient(other) + else: + # Perform "strict" combine. + logger.debug("strict", extra=dict(cls=self.__class__.__name__)) + + def func(field): + left = getattr(self, field) + right = getattr(other, field) + if self._is_attributes(field, left, right): + result = self._combine_strict_attributes(left, right) + else: + result = left if left == right else None + return result + + # Note that, for strict we use "_fields" not "_members". + values = [func(field) for field in self._fields] + + return values + + def _combine_lenient(self, other): + """ + Perform lenient combination of metadata members. + + Args: + + * other (BaseMetadata): + The other metadata participating in the lenient combination. + + Returns: + A list of combined metadata member values. + + """ + + def func(field): + left = getattr(self, field) + right = getattr(other, field) + result = None + if field == "units": + # Perform "strict" combination for "units". + result = left if left == right else None + elif self._is_attributes(field, left, right): + result = self._combine_lenient_attributes(left, right) + else: + if left == right: + result = left + elif left is None: + result = right + elif right is None: + result = left + return result + + # Note that, we use "_members" not "_fields". + return [func(field) for field in BaseMetadata._members] + + @staticmethod + def _combine_lenient_attributes(left, right): + """Leniently combine the dictionary members together.""" + # Copy the dictionaries. + left = deepcopy(left) + right = deepcopy(right) + # Use xxhash to perform an extremely fast non-cryptographic hash of + # each dictionary key rvalue, thus ensuring that the dictionary is + # completely hashable, as required by a set. + sleft = {(k, _hexdigest(v)) for k, v in left.items()} + sright = {(k, _hexdigest(v)) for k, v in right.items()} + # Intersection of common items. + common = sleft & sright + # Items in sleft different from sright. + dsleft = dict(sleft - sright) + # Items in sright different from sleft. + dsright = dict(sright - sleft) + # Intersection of common item keys with different values. + keys = set(dsleft.keys()) & set(dsright.keys()) + # Remove (in-place) common item keys with different values. + [dsleft.pop(key) for key in keys] + [dsright.pop(key) for key in keys] + # Now bring the result together. + result = {k: left[k] for k, _ in common} + result.update({k: left[k] for k in dsleft.keys()}) + result.update({k: right[k] for k in dsright.keys()}) + + return result + + @staticmethod + def _combine_strict_attributes(left, right): + """Perform strict combination of the dictionary members.""" + # Copy the dictionaries. + left = deepcopy(left) + right = deepcopy(right) + # Use xxhash to perform an extremely fast non-cryptographic hash of + # each dictionary key rvalue, thus ensuring that the dictionary is + # completely hashable, as required by a set. + sleft = {(k, _hexdigest(v)) for k, v in left.items()} + sright = {(k, _hexdigest(v)) for k, v in right.items()} + # Intersection of common items. + common = sleft & sright + # Now bring the result together. + result = {k: left[k] for k, _ in common} + + return result + + def _compare_lenient(self, other): + """ + Perform lenient equality of metadata members. + + Args: + + * other (BaseMetadata): + The other metadata participating in the lenient comparison. + + Returns: + Boolean. + + """ + result = False + + # Use the "name" method to leniently compare "standard_name", + # "long_name", and "var_name" in a well defined way. + if self.name() == other.name(): + + def func(field): + left = getattr(self, field) + right = getattr(other, field) + if field == "units": + # Perform "strict" compare for "units". + result = left == right + elif self._is_attributes(field, left, right): + result = self._compare_lenient_attributes(left, right) + else: + # Perform "lenient" compare for members. + result = (left == right) or left is None or right is None + return result + + # Note that, we use "_members" not "_fields". + # Lenient equality explicitly ignores the "var_name" member. + result = all( + [ + func(field) + for field in BaseMetadata._members + if field != "var_name" + ] + ) + + return result + + @staticmethod + def _compare_lenient_attributes(left, right): + """Perform lenient compare between the dictionary members.""" + # Use xxhash to perform an extremely fast non-cryptographic hash of + # each dictionary key rvalue, thus ensuring that the dictionary is + # completely hashable, as required by a set. + sleft = {(k, _hexdigest(v)) for k, v in left.items()} + sright = {(k, _hexdigest(v)) for k, v in right.items()} + # Items in sleft different from sright. + dsleft = dict(sleft - sright) + # Items in sright different from sleft. + dsright = dict(sright - sleft) + # Intersection of common item keys with different values. + keys = set(dsleft.keys()) & set(dsright.keys()) + + return not bool(keys) + + @staticmethod + def _compare_strict_attributes(left, right): + """Perform strict compare between the dictionary members.""" + # Use xxhash to perform an extremely fast non-cryptographic hash of + # each dictionary key rvalue, thus ensuring that the dictionary is + # completely hashable, as required by a set. + sleft = {(k, _hexdigest(v)) for k, v in left.items()} + sright = {(k, _hexdigest(v)) for k, v in right.items()} + + return sleft == sright + + def _difference(self, other): + """Perform associated metadata member difference.""" + if _LENIENT(self.difference): + # Perform "lenient" difference. + logger.debug("lenient", extra=dict(cls=self.__class__.__name__)) + values = self._difference_lenient(other) + else: + # Perform "strict" difference. + logger.debug("strict", extra=dict(cls=self.__class__.__name__)) + + def func(field): + left = getattr(self, field) + right = getattr(other, field) + if self._is_attributes(field, left, right): + result = self._difference_strict_attributes(left, right) + else: + result = None if left == right else (left, right) + return result + + # Note that, for strict we use "_fields" not "_members". + values = [func(field) for field in self._fields] + + return values + + def _difference_lenient(self, other): + """ + Perform lenient difference of metadata members. + + Args: + + * other (BaseMetadata): + The other metadata participating in the lenient difference. + + Returns: + A list of difference metadata member values. + + """ + + def func(field): + left = getattr(self, field) + right = getattr(other, field) + if field == "units": + # Perform "strict" difference for "units". + result = None if left == right else (left, right) + elif self._is_attributes(field, left, right): + result = self._difference_lenient_attributes(left, right) + else: + # Perform "lenient" difference for members. + result = ( + (left, right) + if left is not None and right is not None and left != right + else None + ) + return result + + # Note that, we use "_members" not "_fields". + return [func(field) for field in BaseMetadata._members] + + @staticmethod + def _difference_lenient_attributes(left, right): + """Perform lenient difference between the dictionary members.""" + # Use xxhash to perform an extremely fast non-cryptographic hash of + # each dictionary key rvalue, thus ensuring that the dictionary is + # completely hashable, as required by a set. + sleft = {(k, _hexdigest(v)) for k, v in left.items()} + sright = {(k, _hexdigest(v)) for k, v in right.items()} + # Items in sleft different from sright. + dsleft = dict(sleft - sright) + # Items in sright different from sleft. + dsright = dict(sright - sleft) + # Intersection of common item keys with different values. + keys = set(dsleft.keys()) & set(dsright.keys()) + # Keep (in-place) common item keys with different values. + [dsleft.pop(key) for key in list(dsleft.keys()) if key not in keys] + [dsright.pop(key) for key in list(dsright.keys()) if key not in keys] + + if not bool(dsleft) and not bool(dsright): + result = None + else: + # Replace hash-rvalue with original rvalue. + dsleft = {k: left[k] for k in dsleft.keys()} + dsright = {k: right[k] for k in dsright.keys()} + result = (dsleft, dsright) + + return result + + @staticmethod + def _difference_strict_attributes(left, right): + """Perform strict difference between the dictionary members.""" + # Use xxhash to perform an extremely fast non-cryptographic hash of + # each dictionary key rvalue, thus ensuring that the dictionary is + # completely hashable, as required by a set. + sleft = {(k, _hexdigest(v)) for k, v in left.items()} + sright = {(k, _hexdigest(v)) for k, v in right.items()} + # Items in sleft different from sright. + dsleft = dict(sleft - sright) + # Items in sright different from sleft. + dsright = dict(sright - sleft) + + if not bool(dsleft) and not bool(dsright): + result = None + else: + # Replace hash-rvalue with original rvalue. + dsleft = {k: left[k] for k in dsleft.keys()} + dsright = {k: right[k] for k in dsright.keys()} + result = (dsleft, dsright) + + return result + + @staticmethod + def _is_attributes(field, left, right): + """Determine whether we have two 'attributes' dictionaries.""" + return ( + field == "attributes" + and isinstance(left, Mapping) + and isinstance(right, Mapping) + ) + + @lenient_service + def combine(self, other, lenient=None): + """ + Return a new metadata instance created by combining each of the + associated metadata members. + + Args: + + * other (metadata): + A metadata instance of the same type. + + Kwargs: + + * lenient (boolean): + Enable/disable lenient combination. The default is to automatically + detect whether this lenient operation is enabled. + + Returns: + Metadata instance. + + """ + result = self._api_common( + other, self.combine, self._combine, "combine", lenient=lenient + ) + return self.__class__(*result) + + @lenient_service + def difference(self, other, lenient=None): + """ + Return a new metadata instance created by performing a difference + comparison between each of the associated metadata members. + + A metadata member returned with a value of "None" indicates that there + is no difference between the members being compared. Otherwise, a tuple + of the different values is returned. + + Args: + + * other (metadata): + A metadata instance of the same type. + + Kwargs: + + * lenient (boolean): + Enable/disable lenient difference. The default is to automatically + detect whether this lenient operation is enabled. + + Returns: + Metadata instance of member differences or None. + + """ + result = self._api_common( + other, self.difference, self._difference, "differ", lenient=lenient + ) + result = ( + None + if all([item is None for item in result]) + else self.__class__(*result) + ) + return result + + @lenient_service + def equal(self, other, lenient=None): + """ + Determine whether the associated metadata members are equivalent. + + Args: + + * other (metadata): + A metadata instance of the same type. + + Kwargs: + + * lenient (boolean): + Enable/disable lenient equivalence. The default is to automatically + detect whether this lenient operation is enabled. + + Returns: + Boolean. + + """ + result = self._api_common( + other, self.equal, self.__eq__, "compare", lenient=lenient + ) + return result + + @classmethod + def from_metadata(cls, other): + result = None + if isinstance(other, BaseMetadata): + if other.__class__ is cls: + result = other + else: + kwargs = {field: None for field in cls._fields} + fields = set(cls._fields) & set(other._fields) + for field in fields: + kwargs[field] = getattr(other, field) + result = cls(**kwargs) + return result + + def name(self, default=None, token=False): + """ + Returns a string name representing the identity of the metadata. + + First it tries standard name, then it tries the long name, then + the NetCDF variable name, before falling-back to a default value, + which itself defaults to the string 'unknown'. + + Kwargs: + + * default: + The fall-back string representing the default name. Defaults to + the string 'unknown'. + * token: + If True, ensures that the name returned satisfies the criteria for + the characters required by a valid NetCDF name. If it is not + possible to return a valid name, then a ValueError exception is + raised. Defaults to False. + + Returns: + String. + + """ + + def _check(item): + return self.token(item) if token else item + + default = self.DEFAULT_NAME if default is None else default + + result = ( + _check(self.standard_name) + or _check(self.long_name) + or _check(self.var_name) + or _check(default) + ) + + if token and result is None: + emsg = "Cannot retrieve a valid name token from {!r}" + raise ValueError(emsg.format(self)) + + return result + + @classmethod + def token(cls, name): + """ + Determine whether the provided name is a valid NetCDF name and thus + safe to represent a single parsable token. + + Args: + + * name: + The string name to verify + + Returns: + The provided name if valid, otherwise None. + + """ + if name is not None: + result = _TOKEN_PARSE.match(name) + name = result if result is None else name + + return name + + +class AncillaryVariableMetadata(BaseMetadata): + """ + Metadata container for a :class:`~iris.coords.AncillaryVariableMetadata`. + + """ + + __slots__ = () + + @wraps(BaseMetadata.__eq__, assigned=("__doc__",), updated=()) + @lenient_service + def __eq__(self, other): + return super().__eq__(other) + + @wraps(BaseMetadata.combine, assigned=("__doc__",), updated=()) + @lenient_service + def combine(self, other, lenient=None): + return super().combine(other, lenient=lenient) + + @wraps(BaseMetadata.difference, assigned=("__doc__",), updated=()) + @lenient_service + def difference(self, other, lenient=None): + return super().difference(other, lenient=lenient) + + @wraps(BaseMetadata.equal, assigned=("__doc__",), updated=()) + @lenient_service + def equal(self, other, lenient=None): + return super().equal(other, lenient=lenient) + + +class CellMeasureMetadata(BaseMetadata): + """ + Metadata container for a :class:`~iris.coords.CellMeasure`. + + """ + + _members = "measure" + + __slots__ = () + + @wraps(BaseMetadata.__eq__, assigned=("__doc__",), updated=()) + @lenient_service + def __eq__(self, other): + return super().__eq__(other) + + def _combine_lenient(self, other): + """ + Perform lenient combination of metadata members for cell measures. + + Args: + + * other (CellMeasureMetadata): + The other cell measure metadata participating in the lenient + combination. + + Returns: + A list of combined metadata member values. + + """ + # Perform "strict" combination for "measure". + value = self.measure if self.measure == other.measure else None + # Perform lenient combination of the other parent members. + result = super()._combine_lenient(other) + result.append(value) + + return result + + def _compare_lenient(self, other): + """ + Perform lenient equality of metadata members for cell measures. + + Args: + + * other (CellMeasureMetadata): + The other cell measure metadata participating in the lenient + comparison. + + Returns: + Boolean. + + """ + # Perform "strict" comparison for "measure". + result = self.measure == other.measure + if result: + # Perform lenient comparison of the other parent members. + result = super()._compare_lenient(other) + + return result + + def _difference_lenient(self, other): + """ + Perform lenient difference of metadata members for cell measures. + + Args: + + * other (CellMeasureMetadata): + The other cell measure metadata participating in the lenient + difference. + + Returns: + A list of difference metadata member values. + + """ + # Perform "strict" difference for "measure". + value = ( + None + if self.measure == other.measure + else (self.measure, other.measure) + ) + # Perform lenient difference of the other parent members. + result = super()._difference_lenient(other) + result.append(value) + + return result + + @wraps(BaseMetadata.combine, assigned=("__doc__",), updated=()) + @lenient_service + def combine(self, other, lenient=None): + return super().combine(other, lenient=lenient) + + @wraps(BaseMetadata.difference, assigned=("__doc__",), updated=()) + @lenient_service + def difference(self, other, lenient=None): + return super().difference(other, lenient=lenient) + + @wraps(BaseMetadata.equal, assigned=("__doc__",), updated=()) + @lenient_service + def equal(self, other, lenient=None): + return super().equal(other, lenient=lenient) + + +class CoordMetadata(BaseMetadata): + """ + Metadata container for a :class:`~iris.coords.Coord`. + + """ + + _members = ("coord_system", "climatological") + + __slots__ = () + + @wraps(BaseMetadata.__eq__, assigned=("__doc__",), updated=()) + @lenient_service + def __eq__(self, other): + # Convert a DimCoordMetadata instance to a CoordMetadata instance. + if ( + self.__class__ is CoordMetadata + and hasattr(other, "__class__") + and other.__class__ is DimCoordMetadata + ): + other = self.from_metadata(other) + return super().__eq__(other) + + def __lt__(self, other): + # + # Support Python2 behaviour for a "<" operation involving a + # "NoneType" operand. + # + if not isinstance(other, BaseMetadata): + return NotImplemented + + if other.__class__ is DimCoordMetadata: + other = self.from_metadata(other) + + if not isinstance(other, self.__class__): + return NotImplemented + + def _sort_key(item): + keys = [] + for field in item._fields: + if field not in ("attributes", "coord_system"): + value = getattr(item, field) + keys.extend((value is not None, value)) + return tuple(keys) + + return _sort_key(self) < _sort_key(other) + + def _combine_lenient(self, other): + """ + Perform lenient combination of metadata members for coordinates. + + Args: + + * other (CoordMetadata): + The other coordinate metadata participating in the lenient + combination. + + Returns: + A list of combined metadata member values. + + """ + # Perform "strict" combination for "coord_system" and "climatological". + def func(field): + left = getattr(self, field) + right = getattr(other, field) + return left if left == right else None + + # Note that, we use "_members" not "_fields". + values = [func(field) for field in CoordMetadata._members] + # Perform lenient combination of the other parent members. + result = super()._combine_lenient(other) + result.extend(values) + + return result + + def _compare_lenient(self, other): + """ + Perform lenient equality of metadata members for coordinates. + + Args: + + * other (CoordMetadata): + The other coordinate metadata participating in the lenient + comparison. + + Returns: + Boolean. + + """ + # Perform "strict" comparison for "coord_system" and "climatological". + result = all( + [ + getattr(self, field) == getattr(other, field) + for field in CoordMetadata._members + ] + ) + if result: + # Perform lenient comparison of the other parent members. + result = super()._compare_lenient(other) + + return result + + def _difference_lenient(self, other): + """ + Perform lenient difference of metadata members for coordinates. + + Args: + + * other (CoordMetadata): + The other coordinate metadata participating in the lenient + difference. + + Returns: + A list of difference metadata member values. + + """ + # Perform "strict" difference for "coord_system" and "climatological". + def func(field): + left = getattr(self, field) + right = getattr(other, field) + return None if left == right else (left, right) + + # Note that, we use "_members" not "_fields". + values = [func(field) for field in CoordMetadata._members] + # Perform lenient difference of the other parent members. + result = super()._difference_lenient(other) + result.extend(values) + + return result + + @wraps(BaseMetadata.combine, assigned=("__doc__",), updated=()) + @lenient_service + def combine(self, other, lenient=None): + # Convert a DimCoordMetadata instance to a CoordMetadata instance. + if ( + self.__class__ is CoordMetadata + and hasattr(other, "__class__") + and other.__class__ is DimCoordMetadata + ): + other = self.from_metadata(other) + return super().combine(other, lenient=lenient) + + @wraps(BaseMetadata.difference, assigned=("__doc__",), updated=()) + @lenient_service + def difference(self, other, lenient=None): + # Convert a DimCoordMetadata instance to a CoordMetadata instance. + if ( + self.__class__ is CoordMetadata + and hasattr(other, "__class__") + and other.__class__ is DimCoordMetadata + ): + other = self.from_metadata(other) + return super().difference(other, lenient=lenient) + + @wraps(BaseMetadata.equal, assigned=("__doc__",), updated=()) + @lenient_service + def equal(self, other, lenient=None): + # Convert a DimCoordMetadata instance to a CoordMetadata instance. + if ( + self.__class__ is CoordMetadata + and hasattr(other, "__class__") + and other.__class__ is DimCoordMetadata + ): + other = self.from_metadata(other) + return super().equal(other, lenient=lenient) + + +class CubeMetadata(BaseMetadata): + """ + Metadata container for a :class:`~iris.cube.Cube`. + + """ + + _members = "cell_methods" + + __slots__ = () + + @wraps(BaseMetadata.__eq__, assigned=("__doc__",), updated=()) + @lenient_service + def __eq__(self, other): + return super().__eq__(other) + + def __lt__(self, other): + # + # Support Python2 behaviour for a "<" operation involving a + # "NoneType" operand. + # + if not isinstance(other, self.__class__): + return NotImplemented + + def _sort_key(item): + keys = [] + for field in item._fields: + if field not in ("attributes", "cell_methods"): + value = getattr(item, field) + keys.extend((value is not None, value)) + return tuple(keys) + + return _sort_key(self) < _sort_key(other) + + def _combine_lenient(self, other): + """ + Perform lenient combination of metadata members for cubes. + + Args: + + * other (CubeMetadata): + The other cube metadata participating in the lenient combination. + + Returns: + A list of combined metadata member values. + + """ + # Perform "strict" combination for "cell_methods". + value = ( + self.cell_methods + if self.cell_methods == other.cell_methods + else None + ) + # Perform lenient combination of the other parent members. + result = super()._combine_lenient(other) + result.append(value) + + return result + + def _compare_lenient(self, other): + """ + Perform lenient equality of metadata members for cubes. + + Args: + + * other (CubeMetadata): + The other cube metadata participating in the lenient comparison. + + Returns: + Boolean. + + """ + # Perform "strict" comparison for "cell_methods". + result = self.cell_methods == other.cell_methods + if result: + result = super()._compare_lenient(other) + + return result + + def _difference_lenient(self, other): + """ + Perform lenient difference of metadata members for cubes. + + Args: + + * other (CubeMetadata): + The other cube metadata participating in the lenient difference. + + Returns: + A list of difference metadata member values. + + """ + # Perform "strict" difference for "cell_methods". + value = ( + None + if self.cell_methods == other.cell_methods + else (self.cell_methods, other.cell_methods) + ) + # Perform lenient difference of the other parent members. + result = super()._difference_lenient(other) + result.append(value) + + return result + + @property + def _names(self): + """ + A tuple containing the value of each name participating in the identity + of a :class:`iris.cube.Cube`. This includes the standard name, + long name, NetCDF variable name, and the STASH from the attributes + dictionary. + + """ + standard_name = self.standard_name + long_name = self.long_name + var_name = self.var_name + + # Defensive enforcement of attributes being a dictionary. + if not isinstance(self.attributes, Mapping): + try: + self.attributes = dict() + except AttributeError: + emsg = "Invalid '{}.attributes' member, must be a mapping." + raise AttributeError(emsg.format(self.__class__.__name__)) + + stash_name = self.attributes.get("STASH") + if stash_name is not None: + stash_name = str(stash_name) + + return standard_name, long_name, var_name, stash_name + + @wraps(BaseMetadata.combine, assigned=("__doc__",), updated=()) + @lenient_service + def combine(self, other, lenient=None): + return super().combine(other, lenient=lenient) + + @wraps(BaseMetadata.difference, assigned=("__doc__",), updated=()) + @lenient_service + def difference(self, other, lenient=None): + return super().difference(other, lenient=lenient) + + @wraps(BaseMetadata.equal, assigned=("__doc__",), updated=()) + @lenient_service + def equal(self, other, lenient=None): + return super().equal(other, lenient=lenient) + + @wraps(BaseMetadata.name) + def name(self, default=None, token=False): + def _check(item): + return self.token(item) if token else item + + default = self.DEFAULT_NAME if default is None else default + + # Defensive enforcement of attributes being a dictionary. + if not isinstance(self.attributes, Mapping): + try: + self.attributes = dict() + except AttributeError: + emsg = "Invalid '{}.attributes' member, must be a mapping." + raise AttributeError(emsg.format(self.__class__.__name__)) + + result = ( + _check(self.standard_name) + or _check(self.long_name) + or _check(self.var_name) + or _check(str(self.attributes.get("STASH", ""))) + or _check(default) + ) + + if token and result is None: + emsg = "Cannot retrieve a valid name token from {!r}" + raise ValueError(emsg.format(self)) + + return result + + +class DimCoordMetadata(CoordMetadata): + """ + Metadata container for a :class:`~iris.coords.DimCoord" + + """ + + # The "circular" member is stateful only, and does not participate + # in lenient/strict equivalence. + _members = ("circular",) + + __slots__ = () + + @wraps(CoordMetadata.__eq__, assigned=("__doc__",), updated=()) + @lenient_service + def __eq__(self, other): + # Convert a CoordMetadata instance to a DimCoordMetadata instance. + if hasattr(other, "__class__") and other.__class__ is CoordMetadata: + other = self.from_metadata(other) + return super().__eq__(other) + + def __lt__(self, other): + # + # Support Python2 behaviour for a "<" operation involving a + # "NoneType" operand. + # + if not isinstance(other, BaseMetadata): + return NotImplemented + + if other.__class__ is CoordMetadata: + other = self.from_metadata(other) + + if not isinstance(other, self.__class__): + return NotImplemented + + def _sort_key(item): + keys = [] + for field in item._fields: + if field not in ("attributes", "coord_system"): + value = getattr(item, field) + keys.extend((value is not None, value)) + return tuple(keys) + + return _sort_key(self) < _sort_key(other) + + @wraps(CoordMetadata._combine_lenient, assigned=("__doc__",), updated=()) + def _combine_lenient(self, other): + # Perform "strict" combination for "circular". + value = self.circular if self.circular == other.circular else None + # Perform lenient combination of the other parent members. + result = super()._combine_lenient(other) + result.append(value) + + return result + + @wraps(CoordMetadata._compare_lenient, assigned=("__doc__",), updated=()) + def _compare_lenient(self, other): + # The "circular" member is not part of lenient equivalence. + return super()._compare_lenient(other) + + @wraps( + CoordMetadata._difference_lenient, assigned=("__doc__",), updated=() + ) + def _difference_lenient(self, other): + # Perform "strict" difference for "circular". + value = ( + None + if self.circular == other.circular + else (self.circular, other.circular) + ) + # Perform lenient difference of the other parent members. + result = super()._difference_lenient(other) + result.append(value) + + return result + + @wraps(CoordMetadata.combine, assigned=("__doc__",), updated=()) + @lenient_service + def combine(self, other, lenient=None): + # Convert a CoordMetadata instance to a DimCoordMetadata instance. + if hasattr(other, "__class__") and other.__class__ is CoordMetadata: + other = self.from_metadata(other) + return super().combine(other, lenient=lenient) + + @wraps(CoordMetadata.difference, assigned=("__doc__",), updated=()) + @lenient_service + def difference(self, other, lenient=None): + # Convert a CoordMetadata instance to a DimCoordMetadata instance. + if hasattr(other, "__class__") and other.__class__ is CoordMetadata: + other = self.from_metadata(other) + return super().difference(other, lenient=lenient) + + @wraps(CoordMetadata.equal, assigned=("__doc__",), updated=()) + @lenient_service + def equal(self, other, lenient=None): + # Convert a CoordMetadata instance to a DimCoordMetadata instance. + if hasattr(other, "__class__") and other.__class__ is CoordMetadata: + other = self.from_metadata(other) + return super().equal(other, lenient=lenient) + + +def metadata_manager_factory(cls, **kwargs): + """ + A class instance factory function responsible for manufacturing + metadata instances dynamically at runtime. + + The factory instances returned by the factory are capable of managing + their metadata state, which can be proxied by the owning container. + + Args: + + * cls: + A subclass of :class:`~iris.common.metadata.BaseMetadata`, defining + the metadata to be managed. + + Kwargs: + + * kwargs: + Initial values for the manufactured metadata instance. Unspecified + fields will default to a value of 'None'. + + """ + + def __init__(self, cls, **kwargs): + # Restrict to only dealing with appropriate metadata classes. + if not issubclass(cls, BaseMetadata): + emsg = "Require a subclass of {!r}, got {!r}." + raise TypeError(emsg.format(BaseMetadata.__name__, cls)) + + #: The metadata class to be manufactured by this factory. + self.cls = cls + + # Initialise the metadata class fields in the instance. + for field in self.fields: + setattr(self, field, None) + + # Populate with provided kwargs, which have already been verified + # by the factory. + for field, value in kwargs.items(): + setattr(self, field, value) + + def __eq__(self, other): + if not hasattr(other, "cls"): + return NotImplemented + match = self.cls is other.cls + if match: + match = self.values == other.values + + return match + + def __getstate__(self): + """Return the instance state to be pickled.""" + return {field: getattr(self, field) for field in self.fields} + + def __ne__(self, other): + match = self.__eq__(other) + if match is not NotImplemented: + match = not match + + return match + + def __reduce__(self): + """ + Dynamically created classes at runtime cannot be pickled, due to not + being defined at the top level of a module. As a result, we require to + use the __reduce__ interface to allow 'pickle' to recreate this class + instance, and dump and load instance state successfully. + + """ + return metadata_manager_factory, (self.cls,), self.__getstate__() + + def __repr__(self): + args = ", ".join( + [ + "{}={!r}".format(field, getattr(self, field)) + for field in self.fields + ] + ) + return "{}({})".format(self.__class__.__name__, args) + + def __setstate__(self, state): + """Set the instance state when unpickling.""" + for field, value in state.items(): + setattr(self, field, value) + + @property + def fields(self): + """Return the name of the metadata members.""" + # Proxy for built-in namedtuple._fields property. + return self.cls._fields + + @property + def values(self): + fields = {field: getattr(self, field) for field in self.fields} + return self.cls(**fields) + + # Restrict factory to appropriate metadata classes only. + if not issubclass(cls, BaseMetadata): + emsg = "Require a subclass of {!r}, got {!r}." + raise TypeError(emsg.format(BaseMetadata.__name__, cls)) + + # Check whether kwargs have valid fields for the specified metadata. + if kwargs: + extra = [field for field in kwargs.keys() if field not in cls._fields] + if extra: + bad = ", ".join(map(lambda field: "{!r}".format(field), extra)) + emsg = "Invalid {!r} field parameters, got {}." + raise ValueError(emsg.format(cls.__name__, bad)) + + # Define the name, (inheritance) bases and namespace of the dynamic class. + name = "MetadataManager" + bases = () + namespace = { + "DEFAULT_NAME": cls.DEFAULT_NAME, + "__init__": __init__, + "__eq__": __eq__, + "__getstate__": __getstate__, + "__ne__": __ne__, + "__reduce__": __reduce__, + "__repr__": __repr__, + "__setstate__": __setstate__, + "fields": fields, + "name": cls.name, + "token": cls.token, + "values": values, + } + + # Account for additional "CubeMetadata" specialised class behaviour. + if cls is CubeMetadata: + namespace["_names"] = cls._names + + # Dynamically create the class. + Metadata = type(name, bases, namespace) + # Now manufacture an instance of that class. + metadata = Metadata(cls, **kwargs) + + return metadata + + +#: Convenience collection of lenient metadata combine services. +SERVICES_COMBINE = ( + AncillaryVariableMetadata.combine, + BaseMetadata.combine, + CellMeasureMetadata.combine, + CoordMetadata.combine, + CubeMetadata.combine, + DimCoordMetadata.combine, +) + + +#: Convenience collection of lenient metadata difference services. +SERVICES_DIFFERENCE = ( + AncillaryVariableMetadata.difference, + BaseMetadata.difference, + CellMeasureMetadata.difference, + CoordMetadata.difference, + CubeMetadata.difference, + DimCoordMetadata.difference, +) + + +#: Convenience collection of lenient metadata equality services. +SERVICES_EQUAL = ( + AncillaryVariableMetadata.__eq__, + AncillaryVariableMetadata.equal, + BaseMetadata.__eq__, + BaseMetadata.equal, + CellMeasureMetadata.__eq__, + CellMeasureMetadata.equal, + CoordMetadata.__eq__, + CoordMetadata.equal, + CubeMetadata.__eq__, + CubeMetadata.equal, + DimCoordMetadata.__eq__, + DimCoordMetadata.equal, +) + + +#: Convenience collection of lenient metadata services. +SERVICES = SERVICES_COMBINE + SERVICES_DIFFERENCE + SERVICES_EQUAL diff --git a/lib/iris/_cube_coord_common.py b/lib/iris/common/mixin.py similarity index 51% rename from lib/iris/_cube_coord_common.py rename to lib/iris/common/mixin.py index 541780ca15..50ef561036 100644 --- a/lib/iris/_cube_coord_common.py +++ b/lib/iris/common/mixin.py @@ -5,43 +5,20 @@ # licensing details. -from collections import namedtuple +from collections.abc import Mapping +from functools import wraps import re import cf_units +from iris.common import BaseMetadata import iris.std_names -# https://www.unidata.ucar.edu/software/netcdf/docs/netcdf_data_set_components.html#object_name -_TOKEN_PARSE = re.compile(r"""^[a-zA-Z0-9][\w\.\+\-@]*$""") +__all__ = ["CFVariableMixin"] -class Names( - namedtuple("Names", ["standard_name", "long_name", "var_name", "STASH"]) -): - """ - Immutable container for name metadata. - - Args: - - * standard_name: - A string representing the CF Conventions and Metadata standard name, or - None. - * long_name: - A string representing the CF Conventions and Metadata long name, or - None - * var_name: - A string representing the associated NetCDF variable name, or None. - * STASH: - A string representing the `~iris.fileformats.pp.STASH` code, or None. - - """ - - __slots__ = () - - -def get_valid_standard_name(name): +def _get_valid_standard_name(name): # Standard names are optionally followed by a standard name # modifier, separated by one or more blank spaces @@ -100,7 +77,7 @@ def __init__(self, *args, **kwargs): # Check validity of keys for key in self.keys(): if key in self._forbidden_keys: - raise ValueError("%r is not a permitted attribute" % key) + raise ValueError(f"{key!r} is not a permitted attribute") def __eq__(self, other): # Extend equality to allow for NumPy arrays. @@ -121,7 +98,7 @@ def __ne__(self, other): def __setitem__(self, key, value): if key in self._forbidden_keys: - raise ValueError("%r is not a permitted attribute" % key) + raise ValueError(f"{key!r} is not a permitted attribute") dict.__setitem__(self, key, value) def update(self, other, **kwargs): @@ -137,92 +114,15 @@ def update(self, other, **kwargs): # Check validity of keys for key in keys: if key in self._forbidden_keys: - raise ValueError("%r is not a permitted attribute" % key) + raise ValueError(f"{key!r} is not a permitted attribute") dict.update(self, other, **kwargs) class CFVariableMixin: - - _DEFAULT_NAME = "unknown" # the name default string - - @staticmethod - def token(name): - """ - Determine whether the provided name is a valid NetCDF name and thus - safe to represent a single parsable token. - - Args: - - * name: - The string name to verify - - Returns: - The provided name if valid, otherwise None. - - """ - if name is not None: - result = _TOKEN_PARSE.match(name) - name = result if result is None else name - return name - - def name(self, default=None, token=False): - """ - Returns a human-readable name. - - First it tries :attr:`standard_name`, then 'long_name', then - 'var_name', then the STASH attribute before falling back to - the value of `default` (which itself defaults to 'unknown'). - - Kwargs: - - * default: - The value of the default name. - * token: - If true, ensure that the name returned satisfies the criteria for - the characters required by a valid NetCDF name. If it is not - possible to return a valid name, then a ValueError exception is - raised. - - Returns: - String. - - """ - - def _check(item): - return self.token(item) if token else item - - default = self._DEFAULT_NAME if default is None else default - - result = ( - _check(self.standard_name) - or _check(self.long_name) - or _check(self.var_name) - or _check(str(self.attributes.get("STASH", ""))) - or _check(default) - ) - - if token and result is None: - emsg = "Cannot retrieve a valid name token from {!r}" - raise ValueError(emsg.format(self)) - - return result - - @property - def names(self): - """ - A tuple containing all of the metadata names. This includes the - standard name, long name, NetCDF variable name, and attributes - STASH name. - - """ - standard_name = self.standard_name - long_name = self.long_name - var_name = self.var_name - stash_name = self.attributes.get("STASH") - if stash_name is not None: - stash_name = str(stash_name) - return Names(standard_name, long_name, var_name, stash_name) + @wraps(BaseMetadata.name) + def name(self, default=None, token=None): + return self._metadata_manager.name(default=default, token=token) def rename(self, name): """ @@ -245,40 +145,99 @@ def rename(self, name): @property def standard_name(self): - """The standard name for the Cube's data.""" - return self._standard_name + """The CF Metadata standard name for the object.""" + return self._metadata_manager.standard_name @standard_name.setter def standard_name(self, name): - self._standard_name = get_valid_standard_name(name) + self._metadata_manager.standard_name = _get_valid_standard_name(name) @property - def units(self): - """The :mod:`~cf_units.Unit` instance of the object.""" - return self._units + def long_name(self): + """The CF Metadata long name for the object.""" + return self._metadata_manager.long_name - @units.setter - def units(self, unit): - self._units = cf_units.as_unit(unit) + @long_name.setter + def long_name(self, name): + self._metadata_manager.long_name = name @property def var_name(self): - """The netCDF variable name for the object.""" - return self._var_name + """The NetCDF variable name for the object.""" + return self._metadata_manager.var_name @var_name.setter def var_name(self, name): if name is not None: - result = self.token(name) + result = self._metadata_manager.token(name) if result is None or not name: emsg = "{!r} is not a valid NetCDF variable name." raise ValueError(emsg.format(name)) - self._var_name = name + self._metadata_manager.var_name = name + + @property + def units(self): + """The S.I. unit of the object.""" + return self._metadata_manager.units + + @units.setter + def units(self, unit): + self._metadata_manager.units = cf_units.as_unit(unit) @property def attributes(self): - return self._attributes + return self._metadata_manager.attributes @attributes.setter def attributes(self, attributes): - self._attributes = LimitedAttributeDict(attributes or {}) + self._metadata_manager.attributes = LimitedAttributeDict( + attributes or {} + ) + + @property + def metadata(self): + return self._metadata_manager.values + + @metadata.setter + def metadata(self, metadata): + cls = self._metadata_manager.cls + fields = self._metadata_manager.fields + arg = metadata + + try: + # Try dict-like initialisation... + metadata = cls(**metadata) + except TypeError: + try: + # Try iterator/namedtuple-like initialisation... + metadata = cls(*metadata) + except TypeError: + if hasattr(metadata, "_asdict"): + metadata = metadata._asdict() + + if isinstance(metadata, Mapping): + fields = [field for field in fields if field in metadata] + else: + # Generic iterable/container with no associated keys. + missing = [ + field + for field in fields + if not hasattr(metadata, field) + ] + + if missing: + missing = ", ".join( + map(lambda i: "{!r}".format(i), missing) + ) + emsg = "Invalid {!r} metadata, require {} to be specified." + raise TypeError(emsg.format(type(arg), missing)) + + for field in fields: + if hasattr(metadata, field): + value = getattr(metadata, field) + else: + value = metadata[field] + + # Ensure to always set state through the individual mixin/container + # setter functions. + setattr(self, field, value) diff --git a/lib/iris/common/resolve.py b/lib/iris/common/resolve.py new file mode 100644 index 0000000000..7098eaa65e --- /dev/null +++ b/lib/iris/common/resolve.py @@ -0,0 +1,1542 @@ +# 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. + +from collections import namedtuple +from collections.abc import Iterable +import logging + +from dask.array.core import broadcast_shapes +import numpy as np + +from iris.common import LENIENT + + +__all__ = ["Resolve"] + + +# Configure the logger. +logger = logging.getLogger(__name__) + + +_AuxCoverage = namedtuple( + "AuxCoverage", + [ + "cube", + "common_items_aux", + "common_items_scalar", + "local_items_aux", + "local_items_scalar", + "dims_common", + "dims_local", + "dims_free", + ], +) + +_CategoryItems = namedtuple( + "CategoryItems", ["items_dim", "items_aux", "items_scalar"], +) + +_DimCoverage = namedtuple( + "DimCoverage", + ["cube", "metadata", "coords", "dims_common", "dims_local", "dims_free"], +) + +_Item = namedtuple("Item", ["metadata", "coord", "dims"]) + +_PreparedFactory = namedtuple("PreparedFactory", ["container", "dependencies"]) + +_PreparedItem = namedtuple( + "PreparedItem", ["metadata", "points", "bounds", "dims", "container"], +) + +_PreparedMetadata = namedtuple("PreparedMetadata", ["combined", "src", "tgt"]) + + +class Resolve: + def __init__(self, lhs=None, rhs=None): + if lhs is not None or rhs is not None: + self(lhs, rhs) + + def __call__(self, lhs, rhs): + self._init(lhs, rhs) + + self._metadata_resolve() + self._metadata_coverage() + + if self._debug: + self._debug_items(self.lhs_cube_category_local, title="LHS local") + self._debug_items(self.rhs_cube_category_local, title="RHS local") + self._debug_items(self.category_common, title="common") + logger.debug(f"map_rhs_to_lhs={self.map_rhs_to_lhs}") + + self._metadata_mapping() + self._metadata_prepare() + + def _as_compatible_cubes(self): + from iris.cube import Cube + + src_cube = self._src_cube + tgt_cube = self._tgt_cube + + # Use the mapping to calculate the new src cube shape. + new_src_shape = [1] * tgt_cube.ndim + for src_dim, tgt_dim in self.mapping.items(): + new_src_shape[tgt_dim] = src_cube.shape[src_dim] + new_src_shape = tuple(new_src_shape) + dmsg = ( + f"new src {self._src_cube_position} cube shape {new_src_shape}, " + f"actual shape {src_cube.shape}" + ) + logger.debug(dmsg) + + try: + # Determine whether the tgt cube shape and proposed new src + # cube shape will successfully broadcast together. + self._broadcast_shape = broadcast_shapes( + tgt_cube.shape, new_src_shape + ) + except ValueError: + emsg = ( + "Cannot resolve cubes, as a suitable transpose of the " + f"{self._src_cube_position} cube {src_cube.name()!r} " + f"will not broadcast with the {self._tgt_cube_position} cube " + f"{tgt_cube.name()!r}." + ) + raise ValueError(emsg) + + new_src_data = src_cube.core_data().copy() + + # Use the mapping to determine the transpose sequence of + # src dimensions in increasing tgt dimension order. + order = [ + src_dim + for src_dim, tgt_dim in sorted( + self.mapping.items(), key=lambda pair: pair[1] + ) + ] + + # Determine whether a transpose of the src cube is necessary. + if order != sorted(order): + new_src_data = new_src_data.transpose(order) + logger.debug( + f"transpose src {self._src_cube_position} cube with order {order}" + ) + + # Determine whether a reshape is necessary. + if new_src_shape != new_src_data.shape: + new_src_data = new_src_data.reshape(new_src_shape) + logger.debug( + f"reshape src {self._src_cube_position} cube to new shape {new_src_shape}" + ) + + # Create the new src cube. + new_src_cube = Cube(new_src_data) + new_src_cube.metadata = src_cube.metadata + + def add_coord(coord, dim_coord=False): + src_dims = src_cube.coord_dims(coord) + tgt_dims = [self.mapping[src_dim] for src_dim in src_dims] + if dim_coord: + new_src_cube.add_dim_coord(coord, tgt_dims) + else: + new_src_cube.add_aux_coord(coord, tgt_dims) + + # Add the dim coordinates to the new src cube. + for coord in src_cube.dim_coords: + add_coord(coord, dim_coord=True) + + # Add the aux and scalar coordinates to the new src cube. + for coord in src_cube.aux_coords: + add_coord(coord) + + # Add the aux factories to the new src cube. + for factory in src_cube.aux_factories: + new_src_cube.add_aux_factory(factory) + + # Set the resolved cubes. + self._src_cube_resolved = new_src_cube + self._tgt_cube_resolved = tgt_cube + + @staticmethod + def _aux_coverage( + cube, + cube_items_aux, + cube_items_scalar, + common_aux_metadata, + common_scalar_metadata, + ): + common_items_aux = [] + common_items_scalar = [] + local_items_aux = [] + local_items_scalar = [] + dims_common = [] + dims_local = [] + dims_free = set(range(cube.ndim)) + + for item in cube_items_aux: + [dims_free.discard(dim) for dim in item.dims] + + if item.metadata in common_aux_metadata: + common_items_aux.append(item) + dims_common.extend(item.dims) + else: + local_items_aux.append(item) + dims_local.extend(item.dims) + + for item in cube_items_scalar: + if item.metadata in common_scalar_metadata: + common_items_scalar.append(item) + else: + local_items_scalar.append(item) + + return _AuxCoverage( + cube=cube, + common_items_aux=common_items_aux, + common_items_scalar=common_items_scalar, + local_items_aux=local_items_aux, + local_items_scalar=local_items_scalar, + dims_common=sorted(set(dims_common)), + dims_local=sorted(set(dims_local)), + dims_free=sorted(dims_free), + ) + + def _aux_mapping(self, src_coverage, tgt_coverage): + for tgt_item in tgt_coverage.common_items_aux: + # Search for a src aux metadata match. + tgt_metadata = tgt_item.metadata + src_items = tuple( + filter( + lambda src_item: src_item.metadata == tgt_metadata, + src_coverage.common_items_aux, + ) + ) + if src_items: + # Multiple matching src metadata must cover the same src + # dimensions. + src_dims = src_items[0].dims + if all(map(lambda item: item.dims == src_dims, src_items)): + # Ensure src and tgt have equal rank. + tgt_dims = tgt_item.dims + if len(src_dims) == len(tgt_dims): + for src_dim, tgt_dim in zip(src_dims, tgt_dims): + self.mapping[src_dim] = tgt_dim + logger.debug(f"{src_dim}->{tgt_dim}") + else: + # This situation can only occur due to a systemic internal + # failure to correctly identify common aux coordinate metadata + # coverage between the cubes. + emsg = ( + "Failed to map common aux coordinate metadata from " + "source cube {!r} to target cube {!r}, using {!r} on " + "target cube dimension{} {}." + ) + raise ValueError( + emsg.format( + src_coverage.cube.name(), + tgt_coverage.cube.name(), + tgt_metadata, + "s" if len(tgt_item.dims) > 1 else "", + tgt_item.dims, + ) + ) + + @staticmethod + def _categorise_items(cube): + category = _CategoryItems(items_dim=[], items_aux=[], items_scalar=[]) + + # Categorise the dim coordinates of the cube. + for coord in cube.dim_coords: + item = _Item( + metadata=coord.metadata, + coord=coord, + dims=cube.coord_dims(coord), + ) + category.items_dim.append(item) + + # Categorise the aux and scalar coordinates of the cube. + for coord in cube.aux_coords: + dims = cube.coord_dims(coord) + item = _Item(metadata=coord.metadata, coord=coord, dims=dims) + if dims: + category.items_aux.append(item) + else: + category.items_scalar.append(item) + + return category + + @staticmethod + def _create_prepared_item(coord, dims, src=None, tgt=None): + if src is not None and tgt is not None: + combined = src.combine(tgt) + else: + combined = src or tgt + if not isinstance(dims, Iterable): + dims = (dims,) + prepared_metadata = _PreparedMetadata( + combined=combined, src=src, tgt=tgt + ) + bounds = coord.bounds + result = _PreparedItem( + metadata=prepared_metadata, + points=coord.points.copy(), + bounds=bounds if bounds is None else bounds.copy(), + dims=dims, + container=type(coord), + ) + return result + + @property + def _debug(self): + result = False + level = logger.getEffectiveLevel() + if level != logging.NOTSET: + result = logging.DEBUG >= level + return result + + @staticmethod + def _debug_items(items, title=None): + def _show(items, heading): + logger.debug(f"{title}{heading}:") + for item in items: + dmsg = f"metadata={item.metadata}, dims={item.dims}, bounds={item.coord.has_bounds()}" + logger.debug(dmsg) + + title = f"{title} " if title else "" + _show(items.items_dim, "dim") + _show(items.items_aux, "aux") + _show(items.items_scalar, "scalar") + + @staticmethod + def _dim_coverage(cube, cube_items_dim, common_dim_metadata): + ndim = cube.ndim + metadata = [None] * ndim + coords = [None] * ndim + dims_common = [] + dims_local = [] + dims_free = set(range(ndim)) + + for item in cube_items_dim: + (dim,) = item.dims + dims_free.discard(dim) + metadata[dim] = item.metadata + coords[dim] = item.coord + if item.metadata in common_dim_metadata: + dims_common.append(dim) + else: + dims_local.append(dim) + + return _DimCoverage( + cube=cube, + metadata=metadata, + coords=coords, + dims_common=sorted(dims_common), + dims_local=sorted(dims_local), + dims_free=sorted(dims_free), + ) + + def _dim_mapping(self, src_coverage, tgt_coverage): + for tgt_dim in tgt_coverage.dims_common: + # Search for a src dim metadata match. + tgt_metadata = tgt_coverage.metadata[tgt_dim] + try: + src_dim = src_coverage.metadata.index(tgt_metadata) + self.mapping[src_dim] = tgt_dim + logger.debug(f"{src_dim}->{tgt_dim}") + except ValueError: + # This exception can only occur due to a systemic internal + # failure to correctly identify common dim coordinate metadata + # coverage between the cubes. + emsg = ( + "Failed to map common dim coordinate metadata from " + "source cube {!r} to target cube {!r}, using {!r} on " + "target cube dimension {}." + ) + raise ValueError( + emsg.format( + src_coverage.cube.name(), + tgt_coverage.cube.name(), + tgt_metadata, + tuple([tgt_dim]), + ) + ) + + def _free_mapping( + self, + src_dim_coverage, + tgt_dim_coverage, + src_aux_coverage, + tgt_aux_coverage, + ): + src_cube = src_dim_coverage.cube + tgt_cube = tgt_dim_coverage.cube + src_ndim = src_cube.ndim + tgt_ndim = tgt_cube.ndim + + # mapping src to tgt, involving free dimensions on either the src/tgt. + free_mapping = {} + + # Determine the src/tgt dimensions that are not mapped, + # and not covered by any metadata. + src_free = set(src_dim_coverage.dims_free) & set( + src_aux_coverage.dims_free + ) + tgt_free = set(tgt_dim_coverage.dims_free) & set( + tgt_aux_coverage.dims_free + ) + + if src_free or tgt_free: + # Determine the src/tgt dimensions that are not mapped. + src_unmapped = set(range(src_ndim)) - set(self.mapping) + tgt_unmapped = set(range(tgt_ndim)) - set(self.mapping.values()) + + # Determine the src/tgt dimensions that are not mapped, + # but are covered by a src/tgt local coordinate. + src_unmapped_local = src_unmapped - src_free + tgt_unmapped_local = tgt_unmapped - tgt_free + + src_shape = src_cube.shape + tgt_shape = tgt_cube.shape + src_max, tgt_max = max(src_shape), max(tgt_shape) + + def assign_mapping(extent, unmapped_local_items, free_items=None): + result = None + if free_items is None: + free_items = [] + if extent == 1: + if unmapped_local_items: + result, _ = unmapped_local_items.pop(0) + elif free_items: + result, _ = free_items.pop(0) + else: + + def _filter(items): + return list( + filter(lambda item: item[1] == extent, items) + ) + + def _pop(item, items): + result, _ = item + index = items.index(item) + items.pop(index) + return result + + items = _filter(unmapped_local_items) + if items: + result = _pop(items[0], unmapped_local_items) + else: + items = _filter(free_items) + if items: + result = _pop(items[0], free_items) + return result + + if src_free: + # Attempt to map src free dimensions to tgt unmapped local or free dimensions. + tgt_unmapped_local_items = [ + (dim, tgt_shape[dim]) for dim in tgt_unmapped_local + ] + tgt_free_items = [(dim, tgt_shape[dim]) for dim in tgt_free] + + for src_dim in sorted( + src_free, key=lambda dim: (src_max - src_shape[dim], dim) + ): + tgt_dim = assign_mapping( + src_shape[src_dim], + tgt_unmapped_local_items, + tgt_free_items, + ) + if tgt_dim is None: + # Failed to map the src free dimension + # to a suitable tgt local/free dimension. + dmsg = ( + f"failed to map src free dimension ({src_dim},) from " + f"{self._src_cube_position} cube {src_cube.name()!r} to " + f"{self._tgt_cube_position} cube {tgt_cube.name()!r}." + ) + logger.debug(dmsg) + break + free_mapping[src_dim] = tgt_dim + else: + # Attempt to map tgt free dimensions to src unmapped local dimensions. + src_unmapped_local_items = [ + (dim, src_shape[dim]) for dim in src_unmapped_local + ] + + for tgt_dim in sorted( + tgt_free, key=lambda dim: (tgt_max - tgt_shape[dim], dim) + ): + src_dim = assign_mapping( + tgt_shape[tgt_dim], src_unmapped_local_items + ) + if src_dim is not None: + free_mapping[src_dim] = tgt_dim + if not src_unmapped_local_items: + # There are no more src unmapped local dimensions. + break + + # Determine whether there are still unmapped src dimensions. + src_unmapped = ( + set(range(src_cube.ndim)) - set(self.mapping) - set(free_mapping) + ) + + if src_unmapped: + plural = "s" if len(src_unmapped) > 1 else "" + emsg = ( + "Insufficient matching coordinate metadata to resolve cubes, " + f"cannot map dimension{plural} {tuple(sorted(src_unmapped))} " + f"of the {self._src_cube_position} cube {src_cube.name()!r} " + f"to the {self._tgt_cube_position} cube {tgt_cube.name()!r}." + ) + raise ValueError(emsg) + + # Update the mapping. + self.mapping.update(free_mapping) + logger.debug(f"mapping free dimensions gives, mapping={self.mapping}") + + def _init(self, lhs, rhs): + from iris.cube import Cube + + emsg = ( + "{cls} requires {arg!r} argument to be a 'Cube', got {actual!r}." + ) + clsname = self.__class__.__name__ + + if not isinstance(lhs, Cube): + raise TypeError( + emsg.format(cls=clsname, arg="LHS", actual=type(lhs)) + ) + + if not isinstance(rhs, Cube): + raise TypeError( + emsg.format(cls=clsname, arg="RHS", actual=type(rhs)) + ) + + # The LHS cube to be resolved into the resultant cube. + self.lhs_cube = lhs + # The RHS cube to be resolved into the resultant cube. + self.rhs_cube = rhs + + # The transposed/reshaped (if required) LHS cube, which + # can be broadcast with RHS cube. + self.lhs_cube_resolved = None + # The transposed/reshaped (if required) RHS cube, which + # can be broadcast with LHS cube. + self.rhs_cube_resolved = None + + # Categorised dim, aux and scalar coordinate items for LHS cube. + self.lhs_cube_category = None + # Categorised dim, aux and scalar coordinate items for RHS cube. + self.rhs_cube_category = None + + # Categorised dim, aux and scalar coordinate items local to LHS cube only. + self.lhs_cube_category_local = _CategoryItems( + items_dim=[], items_aux=[], items_scalar=[] + ) + # Categorised dim, aux and scalar coordinate items local to RHS cube only. + self.rhs_cube_category_local = _CategoryItems( + items_dim=[], items_aux=[], items_scalar=[] + ) + # Categorised dim, aux and scalar coordinate items common to both + # LHS cube and RHS cube. + self.category_common = _CategoryItems( + items_dim=[], items_aux=[], items_scalar=[] + ) + + # Analysis of dim coordinates spanning LHS cube. + self.lhs_cube_dim_coverage = None + # Analysis of aux and scalar coordinates spanning LHS cube. + self.lhs_cube_aux_coverage = None + # Analysis of dim coordinates spanning RHS cube. + self.rhs_cube_dim_coverage = None + # Analysis of aux and scalar coordinates spanning RHS cube. + self.rhs_cube_aux_coverage = None + + # Map common metadata from RHS cube to LHS cube if LHS-rank >= RHS-rank, + # otherwise map common metadata from LHS cube to RHS cube. + if self.lhs_cube.ndim >= self.rhs_cube.ndim: + self.map_rhs_to_lhs = True + else: + self.map_rhs_to_lhs = False + + # Mapping of the dimensions between common metadata for the cubes, + # where the direction of the mapping is governed by map_rhs_to_lhs. + self.mapping = None + + # Cache containing a list of dim, aux and scalar coordinates prepared + # and ready for creating and attaching to the resultant cube. + self.prepared_category = None + + # Cache containing a list of aux factories prepared and ready for + # creating and attaching to the resultant cube. + self.prepared_factories = None + + # The shape of the resultant resolved cube. + self._broadcast_shape = None + + def _metadata_coverage(self): + # Determine the common dim coordinate metadata coverage. + common_dim_metadata = [ + item.metadata for item in self.category_common.items_dim + ] + + self.lhs_cube_dim_coverage = self._dim_coverage( + self.lhs_cube, + self.lhs_cube_category.items_dim, + common_dim_metadata, + ) + self.rhs_cube_dim_coverage = self._dim_coverage( + self.rhs_cube, + self.rhs_cube_category.items_dim, + common_dim_metadata, + ) + + # Determine the common aux and scalar coordinate metadata coverage. + common_aux_metadata = [ + item.metadata for item in self.category_common.items_aux + ] + common_scalar_metadata = [ + item.metadata for item in self.category_common.items_scalar + ] + + self.lhs_cube_aux_coverage = self._aux_coverage( + self.lhs_cube, + self.lhs_cube_category.items_aux, + self.lhs_cube_category.items_scalar, + common_aux_metadata, + common_scalar_metadata, + ) + self.rhs_cube_aux_coverage = self._aux_coverage( + self.rhs_cube, + self.rhs_cube_category.items_aux, + self.rhs_cube_category.items_scalar, + common_aux_metadata, + common_scalar_metadata, + ) + + def _metadata_mapping(self): + # Initialise the state. + self.mapping = {} + + # Map RHS cube to LHS cube, or smaller to larger cube rank. + if self.map_rhs_to_lhs: + src_cube = self.rhs_cube + src_dim_coverage = self.rhs_cube_dim_coverage + src_aux_coverage = self.rhs_cube_aux_coverage + tgt_cube = self.lhs_cube + tgt_dim_coverage = self.lhs_cube_dim_coverage + tgt_aux_coverage = self.lhs_cube_aux_coverage + else: + src_cube = self.lhs_cube + src_dim_coverage = self.lhs_cube_dim_coverage + src_aux_coverage = self.lhs_cube_aux_coverage + tgt_cube = self.rhs_cube + tgt_dim_coverage = self.rhs_cube_dim_coverage + tgt_aux_coverage = self.rhs_cube_aux_coverage + + # Use the dim coordinates to fully map the + # src cube dimensions to the tgt cube dimensions. + self._dim_mapping(src_dim_coverage, tgt_dim_coverage) + logger.debug( + f"mapping common dim coordinates gives, mapping={self.mapping}" + ) + + # If necessary, use the aux coordinates to fully map the + # src cube dimensions to the tgt cube dimensions. + if not self.mapped: + self._aux_mapping(src_aux_coverage, tgt_aux_coverage) + logger.debug( + f"mapping common aux coordinates, mapping={self.mapping}" + ) + + if not self.mapped: + # Attempt to complete the mapping using src/tgt free dimensions. + # Note that, this may not be possible and result in an exception. + self._free_mapping( + src_dim_coverage, + tgt_dim_coverage, + src_aux_coverage, + tgt_aux_coverage, + ) + + # Attempt to transpose/reshape the cubes into compatible broadcast shapes. + # Note that, this may not be possible and result in an exception. + self._as_compatible_cubes() + + # Given the resultant broadcast shape, determine whether the + # mapping requires to be reversed. + broadcast_flip = ( + src_cube.ndim == tgt_cube.ndim + and self._tgt_cube_resolved.shape != self.shape + and self._src_cube_resolved.shape == self.shape + ) + + # Given the number of free dimensions, determine whether the + # mapping requires to be reversed. + src_free = set(src_dim_coverage.dims_free) & set( + src_aux_coverage.dims_free + ) + tgt_free = set(tgt_dim_coverage.dims_free) & set( + tgt_aux_coverage.dims_free + ) + free_flip = len(tgt_free) > len(src_free) + + # Reverse the mapping direction. + if broadcast_flip or free_flip: + flip_mapping = { + tgt_dim: src_dim for src_dim, tgt_dim in self.mapping.items() + } + self.map_rhs_to_lhs = not self.map_rhs_to_lhs + dmsg = ( + f"reversing the mapping from {self.mapping} to {flip_mapping}, " + f"now map_rhs_to_lhs={self.map_rhs_to_lhs}" + ) + logger.debug(dmsg) + self.mapping = flip_mapping + # Now require to transpose/reshape the cubes into compatible + # broadcast cubes again, due to possible non-commutative behaviour + # after reversing the mapping direction. + self._as_compatible_cubes() + + def _metadata_prepare(self): + # Initialise the state. + self.prepared_category = _CategoryItems( + items_dim=[], items_aux=[], items_scalar=[] + ) + self.prepared_factories = [] + + # Map RHS cube to LHS cube, or smaller to larger cube rank. + if self.map_rhs_to_lhs: + src_cube = self.rhs_cube + src_category_local = self.rhs_cube_category_local + src_dim_coverage = self.rhs_cube_dim_coverage + src_aux_coverage = self.rhs_cube_aux_coverage + tgt_cube = self.lhs_cube + tgt_category_local = self.lhs_cube_category_local + tgt_dim_coverage = self.lhs_cube_dim_coverage + tgt_aux_coverage = self.lhs_cube_aux_coverage + else: + src_cube = self.lhs_cube + src_category_local = self.lhs_cube_category_local + src_dim_coverage = self.lhs_cube_dim_coverage + src_aux_coverage = self.lhs_cube_aux_coverage + tgt_cube = self.rhs_cube + tgt_category_local = self.rhs_cube_category_local + tgt_dim_coverage = self.rhs_cube_dim_coverage + tgt_aux_coverage = self.rhs_cube_aux_coverage + + # Determine the resultant cube dim coordinate/s. + self._prepare_common_dim_payload(src_dim_coverage, tgt_dim_coverage) + + # Determine the resultant cube aux coordinate/s. + self._prepare_common_aux_payload( + src_aux_coverage.common_items_aux, # input + tgt_aux_coverage.common_items_aux, # input + self.prepared_category.items_aux, # output + ) + + # Determine the resultant cube scalar coordinate/s. + self._prepare_common_aux_payload( + src_aux_coverage.common_items_scalar, # input + tgt_aux_coverage.common_items_scalar, # input + self.prepared_category.items_scalar, # output + ignore_mismatch=True, + ) + + self._prepare_local_payload( + src_dim_coverage, + src_aux_coverage, + tgt_dim_coverage, + tgt_aux_coverage, + ) + + self._prepare_factory_payload( + tgt_cube, tgt_category_local, from_src=False + ) + self._prepare_factory_payload(src_cube, src_category_local) + + def _metadata_resolve(self): + """ + Categorise the coordinate metadata of the cubes into three distinct + groups; metadata from coordinates only available (local) on the LHS + cube, metadata from coordinates only available (local) on the RHS + cube, and metadata from coordinates common to both the LHS and RHS + cubes. + + This is only applicable to coordinates that are members of the + 'aux_coords' or 'dim_coords' of the participating cubes. + + """ + + # Determine the cube dim, aux and scalar coordinate items + # for each individual cube. + self.lhs_cube_category = self._categorise_items(self.lhs_cube) + self.rhs_cube_category = self._categorise_items(self.rhs_cube) + + def _categorise( + lhs_items, + rhs_items, + lhs_local_items, + rhs_local_items, + common_items, + ): + rhs_items_metadata = [item.metadata for item in rhs_items] + # Track common metadata here as a temporary convenience. + common_metadata = [] + + # Determine items local to the lhs, and shared items + # common to both lhs and rhs. + for item in lhs_items: + metadata = item.metadata + if metadata in rhs_items_metadata: + # The metadata is common between lhs and rhs. + if metadata not in common_metadata: + common_items.append(item) + common_metadata.append(metadata) + else: + # The metadata is local to the lhs. + lhs_local_items.append(item) + + # Determine items local to the rhs. + for item in rhs_items: + if item.metadata not in common_metadata: + rhs_local_items.append(item) + + # Determine local and common dim category items. + _categorise( + self.lhs_cube_category.items_dim, # input + self.rhs_cube_category.items_dim, # input + self.lhs_cube_category_local.items_dim, # output + self.rhs_cube_category_local.items_dim, # output + self.category_common.items_dim, # output + ) + + # Determine local and common aux category items. + _categorise( + self.lhs_cube_category.items_aux, # input + self.rhs_cube_category.items_aux, # input + self.lhs_cube_category_local.items_aux, # output + self.rhs_cube_category_local.items_aux, # output + self.category_common.items_aux, # output + ) + + # Determine local and common scalar category items. + _categorise( + self.lhs_cube_category.items_scalar, # input + self.rhs_cube_category.items_scalar, # input + self.lhs_cube_category_local.items_scalar, # output + self.rhs_cube_category_local.items_scalar, # output + self.category_common.items_scalar, # output + ) + + # Sort the resultant categories by metadata name for consistency, + # in-place. + categories = ( + self.lhs_cube_category, + self.rhs_cube_category, + self.lhs_cube_category_local, + self.rhs_cube_category_local, + self.category_common, + ) + key_func = lambda item: item.metadata.name() + + for category in categories: + category.items_dim.sort(key=key_func) + category.items_aux.sort(key=key_func) + category.items_scalar.sort(key=key_func) + + def _prepare_common_aux_payload( + self, + src_common_items, + tgt_common_items, + prepared_items, + ignore_mismatch=None, + ): + from iris.coords import AuxCoord + + if ignore_mismatch is None: + # Configure ability to ignore coordinate points/bounds + # mismatches between common items. + ignore_mismatch = False + + for src_item in src_common_items: + src_metadata = src_item.metadata + tgt_items = tuple( + filter( + lambda tgt_item: tgt_item.metadata == src_metadata, + tgt_common_items, + ) + ) + if not tgt_items: + dmsg = ( + f"ignoring src {self._src_cube_position} cube aux coordinate " + f"{src_metadata}, does not match any common tgt " + f"{self._tgt_cube_position} cube aux coordinate metadata" + ) + logger.debug(dmsg) + elif len(tgt_items) > 1: + dmsg = ( + f"ignoring src {self._src_cube_position} cube aux coordinate " + f"{src_metadata}, matches multiple [{len(tgt_items)}] common " + f"tgt {self._tgt_cube_position} cube aux coordinate metadata" + ) + logger.debug(dmsg) + else: + (tgt_item,) = tgt_items + src_coord = src_item.coord + tgt_coord = tgt_item.coord + points, bounds = self._prepare_points_and_bounds( + src_coord, + tgt_coord, + src_item.dims, + tgt_item.dims, + ignore_mismatch=ignore_mismatch, + ) + if points is not None: + src_type = type(src_coord) + tgt_type = type(tgt_coord) + # Downcast to aux if there are mixed container types. + container = src_type if src_type is tgt_type else AuxCoord + prepared_metadata = _PreparedMetadata( + combined=src_metadata.combine(tgt_item.metadata), + src=src_metadata, + tgt=tgt_item.metadata, + ) + prepared_item = _PreparedItem( + metadata=prepared_metadata, + points=points.copy(), + bounds=bounds if bounds is None else bounds.copy(), + dims=tgt_item.dims, + container=container, + ) + prepared_items.append(prepared_item) + + def _prepare_common_dim_payload( + self, src_coverage, tgt_coverage, ignore_mismatch=None + ): + from iris.coords import DimCoord + + if ignore_mismatch is None: + # Configure ability to ignore coordinate points/bounds + # mismatches between common items. + ignore_mismatch = False + + for src_dim in src_coverage.dims_common: + src_metadata = src_coverage.metadata[src_dim] + src_coord = src_coverage.coords[src_dim] + + tgt_dim = self.mapping[src_dim] + tgt_metadata = tgt_coverage.metadata[tgt_dim] + tgt_coord = tgt_coverage.coords[tgt_dim] + + points, bounds = self._prepare_points_and_bounds( + src_coord, + tgt_coord, + src_dim, + tgt_dim, + ignore_mismatch=ignore_mismatch, + ) + + if points is not None: + prepared_metadata = _PreparedMetadata( + combined=src_metadata.combine(tgt_metadata), + src=src_metadata, + tgt=tgt_metadata, + ) + prepared_item = _PreparedItem( + metadata=prepared_metadata, + points=points.copy(), + bounds=bounds if bounds is None else bounds.copy(), + dims=(tgt_dim,), + container=DimCoord, + ) + self.prepared_category.items_dim.append(prepared_item) + + def _prepare_factory_payload(self, cube, category_local, from_src=True): + def _get_prepared_item(metadata, from_src=True, from_local=False): + result = None + if from_local: + category = category_local + match = lambda item: item.metadata == metadata + else: + category = self.prepared_category + if from_src: + match = lambda item: item.metadata.src == metadata + else: + match = lambda item: item.metadata.tgt == metadata + for member in category._fields: + category_items = getattr(category, member) + matched_items = tuple(filter(match, category_items)) + if matched_items: + if len(matched_items) > 1: + dmsg = ( + f"ignoring factory dependency {metadata}, multiple {'src' if from_src else 'tgt'} " + f"{'local' if from_local else 'prepared'} metadata matches" + ) + logger.debug(dmsg) + else: + (item,) = matched_items + if from_local: + src = tgt = None + if from_src: + src = item.metadata + dims = tuple( + [self.mapping[dim] for dim in item.dims] + ) + else: + tgt = item.metadata + dims = item.dims + result = self._create_prepared_item( + item.coord, dims, src=src, tgt=tgt + ) + getattr(self.prepared_category, member).append( + result + ) + else: + result = item + break + return result + + for factory in cube.aux_factories: + container = type(factory) + dependencies = {} + prepared_item = None + + if tuple( + filter( + lambda item: item.container is container, + self.prepared_factories, + ) + ): + # debug: skipping, factory already exists + dmsg = ( + f"ignoring {'src' if from_src else 'tgt'} {container}, " + f"a similar factory has already been prepared" + ) + logger.debug(dmsg) + continue + + for ( + dependency_name, + dependency_coord, + ) in factory.dependencies.items(): + metadata = dependency_coord.metadata + prepared_item = _get_prepared_item(metadata, from_src=from_src) + if prepared_item is None: + prepared_item = _get_prepared_item( + metadata, from_src=from_src, from_local=True + ) + if prepared_item is None: + dmsg = f"cannot find matching {metadata} for {container} dependency {dependency_name}" + logger.debug(dmsg) + break + dependencies[dependency_name] = prepared_item.metadata + + if prepared_item is not None: + prepared_factory = _PreparedFactory( + container=container, dependencies=dependencies + ) + self.prepared_factories.append(prepared_factory) + else: + dmsg = f"ignoring {'src' if from_src else 'tgt'} {container}, cannot find all dependencies" + logger.debug(dmsg) + + def _prepare_local_payload_aux(self, src_aux_coverage, tgt_aux_coverage): + # Determine whether there are tgt dimensions not mapped to by an + # associated src dimension, and thus may be covered by any local + # tgt aux coordinates. + extra_tgt_dims = set(range(tgt_aux_coverage.cube.ndim)) - set( + self.mapping.values() + ) + + if LENIENT["maths"]: + mapped_src_dims = set(self.mapping.keys()) + mapped_tgt_dims = set(self.mapping.values()) + + # Add local src aux coordinates. + for item in src_aux_coverage.local_items_aux: + if all([dim in mapped_src_dims for dim in item.dims]): + tgt_dims = tuple([self.mapping[dim] for dim in item.dims]) + prepared_item = self._create_prepared_item( + item.coord, tgt_dims, src=item.metadata + ) + self.prepared_category.items_aux.append(prepared_item) + else: + dmsg = ( + f"ignoring local src {self._src_cube_position} cube " + f"aux coordinate {item.metadata}, as not all src " + f"dimensions {item.dims} are mapped" + ) + logger.debug(dmsg) + else: + # For strict maths, only local tgt aux coordinates covering + # the extra dimensions of the tgt cube may be added. + mapped_tgt_dims = set() + + # Add local tgt aux coordinates. + for item in tgt_aux_coverage.local_items_aux: + tgt_dims = item.dims + if all([dim in mapped_tgt_dims for dim in tgt_dims]) or any( + [dim in extra_tgt_dims for dim in tgt_dims] + ): + prepared_item = self._create_prepared_item( + item.coord, tgt_dims, tgt=item.metadata + ) + self.prepared_category.items_aux.append(prepared_item) + else: + dmsg = ( + f"ignoring local tgt {self._tgt_cube_position} cube " + f"aux coordinate {item.metadata}, as not all tgt " + f"dimensions {tgt_dims} are mapped" + ) + logger.debug(dmsg) + + def _prepare_local_payload_dim(self, src_dim_coverage, tgt_dim_coverage): + mapped_tgt_dims = self.mapping.values() + + # Determine whether there are tgt dimensions not mapped to by an + # associated src dimension, and thus may be covered by any local + # tgt dim coordinates. + extra_tgt_dims = set(range(tgt_dim_coverage.cube.ndim)) - set( + mapped_tgt_dims + ) + + if LENIENT["maths"]: + tgt_dims_conflict = set() + + # Add local src dim coordinates. + for src_dim in src_dim_coverage.dims_local: + tgt_dim = self.mapping[src_dim] + # Only add the local src dim coordinate iff there is no + # associated local tgt dim coordinate. + if tgt_dim not in tgt_dim_coverage.dims_local: + metadata = src_dim_coverage.metadata[src_dim] + coord = src_dim_coverage.coords[src_dim] + prepared_item = self._create_prepared_item( + coord, tgt_dim, src=metadata + ) + self.prepared_category.items_dim.append(prepared_item) + else: + tgt_dims_conflict.add(tgt_dim) + if self._debug: + src_metadata = src_dim_coverage.metadata[src_dim] + tgt_metadata = tgt_dim_coverage.metadata[tgt_dim] + dmsg = ( + f"ignoring local src {self._src_cube_position} cube " + f"dim coordinate {src_metadata}, as conflicts with " + f"tgt {self._tgt_cube_position} cube dim coordinate " + f"{tgt_metadata}, mapping ({src_dim},)->({tgt_dim},)" + ) + logger.debug(dmsg) + + # Determine whether there are any tgt dims free to be mapped + # by an available local tgt dim coordinate. + tgt_dims_unmapped = ( + set(tgt_dim_coverage.dims_local) - tgt_dims_conflict + ) + else: + # For strict maths, only local tgt dim coordinates covering + # the extra dimensions of the tgt cube may be added. + tgt_dims_unmapped = extra_tgt_dims + + # Add local tgt dim coordinates. + for tgt_dim in tgt_dims_unmapped: + if tgt_dim in mapped_tgt_dims or tgt_dim in extra_tgt_dims: + metadata = tgt_dim_coverage.metadata[tgt_dim] + if metadata is not None: + coord = tgt_dim_coverage.coords[tgt_dim] + prepared_item = self._create_prepared_item( + coord, tgt_dim, tgt=metadata + ) + self.prepared_category.items_dim.append(prepared_item) + + def _prepare_local_payload_scalar( + self, src_aux_coverage, tgt_aux_coverage + ): + # Add all local tgt scalar coordinates iff the src cube is a + # scalar cube with no local src scalar coordinates. + # Only for strict maths. + src_scalar_cube = ( + not LENIENT["maths"] + and src_aux_coverage.cube.ndim == 0 + and len(src_aux_coverage.local_items_scalar) == 0 + ) + + if src_scalar_cube or LENIENT["maths"]: + # Add any local src scalar coordinates, if available. + for item in src_aux_coverage.local_items_scalar: + prepared_item = self._create_prepared_item( + item.coord, item.dims, src=item.metadata + ) + self.prepared_category.items_scalar.append(prepared_item) + + # Add any local tgt scalar coordinates, if available. + for item in tgt_aux_coverage.local_items_scalar: + prepared_item = self._create_prepared_item( + item.coord, item.dims, tgt=item.metadata + ) + self.prepared_category.items_scalar.append(prepared_item) + + def _prepare_local_payload( + self, + src_dim_coverage, + src_aux_coverage, + tgt_dim_coverage, + tgt_aux_coverage, + ): + # Add local src/tgt dim coordinates. + self._prepare_local_payload_dim(src_dim_coverage, tgt_dim_coverage) + + # Add local src/tgt aux coordinates. + self._prepare_local_payload_aux(src_aux_coverage, tgt_aux_coverage) + + # Add local src/tgt scalar coordinates. + self._prepare_local_payload_scalar(src_aux_coverage, tgt_aux_coverage) + + def _prepare_points_and_bounds( + self, src_coord, tgt_coord, src_dims, tgt_dims, ignore_mismatch=None + ): + from iris.util import array_equal + + if ignore_mismatch is None: + # Configure ability to ignore coordinate points/bounds + # mismatches between common items. + ignore_mismatch = False + + points, bounds = None, None + + if not isinstance(src_dims, Iterable): + src_dims = (src_dims,) + + if not isinstance(tgt_dims, Iterable): + tgt_dims = (tgt_dims,) + + # Deal with coordinates that have been sliced. + if src_coord.ndim != tgt_coord.ndim: + if tgt_coord.ndim > src_coord.ndim: + # Use the tgt coordinate points/bounds. + points = tgt_coord.points + bounds = tgt_coord.bounds + else: + # Use the src coordinate points/bounds. + points = src_coord.points + bounds = src_coord.bounds + + # Deal with coordinates spanning broadcast dimensions. + if ( + points is None + and bounds is None + and src_coord.shape != tgt_coord.shape + ): + # Check whether the src coordinate is broadcasting. + dims = tuple([self.mapping[dim] for dim in src_dims]) + src_shape_broadcast = tuple([self.shape[dim] for dim in dims]) + src_cube_shape = self._src_cube.shape + src_shape = tuple([src_cube_shape[dim] for dim in src_dims]) + src_broadcasting = src_shape != src_shape_broadcast + + # Check whether the tgt coordinate is broadcasting. + tgt_shape_broadcast = tuple([self.shape[dim] for dim in tgt_dims]) + tgt_cube_shape = self._tgt_cube.shape + tgt_shape = tuple([tgt_cube_shape[dim] for dim in tgt_dims]) + tgt_broadcasting = tgt_shape != tgt_shape_broadcast + + if src_broadcasting and tgt_broadcasting: + emsg = ( + f"Cannot broadcast the coordinate {src_coord.name()!r} on " + f"{self._src_cube_position} cube {self._src_cube.name()!r} and " + f"coordinate {tgt_coord.name()!r} on " + f"{self._tgt_cube_position} cube {self._tgt_cube.name()!r} to " + f"broadcast shape {tgt_shape_broadcast}." + ) + raise ValueError(emsg) + elif src_broadcasting: + # Use the tgt coordinate points/bounds. + points = tgt_coord.points + bounds = tgt_coord.bounds + elif tgt_broadcasting: + # Use the src coordinate points/bounds. + points = src_coord.points + bounds = src_coord.bounds + + if points is None and bounds is None: + # Note that, this also ensures shape equality. + eq_points = array_equal( + src_coord.points, tgt_coord.points, withnans=True + ) + if eq_points: + points = src_coord.points + src_has_bounds = src_coord.has_bounds() + tgt_has_bounds = tgt_coord.has_bounds() + + if src_has_bounds and tgt_has_bounds: + src_bounds = src_coord.bounds + eq_bounds = array_equal( + src_bounds, tgt_coord.bounds, withnans=True + ) + + if eq_bounds: + bounds = src_bounds + else: + if LENIENT["maths"] and ignore_mismatch: + # For lenient, ignore coordinate with mis-matched bounds. + dmsg = ( + f"ignoring src {self._src_cube_position} cube " + f"{src_coord.metadata}, unequal bounds with " + f"tgt {self._tgt_cube_position} cube, " + f"{src_dims}->{tgt_dims}" + ) + logger.debug(dmsg) + else: + emsg = ( + f"Coordinate {src_coord.name()!r} has different bounds for the " + f"LHS cube {self.lhs_cube.name()!r} and " + f"RHS cube {self.rhs_cube.name()!r}." + ) + raise ValueError(emsg) + else: + # For lenient, use either of the coordinate bounds, if they exist. + if LENIENT["maths"]: + if src_has_bounds: + dmsg = ( + f"using src {self._src_cube_position} cube " + f"{src_coord.metadata} bounds, tgt has no bounds" + ) + logger.debug(dmsg) + bounds = src_coord.bounds + else: + dmsg = ( + f"using tgt {self._tgt_cube_position} cube " + f"{tgt_coord.metadata} bounds, src has no bounds" + ) + logger.debug(dmsg) + bounds = tgt_coord.bounds + else: + # For strict, both coordinates must have bounds, or both + # coordinates must not have bounds. + if src_has_bounds: + emsg = ( + f"Coordinate {src_coord.name()!r} has bounds for the " + f"{self._src_cube_position} cube {self._src_cube.name()!r}, " + f"but not the {self._tgt_cube_position} cube {self._tgt_cube.name()!r}." + ) + raise ValueError(emsg) + if tgt_has_bounds: + emsg = ( + f"Coordinate {tgt_coord.name()!r} has bounds for the " + f"{self._tgt_cube_position} cube {self._tgt_cube.name()!r}, " + f"but not the {self._src_cube_position} cube {self._src_cube.name()!r}." + ) + raise ValueError(emsg) + else: + if LENIENT["maths"] and ignore_mismatch: + # For lenient, ignore coordinate with mis-matched points. + dmsg = ( + f"ignoring src {self._src_cube_position} cube " + f"{src_coord.metadata}, unequal points with tgt " + f"{src_dims}->{tgt_dims}" + ) + logger.debug(dmsg) + else: + emsg = ( + f"Coordinate {src_coord.name()!r} has different points for the " + f"LHS cube {self.lhs_cube.name()!r} and " + f"RHS cube {self.rhs_cube.name()!r}." + ) + raise ValueError(emsg) + + return points, bounds + + @property + def _src_cube(self): + if self.map_rhs_to_lhs: + result = self.rhs_cube + else: + result = self.lhs_cube + return result + + @property + def _src_cube_position(self): + if self.map_rhs_to_lhs: + result = "RHS" + else: + result = "LHS" + return result + + @property + def _src_cube_resolved(self): + if self.map_rhs_to_lhs: + result = self.rhs_cube_resolved + else: + result = self.lhs_cube_resolved + return result + + @_src_cube_resolved.setter + def _src_cube_resolved(self, cube): + if self.map_rhs_to_lhs: + self.rhs_cube_resolved = cube + else: + self.lhs_cube_resolved = cube + + @property + def _tgt_cube(self): + if self.map_rhs_to_lhs: + result = self.lhs_cube + else: + result = self.rhs_cube + return result + + @property + def _tgt_cube_position(self): + if self.map_rhs_to_lhs: + result = "LHS" + else: + result = "RHS" + return result + + @property + def _tgt_cube_resolved(self): + if self.map_rhs_to_lhs: + result = self.lhs_cube_resolved + else: + result = self.rhs_cube_resolved + return result + + @_tgt_cube_resolved.setter + def _tgt_cube_resolved(self, cube): + if self.map_rhs_to_lhs: + self.lhs_cube_resolved = cube + else: + self.rhs_cube_resolved = cube + + def _tgt_cube_prepare(self, data): + cube = self._tgt_cube + + # Replace existing tgt cube data with the provided data. + cube.data = data + + # Clear the aux factories. + for factory in cube.aux_factories: + cube.remove_aux_factory(factory) + + # Clear the cube coordinates. + for coord in cube.coords(): + cube.remove_coord(coord) + + # Clear the cube cell measures. + for cm in cube.cell_measures(): + cube.remove_cell_measure(cm) + + # Clear the ancillary variables. + for av in cube.ancillary_variables(): + cube.remove_ancillary_variable(av) + + def cube(self, data, in_place=False): + from iris.cube import Cube + + expected_shape = self.shape + + # Ensure that we have been provided with candidate cubes, which are + # now resolved and metadata is prepared, ready and awaiting the + # resultant resolved cube. + if expected_shape is None: + emsg = ( + "Cannot resolve resultant cube, as no candidate cubes have " + "been provided." + ) + raise ValueError(emsg) + + if not hasattr(data, "shape"): + data = np.asanyarray(data) + + # Ensure that the shape of the provided data is the expected + # shape of the resultant resolved cube. + if data.shape != expected_shape: + emsg = ( + "Cannot resolve resultant cube, as the provided data must " + f"have shape {expected_shape}, got data shape {data.shape}." + ) + raise ValueError(emsg) + + if in_place: + result = self._tgt_cube + + if result.shape != expected_shape: + emsg = ( + "Cannot resolve resultant cube in-place, as the " + f"{self._tgt_cube_position} tgt cube {result.name()!r} " + f"requires data with shape {result.shape}, got data " + f"shape {data.shape}. Suggest not performing this " + "operation in-place." + ) + raise ValueError(emsg) + + # Prepare target cube for in-place population with the prepared + # metadata content and the provided data. + self._tgt_cube_prepare(data) + else: + # Create the resultant resolved cube with provided data. + result = Cube(data) + + # Add the combined cube metadata from both the candidate cubes. + result.metadata = self.lhs_cube.metadata.combine( + self.rhs_cube.metadata + ) + + # Add the prepared dim coordinates. + for item in self.prepared_category.items_dim: + coord = item.container(item.points, bounds=item.bounds) + coord.metadata = item.metadata.combined + result.add_dim_coord(coord, item.dims) + + # Add the prepared aux and scalar coordinates. + prepared_aux_coords = ( + self.prepared_category.items_aux + + self.prepared_category.items_scalar + ) + for item in prepared_aux_coords: + coord = item.container(item.points, bounds=item.bounds) + coord.metadata = item.metadata.combined + try: + result.add_aux_coord(coord, item.dims) + except ValueError as err: + scalar = dims = "" + if item.dims: + plural = "s" if len(item.dims) > 1 else "" + dims = f" with tgt dim{plural} {item.dims}" + else: + scalar = "scalar " + dmsg = ( + f"ignoring prepared {scalar}coordinate " + f"{coord.metadata}{dims}, got {err!r}" + ) + logger.debug(dmsg) + + # Add the prepared aux factories. + for prepared_factory in self.prepared_factories: + dependencies = dict() + for ( + dependency_name, + prepared_metadata, + ) in prepared_factory.dependencies.items(): + coord = result.coord(prepared_metadata.combined) + dependencies[dependency_name] = coord + factory = prepared_factory.container(**dependencies) + result.add_aux_factory(factory) + + return result + + @property + def mapped(self): + """ + Returns the state of whether all src cube dimensions have been + associated with relevant tgt cube dimensions. + + """ + return self._src_cube.ndim == len(self.mapping) + + @property + def shape(self): + """Returns the shape of the resultant resolved cube.""" + return getattr(self, "_broadcast_shape", None) diff --git a/lib/iris/config.py b/lib/iris/config.py index e1d7dee29d..eeef1873f9 100644 --- a/lib/iris/config.py +++ b/lib/iris/config.py @@ -32,8 +32,11 @@ import configparser import contextlib +import logging.config import os.path +import pathlib import warnings +import yaml # Returns simple string options @@ -81,6 +84,14 @@ def get_dir_option(section, option, default=None): config = configparser.ConfigParser() config.read([os.path.join(CONFIG_PATH, "site.cfg")]) +# Configure logging. +fname_logging = pathlib.Path(CONFIG_PATH) / "logging.yaml" +if not fname_logging.exists(): + emsg = f"Logging configuration file '{fname_logging!s}' does not exist." + raise FileNotFoundError(emsg) +with open(fname_logging) as fi: + logging.config.dictConfig(yaml.safe_load(fi)) +del fname_logging ################## # Resource options diff --git a/lib/iris/coords.py b/lib/iris/coords.py index b5392579c8..8fbe1abf56 100644 --- a/lib/iris/coords.py +++ b/lib/iris/coords.py @@ -25,13 +25,19 @@ from iris._data_manager import DataManager import iris._lazy_data as _lazy import iris.aux_factory +from iris.common import ( + AncillaryVariableMetadata, + BaseMetadata, + CFVariableMixin, + CellMeasureMetadata, + CoordMetadata, + DimCoordMetadata, + metadata_manager_factory, +) import iris.exceptions import iris.time import iris.util -from iris._cube_coord_common import CFVariableMixin -from iris.util import points_step - class _DimensionalMetadata(CFVariableMixin, metaclass=ABCMeta): """ @@ -92,6 +98,10 @@ def __init__( # its __init__ or __copy__ methods. The only bounds-related behaviour # it provides is a 'has_bounds()' method, which always returns False. + # Configure the metadata manager. + if not hasattr(self, "_metadata_manager"): + self._metadata_manager = metadata_manager_factory(BaseMetadata) + #: CF standard name of the quantity that the metadata represents. self.standard_name = standard_name @@ -340,9 +350,9 @@ def __eq__(self, other): # If the other object has a means of getting its definition, then do # the comparison, otherwise return a NotImplemented to let Python try # to resolve the operator elsewhere. - if hasattr(other, "_as_defn"): + if hasattr(other, "metadata"): # metadata comparison - eq = self._as_defn() == other._as_defn() + eq = self.metadata == other.metadata # data values comparison if eq and eq is not NotImplemented: eq = iris.util.array_equal( @@ -367,17 +377,6 @@ def __ne__(self, other): result = not result return result - def _as_defn(self): - defn = _DMDefn( - self.standard_name, - self.long_name, - self.var_name, - self.units, - self.attributes, - ) - - return defn - # Must supply __hash__ as Python 3 does not enable it if __eq__ is defined. # NOTE: Violates "objects which compare equal must have the same hash". # We ought to remove this, as equality of two dimensional metadata can @@ -714,6 +713,12 @@ def __init__( A dictionary containing other cf and user-defined attributes. """ + # Configure the metadata manager. + if not hasattr(self, "_metadata_manager"): + self._metadata_manager = metadata_manager_factory( + AncillaryVariableMetadata + ) + super().__init__( values=data, standard_name=standard_name, @@ -821,6 +826,9 @@ def __init__( 'area' and 'volume'. The default is 'area'. """ + # Configure the metadata manager. + self._metadata_manager = metadata_manager_factory(CellMeasureMetadata) + super().__init__( data=data, standard_name=standard_name, @@ -838,14 +846,14 @@ def __init__( @property def measure(self): - return self._measure + return self._metadata_manager.measure @measure.setter def measure(self, measure): if measure not in ["area", "volume"]: emsg = f"measure must be 'area' or 'volume', got {measure!r}" raise ValueError(emsg) - self._measure = measure + self._metadata_manager.measure = measure def __str__(self): result = repr(self) @@ -864,17 +872,6 @@ def __repr__(self): ) return result - def _as_defn(self): - defn = CellMeasureDefn( - self.standard_name, - self.long_name, - self.var_name, - self.units, - self.attributes, - self.measure, - ) - return defn - def cube_dims(self, cube): """ Return the cube dimensions of this CellMeasure. @@ -895,160 +892,6 @@ def xml_element(self, doc): return element -class CoordDefn( - namedtuple( - "CoordDefn", - [ - "standard_name", - "long_name", - "var_name", - "units", - "attributes", - "coord_system", - "climatological", - ], - ) -): - """ - Criterion for identifying a specific type of :class:`DimCoord` or - :class:`AuxCoord` based on its metadata. - - """ - - __slots__ = () - - def name(self, default="unknown"): - """ - Returns a human-readable name. - - First it tries self.standard_name, then it tries the 'long_name' - attribute, then the 'var_name' attribute, before falling back to - the value of `default` (which itself defaults to 'unknown'). - - """ - return self.standard_name or self.long_name or self.var_name or default - - def __lt__(self, other): - if not isinstance(other, CoordDefn): - return NotImplemented - - def _sort_key(defn): - # Emulate Python 2 behaviour with None - return ( - defn.standard_name is not None, - defn.standard_name, - defn.long_name is not None, - defn.long_name, - defn.var_name is not None, - defn.var_name, - defn.units is not None, - defn.units, - defn.coord_system is not None, - defn.coord_system, - ) - - return _sort_key(self) < _sort_key(other) - - -class CellMeasureDefn( - namedtuple( - "CellMeasureDefn", - [ - "standard_name", - "long_name", - "var_name", - "units", - "attributes", - "measure", - ], - ) -): - """ - Criterion for identifying a specific type of :class:`CellMeasure` - based on its metadata. - - """ - - __slots__ = () - - def name(self, default="unknown"): - """ - Returns a human-readable name. - - First it tries self.standard_name, then it tries the 'long_name' - attribute, then the 'var_name' attribute, before falling back to - the value of `default` (which itself defaults to 'unknown'). - - """ - return self.standard_name or self.long_name or self.var_name or default - - def __lt__(self, other): - if not isinstance(other, CellMeasureDefn): - return NotImplemented - - def _sort_key(defn): - # Emulate Python 2 behaviour with None - return ( - defn.standard_name is not None, - defn.standard_name, - defn.long_name is not None, - defn.long_name, - defn.var_name is not None, - defn.var_name, - defn.units is not None, - defn.units, - defn.measure is not None, - defn.measure, - ) - - return _sort_key(self) < _sort_key(other) - - -class _DMDefn( - namedtuple( - "DMDefn", - ["standard_name", "long_name", "var_name", "units", "attributes",], - ) -): - """ - Criterion for identifying a specific type of :class:`_DimensionalMetadata` - based on its metadata. - - """ - - __slots__ = () - - def name(self, default="unknown"): - """ - Returns a human-readable name. - - First it tries self.standard_name, then it tries the 'long_name' - attribute, then the 'var_name' attribute, before falling back to - the value of `default` (which itself defaults to 'unknown'). - - """ - return self.standard_name or self.long_name or self.var_name or default - - def __lt__(self, other): - if not isinstance(other, _DMDefn): - return NotImplemented - - def _sort_key(defn): - # Emulate Python 2 behaviour with None - return ( - defn.standard_name is not None, - defn.standard_name, - defn.long_name is not None, - defn.long_name, - defn.var_name is not None, - defn.var_name, - defn.units is not None, - defn.units, - ) - - return _sort_key(self) < _sort_key(other) - - class CoordExtent( namedtuple( "_CoordExtent", @@ -1490,7 +1333,12 @@ def __init__( Will set to True when a climatological time axis is loaded from NetCDF. Always False if no bounds exist. + """ + # Configure the metadata manager. + if not hasattr(self, "_metadata_manager"): + self._metadata_manager = metadata_manager_factory(CoordMetadata) + super().__init__( values=points, standard_name=standard_name, @@ -1589,7 +1437,7 @@ def bounds(self, bounds): # Ensure the bounds are a compatible shape. if bounds is None: self._bounds_dm = None - self._climatological = False + self.climatological = False else: bounds = self._sanitise_array(bounds, 2) if self.shape != bounds.shape[:-1]: @@ -1605,6 +1453,15 @@ def bounds(self, bounds): else: self._bounds_dm.data = bounds + @property + def coord_system(self): + """The coordinate-system of the coordinate.""" + return self._metadata_manager.coord_system + + @coord_system.setter + def coord_system(self, value): + self._metadata_manager.coord_system = value + @property def climatological(self): """ @@ -1615,8 +1472,13 @@ def climatological(self): Always reads as False if there are no bounds. On set, the input value is cast to a boolean, exceptions raised if units are not time units or if there are no bounds. + """ - return self._climatological if self.has_bounds() else False + if not self.has_bounds(): + self._metadata_manager.climatological = False + if not self.units.is_time_reference(): + self._metadata_manager.climatological = False + return self._metadata_manager.climatological @climatological.setter def climatological(self, value): @@ -1634,7 +1496,7 @@ def climatological(self, value): emsg = "Cannot set climatological coordinate, no bounds exist." raise ValueError(emsg) - self._climatological = value + self._metadata_manager.climatological = value def lazy_points(self): """ @@ -1722,18 +1584,6 @@ def _repr_other_metadata(self): result += ", climatological={}".format(self.climatological) return result - def _as_defn(self): - defn = CoordDefn( - self.standard_name, - self.long_name, - self.var_name, - self.units, - self.attributes, - self.coord_system, - self.climatological, - ) - return defn - # Must supply __hash__ as Python 3 does not enable it if __eq__ is defined. # NOTE: Violates "objects which compare equal must have the same hash". # We ought to remove this, as equality of two coords can *change*, so they @@ -1986,8 +1836,9 @@ def is_compatible(self, other, ignore=None): Args: * other: - An instance of :class:`iris.coords.Coord` or - :class:`iris.coords.CoordDefn`. + An instance of :class:`iris.coords.Coord`, + :class:`iris.common.CoordMetadata` or + :class:`iris.common.DimCoordMetadata`. * ignore: A single attribute key or iterable of attribute keys to ignore when comparing the coordinates. Default is None. To ignore all @@ -2442,7 +2293,7 @@ def from_regular( """ points = (zeroth + step) + step * np.arange(count, dtype=np.float32) - _, regular = points_step(points) + _, regular = iris.util.points_step(points) if not regular: points = (zeroth + step) + step * np.arange( count, dtype=np.float64 @@ -2486,6 +2337,9 @@ def __init__( read-only points and bounds. """ + # Configure the metadata manager. + self._metadata_manager = metadata_manager_factory(DimCoordMetadata) + super().__init__( points, standard_name=standard_name, @@ -2499,7 +2353,7 @@ def __init__( ) #: Whether the coordinate wraps by ``coord.units.modulus``. - self.circular = bool(circular) + self.circular = circular def __deepcopy__(self, memo): """ @@ -2515,6 +2369,14 @@ def __deepcopy__(self, memo): new_coord._bounds_dm.data.flags.writeable = False return new_coord + @property + def circular(self): + return self._metadata_manager.circular + + @circular.setter + def circular(self, circular): + self._metadata_manager.circular = bool(circular) + def copy(self, points=None, bounds=None): new_coord = super().copy(points=points, bounds=bounds) # Make the arrays read-only. @@ -2524,13 +2386,13 @@ def copy(self, points=None, bounds=None): return new_coord def __eq__(self, other): - # TODO investigate equality of AuxCoord and DimCoord if circular is - # False. result = NotImplemented if isinstance(other, DimCoord): - result = ( - Coord.__eq__(self, other) and self.circular == other.circular - ) + # The "circular" member participates in DimCoord to DimCoord + # equivalence. We require to do this explicitly here + # as the "circular" member does NOT participate in + # DimCoordMetadata to DimCoordMetadata equivalence. + result = self.circular == other.circular and super().__eq__(other) return result # The __ne__ operator from Coord implements the not __eq__ method. @@ -2779,19 +2641,20 @@ def __init__(self, method, coords=None, intervals=None, comments=None): "'method' must be a string - got a '%s'" % type(method) ) - default_name = CFVariableMixin._DEFAULT_NAME + default_name = BaseMetadata.DEFAULT_NAME _coords = [] + if coords is None: pass elif isinstance(coords, Coord): _coords.append(coords.name(token=True)) elif isinstance(coords, str): - _coords.append(CFVariableMixin.token(coords) or default_name) + _coords.append(BaseMetadata.token(coords) or default_name) else: normalise = ( lambda coord: coord.name(token=True) if isinstance(coord, Coord) - else CFVariableMixin.token(coord) or default_name + else BaseMetadata.token(coord) or default_name ) _coords.extend([normalise(coord) for coord in coords]) diff --git a/lib/iris/cube.py b/lib/iris/cube.py index 964e56c313..7c28018512 100644 --- a/lib/iris/cube.py +++ b/lib/iris/cube.py @@ -9,7 +9,7 @@ """ -from collections import namedtuple, OrderedDict +from collections import OrderedDict from collections.abc import ( Iterable, Container, @@ -29,56 +29,29 @@ import numpy as np import numpy.ma as ma -from iris._cube_coord_common import CFVariableMixin import iris._concatenate import iris._constraints from iris._data_manager import DataManager import iris._lazy_data as _lazy - import iris._merge import iris.analysis from iris.analysis.cartography import wrap_lons import iris.analysis.maths import iris.aux_factory +from iris.common import ( + CFVariableMixin, + CoordMetadata, + CubeMetadata, + DimCoordMetadata, + metadata_manager_factory, +) import iris.coord_systems import iris.coords import iris.exceptions import iris.util -__all__ = ["Cube", "CubeList", "CubeMetadata"] - - -class CubeMetadata( - namedtuple( - "CubeMetadata", - [ - "standard_name", - "long_name", - "var_name", - "units", - "attributes", - "cell_methods", - ], - ) -): - """ - Represents the phenomenon metadata for a single :class:`Cube`. - - """ - - __slots__ = () - - def name(self, default="unknown"): - """ - Returns a human-readable name. - - First it tries self.standard_name, then it tries the 'long_name' - attribute, then the 'var_name' attribute, before falling back to - the value of `default` (which itself defaults to 'unknown'). - - """ - return self.standard_name or self.long_name or self.var_name or default +__all__ = ["Cube", "CubeList"] # The XML namespace to use for CubeML documents @@ -864,6 +837,9 @@ def __init__( if isinstance(data, str): raise TypeError("Invalid data type: {!r}.".format(data)) + # Configure the metadata manager. + self._metadata_manager = metadata_manager_factory(CubeMetadata) + # Initialise the cube data manager. self._data_manager = DataManager(data) @@ -930,43 +906,15 @@ def __init__( self.add_ancillary_variable(ancillary_variable, dims) @property - def metadata(self): + def _names(self): """ - An instance of :class:`CubeMetadata` describing the phenomenon. - - This property can be updated with any of: - - another :class:`CubeMetadata` instance, - - a tuple/dict which can be used to make a :class:`CubeMetadata`, - - or any object providing the attributes exposed by - :class:`CubeMetadata`. + A tuple containing the value of each name participating in the identity + of a :class:`iris.cube.Cube`. This includes the standard name, + long name, NetCDF variable name, and the STASH from the attributes + dictionary. """ - return CubeMetadata( - self.standard_name, - self.long_name, - self.var_name, - self.units, - self.attributes, - self.cell_methods, - ) - - @metadata.setter - def metadata(self, value): - try: - value = CubeMetadata(**value) - except TypeError: - try: - value = CubeMetadata(*value) - except TypeError: - missing_attrs = [ - field - for field in CubeMetadata._fields - if not hasattr(value, field) - ] - if missing_attrs: - raise TypeError("Invalid/incomplete metadata") - for name in CubeMetadata._fields: - setattr(self, name, getattr(value, name)) + return self._metadata_manager._names def is_compatible(self, other, ignore=None): """ @@ -1186,7 +1134,7 @@ def add_cell_measure(self, cell_measure, data_dims=None): data_dims = self._check_multi_dim_metadata(cell_measure, data_dims) self._cell_measures_and_dims.append((cell_measure, data_dims)) self._cell_measures_and_dims.sort( - key=lambda cm_dims: (cm_dims[0]._as_defn(), cm_dims[1]) + key=lambda cm_dims: (cm_dims[0].metadata, cm_dims[1]) ) def add_ancillary_variable(self, ancillary_variable, data_dims=None): @@ -1219,7 +1167,7 @@ def add_ancillary_variable(self, ancillary_variable, data_dims=None): (ancillary_variable, data_dims) ) self._ancillary_variables_and_dims.sort( - key=lambda av_dims: (av_dims[0]._as_defn(), av_dims[1]) + key=lambda av_dims: (av_dims[0].metadata, av_dims[1]) ) def add_dim_coord(self, dim_coord, data_dim): @@ -1304,7 +1252,7 @@ def _remove_coord(self, coord): if coord_ is not coord ] for aux_factory in self.aux_factories: - if coord._as_defn() == aux_factory._as_defn(): + if coord.metadata == aux_factory.metadata: self.remove_aux_factory(aux_factory) def remove_coord(self, coord): @@ -1338,7 +1286,7 @@ def remove_cell_measure(self, cell_measure): (a) a :attr:`standard_name`, :attr:`long_name`, or :attr:`var_name`. Defaults to value of `default` (which itself defaults to `unknown`) as defined in - :class:`iris._cube_coord_common.CFVariableMixin`. + :class:`iris.common.CFVariableMixin`. (b) a cell_measure instance with metadata equal to that of the desired cell_measures. @@ -1431,11 +1379,11 @@ def coord_dims(self, coord): ] # Search derived aux coords - target_defn = coord._as_defn() if not matches: + target_metadata = coord.metadata def match(factory): - return factory._as_defn() == target_defn + return factory.metadata == target_metadata factories = filter(match, self._aux_factories) matches = [ @@ -1591,13 +1539,14 @@ def coords( (a) a :attr:`standard_name`, :attr:`long_name`, or :attr:`var_name`. Defaults to value of `default` (which itself defaults to `unknown`) as defined in - :class:`iris._cube_coord_common.CFVariableMixin`. + :class:`iris.common.CFVariableMixin`. (b) a coordinate instance with metadata equal to that of the desired coordinates. Accepts either a :class:`iris.coords.DimCoord`, :class:`iris.coords.AuxCoord`, - :class:`iris.aux_factory.AuxCoordFactory` - or :class:`iris.coords.CoordDefn`. + :class:`iris.aux_factory.AuxCoordFactory`, + :class:`iris.common.CoordMetadata` or + :class:`iris.common.DimCoordMetadata`. * standard_name The CF standard name of the desired coordinate. If None, does not check for standard name. @@ -1715,14 +1664,17 @@ def attr_filter(coord_): ] if coord is not None: - if isinstance(coord, iris.coords.CoordDefn): - defn = coord + if hasattr(coord, "__class__") and coord.__class__ in ( + CoordMetadata, + DimCoordMetadata, + ): + target_metadata = coord else: - defn = coord._as_defn() + target_metadata = coord.metadata coords_and_factories = [ coord_ for coord_ in coords_and_factories - if coord_._as_defn() == defn + if coord_.metadata == target_metadata ] if contains_dimension is not None: @@ -1888,7 +1840,7 @@ def cell_measures(self, name_or_cell_measure=None): (a) a :attr:`standard_name`, :attr:`long_name`, or :attr:`var_name`. Defaults to value of `default` (which itself defaults to `unknown`) as defined in - :class:`iris._cube_coord_common.CFVariableMixin`. + :class:`iris.common.CFVariableMixin`. (b) a cell_measure instance with metadata equal to that of the desired cell_measures. @@ -1971,7 +1923,7 @@ def ancillary_variables(self, name_or_ancillary_variable=None): (a) a :attr:`standard_name`, :attr:`long_name`, or :attr:`var_name`. Defaults to value of `default` (which itself defaults to `unknown`) as defined in - :class:`iris._cube_coord_common.CFVariableMixin`. + :class:`iris.common.CFVariableMixin`. (b) a ancillary_variable instance with metadata equal to that of the desired ancillary_variables. @@ -2052,11 +2004,13 @@ def cell_methods(self): done on the phenomenon. """ - return self._cell_methods + return self._metadata_manager.cell_methods @cell_methods.setter def cell_methods(self, cell_methods): - self._cell_methods = tuple(cell_methods) if cell_methods else tuple() + self._metadata_manager.cell_methods = ( + tuple(cell_methods) if cell_methods else tuple() + ) def core_data(self): """ @@ -4084,7 +4038,7 @@ def aggregated_by(self, coords, aggregator, **kwargs): ) coords = self._as_list_of_coords(coords) - for coord in sorted(coords, key=lambda coord: coord._as_defn()): + for coord in sorted(coords, key=lambda coord: coord.metadata): if coord.ndim > 1: msg = ( "Cannot aggregate_by coord %s as it is " @@ -4200,7 +4154,7 @@ def aggregated_by(self, coords, aggregator, **kwargs): for coord in groupby.coords: if ( dim_coord is not None - and dim_coord._as_defn() == coord._as_defn() + and dim_coord.metadata == coord.metadata and isinstance(coord, iris.coords.DimCoord) ): aggregateby_cube.add_dim_coord( diff --git a/lib/iris/etc/logging.yaml b/lib/iris/etc/logging.yaml new file mode 100644 index 0000000000..5671916ff9 --- /dev/null +++ b/lib/iris/etc/logging.yaml @@ -0,0 +1,39 @@ +version: 1 + +formatters: + basic: + format: "%(asctime)s %(name)s %(levelname)s - %(message)s" + datefmt: "%d-%m-%Y %H:%M:%S" + basic-cls-func: + format: "%(asctime)s %(name)s %(levelname)s - %(message)s [%(cls)s.%(funcName)s]" + datefmt: "%d-%m-%Y %H:%M:%S" + basic-func: + format: "%(asctime)s %(name)s %(levelname)s - %(message)s [%(funcName)s]" + +handlers: + console: + class: logging.StreamHandler + formatter: basic + stream: ext://sys.stdout + console-cls-func: + class: logging.StreamHandler + formatter: basic-cls-func + stream: ext://sys.stdout + console-func: + class: logging.StreamHandler + formatter: basic-func + stream: ext://sys.stdout + +loggers: + iris.common.metadata: + level: INFO + handlers: [console-cls-func] + propagate: no + iris.common.resolve: + level: INFO + handlers: [console-func] + propagate: no + +root: + level: INFO + handlers: [console] diff --git a/lib/iris/fileformats/_pyke_rules/fc_rules_cf.krb b/lib/iris/fileformats/_pyke_rules/fc_rules_cf.krb index 5ecfeb77b1..ad2c181b0b 100644 --- a/lib/iris/fileformats/_pyke_rules/fc_rules_cf.krb +++ b/lib/iris/fileformats/_pyke_rules/fc_rules_cf.krb @@ -1173,6 +1173,7 @@ fc_extras import numpy.ma as ma import iris.aux_factory + from iris.common.mixin import _get_valid_standard_name import iris.coords import iris.coord_systems import iris.fileformats.cf as cf @@ -1182,7 +1183,6 @@ fc_extras import iris.exceptions import iris.std_names import iris.util - from iris._cube_coord_common import get_valid_standard_name from iris._lazy_data import as_lazy_data @@ -1298,7 +1298,7 @@ fc_extras if standard_name is not None: try: - cube.standard_name = get_valid_standard_name(standard_name) + cube.standard_name = _get_valid_standard_name(standard_name) except ValueError: if cube.long_name is not None: cube.attributes['invalid_standard_name'] = standard_name @@ -1693,7 +1693,7 @@ fc_extras if standard_name is not None: try: - standard_name = get_valid_standard_name(standard_name) + standard_name = _get_valid_standard_name(standard_name) except ValueError: if long_name is not None: attributes['invalid_standard_name'] = standard_name diff --git a/lib/iris/iterate.py b/lib/iris/iterate.py index 6cca135d21..ea2d939280 100644 --- a/lib/iris/iterate.py +++ b/lib/iris/iterate.py @@ -302,12 +302,13 @@ def __init__(self, coord): self._coord = coord # Methods of contained class we need to expose/use. - def _as_defn(self): - return self._coord._as_defn() + @property + def metadata(self): + return self._coord.metadata - # Methods of contained class we want to overide/customise. + # Methods of contained class we want to override/customise. def __eq__(self, other): - return self._coord._as_defn() == other._as_defn() + return self._coord.metadata == other.metadata # Force use of __eq__ for set operations. def __hash__(self): diff --git a/lib/iris/plot.py b/lib/iris/plot.py index 9dff582bc4..36afe906dc 100644 --- a/lib/iris/plot.py +++ b/lib/iris/plot.py @@ -168,7 +168,7 @@ def guess_axis(coord): if isinstance(coord, iris.coords.DimCoord) ] if aux_coords: - aux_coords.sort(key=lambda coord: coord._as_defn()) + aux_coords.sort(key=lambda coord: coord.metadata) coords[dim] = aux_coords[0] # If plotting a 2 dimensional plot, check for 2d coordinates @@ -183,7 +183,7 @@ def guess_axis(coord): coord for coord in two_dim_coords if coord.ndim == 2 ] if len(two_dim_coords) >= 2: - two_dim_coords.sort(key=lambda coord: coord._as_defn()) + two_dim_coords.sort(key=lambda coord: coord.metadata) coords = two_dim_coords[:2] if mode == iris.coords.POINT_MODE: diff --git a/lib/iris/tests/__init__.py b/lib/iris/tests/__init__.py index 9132e16680..b5b80a97ef 100644 --- a/lib/iris/tests/__init__.py +++ b/lib/iris/tests/__init__.py @@ -21,6 +21,7 @@ import codecs import collections +from collections.abc import Mapping import contextlib import datetime import difflib @@ -1004,6 +1005,78 @@ def assertArrayShapeStats(self, result, shape, mean, std_dev, rtol=1e-6): self.assertArrayAllClose(result.data.mean(), mean, rtol=rtol) self.assertArrayAllClose(result.data.std(), std_dev, rtol=rtol) + def assertDictEqual(self, lhs, rhs, msg=None): + """ + This method overrides unittest.TestCase.assertDictEqual (new in Python3.1) + in order to cope with dictionary comparison where the value of a key may + be a numpy array. + + """ + if not isinstance(lhs, Mapping): + emsg = ( + f"Provided LHS argument is not a 'Mapping', got {type(lhs)}." + ) + self.fail(emsg) + + if not isinstance(rhs, Mapping): + emsg = ( + f"Provided RHS argument is not a 'Mapping', got {type(rhs)}." + ) + self.fail(emsg) + + if set(lhs.keys()) != set(rhs.keys()): + emsg = f"{lhs!r} != {rhs!r}." + self.fail(emsg) + + for key in lhs.keys(): + lvalue, rvalue = lhs[key], rhs[key] + + if ma.isMaskedArray(lvalue) or ma.isMaskedArray(rvalue): + if not ma.isMaskedArray(lvalue): + emsg = ( + f"Dictionary key {key!r} values are not equal, " + f"the LHS value has type {type(lvalue)} and " + f"the RHS value has type {ma.core.MaskedArray}." + ) + raise AssertionError(emsg) + + if not ma.isMaskedArray(rvalue): + emsg = ( + f"Dictionary key {key!r} values are not equal, " + f"the LHS value has type {ma.core.MaskedArray} and " + f"the RHS value has type {type(lvalue)}." + ) + raise AssertionError(emsg) + + self.assertMaskedArrayEqual(lvalue, rvalue) + elif isinstance(lvalue, np.ndarray) or isinstance( + rvalue, np.ndarray + ): + if not isinstance(lvalue, np.ndarray): + emsg = ( + f"Dictionary key {key!r} values are not equal, " + f"the LHS value has type {type(lvalue)} and " + f"the RHS value has type {np.ndarray}." + ) + raise AssertionError(emsg) + + if not isinstance(rvalue, np.ndarray): + emsg = ( + f"Dictionary key {key!r} values are not equal, " + f"the LHS value has type {np.ndarray} and " + f"the RHS value has type {type(rvalue)}." + ) + raise AssertionError(emsg) + + self.assertArrayEqual(lvalue, rvalue) + else: + if lvalue != rvalue: + emsg = ( + f"Dictionary key {key!r} values are not equal, " + f"{lvalue!r} != {rvalue!r}." + ) + raise AssertionError(emsg) + # An environment variable controls whether test timings are output. # diff --git a/lib/iris/tests/integration/fast_load/test_fast_load.py b/lib/iris/tests/integration/fast_load/test_fast_load.py index 0a4d186b39..ba50e389a8 100644 --- a/lib/iris/tests/integration/fast_load/test_fast_load.py +++ b/lib/iris/tests/integration/fast_load/test_fast_load.py @@ -9,7 +9,7 @@ # before importing anything else. import iris.tests as tests -from collections import Iterable +from collections.abc import Iterable import tempfile import shutil @@ -377,7 +377,8 @@ def callback(cube, collation, filename): # Make an 'expected' from selected fields, with the expected attribute. expected = CubeList([flds[1], flds[3]]).merge() if not self.do_fast_loads: - expected[0].attributes["LBVC"] = 8 + # This is actually a NumPy int32, so honour that here. + expected[0].attributes["LBVC"] = np.int32(8) else: expected[0].attributes["A_LBVC"] = [8, 8] diff --git a/lib/iris/tests/results/analysis/abs.cml b/lib/iris/tests/results/analysis/abs.cml index e92f96e1cb..b0a37b6074 100644 --- a/lib/iris/tests/results/analysis/abs.cml +++ b/lib/iris/tests/results/analysis/abs.cml @@ -1,6 +1,9 @@ + + + diff --git a/lib/iris/tests/results/analysis/addition.cml b/lib/iris/tests/results/analysis/addition.cml index d673e73bb3..4f9600694d 100644 --- a/lib/iris/tests/results/analysis/addition.cml +++ b/lib/iris/tests/results/analysis/addition.cml @@ -1,6 +1,9 @@ + + + diff --git a/lib/iris/tests/results/analysis/addition_coord_x.cml b/lib/iris/tests/results/analysis/addition_coord_x.cml index af0c5ecc91..a086b8ad8b 100644 --- a/lib/iris/tests/results/analysis/addition_coord_x.cml +++ b/lib/iris/tests/results/analysis/addition_coord_x.cml @@ -1,6 +1,9 @@ + + + diff --git a/lib/iris/tests/results/analysis/addition_coord_y.cml b/lib/iris/tests/results/analysis/addition_coord_y.cml index ba8547b617..266e81c912 100644 --- a/lib/iris/tests/results/analysis/addition_coord_y.cml +++ b/lib/iris/tests/results/analysis/addition_coord_y.cml @@ -1,6 +1,9 @@ + + + diff --git a/lib/iris/tests/results/analysis/addition_different_std_name.cml b/lib/iris/tests/results/analysis/addition_different_std_name.cml index cb77adde99..14b0b42dd8 100644 --- a/lib/iris/tests/results/analysis/addition_different_std_name.cml +++ b/lib/iris/tests/results/analysis/addition_different_std_name.cml @@ -1,6 +1,9 @@ + + + diff --git a/lib/iris/tests/results/analysis/addition_in_place.cml b/lib/iris/tests/results/analysis/addition_in_place.cml index d673e73bb3..4f9600694d 100644 --- a/lib/iris/tests/results/analysis/addition_in_place.cml +++ b/lib/iris/tests/results/analysis/addition_in_place.cml @@ -1,6 +1,9 @@ + + + diff --git a/lib/iris/tests/results/analysis/addition_in_place_coord.cml b/lib/iris/tests/results/analysis/addition_in_place_coord.cml index 6ec39571c1..00dee609eb 100644 --- a/lib/iris/tests/results/analysis/addition_in_place_coord.cml +++ b/lib/iris/tests/results/analysis/addition_in_place_coord.cml @@ -1,6 +1,9 @@ + + + diff --git a/lib/iris/tests/results/analysis/addition_scalar.cml b/lib/iris/tests/results/analysis/addition_scalar.cml index d65d7492fe..daf0050069 100644 --- a/lib/iris/tests/results/analysis/addition_scalar.cml +++ b/lib/iris/tests/results/analysis/addition_scalar.cml @@ -1,6 +1,9 @@ + + + diff --git a/lib/iris/tests/results/analysis/apply_ifunc.cml b/lib/iris/tests/results/analysis/apply_ifunc.cml index f2bac40826..fe0e394ee6 100644 --- a/lib/iris/tests/results/analysis/apply_ifunc.cml +++ b/lib/iris/tests/results/analysis/apply_ifunc.cml @@ -1,6 +1,9 @@ + + + diff --git a/lib/iris/tests/results/analysis/apply_ifunc_frompyfunc.cml b/lib/iris/tests/results/analysis/apply_ifunc_frompyfunc.cml index 2faa06f4a5..29cb6f611e 100644 --- a/lib/iris/tests/results/analysis/apply_ifunc_frompyfunc.cml +++ b/lib/iris/tests/results/analysis/apply_ifunc_frompyfunc.cml @@ -1,6 +1,9 @@ + + + diff --git a/lib/iris/tests/results/analysis/apply_ufunc.cml b/lib/iris/tests/results/analysis/apply_ufunc.cml index f2bac40826..fe0e394ee6 100644 --- a/lib/iris/tests/results/analysis/apply_ufunc.cml +++ b/lib/iris/tests/results/analysis/apply_ufunc.cml @@ -1,6 +1,9 @@ + + + diff --git a/lib/iris/tests/results/analysis/apply_ufunc_frompyfunc.cml b/lib/iris/tests/results/analysis/apply_ufunc_frompyfunc.cml index d4239acbad..7b1511f028 100644 --- a/lib/iris/tests/results/analysis/apply_ufunc_frompyfunc.cml +++ b/lib/iris/tests/results/analysis/apply_ufunc_frompyfunc.cml @@ -1,6 +1,9 @@ + + + diff --git a/lib/iris/tests/results/analysis/division.cml b/lib/iris/tests/results/analysis/division.cml index bbe6c1eb90..762f51ec0a 100644 --- a/lib/iris/tests/results/analysis/division.cml +++ b/lib/iris/tests/results/analysis/division.cml @@ -1,6 +1,9 @@ + + + diff --git a/lib/iris/tests/results/analysis/division_by_array.cml b/lib/iris/tests/results/analysis/division_by_array.cml index cb77adde99..14b0b42dd8 100644 --- a/lib/iris/tests/results/analysis/division_by_array.cml +++ b/lib/iris/tests/results/analysis/division_by_array.cml @@ -1,6 +1,9 @@ + + + diff --git a/lib/iris/tests/results/analysis/division_by_latitude.cml b/lib/iris/tests/results/analysis/division_by_latitude.cml index 3e2abf69cd..42437d1e36 100644 --- a/lib/iris/tests/results/analysis/division_by_latitude.cml +++ b/lib/iris/tests/results/analysis/division_by_latitude.cml @@ -1,6 +1,9 @@ + + + diff --git a/lib/iris/tests/results/analysis/division_by_longitude.cml b/lib/iris/tests/results/analysis/division_by_longitude.cml index b1a0228dc8..264ce9b793 100644 --- a/lib/iris/tests/results/analysis/division_by_longitude.cml +++ b/lib/iris/tests/results/analysis/division_by_longitude.cml @@ -1,6 +1,9 @@ + + + diff --git a/lib/iris/tests/results/analysis/division_by_singular_coord.cml b/lib/iris/tests/results/analysis/division_by_singular_coord.cml index 7f7835a1be..4c9c58d760 100644 --- a/lib/iris/tests/results/analysis/division_by_singular_coord.cml +++ b/lib/iris/tests/results/analysis/division_by_singular_coord.cml @@ -1,6 +1,9 @@ + + + diff --git a/lib/iris/tests/results/analysis/division_scalar.cml b/lib/iris/tests/results/analysis/division_scalar.cml index cb77adde99..14b0b42dd8 100644 --- a/lib/iris/tests/results/analysis/division_scalar.cml +++ b/lib/iris/tests/results/analysis/division_scalar.cml @@ -1,6 +1,9 @@ + + + diff --git a/lib/iris/tests/results/analysis/exponentiate.cml b/lib/iris/tests/results/analysis/exponentiate.cml index a13c6be151..bb825f6714 100644 --- a/lib/iris/tests/results/analysis/exponentiate.cml +++ b/lib/iris/tests/results/analysis/exponentiate.cml @@ -1,6 +1,9 @@ + + + diff --git a/lib/iris/tests/results/analysis/log.cml b/lib/iris/tests/results/analysis/log.cml index 33214d01f1..c24e071dc5 100644 --- a/lib/iris/tests/results/analysis/log.cml +++ b/lib/iris/tests/results/analysis/log.cml @@ -1,6 +1,9 @@ + + + diff --git a/lib/iris/tests/results/analysis/log10.cml b/lib/iris/tests/results/analysis/log10.cml index fbee8f73f0..abd4065526 100644 --- a/lib/iris/tests/results/analysis/log10.cml +++ b/lib/iris/tests/results/analysis/log10.cml @@ -1,6 +1,9 @@ + + + diff --git a/lib/iris/tests/results/analysis/log2.cml b/lib/iris/tests/results/analysis/log2.cml index 6371f3925b..d121ad9a9d 100644 --- a/lib/iris/tests/results/analysis/log2.cml +++ b/lib/iris/tests/results/analysis/log2.cml @@ -1,6 +1,9 @@ + + + diff --git a/lib/iris/tests/results/analysis/multiply.cml b/lib/iris/tests/results/analysis/multiply.cml index 44996a9138..8fb8658f5d 100644 --- a/lib/iris/tests/results/analysis/multiply.cml +++ b/lib/iris/tests/results/analysis/multiply.cml @@ -1,6 +1,9 @@ + + + diff --git a/lib/iris/tests/results/analysis/multiply_different_std_name.cml b/lib/iris/tests/results/analysis/multiply_different_std_name.cml index 49f1779b77..2d89e5882f 100644 --- a/lib/iris/tests/results/analysis/multiply_different_std_name.cml +++ b/lib/iris/tests/results/analysis/multiply_different_std_name.cml @@ -1,6 +1,9 @@ + + + diff --git a/lib/iris/tests/results/analysis/sqrt.cml b/lib/iris/tests/results/analysis/sqrt.cml index 3a7bff138c..0dd0fe20b3 100644 --- a/lib/iris/tests/results/analysis/sqrt.cml +++ b/lib/iris/tests/results/analysis/sqrt.cml @@ -1,6 +1,9 @@ + + + diff --git a/lib/iris/tests/results/analysis/subtract.cml b/lib/iris/tests/results/analysis/subtract.cml index 7b0740888d..3466578756 100644 --- a/lib/iris/tests/results/analysis/subtract.cml +++ b/lib/iris/tests/results/analysis/subtract.cml @@ -1,6 +1,9 @@ + + + diff --git a/lib/iris/tests/results/analysis/subtract_array.cml b/lib/iris/tests/results/analysis/subtract_array.cml index cb77adde99..14b0b42dd8 100644 --- a/lib/iris/tests/results/analysis/subtract_array.cml +++ b/lib/iris/tests/results/analysis/subtract_array.cml @@ -1,6 +1,9 @@ + + + diff --git a/lib/iris/tests/results/analysis/subtract_coord_x.cml b/lib/iris/tests/results/analysis/subtract_coord_x.cml index c7aee8395b..060814c6ba 100644 --- a/lib/iris/tests/results/analysis/subtract_coord_x.cml +++ b/lib/iris/tests/results/analysis/subtract_coord_x.cml @@ -1,6 +1,9 @@ + + + diff --git a/lib/iris/tests/results/analysis/subtract_coord_y.cml b/lib/iris/tests/results/analysis/subtract_coord_y.cml index 355692b27b..4a9351cf6f 100644 --- a/lib/iris/tests/results/analysis/subtract_coord_y.cml +++ b/lib/iris/tests/results/analysis/subtract_coord_y.cml @@ -1,6 +1,9 @@ + + + diff --git a/lib/iris/tests/results/analysis/subtract_scalar.cml b/lib/iris/tests/results/analysis/subtract_scalar.cml index ab8e9d0d60..f458364143 100644 --- a/lib/iris/tests/results/analysis/subtract_scalar.cml +++ b/lib/iris/tests/results/analysis/subtract_scalar.cml @@ -1,6 +1,9 @@ + + + diff --git a/lib/iris/tests/results/unit/analysis/maths/add/TestBroadcasting/collapse_all_dims.cml b/lib/iris/tests/results/unit/analysis/maths/add/TestBroadcasting/collapse_all_dims.cml index c6e6271a63..bea6795b38 100644 --- a/lib/iris/tests/results/unit/analysis/maths/add/TestBroadcasting/collapse_all_dims.cml +++ b/lib/iris/tests/results/unit/analysis/maths/add/TestBroadcasting/collapse_all_dims.cml @@ -1,6 +1,9 @@ + + + diff --git a/lib/iris/tests/results/unit/analysis/maths/add/TestBroadcasting/collapse_last_dims.cml b/lib/iris/tests/results/unit/analysis/maths/add/TestBroadcasting/collapse_last_dims.cml index c6e6271a63..bea6795b38 100644 --- a/lib/iris/tests/results/unit/analysis/maths/add/TestBroadcasting/collapse_last_dims.cml +++ b/lib/iris/tests/results/unit/analysis/maths/add/TestBroadcasting/collapse_last_dims.cml @@ -1,6 +1,9 @@ + + + diff --git a/lib/iris/tests/results/unit/analysis/maths/add/TestBroadcasting/collapse_middle_dim.cml b/lib/iris/tests/results/unit/analysis/maths/add/TestBroadcasting/collapse_middle_dim.cml index c6e6271a63..bea6795b38 100644 --- a/lib/iris/tests/results/unit/analysis/maths/add/TestBroadcasting/collapse_middle_dim.cml +++ b/lib/iris/tests/results/unit/analysis/maths/add/TestBroadcasting/collapse_middle_dim.cml @@ -1,6 +1,9 @@ + + + diff --git a/lib/iris/tests/results/unit/analysis/maths/add/TestBroadcasting/collapse_zeroth_dim.cml b/lib/iris/tests/results/unit/analysis/maths/add/TestBroadcasting/collapse_zeroth_dim.cml index c6e6271a63..bea6795b38 100644 --- a/lib/iris/tests/results/unit/analysis/maths/add/TestBroadcasting/collapse_zeroth_dim.cml +++ b/lib/iris/tests/results/unit/analysis/maths/add/TestBroadcasting/collapse_zeroth_dim.cml @@ -1,6 +1,9 @@ + + + diff --git a/lib/iris/tests/results/unit/analysis/maths/add/TestBroadcasting/slice.cml b/lib/iris/tests/results/unit/analysis/maths/add/TestBroadcasting/slice.cml index c6e6271a63..bea6795b38 100644 --- a/lib/iris/tests/results/unit/analysis/maths/add/TestBroadcasting/slice.cml +++ b/lib/iris/tests/results/unit/analysis/maths/add/TestBroadcasting/slice.cml @@ -1,6 +1,9 @@ + + + diff --git a/lib/iris/tests/results/unit/analysis/maths/add/TestBroadcasting/transposed.cml b/lib/iris/tests/results/unit/analysis/maths/add/TestBroadcasting/transposed.cml index c6e6271a63..bea6795b38 100644 --- a/lib/iris/tests/results/unit/analysis/maths/add/TestBroadcasting/transposed.cml +++ b/lib/iris/tests/results/unit/analysis/maths/add/TestBroadcasting/transposed.cml @@ -1,6 +1,9 @@ + + + diff --git a/lib/iris/tests/results/unit/analysis/maths/divide/TestBroadcasting/collapse_all_dims.cml b/lib/iris/tests/results/unit/analysis/maths/divide/TestBroadcasting/collapse_all_dims.cml index 940661c230..d4a90d37ac 100644 --- a/lib/iris/tests/results/unit/analysis/maths/divide/TestBroadcasting/collapse_all_dims.cml +++ b/lib/iris/tests/results/unit/analysis/maths/divide/TestBroadcasting/collapse_all_dims.cml @@ -1,6 +1,9 @@ + + + diff --git a/lib/iris/tests/results/unit/analysis/maths/divide/TestBroadcasting/collapse_last_dims.cml b/lib/iris/tests/results/unit/analysis/maths/divide/TestBroadcasting/collapse_last_dims.cml index 940661c230..d4a90d37ac 100644 --- a/lib/iris/tests/results/unit/analysis/maths/divide/TestBroadcasting/collapse_last_dims.cml +++ b/lib/iris/tests/results/unit/analysis/maths/divide/TestBroadcasting/collapse_last_dims.cml @@ -1,6 +1,9 @@ + + + diff --git a/lib/iris/tests/results/unit/analysis/maths/divide/TestBroadcasting/collapse_middle_dim.cml b/lib/iris/tests/results/unit/analysis/maths/divide/TestBroadcasting/collapse_middle_dim.cml index 940661c230..d4a90d37ac 100644 --- a/lib/iris/tests/results/unit/analysis/maths/divide/TestBroadcasting/collapse_middle_dim.cml +++ b/lib/iris/tests/results/unit/analysis/maths/divide/TestBroadcasting/collapse_middle_dim.cml @@ -1,6 +1,9 @@ + + + diff --git a/lib/iris/tests/results/unit/analysis/maths/divide/TestBroadcasting/collapse_zeroth_dim.cml b/lib/iris/tests/results/unit/analysis/maths/divide/TestBroadcasting/collapse_zeroth_dim.cml index 940661c230..d4a90d37ac 100644 --- a/lib/iris/tests/results/unit/analysis/maths/divide/TestBroadcasting/collapse_zeroth_dim.cml +++ b/lib/iris/tests/results/unit/analysis/maths/divide/TestBroadcasting/collapse_zeroth_dim.cml @@ -1,6 +1,9 @@ + + + diff --git a/lib/iris/tests/results/unit/analysis/maths/divide/TestBroadcasting/slice.cml b/lib/iris/tests/results/unit/analysis/maths/divide/TestBroadcasting/slice.cml index 940661c230..d4a90d37ac 100644 --- a/lib/iris/tests/results/unit/analysis/maths/divide/TestBroadcasting/slice.cml +++ b/lib/iris/tests/results/unit/analysis/maths/divide/TestBroadcasting/slice.cml @@ -1,6 +1,9 @@ + + + diff --git a/lib/iris/tests/results/unit/analysis/maths/divide/TestBroadcasting/transposed.cml b/lib/iris/tests/results/unit/analysis/maths/divide/TestBroadcasting/transposed.cml index 940661c230..d4a90d37ac 100644 --- a/lib/iris/tests/results/unit/analysis/maths/divide/TestBroadcasting/transposed.cml +++ b/lib/iris/tests/results/unit/analysis/maths/divide/TestBroadcasting/transposed.cml @@ -1,6 +1,9 @@ + + + diff --git a/lib/iris/tests/results/unit/analysis/maths/multiply/TestBroadcasting/collapse_all_dims.cml b/lib/iris/tests/results/unit/analysis/maths/multiply/TestBroadcasting/collapse_all_dims.cml index b646e8b550..7ae36e51c3 100644 --- a/lib/iris/tests/results/unit/analysis/maths/multiply/TestBroadcasting/collapse_all_dims.cml +++ b/lib/iris/tests/results/unit/analysis/maths/multiply/TestBroadcasting/collapse_all_dims.cml @@ -1,6 +1,9 @@ + + + diff --git a/lib/iris/tests/results/unit/analysis/maths/multiply/TestBroadcasting/collapse_last_dims.cml b/lib/iris/tests/results/unit/analysis/maths/multiply/TestBroadcasting/collapse_last_dims.cml index b646e8b550..7ae36e51c3 100644 --- a/lib/iris/tests/results/unit/analysis/maths/multiply/TestBroadcasting/collapse_last_dims.cml +++ b/lib/iris/tests/results/unit/analysis/maths/multiply/TestBroadcasting/collapse_last_dims.cml @@ -1,6 +1,9 @@ + + + diff --git a/lib/iris/tests/results/unit/analysis/maths/multiply/TestBroadcasting/collapse_middle_dim.cml b/lib/iris/tests/results/unit/analysis/maths/multiply/TestBroadcasting/collapse_middle_dim.cml index b646e8b550..7ae36e51c3 100644 --- a/lib/iris/tests/results/unit/analysis/maths/multiply/TestBroadcasting/collapse_middle_dim.cml +++ b/lib/iris/tests/results/unit/analysis/maths/multiply/TestBroadcasting/collapse_middle_dim.cml @@ -1,6 +1,9 @@ + + + diff --git a/lib/iris/tests/results/unit/analysis/maths/multiply/TestBroadcasting/collapse_zeroth_dim.cml b/lib/iris/tests/results/unit/analysis/maths/multiply/TestBroadcasting/collapse_zeroth_dim.cml index b646e8b550..7ae36e51c3 100644 --- a/lib/iris/tests/results/unit/analysis/maths/multiply/TestBroadcasting/collapse_zeroth_dim.cml +++ b/lib/iris/tests/results/unit/analysis/maths/multiply/TestBroadcasting/collapse_zeroth_dim.cml @@ -1,6 +1,9 @@ + + + diff --git a/lib/iris/tests/results/unit/analysis/maths/multiply/TestBroadcasting/slice.cml b/lib/iris/tests/results/unit/analysis/maths/multiply/TestBroadcasting/slice.cml index b646e8b550..7ae36e51c3 100644 --- a/lib/iris/tests/results/unit/analysis/maths/multiply/TestBroadcasting/slice.cml +++ b/lib/iris/tests/results/unit/analysis/maths/multiply/TestBroadcasting/slice.cml @@ -1,6 +1,9 @@ + + + diff --git a/lib/iris/tests/results/unit/analysis/maths/multiply/TestBroadcasting/transposed.cml b/lib/iris/tests/results/unit/analysis/maths/multiply/TestBroadcasting/transposed.cml index b646e8b550..7ae36e51c3 100644 --- a/lib/iris/tests/results/unit/analysis/maths/multiply/TestBroadcasting/transposed.cml +++ b/lib/iris/tests/results/unit/analysis/maths/multiply/TestBroadcasting/transposed.cml @@ -1,6 +1,9 @@ + + + diff --git a/lib/iris/tests/results/unit/analysis/maths/subtract/TestBroadcasting/collapse_all_dims.cml b/lib/iris/tests/results/unit/analysis/maths/subtract/TestBroadcasting/collapse_all_dims.cml index c6e6271a63..bea6795b38 100644 --- a/lib/iris/tests/results/unit/analysis/maths/subtract/TestBroadcasting/collapse_all_dims.cml +++ b/lib/iris/tests/results/unit/analysis/maths/subtract/TestBroadcasting/collapse_all_dims.cml @@ -1,6 +1,9 @@ + + + diff --git a/lib/iris/tests/results/unit/analysis/maths/subtract/TestBroadcasting/collapse_last_dims.cml b/lib/iris/tests/results/unit/analysis/maths/subtract/TestBroadcasting/collapse_last_dims.cml index c6e6271a63..bea6795b38 100644 --- a/lib/iris/tests/results/unit/analysis/maths/subtract/TestBroadcasting/collapse_last_dims.cml +++ b/lib/iris/tests/results/unit/analysis/maths/subtract/TestBroadcasting/collapse_last_dims.cml @@ -1,6 +1,9 @@ + + + diff --git a/lib/iris/tests/results/unit/analysis/maths/subtract/TestBroadcasting/collapse_middle_dim.cml b/lib/iris/tests/results/unit/analysis/maths/subtract/TestBroadcasting/collapse_middle_dim.cml index c6e6271a63..bea6795b38 100644 --- a/lib/iris/tests/results/unit/analysis/maths/subtract/TestBroadcasting/collapse_middle_dim.cml +++ b/lib/iris/tests/results/unit/analysis/maths/subtract/TestBroadcasting/collapse_middle_dim.cml @@ -1,6 +1,9 @@ + + + diff --git a/lib/iris/tests/results/unit/analysis/maths/subtract/TestBroadcasting/collapse_zeroth_dim.cml b/lib/iris/tests/results/unit/analysis/maths/subtract/TestBroadcasting/collapse_zeroth_dim.cml index c6e6271a63..bea6795b38 100644 --- a/lib/iris/tests/results/unit/analysis/maths/subtract/TestBroadcasting/collapse_zeroth_dim.cml +++ b/lib/iris/tests/results/unit/analysis/maths/subtract/TestBroadcasting/collapse_zeroth_dim.cml @@ -1,6 +1,9 @@ + + + diff --git a/lib/iris/tests/results/unit/analysis/maths/subtract/TestBroadcasting/slice.cml b/lib/iris/tests/results/unit/analysis/maths/subtract/TestBroadcasting/slice.cml index c6e6271a63..bea6795b38 100644 --- a/lib/iris/tests/results/unit/analysis/maths/subtract/TestBroadcasting/slice.cml +++ b/lib/iris/tests/results/unit/analysis/maths/subtract/TestBroadcasting/slice.cml @@ -1,6 +1,9 @@ + + + diff --git a/lib/iris/tests/results/unit/analysis/maths/subtract/TestBroadcasting/transposed.cml b/lib/iris/tests/results/unit/analysis/maths/subtract/TestBroadcasting/transposed.cml index c6e6271a63..bea6795b38 100644 --- a/lib/iris/tests/results/unit/analysis/maths/subtract/TestBroadcasting/transposed.cml +++ b/lib/iris/tests/results/unit/analysis/maths/subtract/TestBroadcasting/transposed.cml @@ -1,6 +1,9 @@ + + + diff --git a/lib/iris/tests/test_basic_maths.py b/lib/iris/tests/test_basic_maths.py index 94880d6ed1..a559ee0e8a 100644 --- a/lib/iris/tests/test_basic_maths.py +++ b/lib/iris/tests/test_basic_maths.py @@ -235,7 +235,11 @@ def test_addition_different_attributes(self): b.attributes["my attribute"] = "foobar" c = a + b self.assertIsNone(c.standard_name) - self.assertEqual(c.attributes, {}) + expected = { + "my attribute": "foobar", + "source": "Data from Met Office Unified Model", + } + self.assertEqual(expected, c.attributes) def test_apply_ufunc(self): a = self.cube @@ -344,10 +348,13 @@ def test_ifunc_call_fail(self): my_ifunc = iris.analysis.maths.IFunc(np.square, lambda a: a.units ** 2) - # should fail because giving 2 arguments to an ifunc that expects - # only one - with self.assertRaises(ValueError): - my_ifunc(a, a) + # should now NOT fail because giving 2 arguments to an ifunc that + # expects only one will now ignore the surplus argument and raise + # a logging message instead, and go on to perform the operation. + emsg = "ValueError not raised" + with self.assertRaisesRegex(AssertionError, emsg): + with self.assertRaises(ValueError): + my_ifunc(a, a) my_ifunc = iris.analysis.maths.IFunc( np.multiply, lambda a: cf_units.Unit("1") @@ -509,7 +516,11 @@ def test_multiplication_different_attributes(self): b.attributes["my attribute"] = "foobar" c = a * b self.assertIsNone(c.standard_name) - self.assertEqual(c.attributes, {}) + expected = { + "source": "Data from Met Office Unified Model", + "my attribute": "foobar", + } + self.assertEqual(expected, c.attributes) def test_multiplication_in_place(self): a = self.cube.copy() diff --git a/lib/iris/tests/test_cdm.py b/lib/iris/tests/test_cdm.py index ab27ad6040..bbaae1a8de 100644 --- a/lib/iris/tests/test_cdm.py +++ b/lib/iris/tests/test_cdm.py @@ -1022,14 +1022,6 @@ def test_metadata_fail(self): (), ) with self.assertRaises(TypeError): - self.t.metadata = { - "standard_name": "air_pressure", - "long_name": "foo", - "var_name": "bar", - "units": "", - "attributes": {"random": "12"}, - } - with self.assertRaises(TypeError): class Metadata: pass diff --git a/lib/iris/tests/test_coord_api.py b/lib/iris/tests/test_coord_api.py index 053b6b509b..bdc6fcc609 100644 --- a/lib/iris/tests/test_coord_api.py +++ b/lib/iris/tests/test_coord_api.py @@ -944,11 +944,11 @@ def test_circular(self): r.circular = False self.assertTrue(r.is_compatible(self.dim_coord)) - def test_defn(self): - coord_defn = self.aux_coord._as_defn() - self.assertTrue(self.aux_coord.is_compatible(coord_defn)) - coord_defn = self.dim_coord._as_defn() - self.assertTrue(self.dim_coord.is_compatible(coord_defn)) + def test_metadata(self): + metadata = self.aux_coord.metadata + self.assertTrue(self.aux_coord.is_compatible(metadata)) + metadata = self.dim_coord.metadata + self.assertTrue(self.dim_coord.is_compatible(metadata)) def test_is_ignore(self): r = self.aux_coord.copy() diff --git a/lib/iris/tests/unit/cube_coord_common/__init__.py b/lib/iris/tests/unit/common/__init__.py similarity index 75% rename from lib/iris/tests/unit/cube_coord_common/__init__.py rename to lib/iris/tests/unit/common/__init__.py index 4390f95921..5380785042 100644 --- a/lib/iris/tests/unit/cube_coord_common/__init__.py +++ b/lib/iris/tests/unit/common/__init__.py @@ -3,4 +3,4 @@ # 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. -"""Unit tests for the :mod:`iris._cube_coord_common` module.""" +"""Unit tests for the :mod:`iris.common` module.""" diff --git a/lib/iris/tests/unit/common/lenient/__init__.py b/lib/iris/tests/unit/common/lenient/__init__.py new file mode 100644 index 0000000000..2a99e7a4c2 --- /dev/null +++ b/lib/iris/tests/unit/common/lenient/__init__.py @@ -0,0 +1,6 @@ +# 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. +"""Unit tests for the :mod:`iris.common.lenient` package.""" diff --git a/lib/iris/tests/unit/common/lenient/test_Lenient.py b/lib/iris/tests/unit/common/lenient/test_Lenient.py new file mode 100644 index 0000000000..8ca98342ca --- /dev/null +++ b/lib/iris/tests/unit/common/lenient/test_Lenient.py @@ -0,0 +1,182 @@ +# 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. +""" +Unit tests for the :class:`iris.common.lenient.Lenient`. + +""" + +# Import iris.tests first so that some things can be initialised before +# importing anything else. +import iris.tests as tests + +from unittest.mock import sentinel + +from iris.common.lenient import Lenient, _LENIENT + + +class Test___init__(tests.IrisTest): + def test_default(self): + lenient = Lenient() + expected = dict(maths=True) + self.assertEqual(expected, lenient.__dict__) + + def test_kwargs(self): + lenient = Lenient(maths=False) + expected = dict(maths=False) + self.assertEqual(expected, lenient.__dict__) + + def test_kwargs_invalid(self): + emsg = "Invalid .* option, got 'merge'." + with self.assertRaisesRegex(KeyError, emsg): + _ = Lenient(merge=True) + + +class Test___contains__(tests.IrisTest): + def setUp(self): + self.lenient = Lenient() + + def test_in(self): + self.assertTrue("maths", self.lenient) + + def test_not_in(self): + self.assertTrue(("concatenate", self.lenient)) + + +class Test___getitem__(tests.IrisTest): + def setUp(self): + self.lenient = Lenient() + + def test_in(self): + self.assertTrue(self.lenient["maths"]) + + def test_not_in(self): + emsg = "Invalid .* option, got 'MATHS'." + with self.assertRaisesRegex(KeyError, emsg): + _ = self.lenient["MATHS"] + + +class Test___repr__(tests.IrisTest): + def setUp(self): + self.lenient = Lenient() + + def test(self): + expected = "Lenient(maths=True)" + self.assertEqual(expected, repr(self.lenient)) + + +class Test___setitem__(tests.IrisTest): + def setUp(self): + self.lenient = Lenient() + + def test_key_invalid(self): + emsg = "Invalid .* option, got 'MATHS." + with self.assertRaisesRegex(KeyError, emsg): + self.lenient["MATHS"] = False + + def test_maths_value_invalid(self): + value = sentinel.value + emsg = f"Invalid .* option 'maths' value, got {value!r}." + with self.assertRaisesRegex(ValueError, emsg): + self.lenient["maths"] = value + + def test_maths_disable__lenient_enable_true(self): + self.assertTrue(_LENIENT.enable) + self.lenient["maths"] = False + self.assertFalse(self.lenient.__dict__["maths"]) + self.assertFalse(_LENIENT.enable) + + def test_maths_disable__lenient_enable_false(self): + _LENIENT.__dict__["enable"] = False + self.assertFalse(_LENIENT.enable) + self.lenient["maths"] = False + self.assertFalse(self.lenient.__dict__["maths"]) + self.assertFalse(_LENIENT.enable) + + def test_maths_enable__lenient_enable_true(self): + self.assertTrue(_LENIENT.enable) + self.lenient["maths"] = True + self.assertTrue(self.lenient.__dict__["maths"]) + self.assertTrue(_LENIENT.enable) + + def test_maths_enable__lenient_enable_false(self): + _LENIENT.__dict__["enable"] = False + self.assertFalse(_LENIENT.enable) + self.lenient["maths"] = True + self.assertTrue(self.lenient.__dict__["maths"]) + self.assertTrue(_LENIENT.enable) + + +class Test_context(tests.IrisTest): + def setUp(self): + self.lenient = Lenient() + + def test_nop(self): + self.assertTrue(self.lenient["maths"]) + + with self.lenient.context(): + self.assertTrue(self.lenient["maths"]) + + self.assertTrue(self.lenient["maths"]) + + def test_maths_disable__lenient_true(self): + # synchronised + self.assertTrue(_LENIENT.enable) + self.assertTrue(self.lenient["maths"]) + + with self.lenient.context(maths=False): + # still synchronised + self.assertFalse(_LENIENT.enable) + self.assertFalse(self.lenient["maths"]) + + # still synchronised + self.assertTrue(_LENIENT.enable) + self.assertTrue(self.lenient["maths"]) + + def test_maths_disable__lenient_false(self): + # not synchronised + _LENIENT.__dict__["enable"] = False + self.assertFalse(_LENIENT.enable) + self.assertTrue(self.lenient["maths"]) + + with self.lenient.context(maths=False): + # now synchronised + self.assertFalse(_LENIENT.enable) + self.assertFalse(self.lenient["maths"]) + + # still synchronised + self.assertTrue(_LENIENT.enable) + self.assertTrue(self.lenient["maths"]) + + def test_maths_enable__lenient_true(self): + # not synchronised + self.assertTrue(_LENIENT.enable) + self.lenient.__dict__["maths"] = False + self.assertFalse(self.lenient["maths"]) + + with self.lenient.context(maths=True): + # now synchronised + self.assertTrue(_LENIENT.enable) + self.assertTrue(self.lenient["maths"]) + + # still synchronised + self.assertFalse(_LENIENT.enable) + self.assertFalse(self.lenient["maths"]) + + def test_maths_enable__lenient_false(self): + # synchronised + _LENIENT.__dict__["enable"] = False + self.assertFalse(_LENIENT.enable) + self.lenient.__dict__["maths"] = False + self.assertFalse(self.lenient["maths"]) + + with self.lenient.context(maths=True): + # still synchronised + self.assertTrue(_LENIENT.enable) + self.assertTrue(self.lenient["maths"]) + + # still synchronised + self.assertFalse(_LENIENT.enable) + self.assertFalse(self.lenient["maths"]) diff --git a/lib/iris/tests/unit/common/lenient/test__Lenient.py b/lib/iris/tests/unit/common/lenient/test__Lenient.py new file mode 100644 index 0000000000..d6bc2882d6 --- /dev/null +++ b/lib/iris/tests/unit/common/lenient/test__Lenient.py @@ -0,0 +1,835 @@ +# 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. +""" +Unit tests for the :class:`iris.common.lenient._Lenient`. + +""" + +# Import iris.tests first so that some things can be initialised before +# importing anything else. +import iris.tests as tests + +from collections.abc import Iterable + +from iris.common.lenient import ( + _LENIENT_ENABLE_DEFAULT, + _LENIENT_PROTECTED, + _Lenient, + _qualname, +) + + +class Test___init__(tests.IrisTest): + def setUp(self): + self.expected = dict(active=None, enable=_LENIENT_ENABLE_DEFAULT) + + def test_default(self): + lenient = _Lenient() + self.assertEqual(self.expected, lenient.__dict__) + + def test_args_service_str(self): + service = "service1" + lenient = _Lenient(service) + self.expected.update(dict(service1=True)) + self.assertEqual(self.expected, lenient.__dict__) + + def test_args_services_str(self): + services = ("service1", "service2") + lenient = _Lenient(*services) + self.expected.update(dict(service1=True, service2=True)) + self.assertEqual(self.expected, lenient.__dict__) + + def test_args_services_callable(self): + def service1(): + pass + + def service2(): + pass + + services = (service1, service2) + lenient = _Lenient(*services) + self.expected.update( + {_qualname(service1): True, _qualname(service2): True,} + ) + self.assertEqual(self.expected, lenient.__dict__) + + def test_kwargs_client_str(self): + client = dict(client1="service1") + lenient = _Lenient(**client) + self.expected.update(dict(client1=("service1",))) + self.assertEqual(self.expected, lenient.__dict__) + + def test_kwargs_clients_str(self): + clients = dict(client1="service1", client2="service2") + lenient = _Lenient(**clients) + self.expected.update( + dict(client1=("service1",), client2=("service2",)) + ) + self.assertEqual(self.expected, lenient.__dict__) + + def test_kwargs_clients_callable(self): + def client1(): + pass + + def client2(): + pass + + def service1(): + pass + + def service2(): + pass + + qualname_client1 = _qualname(client1) + qualname_client2 = _qualname(client2) + clients = { + qualname_client1: service1, + qualname_client2: (service1, service2), + } + lenient = _Lenient(**clients) + self.expected.update( + { + _qualname(client1): (_qualname(service1),), + _qualname(client2): (_qualname(service1), _qualname(service2)), + } + ) + self.assertEqual(self.expected, lenient.__dict__) + + +class Test___call__(tests.IrisTest): + def setUp(self): + self.client = "myclient" + self.lenient = _Lenient() + + def test_missing_service_str(self): + self.assertFalse(self.lenient("myservice")) + + def test_missing_service_callable(self): + def myservice(): + pass + + self.assertFalse(self.lenient(myservice)) + + def test_disabled_service_str(self): + service = "myservice" + self.lenient.__dict__[service] = False + self.assertFalse(self.lenient(service)) + + def test_disable_service_callable(self): + def myservice(): + pass + + qualname_service = _qualname(myservice) + self.lenient.__dict__[qualname_service] = False + self.assertFalse(self.lenient(myservice)) + + def test_service_str_with_no_active_client(self): + service = "myservice" + self.lenient.__dict__[service] = True + self.assertFalse(self.lenient(service)) + + def test_service_callable_with_no_active_client(self): + def myservice(): + pass + + qualname_service = _qualname(myservice) + self.lenient.__dict__[qualname_service] = True + self.assertFalse(self.lenient(myservice)) + + def test_service_str_with_active_client_with_no_registered_services(self): + service = "myservice" + self.lenient.__dict__[service] = True + self.lenient.__dict__["active"] = self.client + self.assertFalse(self.lenient(service)) + + def test_service_callable_with_active_client_with_no_registered_services( + self, + ): + def myservice(): + pass + + def myclient(): + pass + + qualname_service = _qualname(myservice) + self.lenient.__dict__[qualname_service] = True + self.lenient.__dict__["active"] = _qualname(myclient) + self.assertFalse(self.lenient(myservice)) + + def test_service_str_with_active_client_with_unmatched_registered_services( + self, + ): + service = "myservice" + self.lenient.__dict__[service] = True + self.lenient.__dict__["active"] = self.client + self.lenient.__dict__[self.client] = ("service1", "service2") + self.assertFalse(self.lenient(service)) + + def test_service_callable_with_active_client_with_unmatched_registered_services( + self, + ): + def myservice(): + pass + + def myclient(): + pass + + qualname_service = _qualname(myservice) + qualname_client = _qualname(myclient) + self.lenient.__dict__[qualname_service] = True + self.lenient.__dict__["active"] = qualname_client + self.lenient.__dict__[qualname_client] = ("service1", "service2") + self.assertFalse(self.lenient(myservice)) + + def test_service_str_with_active_client_with_registered_services(self): + service = "myservice" + self.lenient.__dict__[service] = True + self.lenient.__dict__["active"] = self.client + self.lenient.__dict__[self.client] = ("service1", "service2", service) + self.assertTrue(self.lenient(service)) + + def test_service_callable_with_active_client_with_registered_services( + self, + ): + def myservice(): + pass + + def myclient(): + pass + + qualname_service = _qualname(myservice) + qualname_client = _qualname(myclient) + self.lenient.__dict__[qualname_service] = True + self.lenient.__dict__["active"] = qualname_client + self.lenient.__dict__[qualname_client] = ( + "service1", + "service2", + qualname_service, + ) + self.assertTrue(self.lenient(myservice)) + + def test_service_str_with_active_client_with_unmatched_registered_service_str( + self, + ): + service = "myservice" + self.lenient.__dict__[service] = True + self.lenient.__dict__["active"] = self.client + self.lenient.__dict__[self.client] = "serviceXXX" + self.assertFalse(self.lenient(service)) + + def test_service_callable_with_active_client_with_unmatched_registered_service_str( + self, + ): + def myservice(): + pass + + def myclient(): + pass + + qualname_service = _qualname(myservice) + qualname_client = _qualname(myclient) + self.lenient.__dict__[qualname_service] = True + self.lenient.__dict__["active"] = qualname_client + self.lenient.__dict__[qualname_client] = f"{qualname_service}XXX" + self.assertFalse(self.lenient(myservice)) + + def test_service_str_with_active_client_with_registered_service_str(self): + service = "myservice" + self.lenient.__dict__[service] = True + self.lenient.__dict__["active"] = self.client + self.lenient.__dict__[self.client] = service + self.assertTrue(self.lenient(service)) + + def test_service_callable_with_active_client_with_registered_service_str( + self, + ): + def myservice(): + pass + + def myclient(): + pass + + qualname_service = _qualname(myservice) + qualname_client = _qualname(myclient) + self.lenient.__dict__[qualname_service] = True + self.lenient.__dict__["active"] = qualname_client + self.lenient.__dict__[qualname_client] = qualname_service + self.assertTrue(self.lenient(myservice)) + + def test_enable(self): + service = "myservice" + self.lenient.__dict__[service] = True + self.lenient.__dict__["active"] = self.client + self.lenient.__dict__[self.client] = service + self.assertTrue(self.lenient(service)) + self.lenient.__dict__["enable"] = False + self.assertFalse(self.lenient(service)) + + +class Test___contains__(tests.IrisTest): + def setUp(self): + self.lenient = _Lenient() + + def test_in(self): + self.assertIn("active", self.lenient) + + def test_not_in(self): + self.assertNotIn("ACTIVATE", self.lenient) + + def test_in_qualname(self): + def func(): + pass + + qualname_func = _qualname(func) + lenient = _Lenient() + lenient.__dict__[qualname_func] = None + self.assertIn(func, lenient) + self.assertIn(qualname_func, lenient) + + +class Test___getattr__(tests.IrisTest): + def setUp(self): + self.lenient = _Lenient() + + def test_in(self): + self.assertIsNone(self.lenient.active) + + def test_not_in(self): + emsg = "Invalid .* option, got 'wibble'." + with self.assertRaisesRegex(AttributeError, emsg): + _ = self.lenient.wibble + + +class Test__getitem__(tests.IrisTest): + def setUp(self): + self.lenient = _Lenient() + + def test_in(self): + self.assertIsNone(self.lenient["active"]) + + def test_in_callable(self): + def service(): + pass + + qualname_service = _qualname(service) + self.lenient.__dict__[qualname_service] = True + self.assertTrue(self.lenient[service]) + + def test_not_in(self): + emsg = "Invalid .* option, got 'wibble'." + with self.assertRaisesRegex(KeyError, emsg): + _ = self.lenient["wibble"] + + def test_not_in_callable(self): + def service(): + pass + + qualname_service = _qualname(service) + emsg = f"Invalid .* option, got '{qualname_service}'." + with self.assertRaisesRegex(KeyError, emsg): + _ = self.lenient[service] + + +class Test___setitem__(tests.IrisTest): + def setUp(self): + self.lenient = _Lenient() + + def test_not_in(self): + emsg = "Invalid .* option, got 'wibble'." + with self.assertRaisesRegex(KeyError, emsg): + self.lenient["wibble"] = None + + def test_in_value_str(self): + client = "client" + service = "service" + self.lenient.__dict__[client] = None + self.lenient[client] = service + self.assertEqual(self.lenient.__dict__[client], (service,)) + + def test_callable_in_value_str(self): + def client(): + pass + + service = "service" + qualname_client = _qualname(client) + self.lenient.__dict__[qualname_client] = None + self.lenient[client] = service + self.assertEqual(self.lenient.__dict__[qualname_client], (service,)) + + def test_in_value_callable(self): + def service(): + pass + + client = "client" + qualname_service = _qualname(service) + self.lenient.__dict__[client] = None + self.lenient[client] = service + self.assertEqual(self.lenient.__dict__[client], (qualname_service,)) + + def test_callable_in_value_callable(self): + def client(): + pass + + def service(): + pass + + qualname_client = _qualname(client) + qualname_service = _qualname(service) + self.lenient.__dict__[qualname_client] = None + self.lenient[client] = service + self.assertEqual( + self.lenient.__dict__[qualname_client], (qualname_service,) + ) + + def test_in_value_bool(self): + client = "client" + self.lenient.__dict__[client] = None + self.lenient[client] = True + self.assertTrue(self.lenient.__dict__[client]) + self.assertFalse(isinstance(self.lenient.__dict__[client], Iterable)) + + def test_callable_in_value_bool(self): + def client(): + pass + + qualname_client = _qualname(client) + self.lenient.__dict__[qualname_client] = None + self.lenient[client] = True + self.assertTrue(self.lenient.__dict__[qualname_client]) + self.assertFalse( + isinstance(self.lenient.__dict__[qualname_client], Iterable) + ) + + def test_in_value_iterable(self): + client = "client" + services = ("service1", "service2") + self.lenient.__dict__[client] = None + self.lenient[client] = services + self.assertEqual(self.lenient.__dict__[client], services) + + def test_callable_in_value_iterable(self): + def client(): + pass + + qualname_client = _qualname(client) + services = ("service1", "service2") + self.lenient.__dict__[qualname_client] = None + self.lenient[client] = services + self.assertEqual(self.lenient.__dict__[qualname_client], services) + + def test_in_value_iterable_callable(self): + def service1(): + pass + + def service2(): + pass + + client = "client" + self.lenient.__dict__[client] = None + qualname_services = (_qualname(service1), _qualname(service2)) + self.lenient[client] = (service1, service2) + self.assertEqual(self.lenient.__dict__[client], qualname_services) + + def test_callable_in_value_iterable_callable(self): + def client(): + pass + + def service1(): + pass + + def service2(): + pass + + qualname_client = _qualname(client) + self.lenient.__dict__[qualname_client] = None + qualname_services = (_qualname(service1), _qualname(service2)) + self.lenient[client] = (service1, service2) + self.assertEqual( + self.lenient.__dict__[qualname_client], qualname_services + ) + + def test_active_iterable(self): + active = "active" + self.assertIsNone(self.lenient.__dict__[active]) + emsg = "Invalid .* option 'active'" + with self.assertRaisesRegex(ValueError, emsg): + self.lenient[active] = (None,) + + def test_active_str(self): + active = "active" + client = "client1" + self.assertIsNone(self.lenient.__dict__[active]) + self.lenient[active] = client + self.assertEqual(self.lenient.__dict__[active], client) + + def test_active_callable(self): + def client(): + pass + + active = "active" + qualname_client = _qualname(client) + self.assertIsNone(self.lenient.__dict__[active]) + self.lenient[active] = client + self.assertEqual(self.lenient.__dict__[active], qualname_client) + + def test_enable(self): + enable = "enable" + self.assertEqual( + self.lenient.__dict__[enable], _LENIENT_ENABLE_DEFAULT + ) + self.lenient[enable] = True + self.assertTrue(self.lenient.__dict__[enable]) + self.lenient[enable] = False + self.assertFalse(self.lenient.__dict__[enable]) + + def test_enable_invalid(self): + emsg = "Invalid .* option 'enable'" + with self.assertRaisesRegex(ValueError, emsg): + self.lenient["enable"] = None + + +class Test_context(tests.IrisTest): + def setUp(self): + self.lenient = _Lenient() + self.default = dict(active=None, enable=_LENIENT_ENABLE_DEFAULT) + + def copy(self): + return self.lenient.__dict__.copy() + + def test_nop(self): + pre = self.copy() + with self.lenient.context(): + context = self.copy() + post = self.copy() + self.assertEqual(pre, self.default) + self.assertEqual(context, self.default) + self.assertEqual(post, self.default) + + def test_active_str(self): + client = "client" + pre = self.copy() + with self.lenient.context(active=client): + context = self.copy() + post = self.copy() + self.assertEqual(pre, self.default) + expected = self.default.copy() + expected.update(dict(active=client)) + self.assertEqual(context, expected) + self.assertEqual(post, self.default) + + def test_active_callable(self): + def client(): + pass + + pre = self.copy() + with self.lenient.context(active=client): + context = self.copy() + post = self.copy() + qualname_client = _qualname(client) + self.assertEqual(pre, self.default) + expected = self.default.copy() + expected.update(dict(active=qualname_client)) + self.assertEqual(context, expected) + self.assertEqual(post, self.default) + + def test_kwargs(self): + client = "client" + self.lenient.__dict__["service1"] = False + self.lenient.__dict__["service2"] = False + pre = self.copy() + with self.lenient.context(active=client, service1=True, service2=True): + context = self.copy() + post = self.copy() + self.default.update(dict(service1=False, service2=False)) + self.assertEqual(pre, self.default) + expected = self.default.copy() + expected.update(dict(active=client, service1=True, service2=True)) + self.assertEqual(context, expected) + self.assertEqual(post, self.default) + + def test_args_str(self): + client = "client" + services = ("service1", "service2") + pre = self.copy() + with self.lenient.context(*services, active=client): + context = self.copy() + post = self.copy() + self.assertEqual(pre, self.default) + expected = self.default.copy() + expected.update(dict(active=client, client=services)) + self.assertEqual(context["active"], expected["active"]) + self.assertEqual(set(context["client"]), set(expected["client"])) + self.assertEqual(post, self.default) + + def test_args_callable(self): + def service1(): + pass + + def service2(): + pass + + client = "client" + services = (service1, service2) + pre = self.copy() + with self.lenient.context(*services, active=client): + context = self.copy() + post = self.copy() + qualname_services = tuple([_qualname(service) for service in services]) + self.assertEqual(pre, self.default) + expected = self.default.copy() + expected.update(dict(active=client, client=qualname_services)) + self.assertEqual(context["active"], expected["active"]) + self.assertEqual(set(context["client"]), set(expected["client"])) + self.assertEqual(post, self.default) + + def test_context_runtime(self): + services = ("service1", "service2") + pre = self.copy() + with self.lenient.context(*services): + context = self.copy() + post = self.copy() + self.assertEqual(pre, self.default) + expected = self.default.copy() + expected.update(dict(active="__context", __context=services)) + self.assertEqual(context, expected) + self.assertEqual(post, self.default) + + +class Test_enable(tests.IrisTest): + def setUp(self): + self.lenient = _Lenient() + + def test_getter(self): + self.assertEqual(self.lenient.enable, _LENIENT_ENABLE_DEFAULT) + + def test_setter_invalid(self): + emsg = "Invalid .* option 'enable'" + with self.assertRaisesRegex(ValueError, emsg): + self.lenient.enable = 0 + + def test_setter(self): + self.assertEqual(self.lenient.enable, _LENIENT_ENABLE_DEFAULT) + self.lenient.enable = False + self.assertFalse(self.lenient.enable) + + +class Test_register_client(tests.IrisTest): + def setUp(self): + self.lenient = _Lenient() + + def test_not_protected(self): + emsg = "Cannot register .* client" + for protected in _LENIENT_PROTECTED: + with self.assertRaisesRegex(ValueError, emsg): + self.lenient.register_client(protected, "service") + + def test_str_service_str(self): + client = "client" + services = "service" + self.lenient.register_client(client, services) + self.assertIn(client, self.lenient.__dict__) + self.assertEqual(self.lenient.__dict__[client], (services,)) + + def test_str_services_str(self): + client = "client" + services = ("service1", "service2") + self.lenient.register_client(client, services) + self.assertIn(client, self.lenient.__dict__) + self.assertEqual(self.lenient.__dict__[client], services) + + def test_callable_service_callable(self): + def client(): + pass + + def service(): + pass + + qualname_client = _qualname(client) + qualname_service = _qualname(service) + self.lenient.register_client(client, service) + self.assertIn(qualname_client, self.lenient.__dict__) + self.assertEqual( + self.lenient.__dict__[qualname_client], (qualname_service,) + ) + + def test_callable_services_callable(self): + def client(): + pass + + def service1(): + pass + + def service2(): + pass + + qualname_client = _qualname(client) + qualname_services = (_qualname(service1), _qualname(service2)) + self.lenient.register_client(client, (service1, service2)) + self.assertIn(qualname_client, self.lenient.__dict__) + self.assertEqual( + self.lenient.__dict__[qualname_client], qualname_services + ) + + def test_services_empty(self): + emsg = "Require at least one .* client service." + with self.assertRaisesRegex(ValueError, emsg): + self.lenient.register_client("client", ()) + + def test_services_overwrite(self): + client = "client" + services = ("service1", "service2") + self.lenient.__dict__[client] = services + self.assertEqual(self.lenient[client], services) + new_services = ("service3", "service4") + self.lenient.register_client(client, services=new_services) + self.assertEqual(self.lenient[client], new_services) + + def test_services_append(self): + client = "client" + services = ("service1", "service2") + self.lenient.__dict__[client] = services + self.assertEqual(self.lenient[client], services) + new_services = ("service3", "service4") + self.lenient.register_client( + client, services=new_services, append=True + ) + expected = set(services + new_services) + self.assertEqual(set(self.lenient[client]), expected) + + +class Test_register_service(tests.IrisTest): + def setUp(self): + self.lenient = _Lenient() + + def test_str(self): + service = "service" + self.assertNotIn(service, self.lenient.__dict__) + self.lenient.register_service(service) + self.assertIn(service, self.lenient.__dict__) + self.assertFalse(isinstance(self.lenient.__dict__[service], Iterable)) + self.assertTrue(self.lenient.__dict__[service]) + + def test_callable(self): + def service(): + pass + + qualname_service = _qualname(service) + self.assertNotIn(qualname_service, self.lenient.__dict__) + self.lenient.register_service(service) + self.assertIn(qualname_service, self.lenient.__dict__) + self.assertFalse( + isinstance(self.lenient.__dict__[qualname_service], Iterable) + ) + self.assertTrue(self.lenient.__dict__[qualname_service]) + + def test_not_protected(self): + emsg = "Cannot register .* service" + for protected in _LENIENT_PROTECTED: + self.lenient.__dict__[protected] = None + with self.assertRaisesRegex(ValueError, emsg): + self.lenient.register_service("active") + + +class Test_unregister_client(tests.IrisTest): + def setUp(self): + self.lenient = _Lenient() + + def test_not_protected(self): + emsg = "Cannot unregister .* client, as .* is a protected .* option." + for protected in _LENIENT_PROTECTED: + self.lenient.__dict__[protected] = None + with self.assertRaisesRegex(ValueError, emsg): + self.lenient.unregister_client(protected) + + def test_not_in(self): + emsg = "Cannot unregister unknown .* client" + with self.assertRaisesRegex(ValueError, emsg): + self.lenient.unregister_client("client") + + def test_not_client(self): + client = "client" + self.lenient.__dict__[client] = True + emsg = "Cannot unregister .* client, as .* is not a valid .* client." + with self.assertRaisesRegex(ValueError, emsg): + self.lenient.unregister_client(client) + + def test_not_client_callable(self): + def client(): + pass + + qualname_client = _qualname(client) + self.lenient.__dict__[qualname_client] = True + emsg = "Cannot unregister .* client, as .* is not a valid .* client." + with self.assertRaisesRegex(ValueError, emsg): + self.lenient.unregister_client(client) + + def test_str(self): + client = "client" + self.lenient.__dict__[client] = (None,) + self.lenient.unregister_client(client) + self.assertNotIn(client, self.lenient.__dict__) + + def test_callable(self): + def client(): + pass + + qualname_client = _qualname(client) + self.lenient.__dict__[qualname_client] = (None,) + self.lenient.unregister_client(client) + self.assertNotIn(qualname_client, self.lenient.__dict__) + + +class Test_unregister_service(tests.IrisTest): + def setUp(self): + self.lenient = _Lenient() + + def test_not_protected(self): + emsg = "Cannot unregister .* service, as .* is a protected .* option." + for protected in _LENIENT_PROTECTED: + self.lenient.__dict__[protected] = None + with self.assertRaisesRegex(ValueError, emsg): + self.lenient.unregister_service(protected) + + def test_not_in(self): + emsg = "Cannot unregister unknown .* service" + with self.assertRaisesRegex(ValueError, emsg): + self.lenient.unregister_service("service") + + def test_not_service(self): + service = "service" + self.lenient.__dict__[service] = (None,) + emsg = "Cannot unregister .* service, as .* is not a valid .* service." + with self.assertRaisesRegex(ValueError, emsg): + self.lenient.unregister_service(service) + + def test_not_service_callable(self): + def service(): + pass + + qualname_service = _qualname(service) + self.lenient.__dict__[qualname_service] = (None,) + emsg = "Cannot unregister .* service, as .* is not a valid .* service." + with self.assertRaisesRegex(ValueError, emsg): + self.lenient.unregister_service(service) + + def test_str(self): + service = "service" + self.lenient.__dict__[service] = True + self.lenient.unregister_service(service) + self.assertNotIn(service, self.lenient.__dict__) + + def test_callable(self): + def service(): + pass + + qualname_service = _qualname(service) + self.lenient.__dict__[qualname_service] = True + self.lenient.unregister_service(service) + self.assertNotIn(qualname_service, self.lenient.__dict__) + + +if __name__ == "__main__": + tests.main() diff --git a/lib/iris/tests/unit/common/lenient/test__lenient_client.py b/lib/iris/tests/unit/common/lenient/test__lenient_client.py new file mode 100644 index 0000000000..29cf5e7f82 --- /dev/null +++ b/lib/iris/tests/unit/common/lenient/test__lenient_client.py @@ -0,0 +1,182 @@ +# 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. +""" +Unit tests for the :func:`iris.common.lenient._lenient_client`. + +""" + +# Import iris.tests first so that some things can be initialised before +# importing anything else. +import iris.tests as tests + +from inspect import getmodule +from unittest.mock import sentinel + +from iris.common.lenient import _LENIENT, _lenient_client + + +class Test(tests.IrisTest): + def setUp(self): + module_name = getmodule(self).__name__ + self.client = f"{module_name}" + ".Test.{}..myclient" + self.service = f"{module_name}" + ".Test.{}..myservice" + self.active = "active" + self.args_in = sentinel.arg1, sentinel.arg2 + self.kwargs_in = dict(kwarg1=sentinel.kwarg1, kwarg2=sentinel.kwarg2) + + def test_args_too_many(self): + emsg = "Invalid lenient client arguments, expecting 1" + with self.assertRaisesRegex(AssertionError, emsg): + _lenient_client(None, None) + + def test_args_not_callable(self): + emsg = "Invalid lenient client argument, expecting a callable" + with self.assertRaisesRegex(AssertionError, emsg): + _lenient_client(None) + + def test_args_and_kwargs(self): + def func(): + pass + + emsg = ( + "Invalid lenient client, got both arguments and keyword arguments" + ) + with self.assertRaisesRegex(AssertionError, emsg): + _lenient_client(func, services=func) + + def test_call_naked(self): + @_lenient_client + def myclient(): + return _LENIENT.__dict__.copy() + + result = myclient() + self.assertIn(self.active, result) + qualname_client = self.client.format("test_call_naked") + self.assertEqual(result[self.active], qualname_client) + self.assertNotIn(qualname_client, result) + + def test_call_naked_alternative(self): + def myclient(): + return _LENIENT.__dict__.copy() + + result = _lenient_client(myclient)() + self.assertIn(self.active, result) + qualname_client = self.client.format("test_call_naked_alternative") + self.assertEqual(result[self.active], qualname_client) + self.assertNotIn(qualname_client, result) + + def test_call_naked_client_args_kwargs(self): + @_lenient_client + def myclient(*args, **kwargs): + return args, kwargs + + args_out, kwargs_out = myclient(*self.args_in, **self.kwargs_in) + self.assertEqual(args_out, self.args_in) + self.assertEqual(kwargs_out, self.kwargs_in) + + def test_call_naked_doc(self): + @_lenient_client + def myclient(): + """myclient doc-string""" + + self.assertEqual(myclient.__doc__, "myclient doc-string") + + def test_call_no_kwargs(self): + @_lenient_client() + def myclient(): + return _LENIENT.__dict__.copy() + + result = myclient() + self.assertIn(self.active, result) + qualname_client = self.client.format("test_call_no_kwargs") + self.assertEqual(result[self.active], qualname_client) + self.assertNotIn(qualname_client, result) + + def test_call_no_kwargs_alternative(self): + def myclient(): + return _LENIENT.__dict__.copy() + + result = (_lenient_client())(myclient)() + self.assertIn(self.active, result) + qualname_client = self.client.format("test_call_no_kwargs_alternative") + self.assertEqual(result[self.active], qualname_client) + self.assertNotIn(qualname_client, result) + + def test_call_kwargs_none(self): + @_lenient_client(services=None) + def myclient(): + return _LENIENT.__dict__.copy() + + result = myclient() + self.assertIn(self.active, result) + qualname_client = self.client.format("test_call_kwargs_none") + self.assertEqual(result[self.active], qualname_client) + self.assertNotIn(qualname_client, result) + + def test_call_kwargs_single(self): + service = sentinel.service + + @_lenient_client(services=service) + def myclient(): + return _LENIENT.__dict__.copy() + + result = myclient() + self.assertIn(self.active, result) + qualname_client = self.client.format("test_call_kwargs_single") + self.assertEqual(result[self.active], qualname_client) + self.assertIn(qualname_client, result) + self.assertEqual(result[qualname_client], (service,)) + + def test_call_kwargs_single_callable(self): + def myservice(): + pass + + @_lenient_client(services=myservice) + def myclient(): + return _LENIENT.__dict__.copy() + + test_name = "test_call_kwargs_single_callable" + result = myclient() + self.assertIn(self.active, result) + qualname_client = self.client.format(test_name) + self.assertEqual(result[self.active], qualname_client) + self.assertIn(qualname_client, result) + qualname_services = (self.service.format(test_name),) + self.assertEqual(result[qualname_client], qualname_services) + + def test_call_kwargs_iterable(self): + services = (sentinel.service1, sentinel.service2) + + @_lenient_client(services=services) + def myclient(): + return _LENIENT.__dict__.copy() + + result = myclient() + self.assertIn(self.active, result) + qualname_client = self.client.format("test_call_kwargs_iterable") + self.assertEqual(result[self.active], qualname_client) + self.assertIn(qualname_client, result) + self.assertEqual(set(result[qualname_client]), set(services)) + + def test_call_client_args_kwargs(self): + @_lenient_client() + def myclient(*args, **kwargs): + return args, kwargs + + args_out, kwargs_out = myclient(*self.args_in, **self.kwargs_in) + self.assertEqual(args_out, self.args_in) + self.assertEqual(kwargs_out, self.kwargs_in) + + def test_call_doc(self): + @_lenient_client() + def myclient(): + """myclient doc-string""" + + self.assertEqual(myclient.__doc__, "myclient doc-string") + + +if __name__ == "__main__": + tests.main() diff --git a/lib/iris/tests/unit/common/lenient/test__lenient_service.py b/lib/iris/tests/unit/common/lenient/test__lenient_service.py new file mode 100644 index 0000000000..3b019c9de5 --- /dev/null +++ b/lib/iris/tests/unit/common/lenient/test__lenient_service.py @@ -0,0 +1,116 @@ +# 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. +""" +Unit tests for the :func:`iris.common.lenient._lenient_service`. + +""" + +# Import iris.tests first so that some things can be initialised before +# importing anything else. +import iris.tests as tests + +from inspect import getmodule +from unittest.mock import sentinel + +from iris.common.lenient import _LENIENT, _lenient_service + + +class Test(tests.IrisTest): + def setUp(self): + module_name = getmodule(self).__name__ + self.service = f"{module_name}" + ".Test.{}..myservice" + self.args_in = sentinel.arg1, sentinel.arg2 + self.kwargs_in = dict(kwarg1=sentinel.kwarg1, kwarg2=sentinel.kwarg2) + + def test_args_too_many(self): + emsg = "Invalid lenient service arguments, expecting 1" + with self.assertRaisesRegex(AssertionError, emsg): + _lenient_service(None, None) + + def test_args_not_callable(self): + emsg = "Invalid lenient service argument, expecting a callable" + with self.assertRaisesRegex(AssertionError, emsg): + _lenient_service(None) + + def test_call_naked(self): + @_lenient_service + def myservice(): + return _LENIENT.__dict__.copy() + + qualname_service = self.service.format("test_call_naked") + state = _LENIENT.__dict__ + self.assertIn(qualname_service, state) + self.assertTrue(state[qualname_service]) + result = myservice() + self.assertIn(qualname_service, result) + self.assertTrue(result[qualname_service]) + + def test_call_naked_alternative(self): + def myservice(): + return _LENIENT.__dict__.copy() + + qualname_service = self.service.format("test_call_naked_alternative") + result = _lenient_service(myservice)() + self.assertIn(qualname_service, result) + self.assertTrue(result[qualname_service]) + + def test_call_naked_service_args_kwargs(self): + @_lenient_service + def myservice(*args, **kwargs): + return args, kwargs + + args_out, kwargs_out = myservice(*self.args_in, **self.kwargs_in) + self.assertEqual(args_out, self.args_in) + self.assertEqual(kwargs_out, self.kwargs_in) + + def test_call_naked_doc(self): + @_lenient_service + def myservice(): + """myservice doc-string""" + + self.assertEqual(myservice.__doc__, "myservice doc-string") + + def test_call(self): + @_lenient_service() + def myservice(): + return _LENIENT.__dict__.copy() + + qualname_service = self.service.format("test_call") + state = _LENIENT.__dict__ + self.assertIn(qualname_service, state) + self.assertTrue(state[qualname_service]) + result = myservice() + self.assertIn(qualname_service, result) + self.assertTrue(result[qualname_service]) + + def test_call_alternative(self): + def myservice(): + return _LENIENT.__dict__.copy() + + qualname_service = self.service.format("test_call_alternative") + result = (_lenient_service())(myservice)() + self.assertIn(qualname_service, result) + self.assertTrue(result[qualname_service]) + + def test_call_service_args_kwargs(self): + @_lenient_service() + def myservice(*args, **kwargs): + return args, kwargs + + args_out, kwargs_out = myservice(*self.args_in, **self.kwargs_in) + self.assertEqual(args_out, self.args_in) + self.assertEqual(kwargs_out, self.kwargs_in) + + def test_call_doc(self): + @_lenient_service() + def myservice(): + """myservice doc-string""" + + self.assertEqual(myservice.__doc__, "myservice doc-string") + + +if __name__ == "__main__": + tests.main() diff --git a/lib/iris/tests/unit/common/lenient/test__qualname.py b/lib/iris/tests/unit/common/lenient/test__qualname.py new file mode 100644 index 0000000000..e233b2ac78 --- /dev/null +++ b/lib/iris/tests/unit/common/lenient/test__qualname.py @@ -0,0 +1,66 @@ +# 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. +""" +Unit tests for the :func:`iris.common.lenient._qualname`. + +""" + +# Import iris.tests first so that some things can be initialised before +# importing anything else. +import iris.tests as tests + +from inspect import getmodule +from unittest.mock import sentinel + +from iris.common.lenient import _qualname + + +class Test(tests.IrisTest): + def setUp(self): + module_name = getmodule(self).__name__ + self.locals = f"{module_name}" + ".Test.{}..{}" + + def test_pass_thru_non_callable(self): + func = sentinel.func + result = _qualname(func) + self.assertEqual(result, func) + + def test_callable_function_local(self): + def myfunc(): + pass + + qualname_func = self.locals.format( + "test_callable_function_local", "myfunc" + ) + result = _qualname(myfunc) + self.assertEqual(result, qualname_func) + + def test_callable_function(self): + import iris + + result = _qualname(iris.load) + self.assertEqual(result, "iris.load") + + def test_callable_method_local(self): + class MyClass: + def mymethod(self): + pass + + qualname_method = self.locals.format( + "test_callable_method_local", "MyClass.mymethod" + ) + result = _qualname(MyClass.mymethod) + self.assertEqual(result, qualname_method) + + def test_callable_method(self): + import iris + + result = _qualname(iris.cube.Cube.add_ancillary_variable) + self.assertEqual(result, "iris.cube.Cube.add_ancillary_variable") + + +if __name__ == "__main__": + tests.main() diff --git a/lib/iris/tests/unit/common/metadata/__init__.py b/lib/iris/tests/unit/common/metadata/__init__.py new file mode 100644 index 0000000000..aba33c8312 --- /dev/null +++ b/lib/iris/tests/unit/common/metadata/__init__.py @@ -0,0 +1,6 @@ +# 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. +"""Unit tests for the :mod:`iris.common.metadata` package.""" diff --git a/lib/iris/tests/unit/common/metadata/test_AncillaryVariableMetadata.py b/lib/iris/tests/unit/common/metadata/test_AncillaryVariableMetadata.py new file mode 100644 index 0000000000..0e2ca52c47 --- /dev/null +++ b/lib/iris/tests/unit/common/metadata/test_AncillaryVariableMetadata.py @@ -0,0 +1,494 @@ +# 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. +""" +Unit tests for the :class:`iris.common.metadata.AncillaryVariableMetadata`. + +""" + +# Import iris.tests first so that some things can be initialised before +# importing anything else. +import iris.tests as tests + +from copy import deepcopy +import unittest.mock as mock +from unittest.mock import sentinel + +from iris.common.lenient import _LENIENT, _qualname +from iris.common.metadata import BaseMetadata, AncillaryVariableMetadata + + +class Test(tests.IrisTest): + def setUp(self): + self.standard_name = mock.sentinel.standard_name + self.long_name = mock.sentinel.long_name + self.var_name = mock.sentinel.var_name + self.units = mock.sentinel.units + self.attributes = mock.sentinel.attributes + self.cls = AncillaryVariableMetadata + + def test_repr(self): + metadata = self.cls( + standard_name=self.standard_name, + long_name=self.long_name, + var_name=self.var_name, + units=self.units, + attributes=self.attributes, + ) + fmt = ( + "AncillaryVariableMetadata(standard_name={!r}, long_name={!r}, " + "var_name={!r}, units={!r}, attributes={!r})" + ) + expected = fmt.format( + self.standard_name, + self.long_name, + self.var_name, + self.units, + self.attributes, + ) + self.assertEqual(expected, repr(metadata)) + + def test__fields(self): + expected = ( + "standard_name", + "long_name", + "var_name", + "units", + "attributes", + ) + self.assertEqual(self.cls._fields, expected) + + def test_bases(self): + self.assertTrue(issubclass(self.cls, BaseMetadata)) + + +class Test___eq__(tests.IrisTest): + def setUp(self): + self.values = dict( + standard_name=sentinel.standard_name, + long_name=sentinel.long_name, + var_name=sentinel.var_name, + units=sentinel.units, + attributes=sentinel.attributes, + ) + self.dummy = sentinel.dummy + self.cls = AncillaryVariableMetadata + + def test_wraps_docstring(self): + self.assertEqual( + BaseMetadata.__eq__.__doc__, self.cls.__eq__.__doc__, + ) + + def test_lenient_service(self): + qualname___eq__ = _qualname(self.cls.__eq__) + self.assertIn(qualname___eq__, _LENIENT) + self.assertTrue(_LENIENT[qualname___eq__]) + self.assertTrue(_LENIENT[self.cls.__eq__]) + + def test_call(self): + other = sentinel.other + return_value = sentinel.return_value + metadata = self.cls(*(None,) * len(self.cls._fields)) + with mock.patch.object( + BaseMetadata, "__eq__", return_value=return_value + ) as mocker: + result = metadata.__eq__(other) + + self.assertEqual(return_value, result) + self.assertEqual(1, mocker.call_count) + (arg,), kwargs = mocker.call_args + self.assertEqual(other, arg) + self.assertEqual(dict(), kwargs) + + def test_op_lenient_same(self): + lmetadata = self.cls(**self.values) + rmetadata = self.cls(**self.values) + + with mock.patch("iris.common.metadata._LENIENT", return_value=True): + self.assertTrue(lmetadata.__eq__(rmetadata)) + self.assertTrue(rmetadata.__eq__(lmetadata)) + + def test_op_lenient_same_none(self): + lmetadata = self.cls(**self.values) + right = self.values.copy() + right["var_name"] = None + rmetadata = self.cls(**right) + + with mock.patch("iris.common.metadata._LENIENT", return_value=True): + self.assertTrue(lmetadata.__eq__(rmetadata)) + self.assertTrue(rmetadata.__eq__(lmetadata)) + + def test_op_lenient_different(self): + lmetadata = self.cls(**self.values) + right = self.values.copy() + right["units"] = self.dummy + rmetadata = self.cls(**right) + + with mock.patch("iris.common.metadata._LENIENT", return_value=True): + self.assertFalse(lmetadata.__eq__(rmetadata)) + self.assertFalse(rmetadata.__eq__(lmetadata)) + + def test_op_strict_same(self): + lmetadata = self.cls(**self.values) + rmetadata = self.cls(**self.values) + + with mock.patch("iris.common.metadata._LENIENT", return_value=False): + self.assertTrue(lmetadata.__eq__(rmetadata)) + self.assertTrue(rmetadata.__eq__(lmetadata)) + + def test_op_strict_different(self): + lmetadata = self.cls(**self.values) + right = self.values.copy() + right["long_name"] = self.dummy + rmetadata = self.cls(**right) + + with mock.patch("iris.common.metadata._LENIENT", return_value=False): + self.assertFalse(lmetadata.__eq__(rmetadata)) + self.assertFalse(rmetadata.__eq__(lmetadata)) + + def test_op_strict_different_none(self): + lmetadata = self.cls(**self.values) + right = self.values.copy() + right["long_name"] = None + rmetadata = self.cls(**right) + + with mock.patch("iris.common.metadata._LENIENT", return_value=False): + self.assertFalse(lmetadata.__eq__(rmetadata)) + self.assertFalse(rmetadata.__eq__(lmetadata)) + + +class Test___lt__(tests.IrisTest): + def setUp(self): + self.cls = AncillaryVariableMetadata + self.one = self.cls(1, 1, 1, 1, 1) + self.two = self.cls(1, 1, 1, 2, 1) + self.none = self.cls(1, 1, 1, None, 1) + self.attributes = self.cls(1, 1, 1, 1, 10) + + def test__ascending_lt(self): + result = self.one < self.two + self.assertTrue(result) + + def test__descending_lt(self): + result = self.two < self.one + self.assertFalse(result) + + def test__none_rhs_operand(self): + result = self.one < self.none + self.assertFalse(result) + + def test__none_lhs_operand(self): + result = self.none < self.one + self.assertTrue(result) + + def test__ignore_attributes(self): + result = self.one < self.attributes + self.assertFalse(result) + result = self.attributes < self.one + self.assertFalse(result) + + +class Test_combine(tests.IrisTest): + def setUp(self): + self.values = dict( + standard_name=sentinel.standard_name, + long_name=sentinel.long_name, + var_name=sentinel.var_name, + units=sentinel.units, + attributes=sentinel.attributes, + ) + self.dummy = sentinel.dummy + self.cls = AncillaryVariableMetadata + self.none = self.cls(*(None,) * len(self.cls._fields)) + + def test_wraps_docstring(self): + self.assertEqual( + BaseMetadata.combine.__doc__, self.cls.combine.__doc__, + ) + + def test_lenient_service(self): + qualname_combine = _qualname(self.cls.combine) + self.assertIn(qualname_combine, _LENIENT) + self.assertTrue(_LENIENT[qualname_combine]) + self.assertTrue(_LENIENT[self.cls.combine]) + + def test_lenient_default(self): + other = sentinel.other + return_value = sentinel.return_value + with mock.patch.object( + BaseMetadata, "combine", return_value=return_value + ) as mocker: + result = self.none.combine(other) + + self.assertEqual(return_value, result) + self.assertEqual(1, mocker.call_count) + (arg,), kwargs = mocker.call_args + self.assertEqual(other, arg) + self.assertEqual(dict(lenient=None), kwargs) + + def test_lenient(self): + other = sentinel.other + lenient = sentinel.lenient + return_value = sentinel.return_value + with mock.patch.object( + BaseMetadata, "combine", return_value=return_value + ) as mocker: + result = self.none.combine(other, lenient=lenient) + + self.assertEqual(return_value, result) + self.assertEqual(1, mocker.call_count) + (arg,), kwargs = mocker.call_args + self.assertEqual(other, arg) + self.assertEqual(dict(lenient=lenient), kwargs) + + def test_op_lenient_same(self): + lmetadata = self.cls(**self.values) + rmetadata = self.cls(**self.values) + expected = self.values + + with mock.patch("iris.common.metadata._LENIENT", return_value=True): + self.assertEqual(expected, lmetadata.combine(rmetadata)._asdict()) + self.assertEqual(expected, rmetadata.combine(lmetadata)._asdict()) + + def test_op_lenient_same_none(self): + lmetadata = self.cls(**self.values) + right = self.values.copy() + right["var_name"] = None + rmetadata = self.cls(**right) + expected = self.values + + with mock.patch("iris.common.metadata._LENIENT", return_value=True): + self.assertEqual(expected, lmetadata.combine(rmetadata)._asdict()) + self.assertEqual(expected, rmetadata.combine(lmetadata)._asdict()) + + def test_op_lenient_different(self): + lmetadata = self.cls(**self.values) + right = self.values.copy() + right["units"] = self.dummy + rmetadata = self.cls(**right) + expected = self.values.copy() + expected["units"] = None + + with mock.patch("iris.common.metadata._LENIENT", return_value=True): + self.assertEqual(expected, lmetadata.combine(rmetadata)._asdict()) + self.assertEqual(expected, rmetadata.combine(lmetadata)._asdict()) + + def test_op_strict_same(self): + lmetadata = self.cls(**self.values) + rmetadata = self.cls(**self.values) + expected = self.values.copy() + + with mock.patch("iris.common.metadata._LENIENT", return_value=False): + self.assertEqual(expected, lmetadata.combine(rmetadata)._asdict()) + self.assertEqual(expected, rmetadata.combine(lmetadata)._asdict()) + + def test_op_strict_different(self): + lmetadata = self.cls(**self.values) + right = self.values.copy() + right["long_name"] = self.dummy + rmetadata = self.cls(**right) + expected = self.values.copy() + expected["long_name"] = None + + with mock.patch("iris.common.metadata._LENIENT", return_value=False): + self.assertEqual(expected, lmetadata.combine(rmetadata)._asdict()) + self.assertEqual(expected, rmetadata.combine(lmetadata)._asdict()) + + def test_op_strict_different_none(self): + lmetadata = self.cls(**self.values) + right = self.values.copy() + right["long_name"] = None + rmetadata = self.cls(**right) + expected = self.values.copy() + expected["long_name"] = None + + with mock.patch("iris.common.metadata._LENIENT", return_value=False): + self.assertEqual(expected, lmetadata.combine(rmetadata)._asdict()) + self.assertEqual(expected, rmetadata.combine(lmetadata)._asdict()) + + +class Test_difference(tests.IrisTest): + def setUp(self): + self.values = dict( + standard_name=sentinel.standard_name, + long_name=sentinel.long_name, + var_name=sentinel.var_name, + units=sentinel.units, + attributes=sentinel.attributes, + ) + self.dummy = sentinel.dummy + self.cls = AncillaryVariableMetadata + self.none = self.cls(*(None,) * len(self.cls._fields)) + + def test_wraps_docstring(self): + self.assertEqual( + BaseMetadata.difference.__doc__, self.cls.difference.__doc__, + ) + + def test_lenient_service(self): + qualname_difference = _qualname(self.cls.difference) + self.assertIn(qualname_difference, _LENIENT) + self.assertTrue(_LENIENT[qualname_difference]) + self.assertTrue(_LENIENT[self.cls.difference]) + + def test_lenient_default(self): + other = sentinel.other + return_value = sentinel.return_value + with mock.patch.object( + BaseMetadata, "difference", return_value=return_value + ) as mocker: + result = self.none.difference(other) + + self.assertEqual(return_value, result) + self.assertEqual(1, mocker.call_count) + (arg,), kwargs = mocker.call_args + self.assertEqual(other, arg) + self.assertEqual(dict(lenient=None), kwargs) + + def test_lenient(self): + other = sentinel.other + lenient = sentinel.lenient + return_value = sentinel.return_value + with mock.patch.object( + BaseMetadata, "difference", return_value=return_value + ) as mocker: + result = self.none.difference(other, lenient=lenient) + + self.assertEqual(return_value, result) + self.assertEqual(1, mocker.call_count) + (arg,), kwargs = mocker.call_args + self.assertEqual(other, arg) + self.assertEqual(dict(lenient=lenient), kwargs) + + def test_op_lenient_same(self): + lmetadata = self.cls(**self.values) + rmetadata = self.cls(**self.values) + + with mock.patch("iris.common.metadata._LENIENT", return_value=True): + self.assertIsNone(lmetadata.difference(rmetadata)) + self.assertIsNone(rmetadata.difference(lmetadata)) + + def test_op_lenient_same_none(self): + lmetadata = self.cls(**self.values) + right = self.values.copy() + right["var_name"] = None + rmetadata = self.cls(**right) + + with mock.patch("iris.common.metadata._LENIENT", return_value=True): + self.assertIsNone(lmetadata.difference(rmetadata)) + self.assertIsNone(rmetadata.difference(lmetadata)) + + def test_op_lenient_different(self): + left = self.values.copy() + lmetadata = self.cls(**left) + right = self.values.copy() + right["units"] = self.dummy + rmetadata = self.cls(**right) + lexpected = deepcopy(self.none)._asdict() + lexpected["units"] = (left["units"], right["units"]) + rexpected = deepcopy(self.none)._asdict() + rexpected["units"] = lexpected["units"][::-1] + + with mock.patch("iris.common.metadata._LENIENT", return_value=True): + self.assertEqual( + lexpected, lmetadata.difference(rmetadata)._asdict() + ) + self.assertEqual( + rexpected, rmetadata.difference(lmetadata)._asdict() + ) + + def test_op_strict_same(self): + lmetadata = self.cls(**self.values) + rmetadata = self.cls(**self.values) + + with mock.patch("iris.common.metadata._LENIENT", return_value=False): + self.assertIsNone(lmetadata.difference(rmetadata)) + self.assertIsNone(rmetadata.difference(lmetadata)) + + def test_op_strict_different(self): + left = self.values.copy() + lmetadata = self.cls(**left) + right = self.values.copy() + right["long_name"] = self.dummy + rmetadata = self.cls(**right) + lexpected = deepcopy(self.none)._asdict() + lexpected["long_name"] = (left["long_name"], right["long_name"]) + rexpected = deepcopy(self.none)._asdict() + rexpected["long_name"] = lexpected["long_name"][::-1] + + with mock.patch("iris.common.metadata._LENIENT", return_value=False): + self.assertEqual( + lexpected, lmetadata.difference(rmetadata)._asdict() + ) + self.assertEqual( + rexpected, rmetadata.difference(lmetadata)._asdict() + ) + + def test_op_strict_different_none(self): + left = self.values.copy() + lmetadata = self.cls(**left) + right = self.values.copy() + right["long_name"] = None + rmetadata = self.cls(**right) + lexpected = deepcopy(self.none)._asdict() + lexpected["long_name"] = (left["long_name"], right["long_name"]) + rexpected = deepcopy(self.none)._asdict() + rexpected["long_name"] = lexpected["long_name"][::-1] + + with mock.patch("iris.common.metadata._LENIENT", return_value=False): + self.assertEqual( + lexpected, lmetadata.difference(rmetadata)._asdict() + ) + self.assertEqual( + rexpected, rmetadata.difference(lmetadata)._asdict() + ) + + +class Test_equal(tests.IrisTest): + def setUp(self): + self.cls = AncillaryVariableMetadata + self.none = self.cls(*(None,) * len(self.cls._fields)) + + def test_wraps_docstring(self): + self.assertEqual(BaseMetadata.equal.__doc__, self.cls.equal.__doc__) + + def test_lenient_service(self): + qualname_equal = _qualname(self.cls.equal) + self.assertIn(qualname_equal, _LENIENT) + self.assertTrue(_LENIENT[qualname_equal]) + self.assertTrue(_LENIENT[self.cls.equal]) + + def test_lenient_default(self): + other = sentinel.other + return_value = sentinel.return_value + with mock.patch.object( + BaseMetadata, "equal", return_value=return_value + ) as mocker: + result = self.none.equal(other) + + self.assertEqual(return_value, result) + self.assertEqual(1, mocker.call_count) + (arg,), kwargs = mocker.call_args + self.assertEqual(other, arg) + self.assertEqual(dict(lenient=None), kwargs) + + def test_lenient(self): + other = sentinel.other + lenient = sentinel.lenient + return_value = sentinel.return_value + with mock.patch.object( + BaseMetadata, "equal", return_value=return_value + ) as mocker: + result = self.none.equal(other, lenient=lenient) + + self.assertEqual(return_value, result) + self.assertEqual(1, mocker.call_count) + (arg,), kwargs = mocker.call_args + self.assertEqual(other, arg) + self.assertEqual(dict(lenient=lenient), kwargs) + + +if __name__ == "__main__": + tests.main() diff --git a/lib/iris/tests/unit/common/metadata/test_BaseMetadata.py b/lib/iris/tests/unit/common/metadata/test_BaseMetadata.py new file mode 100644 index 0000000000..eb0ee9d659 --- /dev/null +++ b/lib/iris/tests/unit/common/metadata/test_BaseMetadata.py @@ -0,0 +1,1636 @@ +# 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. +""" +Unit tests for the :class:`iris.common.metadata.BaseMetadata`. + +""" + +# Import iris.tests first so that some things can be initialised before +# importing anything else. +import iris.tests as tests + +from collections import OrderedDict +import unittest.mock as mock +from unittest.mock import sentinel + +import numpy.ma as ma +import numpy as np + +from iris.common.lenient import _LENIENT, _qualname +from iris.common.metadata import BaseMetadata, CubeMetadata + + +class Test(tests.IrisTest): + def setUp(self): + self.standard_name = mock.sentinel.standard_name + self.long_name = mock.sentinel.long_name + self.var_name = mock.sentinel.var_name + self.units = mock.sentinel.units + self.attributes = mock.sentinel.attributes + self.cls = BaseMetadata + + def test_repr(self): + metadata = self.cls( + standard_name=self.standard_name, + long_name=self.long_name, + var_name=self.var_name, + units=self.units, + attributes=self.attributes, + ) + fmt = ( + "BaseMetadata(standard_name={!r}, long_name={!r}, " + "var_name={!r}, units={!r}, attributes={!r})" + ) + expected = fmt.format( + self.standard_name, + self.long_name, + self.var_name, + self.units, + self.attributes, + ) + self.assertEqual(expected, repr(metadata)) + + def test__fields(self): + expected = ( + "standard_name", + "long_name", + "var_name", + "units", + "attributes", + ) + self.assertEqual(expected, self.cls._fields) + + +class Test___eq__(tests.IrisTest): + def setUp(self): + self.kwargs = dict( + standard_name=sentinel.standard_name, + long_name=sentinel.long_name, + var_name=sentinel.var_name, + units=sentinel.units, + attributes=sentinel.attributes, + ) + self.cls = BaseMetadata + self.metadata = self.cls(**self.kwargs) + + def test_lenient_service(self): + qualname___eq__ = _qualname(self.cls.__eq__) + self.assertIn(qualname___eq__, _LENIENT) + self.assertTrue(_LENIENT[qualname___eq__]) + self.assertTrue(_LENIENT[self.cls.__eq__]) + + def test_cannot_compare_non_class(self): + result = self.metadata.__eq__(None) + self.assertIs(NotImplemented, result) + + def test_cannot_compare_different_class(self): + other = CubeMetadata(*(None,) * len(CubeMetadata._fields)) + result = self.metadata.__eq__(other) + self.assertIs(NotImplemented, result) + + def test_lenient(self): + return_value = sentinel.return_value + with mock.patch( + "iris.common.metadata._LENIENT", return_value=True + ) as mlenient: + with mock.patch.object( + self.cls, "_compare_lenient", return_value=return_value + ) as mcompare: + result = self.metadata.__eq__(self.metadata) + + self.assertEqual(return_value, result) + self.assertEqual(1, mcompare.call_count) + (arg,), kwargs = mcompare.call_args + self.assertEqual(id(self.metadata), id(arg)) + self.assertEqual(dict(), kwargs) + + self.assertEqual(1, mlenient.call_count) + (arg,), kwargs = mlenient.call_args + self.assertEqual(_qualname(self.cls.__eq__), _qualname(arg)) + self.assertEqual(dict(), kwargs) + + def test_strict_same(self): + self.assertTrue(self.metadata.__eq__(self.metadata)) + other = self.cls(**self.kwargs) + self.assertTrue(self.metadata.__eq__(other)) + self.assertTrue(other.__eq__(self.metadata)) + + def test_strict_different(self): + self.kwargs["var_name"] = None + other = self.cls(**self.kwargs) + self.assertFalse(self.metadata.__eq__(other)) + self.assertFalse(other.__eq__(self.metadata)) + + +class Test___lt__(tests.IrisTest): + def setUp(self): + self.cls = BaseMetadata + self.one = self.cls(1, 1, 1, 1, 1) + self.two = self.cls(1, 1, 1, 2, 1) + self.none = self.cls(1, 1, 1, None, 1) + self.attributes = self.cls(1, 1, 1, 1, 10) + + def test__ascending_lt(self): + result = self.one < self.two + self.assertTrue(result) + + def test__descending_lt(self): + result = self.two < self.one + self.assertFalse(result) + + def test__none_rhs_operand(self): + result = self.one < self.none + self.assertFalse(result) + + def test__none_lhs_operand(self): + result = self.none < self.one + self.assertTrue(result) + + def test__ignore_attributes(self): + result = self.one < self.attributes + self.assertFalse(result) + result = self.attributes < self.one + self.assertFalse(result) + + +class Test___ne__(tests.IrisTest): + def setUp(self): + self.cls = BaseMetadata + self.metadata = self.cls(*(None,) * len(self.cls._fields)) + self.other = sentinel.other + + def test_notimplemented(self): + return_value = NotImplemented + with mock.patch.object( + self.cls, "__eq__", return_value=return_value + ) as mocker: + result = self.metadata.__ne__(self.other) + + self.assertIs(return_value, result) + self.assertEqual(1, mocker.call_count) + (arg,), kwargs = mocker.call_args + self.assertEqual(self.other, arg) + self.assertEqual(dict(), kwargs) + + def test_negate_true(self): + return_value = True + with mock.patch.object( + self.cls, "__eq__", return_value=return_value + ) as mocker: + result = self.metadata.__ne__(self.other) + + self.assertFalse(result) + (arg,), kwargs = mocker.call_args + self.assertEqual(self.other, arg) + self.assertEqual(dict(), kwargs) + + def test_negate_false(self): + return_value = False + with mock.patch.object( + self.cls, "__eq__", return_value=return_value + ) as mocker: + result = self.metadata.__ne__(self.other) + + self.assertTrue(result) + (arg,), kwargs = mocker.call_args + self.assertEqual(self.other, arg) + self.assertEqual(dict(), kwargs) + + +class Test__combine(tests.IrisTest): + def setUp(self): + self.kwargs = dict( + standard_name="standard_name", + long_name="long_name", + var_name="var_name", + units="units", + attributes=dict(one=sentinel.one, two=sentinel.two), + ) + self.cls = BaseMetadata + self.metadata = self.cls(**self.kwargs) + + def test_lenient(self): + return_value = sentinel._combine_lenient + other = sentinel.other + with mock.patch( + "iris.common.metadata._LENIENT", return_value=True + ) as mlenient: + with mock.patch.object( + self.cls, "_combine_lenient", return_value=return_value + ) as mcombine: + result = self.metadata._combine(other) + + self.assertEqual(1, mlenient.call_count) + (arg,), kwargs = mlenient.call_args + self.assertEqual(self.metadata.combine, arg) + self.assertEqual(dict(), kwargs) + + self.assertEqual(return_value, result) + self.assertEqual(1, mcombine.call_count) + (arg,), kwargs = mcombine.call_args + self.assertEqual(other, arg) + self.assertEqual(dict(), kwargs) + + def test_strict(self): + dummy = sentinel.dummy + values = self.kwargs.copy() + values["standard_name"] = dummy + values["var_name"] = dummy + values["attributes"] = dummy + other = self.cls(**values) + with mock.patch("iris.common.metadata._LENIENT", return_value=False): + result = self.metadata._combine(other) + + expected = [ + None if values[field] == dummy else values[field] + for field in self.cls._fields + ] + self.assertEqual(expected, result) + + +class Test__combine_lenient(tests.IrisTest): + def setUp(self): + self.cls = BaseMetadata + self.none = self.cls(*(None,) * len(self.cls._fields))._asdict() + self.names = dict( + standard_name=sentinel.standard_name, + long_name=sentinel.long_name, + var_name=sentinel.var_name, + ) + + def test_strict_units(self): + left = self.none.copy() + left["units"] = "K" + right = left.copy() + lmetadata = self.cls(**left) + rmetadata = self.cls(**right) + + expected = list(left.values()) + self.assertEqual(expected, lmetadata._combine_lenient(rmetadata)) + self.assertEqual(expected, rmetadata._combine_lenient(lmetadata)) + + def test_strict_units_different(self): + left = self.none.copy() + right = self.none.copy() + left["units"] = "K" + right["units"] = "km" + lmetadata = self.cls(**left) + rmetadata = self.cls(**right) + + result = lmetadata._combine_lenient(rmetadata) + expected = list(self.none.values()) + self.assertEqual(expected, result) + result = rmetadata._combine_lenient(lmetadata) + self.assertEqual(expected, result) + + def test_strict_units_different_none(self): + left = self.none.copy() + right = self.none.copy() + left["units"] = "K" + lmetadata = self.cls(**left) + rmetadata = self.cls(**right) + + result = lmetadata._combine_lenient(rmetadata) + expected = list(self.none.values()) + self.assertEqual(expected, result) + + result = rmetadata._combine_lenient(lmetadata) + self.assertEqual(expected, result) + + def test_attributes(self): + left = self.none.copy() + right = self.none.copy() + ldict = dict(item=sentinel.left) + rdict = dict(item=sentinel.right) + left["attributes"] = ldict + right["attributes"] = rdict + rmetadata = self.cls(**right) + return_value = sentinel.return_value + with mock.patch.object( + self.cls, "_combine_lenient_attributes", return_value=return_value, + ) as mocker: + lmetadata = self.cls(**left) + result = lmetadata._combine_lenient(rmetadata) + + expected = self.none.copy() + expected["attributes"] = return_value + expected = list(expected.values()) + self.assertEqual(expected, result) + + self.assertEqual(1, mocker.call_count) + args, kwargs = mocker.call_args + expected = (ldict, rdict) + self.assertEqual(expected, args) + self.assertEqual(dict(), kwargs) + + def test_attributes_non_mapping_different(self): + left = self.none.copy() + right = self.none.copy() + ldict = dict(item=sentinel.left) + rdict = sentinel.right + left["attributes"] = ldict + right["attributes"] = rdict + lmetadata = self.cls(**left) + rmetadata = self.cls(**right) + + expected = list(self.none.copy().values()) + self.assertEqual(expected, lmetadata._combine_lenient(rmetadata)) + self.assertEqual(expected, rmetadata._combine_lenient(lmetadata)) + + def test_attributes_non_mapping_different_none(self): + left = self.none.copy() + right = self.none.copy() + ldict = dict(item=sentinel.left) + left["attributes"] = ldict + lmetadata = self.cls(**left) + rmetadata = self.cls(**right) + + result = lmetadata._combine_lenient(rmetadata) + expected = self.none.copy() + expected["attributes"] = ldict + expected = list(expected.values()) + self.assertEqual(expected, result) + + result = rmetadata._combine_lenient(lmetadata) + self.assertEqual(expected, result) + + def test_names(self): + left = self.none.copy() + left.update(self.names) + right = left.copy() + lmetadata = self.cls(**left) + rmetadata = self.cls(**right) + + expected = list(left.values()) + self.assertEqual(expected, lmetadata._combine_lenient(rmetadata)) + self.assertEqual(expected, rmetadata._combine_lenient(lmetadata)) + + def test_names_different(self): + dummy = sentinel.dummy + left = self.none.copy() + right = self.none.copy() + left.update(self.names) + right["standard_name"] = dummy + right["long_name"] = dummy + right["var_name"] = dummy + lmetadata = self.cls(**left) + rmetadata = self.cls(**right) + + expected = list(self.none.copy().values()) + self.assertEqual(expected, lmetadata._combine_lenient(rmetadata)) + self.assertEqual(expected, rmetadata._combine_lenient(lmetadata)) + + def test_names_different_none(self): + left = self.none.copy() + right = self.none.copy() + left.update(self.names) + lmetadata = self.cls(**left) + rmetadata = self.cls(**right) + + result = lmetadata._combine_lenient(rmetadata) + expected = list(left.values()) + self.assertEqual(expected, result) + + result = rmetadata._combine_lenient(lmetadata) + self.assertEqual(expected, result) + + +class Test__combine_lenient_attributes(tests.IrisTest): + def setUp(self): + self.values = OrderedDict( + one="one", + two="two", + three=np.int16(123), + four=np.arange(10), + five=ma.arange(10), + ) + self.cls = BaseMetadata + self.metadata = self.cls(*(None,) * len(self.cls._fields)) + self.dummy = sentinel.dummy + + def test_same(self): + left = self.values.copy() + right = self.values.copy() + + result = self.metadata._combine_lenient_attributes(left, right) + expected = left + self.assertDictEqual(expected, result) + + result = self.metadata._combine_lenient_attributes(right, left) + self.assertDictEqual(expected, result) + + def test_different(self): + left = self.values.copy() + right = self.values.copy() + left["two"] = left["four"] = self.dummy + + result = self.metadata._combine_lenient_attributes(left, right) + expected = self.values.copy() + for key in ["two", "four"]: + del expected[key] + self.assertDictEqual(expected, result) + + result = self.metadata._combine_lenient_attributes(right, left) + self.assertDictEqual(expected, result) + + def test_different_none(self): + left = self.values.copy() + right = self.values.copy() + left["one"] = left["three"] = left["five"] = None + + result = self.metadata._combine_lenient_attributes(left, right) + expected = self.values.copy() + for key in ["one", "three", "five"]: + del expected[key] + self.assertDictEqual(expected, result) + + result = self.metadata._combine_lenient_attributes(right, left) + self.assertDictEqual(expected, result) + + def test_extra(self): + left = self.values.copy() + right = self.values.copy() + left["extra_left"] = "extra_left" + right["extra_right"] = "extra_right" + + result = self.metadata._combine_lenient_attributes(left, right) + expected = self.values.copy() + expected["extra_left"] = left["extra_left"] + expected["extra_right"] = right["extra_right"] + self.assertDictEqual(expected, result) + + result = self.metadata._combine_lenient_attributes(right, left) + self.assertDictEqual(expected, result) + + +class Test__combine_strict_attributes(tests.IrisTest): + def setUp(self): + self.values = OrderedDict( + one="one", + two="two", + three=np.int32(123), + four=np.arange(10), + five=ma.arange(10), + ) + self.cls = BaseMetadata + self.metadata = self.cls(*(None,) * len(self.cls._fields)) + self.dummy = sentinel.dummy + + def test_same(self): + left = self.values.copy() + right = self.values.copy() + + result = self.metadata._combine_strict_attributes(left, right) + expected = left + self.assertDictEqual(expected, result) + + result = self.metadata._combine_strict_attributes(right, left) + self.assertDictEqual(expected, result) + + def test_different(self): + left = self.values.copy() + right = self.values.copy() + left["one"] = left["three"] = self.dummy + + result = self.metadata._combine_strict_attributes(left, right) + expected = self.values.copy() + for key in ["one", "three"]: + del expected[key] + self.assertDictEqual(expected, result) + + result = self.metadata._combine_strict_attributes(right, left) + self.assertDictEqual(expected, result) + + def test_different_none(self): + left = self.values.copy() + right = self.values.copy() + left["one"] = left["three"] = left["five"] = None + + result = self.metadata._combine_strict_attributes(left, right) + expected = self.values.copy() + for key in ["one", "three", "five"]: + del expected[key] + self.assertDictEqual(expected, result) + + result = self.metadata._combine_strict_attributes(right, left) + self.assertDictEqual(expected, result) + + def test_extra(self): + left = self.values.copy() + right = self.values.copy() + left["extra_left"] = "extra_left" + right["extra_right"] = "extra_right" + + result = self.metadata._combine_strict_attributes(left, right) + expected = self.values.copy() + self.assertDictEqual(expected, result) + + result = self.metadata._combine_strict_attributes(right, left) + self.assertDictEqual(expected, result) + + +class Test__compare_lenient(tests.IrisTest): + def setUp(self): + self.cls = BaseMetadata + self.none = self.cls(*(None,) * len(self.cls._fields))._asdict() + self.names = dict( + standard_name=sentinel.standard_name, + long_name=sentinel.long_name, + var_name=sentinel.var_name, + ) + + def test_name_same(self): + left = self.none.copy() + left.update(self.names) + right = left.copy() + lmetadata = self.cls(**left) + rmetadata = self.cls(**right) + + with mock.patch.object( + self.cls, "_is_attributes", return_value=False + ) as mocker: + self.assertTrue(lmetadata._compare_lenient(rmetadata)) + self.assertTrue(rmetadata._compare_lenient(lmetadata)) + + # mocker not called for "units" nor "var_name" members. + expected = (len(self.cls._fields) - 2) * 2 + self.assertEqual(expected, mocker.call_count) + + def test_name_same_lenient_false__long_name_different(self): + left = self.none.copy() + left.update(self.names) + right = left.copy() + right["long_name"] = sentinel.dummy + lmetadata = self.cls(**left) + rmetadata = self.cls(**right) + + with mock.patch.object( + self.cls, "_is_attributes", return_value=False + ) as mocker: + self.assertFalse(lmetadata._compare_lenient(rmetadata)) + self.assertFalse(rmetadata._compare_lenient(lmetadata)) + + # mocker not called for "units" nor "var_name" members. + expected = (len(self.cls._fields) - 2) * 2 + self.assertEqual(expected, mocker.call_count) + + def test_name_same_lenient_true__var_name_different(self): + left = self.none.copy() + left.update(self.names) + right = left.copy() + right["var_name"] = sentinel.dummy + lmetadata = self.cls(**left) + rmetadata = self.cls(**right) + + with mock.patch.object( + self.cls, "_is_attributes", return_value=False + ) as mocker: + self.assertTrue(lmetadata._compare_lenient(rmetadata)) + self.assertTrue(rmetadata._compare_lenient(lmetadata)) + + # mocker not called for "units" nor "var_name" members. + expected = (len(self.cls._fields) - 2) * 2 + self.assertEqual(expected, mocker.call_count) + + def test_name_different(self): + left = self.none.copy() + left.update(self.names) + right = left.copy() + right["standard_name"] = None + lmetadata = self.cls(**left) + rmetadata = self.cls(**right) + + with mock.patch.object(self.cls, "_is_attributes") as mocker: + self.assertFalse(lmetadata._compare_lenient(rmetadata)) + self.assertFalse(rmetadata._compare_lenient(lmetadata)) + + self.assertEqual(0, mocker.call_count) + + def test_strict_units(self): + left = self.none.copy() + left.update(self.names) + left["units"] = "K" + right = left.copy() + lmetadata = self.cls(**left) + rmetadata = self.cls(**right) + + with mock.patch.object( + self.cls, "_is_attributes", return_value=False + ) as mocker: + self.assertTrue(lmetadata._compare_lenient(rmetadata)) + self.assertTrue(rmetadata._compare_lenient(lmetadata)) + + # mocker not called for "units" nor "var_name" members. + expected = (len(self.cls._fields) - 2) * 2 + self.assertEqual(expected, mocker.call_count) + + def test_strict_units_different(self): + left = self.none.copy() + left.update(self.names) + left["units"] = "K" + right = left.copy() + right["units"] = "m" + lmetadata = self.cls(**left) + rmetadata = self.cls(**right) + + with mock.patch.object( + self.cls, "_is_attributes", return_value=False + ) as mocker: + self.assertFalse(lmetadata._compare_lenient(rmetadata)) + self.assertFalse(rmetadata._compare_lenient(lmetadata)) + + # mocker not called for "units" nor "var_name" members. + expected = (len(self.cls._fields) - 2) * 2 + self.assertEqual(expected, mocker.call_count) + + def test_attributes(self): + left = self.none.copy() + left.update(self.names) + right = left.copy() + ldict = dict(item=sentinel.left) + rdict = dict(item=sentinel.right) + left["attributes"] = ldict + right["attributes"] = rdict + rmetadata = self.cls(**right) + with mock.patch.object( + self.cls, "_compare_lenient_attributes", return_value=True, + ) as mocker: + lmetadata = self.cls(**left) + self.assertTrue(lmetadata._compare_lenient(rmetadata)) + self.assertTrue(rmetadata._compare_lenient(lmetadata)) + + self.assertEqual(2, mocker.call_count) + expected = [((ldict, rdict),), ((rdict, ldict),)] + self.assertEqual(expected, mocker.call_args_list) + + def test_attributes_non_mapping_different(self): + left = self.none.copy() + left.update(self.names) + right = left.copy() + ldict = dict(item=sentinel.left) + rdict = sentinel.right + left["attributes"] = ldict + right["attributes"] = rdict + lmetadata = self.cls(**left) + rmetadata = self.cls(**right) + + self.assertFalse(lmetadata._compare_lenient(rmetadata)) + self.assertFalse(rmetadata._compare_lenient(lmetadata)) + + def test_attributes_non_mapping_different_none(self): + left = self.none.copy() + left.update(self.names) + right = left.copy() + ldict = dict(item=sentinel.left) + left["attributes"] = ldict + lmetadata = self.cls(**left) + rmetadata = self.cls(**right) + + self.assertTrue(lmetadata._compare_lenient(rmetadata)) + self.assertTrue(rmetadata._compare_lenient(lmetadata)) + + def test_names(self): + left = self.none.copy() + left.update(self.names) + left["long_name"] = None + right = self.none.copy() + right["long_name"] = left["standard_name"] + lmetadata = self.cls(**left) + rmetadata = self.cls(**right) + + self.assertTrue(lmetadata._compare_lenient(rmetadata)) + self.assertTrue(rmetadata._combine_lenient(lmetadata)) + + +class Test__compare_lenient_attributes(tests.IrisTest): + def setUp(self): + self.values = OrderedDict( + one=sentinel.one, + two=sentinel.two, + three=np.int16(123), + four=np.arange(10), + five=ma.arange(5), + ) + self.cls = BaseMetadata + self.metadata = self.cls(*(None,) * len(self.cls._fields)) + self.dummy = sentinel.dummy + + def test_same(self): + left = self.values.copy() + right = self.values.copy() + + self.assertTrue(self.metadata._compare_lenient_attributes(left, right)) + self.assertTrue(self.metadata._compare_lenient_attributes(right, left)) + + def test_different(self): + left = self.values.copy() + right = self.values.copy() + left["two"] = left["four"] = self.dummy + + self.assertFalse( + self.metadata._compare_lenient_attributes(left, right) + ) + self.assertFalse( + self.metadata._compare_lenient_attributes(right, left) + ) + + def test_different_none(self): + left = self.values.copy() + right = self.values.copy() + left["one"] = left["three"] = left["five"] = None + + self.assertFalse( + self.metadata._compare_lenient_attributes(left, right) + ) + self.assertFalse( + self.metadata._compare_lenient_attributes(right, left) + ) + + def test_extra(self): + left = self.values.copy() + right = self.values.copy() + left["extra_left"] = sentinel.extra_left + right["extra_right"] = sentinel.extra_right + + self.assertTrue(self.metadata._compare_lenient_attributes(left, right)) + self.assertTrue(self.metadata._compare_lenient_attributes(right, left)) + + +class Test__compare_strict_attributes(tests.IrisTest): + def setUp(self): + self.values = OrderedDict( + one=sentinel.one, + two=sentinel.two, + three=np.int16(123), + four=np.arange(10), + five=ma.arange(5), + ) + self.cls = BaseMetadata + self.metadata = self.cls(*(None,) * len(self.cls._fields)) + self.dummy = sentinel.dummy + + def test_same(self): + left = self.values.copy() + right = self.values.copy() + + self.assertTrue(self.metadata._compare_strict_attributes(left, right)) + self.assertTrue(self.metadata._compare_strict_attributes(right, left)) + + def test_different(self): + left = self.values.copy() + right = self.values.copy() + left["two"] = left["four"] = self.dummy + + self.assertFalse(self.metadata._compare_strict_attributes(left, right)) + self.assertFalse(self.metadata._compare_strict_attributes(right, left)) + + def test_different_none(self): + left = self.values.copy() + right = self.values.copy() + left["one"] = left["three"] = left["five"] = None + + self.assertFalse(self.metadata._compare_strict_attributes(left, right)) + self.assertFalse(self.metadata._compare_strict_attributes(right, left)) + + def test_extra(self): + left = self.values.copy() + right = self.values.copy() + left["extra_left"] = sentinel.extra_left + right["extra_right"] = sentinel.extra_right + + self.assertFalse(self.metadata._compare_strict_attributes(left, right)) + self.assertFalse(self.metadata._compare_strict_attributes(right, left)) + + +class Test__difference(tests.IrisTest): + def setUp(self): + self.kwargs = dict( + standard_name="standard_name", + long_name="long_name", + var_name="var_name", + units="units", + attributes=dict(one=sentinel.one, two=sentinel.two), + ) + self.cls = BaseMetadata + self.metadata = self.cls(**self.kwargs) + + def test_lenient(self): + return_value = sentinel._difference_lenient + other = sentinel.other + with mock.patch( + "iris.common.metadata._LENIENT", return_value=True + ) as mlenient: + with mock.patch.object( + self.cls, "_difference_lenient", return_value=return_value + ) as mdifference: + result = self.metadata._difference(other) + + self.assertEqual(1, mlenient.call_count) + (arg,), kwargs = mlenient.call_args + self.assertEqual(self.metadata.difference, arg) + self.assertEqual(dict(), kwargs) + + self.assertEqual(return_value, result) + self.assertEqual(1, mdifference.call_count) + (arg,), kwargs = mdifference.call_args + self.assertEqual(other, arg) + self.assertEqual(dict(), kwargs) + + def test_strict(self): + dummy = sentinel.dummy + values = self.kwargs.copy() + values["long_name"] = dummy + values["units"] = dummy + other = self.cls(**values) + method = "_difference_strict_attributes" + with mock.patch("iris.common.metadata._LENIENT", return_value=False): + with mock.patch.object( + self.cls, method, return_value=None + ) as mdifference: + result = self.metadata._difference(other) + + expected = [ + (self.kwargs[field], dummy) if values[field] == dummy else None + for field in self.cls._fields + ] + self.assertEqual(expected, result) + self.assertEqual(1, mdifference.call_count) + args, kwargs = mdifference.call_args + expected = (self.kwargs["attributes"], values["attributes"]) + self.assertEqual(expected, args) + self.assertEqual(dict(), kwargs) + + with mock.patch.object( + self.cls, method, return_value=None + ) as mdifference: + result = other._difference(self.metadata) + + expected = [ + (dummy, self.kwargs[field]) if values[field] == dummy else None + for field in self.cls._fields + ] + self.assertEqual(expected, result) + self.assertEqual(1, mdifference.call_count) + args, kwargs = mdifference.call_args + expected = (self.kwargs["attributes"], values["attributes"]) + self.assertEqual(expected, args) + self.assertEqual(dict(), kwargs) + + +class Test__difference_lenient(tests.IrisTest): + def setUp(self): + self.cls = BaseMetadata + self.none = self.cls(*(None,) * len(self.cls._fields))._asdict() + self.names = dict( + standard_name=sentinel.standard_name, + long_name=sentinel.long_name, + var_name=sentinel.var_name, + ) + + def test_strict_units(self): + left = self.none.copy() + left["units"] = "km" + right = left.copy() + lmetadata = self.cls(**left) + rmetadata = self.cls(**right) + expected = list(self.none.values()) + self.assertEqual(expected, lmetadata._difference_lenient(rmetadata)) + self.assertEqual(expected, rmetadata._difference_lenient(lmetadata)) + + def test_strict_units_different(self): + left = self.none.copy() + right = self.none.copy() + lunits, runits = "m", "km" + left["units"] = lunits + right["units"] = runits + lmetadata = self.cls(**left) + rmetadata = self.cls(**right) + + result = lmetadata._difference_lenient(rmetadata) + expected = self.none.copy() + expected["units"] = (lunits, runits) + expected = list(expected.values()) + self.assertEqual(expected, result) + + result = rmetadata._difference_lenient(lmetadata) + expected = self.none.copy() + expected["units"] = (runits, lunits) + expected = list(expected.values()) + self.assertEqual(expected, result) + + def test_strict_units_different_none(self): + left = self.none.copy() + right = self.none.copy() + lunits, runits = "m", None + left["units"] = lunits + lmetadata = self.cls(**left) + rmetadata = self.cls(**right) + + result = lmetadata._difference_lenient(rmetadata) + expected = self.none.copy() + expected["units"] = (lunits, runits) + expected = list(expected.values()) + + self.assertEqual(expected, result) + result = rmetadata._difference_lenient(lmetadata) + expected = self.none.copy() + expected["units"] = (runits, lunits) + expected = list(expected.values()) + self.assertEqual(expected, result) + + def test_attributes(self): + left = self.none.copy() + right = self.none.copy() + ldict = dict(item=sentinel.left) + rdict = dict(item=sentinel.right) + left["attributes"] = ldict + right["attributes"] = rdict + rmetadata = self.cls(**right) + return_value = sentinel.return_value + with mock.patch.object( + self.cls, + "_difference_lenient_attributes", + return_value=return_value, + ) as mocker: + lmetadata = self.cls(**left) + result = lmetadata._difference_lenient(rmetadata) + + expected = self.none.copy() + expected["attributes"] = return_value + expected = list(expected.values()) + self.assertEqual(expected, result) + + self.assertEqual(1, mocker.call_count) + args, kwargs = mocker.call_args + expected = (ldict, rdict) + self.assertEqual(expected, args) + self.assertEqual(dict(), kwargs) + + def test_attributes_non_mapping_different(self): + left = self.none.copy() + right = self.none.copy() + ldict = dict(item=sentinel.left) + rdict = sentinel.right + left["attributes"] = ldict + right["attributes"] = rdict + lmetadata = self.cls(**left) + rmetadata = self.cls(**right) + + result = lmetadata._difference_lenient(rmetadata) + expected = self.none.copy() + expected["attributes"] = (ldict, rdict) + expected = list(expected.values()) + self.assertEqual(expected, result) + + result = rmetadata._difference_lenient(lmetadata) + expected = self.none.copy() + expected["attributes"] = (rdict, ldict) + expected = list(expected.values()) + self.assertEqual(expected, result) + + def test_attributes_non_mapping_different_none(self): + left = self.none.copy() + right = self.none.copy() + ldict = dict(item=sentinel.left) + left["attributes"] = ldict + lmetadata = self.cls(**left) + rmetadata = self.cls(**right) + + result = lmetadata._difference_lenient(rmetadata) + expected = list(self.none.copy().values()) + self.assertEqual(expected, result) + + result = rmetadata._difference_lenient(lmetadata) + self.assertEqual(expected, result) + + def test_names(self): + left = self.none.copy() + left.update(self.names) + right = left.copy() + lmetadata = self.cls(**left) + rmetadata = self.cls(**right) + + expected = list(self.none.values()) + self.assertEqual(expected, lmetadata._difference_lenient(rmetadata)) + self.assertEqual(expected, rmetadata._difference_lenient(lmetadata)) + + def test_names_different(self): + dummy = sentinel.dummy + left = self.none.copy() + right = self.none.copy() + left.update(self.names) + right["standard_name"] = dummy + right["long_name"] = dummy + right["var_name"] = dummy + lmetadata = self.cls(**left) + rmetadata = self.cls(**right) + + result = lmetadata._difference_lenient(rmetadata) + expected = self.none.copy() + expected["standard_name"] = ( + left["standard_name"], + right["standard_name"], + ) + expected["long_name"] = (left["long_name"], right["long_name"]) + expected["var_name"] = (left["var_name"], right["var_name"]) + expected = list(expected.values()) + self.assertEqual(expected, result) + + result = rmetadata._difference_lenient(lmetadata) + expected = self.none.copy() + expected["standard_name"] = ( + right["standard_name"], + left["standard_name"], + ) + expected["long_name"] = (right["long_name"], left["long_name"]) + expected["var_name"] = (right["var_name"], left["var_name"]) + expected = list(expected.values()) + self.assertEqual(expected, result) + + def test_names_different_none(self): + left = self.none.copy() + right = self.none.copy() + left.update(self.names) + lmetadata = self.cls(**left) + rmetadata = self.cls(**right) + + result = lmetadata._difference_lenient(rmetadata) + expected = list(self.none.values()) + self.assertEqual(expected, result) + + result = rmetadata._difference_lenient(lmetadata) + self.assertEqual(expected, result) + + +class Test__difference_lenient_attributes(tests.IrisTest): + def setUp(self): + self.values = OrderedDict( + one=sentinel.one, + two=sentinel.two, + three=np.float(3.14), + four=np.arange(10, dtype=np.float), + five=ma.arange(10, dtype=np.int16), + ) + self.cls = BaseMetadata + self.metadata = self.cls(*(None,) * len(self.cls._fields)) + self.dummy = sentinel.dummy + + def test_same(self): + left = self.values.copy() + right = self.values.copy() + + result = self.metadata._difference_lenient_attributes(left, right) + self.assertIsNone(result) + + result = self.metadata._difference_lenient_attributes(right, left) + self.assertIsNone(result) + + def test_different(self): + left = self.values.copy() + right = self.values.copy() + left["two"] = left["four"] = self.dummy + + result = self.metadata._difference_lenient_attributes(left, right) + for key in ["one", "three", "five"]: + del left[key] + del right[key] + expected_left, expected_right = (left, right) + result_left, result_right = result + self.assertDictEqual(expected_left, result_left) + self.assertDictEqual(expected_right, result_right) + + result = self.metadata._difference_lenient_attributes(right, left) + result_left, result_right = result + self.assertDictEqual(expected_right, result_left) + self.assertDictEqual(expected_left, result_right) + + def test_different_none(self): + left = self.values.copy() + right = self.values.copy() + left["one"] = left["three"] = left["five"] = None + + result = self.metadata._difference_lenient_attributes(left, right) + for key in ["two", "four"]: + del left[key] + del right[key] + expected_left, expected_right = (left, right) + result_left, result_right = result + self.assertDictEqual(expected_left, result_left) + self.assertDictEqual(expected_right, result_right) + + result = self.metadata._difference_lenient_attributes(right, left) + result_left, result_right = result + self.assertDictEqual(expected_right, result_left) + self.assertDictEqual(expected_left, result_right) + + def test_extra(self): + left = self.values.copy() + right = self.values.copy() + left["extra_left"] = sentinel.extra_left + right["extra_right"] = sentinel.extra_right + result = self.metadata._difference_lenient_attributes(left, right) + self.assertIsNone(result) + + result = self.metadata._difference_lenient_attributes(right, left) + self.assertIsNone(result) + + +class Test__difference_strict_attributes(tests.IrisTest): + def setUp(self): + self.values = OrderedDict( + one=sentinel.one, + two=sentinel.two, + three=np.int32(123), + four=np.arange(10), + five=ma.arange(10), + ) + self.cls = BaseMetadata + self.metadata = self.cls(*(None,) * len(self.cls._fields)) + self.dummy = sentinel.dummy + + def test_same(self): + left = self.values.copy() + right = self.values.copy() + + result = self.metadata._difference_strict_attributes(left, right) + self.assertIsNone(result) + result = self.metadata._difference_strict_attributes(right, left) + self.assertIsNone(result) + + def test_different(self): + left = self.values.copy() + right = self.values.copy() + left["one"] = left["three"] = left["five"] = self.dummy + + result = self.metadata._difference_strict_attributes(left, right) + expected_left = left.copy() + expected_right = right.copy() + for key in ["two", "four"]: + del expected_left[key] + del expected_right[key] + result_left, result_right = result + self.assertDictEqual(expected_left, result_left) + self.assertDictEqual(expected_right, result_right) + + result = self.metadata._difference_strict_attributes(right, left) + result_left, result_right = result + self.assertDictEqual(expected_right, result_left) + self.assertDictEqual(expected_left, result_right) + + def test_different_none(self): + left = self.values.copy() + right = self.values.copy() + left["one"] = left["three"] = left["five"] = None + + result = self.metadata._difference_strict_attributes(left, right) + expected_left = left.copy() + expected_right = right.copy() + for key in ["two", "four"]: + del expected_left[key] + del expected_right[key] + result_left, result_right = result + self.assertDictEqual(expected_left, result_left) + self.assertDictEqual(expected_right, result_right) + + result = self.metadata._difference_strict_attributes(right, left) + result_left, result_right = result + self.assertDictEqual(expected_right, result_left) + self.assertDictEqual(expected_left, result_right) + + def test_extra(self): + left = self.values.copy() + right = self.values.copy() + left["extra_left"] = sentinel.extra_left + right["extra_right"] = sentinel.extra_right + + result = self.metadata._difference_strict_attributes(left, right) + expected_left = dict(extra_left=left["extra_left"]) + expected_right = dict(extra_right=right["extra_right"]) + result_left, result_right = result + self.assertDictEqual(expected_left, result_left) + self.assertDictEqual(expected_right, result_right) + + result = self.metadata._difference_strict_attributes(right, left) + result_left, result_right = result + self.assertDictEqual(expected_right, result_left) + self.assertDictEqual(expected_left, result_right) + + +class Test__is_attributes(tests.IrisTest): + def setUp(self): + self.cls = BaseMetadata + self.metadata = self.cls(*(None,) * len(self.cls._fields)) + self.field = "attributes" + + def test_field(self): + self.assertTrue(self.metadata._is_attributes(self.field, {}, {})) + + def test_field_not_attributes(self): + self.assertFalse(self.metadata._is_attributes(None, {}, {})) + + def test_left_not_mapping(self): + self.assertFalse(self.metadata._is_attributes(self.field, None, {})) + + def test_right_not_mapping(self): + self.assertFalse(self.metadata._is_attributes(self.field, {}, None)) + + +class Test_combine(tests.IrisTest): + def setUp(self): + kwargs = dict( + standard_name="standard_name", + long_name="long_name", + var_name="var_name", + units="units", + attributes="attributes", + ) + self.cls = BaseMetadata + self.metadata = self.cls(**kwargs) + self.mock_kwargs = OrderedDict( + standard_name=sentinel.standard_name, + long_name=sentinel.long_name, + var_name=sentinel.var_name, + units=sentinel.units, + attributes=sentinel.attributes, + ) + + def test_lenient_service(self): + qualname_combine = _qualname(self.cls.combine) + self.assertIn(qualname_combine, _LENIENT) + self.assertTrue(_LENIENT[qualname_combine]) + self.assertTrue(_LENIENT[self.cls.combine]) + + def test_cannot_combine_non_class(self): + emsg = "Cannot combine" + with self.assertRaisesRegex(TypeError, emsg): + self.metadata.combine(None) + + def test_cannot_combine_different_class(self): + other = CubeMetadata(*(None,) * len(CubeMetadata._fields)) + emsg = "Cannot combine" + with self.assertRaisesRegex(TypeError, emsg): + self.metadata.combine(other) + + def test_lenient_default(self): + return_value = self.mock_kwargs.values() + with mock.patch.object( + self.cls, "_combine", return_value=return_value + ) as mocker: + result = self.metadata.combine(self.metadata) + + self.assertEqual(self.mock_kwargs, result._asdict()) + self.assertEqual(1, mocker.call_count) + (arg,), kwargs = mocker.call_args + self.assertEqual(id(self.metadata), id(arg)) + self.assertEqual(dict(), kwargs) + + def test_lenient_true(self): + return_value = self.mock_kwargs.values() + with mock.patch.object( + self.cls, "_combine", return_value=return_value + ) as mcombine: + with mock.patch.object(_LENIENT, "context") as mcontext: + result = self.metadata.combine(self.metadata, lenient=True) + + self.assertEqual(1, mcontext.call_count) + (arg,), kwargs = mcontext.call_args + self.assertEqual(_qualname(self.cls.combine), arg) + self.assertEqual(dict(), kwargs) + + self.assertEqual(result._asdict(), self.mock_kwargs) + self.assertEqual(1, mcombine.call_count) + (arg,), kwargs = mcombine.call_args + self.assertEqual(id(self.metadata), id(arg)) + self.assertEqual(dict(), kwargs) + + def test_lenient_false(self): + return_value = self.mock_kwargs.values() + with mock.patch.object( + self.cls, "_combine", return_value=return_value + ) as mcombine: + with mock.patch.object(_LENIENT, "context") as mcontext: + result = self.metadata.combine(self.metadata, lenient=False) + + self.assertEqual(1, mcontext.call_count) + args, kwargs = mcontext.call_args + self.assertEqual((), args) + self.assertEqual({_qualname(self.cls.combine): False}, kwargs) + + self.assertEqual(self.mock_kwargs, result._asdict()) + self.assertEqual(1, mcombine.call_count) + (arg,), kwargs = mcombine.call_args + self.assertEqual(id(self.metadata), id(arg)) + self.assertEqual(dict(), kwargs) + + +class Test_difference(tests.IrisTest): + def setUp(self): + kwargs = dict( + standard_name="standard_name", + long_name="long_name", + var_name="var_name", + units="units", + attributes="attributes", + ) + self.cls = BaseMetadata + self.metadata = self.cls(**kwargs) + self.mock_kwargs = OrderedDict( + standard_name=sentinel.standard_name, + long_name=sentinel.long_name, + var_name=sentinel.var_name, + units=sentinel.units, + attributes=sentinel.attributes, + ) + + def test_lenient_service(self): + qualname_difference = _qualname(self.cls.difference) + self.assertIn(qualname_difference, _LENIENT) + self.assertTrue(_LENIENT[qualname_difference]) + self.assertTrue(_LENIENT[self.cls.difference]) + + def test_cannot_differ_non_class(self): + emsg = "Cannot differ" + with self.assertRaisesRegex(TypeError, emsg): + self.metadata.difference(None) + + def test_cannot_differ_different_class(self): + other = CubeMetadata(*(None,) * len(CubeMetadata._fields)) + emsg = "Cannot differ" + with self.assertRaisesRegex(TypeError, emsg): + self.metadata.difference(other) + + def test_lenient_default(self): + return_value = self.mock_kwargs.values() + with mock.patch.object( + self.cls, "_difference", return_value=return_value + ) as mocker: + result = self.metadata.difference(self.metadata) + + self.assertEqual(self.mock_kwargs, result._asdict()) + self.assertEqual(1, mocker.call_count) + (arg,), kwargs = mocker.call_args + self.assertEqual(id(self.metadata), id(arg)) + self.assertEqual(dict(), kwargs) + + def test_lenient_true(self): + return_value = self.mock_kwargs.values() + with mock.patch.object( + self.cls, "_difference", return_value=return_value + ) as mdifference: + with mock.patch.object(_LENIENT, "context") as mcontext: + result = self.metadata.difference(self.metadata, lenient=True) + + self.assertEqual(1, mcontext.call_count) + (arg,), kwargs = mcontext.call_args + self.assertEqual(_qualname(self.cls.difference), arg) + self.assertEqual(dict(), kwargs) + + self.assertEqual(self.mock_kwargs, result._asdict()) + self.assertEqual(1, mdifference.call_count) + (arg,), kwargs = mdifference.call_args + self.assertEqual(id(self.metadata), id(arg)) + self.assertEqual(dict(), kwargs) + + def test_lenient_false(self): + return_value = self.mock_kwargs.values() + with mock.patch.object( + self.cls, "_difference", return_value=return_value + ) as mdifference: + with mock.patch.object(_LENIENT, "context") as mcontext: + result = self.metadata.difference(self.metadata, lenient=False) + + self.assertEqual(mcontext.call_count, 1) + args, kwargs = mcontext.call_args + self.assertEqual((), args) + self.assertEqual({_qualname(self.cls.difference): False}, kwargs) + + self.assertEqual(self.mock_kwargs, result._asdict()) + self.assertEqual(1, mdifference.call_count) + (arg,), kwargs = mdifference.call_args + self.assertEqual(id(self.metadata), id(arg)) + self.assertEqual(dict(), kwargs) + + +class Test_equal(tests.IrisTest): + def setUp(self): + kwargs = dict( + standard_name=sentinel.standard_name, + long_name=sentinel.long_name, + var_name=sentinel.var_name, + units=sentinel.units, + attributes=sentinel.attributes, + ) + self.cls = BaseMetadata + self.metadata = self.cls(**kwargs) + + def test_lenient_service(self): + qualname_equal = _qualname(self.cls.equal) + self.assertIn(qualname_equal, _LENIENT) + self.assertTrue(_LENIENT[qualname_equal]) + self.assertTrue((_LENIENT[self.cls.equal])) + + def test_cannot_compare_non_class(self): + emsg = "Cannot compare" + with self.assertRaisesRegex(TypeError, emsg): + self.metadata.equal(None) + + def test_cannot_compare_different_class(self): + other = CubeMetadata(*(None,) * len(CubeMetadata._fields)) + emsg = "Cannot compare" + with self.assertRaisesRegex(TypeError, emsg): + self.metadata.equal(other) + + def test_lenient_default(self): + return_value = sentinel.return_value + with mock.patch.object( + self.cls, "__eq__", return_value=return_value + ) as mocker: + result = self.metadata.equal(self.metadata) + + self.assertEqual(return_value, result) + self.assertEqual(1, mocker.call_count) + (arg,), kwargs = mocker.call_args + self.assertEqual(id(self.metadata), id(arg)) + self.assertEqual(dict(), kwargs) + + def test_lenient_true(self): + return_value = sentinel.return_value + with mock.patch.object( + self.cls, "__eq__", return_value=return_value + ) as m__eq__: + with mock.patch.object(_LENIENT, "context") as mcontext: + result = self.metadata.equal(self.metadata, lenient=True) + + self.assertEqual(return_value, result) + self.assertEqual(1, mcontext.call_count) + (arg,), kwargs = mcontext.call_args + self.assertEqual(_qualname(self.cls.equal), arg) + self.assertEqual(dict(), kwargs) + + self.assertEqual(1, m__eq__.call_count) + (arg,), kwargs = m__eq__.call_args + self.assertEqual(id(self.metadata), id(arg)) + self.assertEqual(dict(), kwargs) + + def test_lenient_false(self): + return_value = sentinel.return_value + with mock.patch.object( + self.cls, "__eq__", return_value=return_value + ) as m__eq__: + with mock.patch.object(_LENIENT, "context") as mcontext: + result = self.metadata.equal(self.metadata, lenient=False) + + self.assertEqual(1, mcontext.call_count) + args, kwargs = mcontext.call_args + self.assertEqual((), args) + self.assertEqual({_qualname(self.cls.equal): False}, kwargs) + + self.assertEqual(return_value, result) + self.assertEqual(1, m__eq__.call_count) + (arg,), kwargs = m__eq__.call_args + self.assertEqual(id(self.metadata), id(arg)) + self.assertEqual(dict(), kwargs) + + +class Test_name(tests.IrisTest): + def setUp(self): + self.cls = BaseMetadata + self.default = self.cls.DEFAULT_NAME + + @staticmethod + def _make(standard_name=None, long_name=None, var_name=None): + return BaseMetadata( + standard_name=standard_name, + long_name=long_name, + var_name=var_name, + units=None, + attributes=None, + ) + + def test_standard_name(self): + token = "standard_name" + metadata = self._make(standard_name=token) + + result = metadata.name() + self.assertEqual(token, result) + result = metadata.name(token=True) + self.assertEqual(token, result) + + def test_standard_name__invalid_token(self): + token = "nope nope" + metadata = self._make(standard_name=token) + + result = metadata.name() + self.assertEqual(token, result) + result = metadata.name(token=True) + self.assertEqual(self.default, result) + + def test_long_name(self): + token = "long_name" + metadata = self._make(long_name=token) + + result = metadata.name() + self.assertEqual(token, result) + result = metadata.name(token=True) + self.assertEqual(token, result) + + def test_long_name__invalid_token(self): + token = "nope nope" + metadata = self._make(long_name=token) + + result = metadata.name() + self.assertEqual(token, result) + result = metadata.name(token=True) + self.assertEqual(self.default, result) + + def test_var_name(self): + token = "var_name" + metadata = self._make(var_name=token) + + result = metadata.name() + self.assertEqual(token, result) + result = metadata.name(token=True) + self.assertEqual(token, result) + + def test_var_name__invalid_token(self): + token = "nope nope" + metadata = self._make(var_name=token) + + result = metadata.name() + self.assertEqual(token, result) + result = metadata.name(token=True) + self.assertEqual(self.default, result) + + def test_default(self): + metadata = self._make() + + result = metadata.name() + self.assertEqual(self.default, result) + result = metadata.name(token=True) + self.assertEqual(self.default, result) + + def test_default__invalid_token(self): + token = "nope nope" + metadata = self._make() + + result = metadata.name(default=token) + self.assertEqual(token, result) + + emsg = "Cannot retrieve a valid name token" + with self.assertRaisesRegex(ValueError, emsg): + metadata.name(default=token, token=True) + + +class Test_token(tests.IrisTest): + def setUp(self): + self.cls = BaseMetadata + + def test_passthru_None(self): + result = self.cls.token(None) + self.assertIsNone(result) + + def test_fail_leading_underscore(self): + result = self.cls.token("_nope") + self.assertIsNone(result) + + def test_fail_leading_dot(self): + result = self.cls.token(".nope") + self.assertIsNone(result) + + def test_fail_leading_plus(self): + result = self.cls.token("+nope") + self.assertIsNone(result) + + def test_fail_leading_at(self): + result = self.cls.token("@nope") + self.assertIsNone(result) + + def test_fail_space(self): + result = self.cls.token("nope nope") + self.assertIsNone(result) + + def test_fail_colon(self): + result = self.cls.token("nope:") + self.assertIsNone(result) + + def test_pass_simple(self): + token = "simple" + result = self.cls.token(token) + self.assertEqual(token, result) + + def test_pass_leading_digit(self): + token = "123simple" + result = self.cls.token(token) + self.assertEqual(token, result) + + def test_pass_mixture(self): + token = "S.imple@one+two_3" + result = self.cls.token(token) + self.assertEqual(token, result) + + +if __name__ == "__main__": + tests.main() diff --git a/lib/iris/tests/unit/common/metadata/test_CellMeasureMetadata.py b/lib/iris/tests/unit/common/metadata/test_CellMeasureMetadata.py new file mode 100644 index 0000000000..6044fbc628 --- /dev/null +++ b/lib/iris/tests/unit/common/metadata/test_CellMeasureMetadata.py @@ -0,0 +1,663 @@ +# 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. +""" +Unit tests for the :class:`iris.common.metadata.CellMeasureMetadata`. + +""" + +# Import iris.tests first so that some things can be initialised before +# importing anything else. +import iris.tests as tests + +from copy import deepcopy +import unittest.mock as mock +from unittest.mock import sentinel + +from iris.common.lenient import _LENIENT, _qualname +from iris.common.metadata import BaseMetadata, CellMeasureMetadata + + +class Test(tests.IrisTest): + def setUp(self): + self.standard_name = mock.sentinel.standard_name + self.long_name = mock.sentinel.long_name + self.var_name = mock.sentinel.var_name + self.units = mock.sentinel.units + self.attributes = mock.sentinel.attributes + self.measure = mock.sentinel.measure + self.cls = CellMeasureMetadata + + def test_repr(self): + metadata = self.cls( + standard_name=self.standard_name, + long_name=self.long_name, + var_name=self.var_name, + units=self.units, + attributes=self.attributes, + measure=self.measure, + ) + fmt = ( + "CellMeasureMetadata(standard_name={!r}, long_name={!r}, " + "var_name={!r}, units={!r}, attributes={!r}, measure={!r})" + ) + expected = fmt.format( + self.standard_name, + self.long_name, + self.var_name, + self.units, + self.attributes, + self.measure, + ) + self.assertEqual(expected, repr(metadata)) + + def test__fields(self): + expected = ( + "standard_name", + "long_name", + "var_name", + "units", + "attributes", + "measure", + ) + self.assertEqual(self.cls._fields, expected) + + def test_bases(self): + self.assertTrue(issubclass(self.cls, BaseMetadata)) + + +class Test___eq__(tests.IrisTest): + def setUp(self): + self.values = dict( + standard_name=sentinel.standard_name, + long_name=sentinel.long_name, + var_name=sentinel.var_name, + units=sentinel.units, + attributes=sentinel.attributes, + measure=sentinel.measure, + ) + self.dummy = sentinel.dummy + self.cls = CellMeasureMetadata + + def test_wraps_docstring(self): + self.assertEqual( + BaseMetadata.__eq__.__doc__, self.cls.__eq__.__doc__, + ) + + def test_lenient_service(self): + qualname___eq__ = _qualname(self.cls.__eq__) + self.assertIn(qualname___eq__, _LENIENT) + self.assertTrue(_LENIENT[qualname___eq__]) + self.assertTrue(_LENIENT[self.cls.__eq__]) + + def test_call(self): + other = sentinel.other + return_value = sentinel.return_value + metadata = self.cls(*(None,) * len(self.cls._fields)) + with mock.patch.object( + BaseMetadata, "__eq__", return_value=return_value + ) as mocker: + result = metadata.__eq__(other) + + self.assertEqual(return_value, result) + self.assertEqual(1, mocker.call_count) + (arg,), kwargs = mocker.call_args + self.assertEqual(other, arg) + self.assertEqual(dict(), kwargs) + + def test_op_lenient_same(self): + lmetadata = self.cls(**self.values) + rmetadata = self.cls(**self.values) + + with mock.patch("iris.common.metadata._LENIENT", return_value=True): + self.assertTrue(lmetadata.__eq__(rmetadata)) + self.assertTrue(rmetadata.__eq__(lmetadata)) + + def test_op_lenient_same_none(self): + lmetadata = self.cls(**self.values) + right = self.values.copy() + right["var_name"] = None + rmetadata = self.cls(**right) + + with mock.patch("iris.common.metadata._LENIENT", return_value=True): + self.assertTrue(lmetadata.__eq__(rmetadata)) + self.assertTrue(rmetadata.__eq__(lmetadata)) + + def test_op_lenient_same_measure_none(self): + lmetadata = self.cls(**self.values) + right = self.values.copy() + right["measure"] = None + rmetadata = self.cls(**right) + + with mock.patch("iris.common.metadata._LENIENT", return_value=True): + self.assertFalse(lmetadata.__eq__(rmetadata)) + self.assertFalse(rmetadata.__eq__(lmetadata)) + + def test_op_lenient_different(self): + lmetadata = self.cls(**self.values) + right = self.values.copy() + right["units"] = self.dummy + rmetadata = self.cls(**right) + + with mock.patch("iris.common.metadata._LENIENT", return_value=True): + self.assertFalse(lmetadata.__eq__(rmetadata)) + self.assertFalse(rmetadata.__eq__(lmetadata)) + + def test_op_lenient_different_measure(self): + lmetadata = self.cls(**self.values) + right = self.values.copy() + right["measure"] = self.dummy + rmetadata = self.cls(**right) + + with mock.patch("iris.common.metadata._LENIENT", return_value=True): + self.assertFalse(lmetadata.__eq__(rmetadata)) + self.assertFalse(rmetadata.__eq__(lmetadata)) + + def test_op_strict_same(self): + lmetadata = self.cls(**self.values) + rmetadata = self.cls(**self.values) + + with mock.patch("iris.common.metadata._LENIENT", return_value=False): + self.assertTrue(lmetadata.__eq__(rmetadata)) + self.assertTrue(rmetadata.__eq__(lmetadata)) + + def test_op_strict_different(self): + lmetadata = self.cls(**self.values) + right = self.values.copy() + right["long_name"] = self.dummy + rmetadata = self.cls(**right) + + with mock.patch("iris.common.metadata._LENIENT", return_value=False): + self.assertFalse(lmetadata.__eq__(rmetadata)) + self.assertFalse(rmetadata.__eq__(lmetadata)) + + def test_op_strict_different_measure(self): + lmetadata = self.cls(**self.values) + right = self.values.copy() + right["measure"] = self.dummy + rmetadata = self.cls(**right) + + with mock.patch("iris.common.metadata._LENIENT", return_value=False): + self.assertFalse(lmetadata.__eq__(rmetadata)) + self.assertFalse(rmetadata.__eq__(lmetadata)) + + def test_op_strict_different_none(self): + lmetadata = self.cls(**self.values) + right = self.values.copy() + right["long_name"] = None + rmetadata = self.cls(**right) + + with mock.patch("iris.common.metadata._LENIENT", return_value=False): + self.assertFalse(lmetadata.__eq__(rmetadata)) + self.assertFalse(rmetadata.__eq__(lmetadata)) + + def test_op_strict_different_measure_none(self): + lmetadata = self.cls(**self.values) + right = self.values.copy() + right["measure"] = None + rmetadata = self.cls(**right) + + with mock.patch("iris.common.metadata._LENIENT", return_value=False): + self.assertFalse(lmetadata.__eq__(rmetadata)) + self.assertFalse(rmetadata.__eq__(lmetadata)) + + +class Test___lt__(tests.IrisTest): + def setUp(self): + self.cls = CellMeasureMetadata + self.one = self.cls(1, 1, 1, 1, 1, 1) + self.two = self.cls(1, 1, 1, 2, 1, 1) + self.none = self.cls(1, 1, 1, None, 1, 1) + self.attributes = self.cls(1, 1, 1, 1, 10, 1) + + def test__ascending_lt(self): + result = self.one < self.two + self.assertTrue(result) + + def test__descending_lt(self): + result = self.two < self.one + self.assertFalse(result) + + def test__none_rhs_operand(self): + result = self.one < self.none + self.assertFalse(result) + + def test__none_lhs_operand(self): + result = self.none < self.one + self.assertTrue(result) + + def test__ignore_attributes(self): + result = self.one < self.attributes + self.assertFalse(result) + result = self.attributes < self.one + self.assertFalse(result) + + +class Test_combine(tests.IrisTest): + def setUp(self): + self.values = dict( + standard_name=sentinel.standard_name, + long_name=sentinel.long_name, + var_name=sentinel.var_name, + units=sentinel.units, + attributes=sentinel.attributes, + measure=sentinel.measure, + ) + self.dummy = sentinel.dummy + self.cls = CellMeasureMetadata + self.none = self.cls(*(None,) * len(self.cls._fields)) + + def test_wraps_docstring(self): + self.assertEqual( + BaseMetadata.combine.__doc__, self.cls.combine.__doc__, + ) + + def test_lenient_service(self): + qualname_combine = _qualname(self.cls.combine) + self.assertIn(qualname_combine, _LENIENT) + self.assertTrue(_LENIENT[qualname_combine]) + self.assertTrue(_LENIENT[self.cls.combine]) + + def test_lenient_default(self): + other = sentinel.other + return_value = sentinel.return_value + with mock.patch.object( + BaseMetadata, "combine", return_value=return_value + ) as mocker: + result = self.none.combine(other) + + self.assertEqual(return_value, result) + self.assertEqual(1, mocker.call_count) + (arg,), kwargs = mocker.call_args + self.assertEqual(other, arg) + self.assertEqual(dict(lenient=None), kwargs) + + def test_lenient(self): + other = sentinel.other + lenient = sentinel.lenient + return_value = sentinel.return_value + with mock.patch.object( + BaseMetadata, "combine", return_value=return_value + ) as mocker: + result = self.none.combine(other, lenient=lenient) + + self.assertEqual(return_value, result) + self.assertEqual(1, mocker.call_count) + (arg,), kwargs = mocker.call_args + self.assertEqual(other, arg) + self.assertEqual(dict(lenient=lenient), kwargs) + + def test_op_lenient_same(self): + lmetadata = self.cls(**self.values) + rmetadata = self.cls(**self.values) + expected = self.values + + with mock.patch("iris.common.metadata._LENIENT", return_value=True): + self.assertEqual(expected, lmetadata.combine(rmetadata)._asdict()) + self.assertEqual(expected, rmetadata.combine(lmetadata)._asdict()) + + def test_op_lenient_same_none(self): + lmetadata = self.cls(**self.values) + right = self.values.copy() + right["var_name"] = None + rmetadata = self.cls(**right) + expected = self.values + + with mock.patch("iris.common.metadata._LENIENT", return_value=True): + self.assertEqual(expected, lmetadata.combine(rmetadata)._asdict()) + self.assertEqual(expected, rmetadata.combine(lmetadata)._asdict()) + + def test_op_lenient_same_measure_none(self): + lmetadata = self.cls(**self.values) + right = self.values.copy() + right["measure"] = None + rmetadata = self.cls(**right) + expected = right.copy() + + with mock.patch("iris.common.metadata._LENIENT", return_value=True): + self.assertTrue(expected, lmetadata.combine(rmetadata)._asdict()) + self.assertTrue(expected, rmetadata.combine(lmetadata)._asdict()) + + def test_op_lenient_different(self): + lmetadata = self.cls(**self.values) + right = self.values.copy() + right["units"] = self.dummy + rmetadata = self.cls(**right) + expected = self.values.copy() + expected["units"] = None + + with mock.patch("iris.common.metadata._LENIENT", return_value=True): + self.assertEqual(expected, lmetadata.combine(rmetadata)._asdict()) + self.assertEqual(expected, rmetadata.combine(lmetadata)._asdict()) + + def test_op_lenient_different_measure(self): + lmetadata = self.cls(**self.values) + right = self.values.copy() + right["measure"] = self.dummy + rmetadata = self.cls(**right) + expected = self.values.copy() + expected["measure"] = None + + with mock.patch("iris.common.metadata._LENIENT", return_value=True): + self.assertEqual(expected, lmetadata.combine(rmetadata)._asdict()) + self.assertEqual(expected, rmetadata.combine(lmetadata)._asdict()) + + def test_op_strict_same(self): + lmetadata = self.cls(**self.values) + rmetadata = self.cls(**self.values) + expected = self.values.copy() + + with mock.patch("iris.common.metadata._LENIENT", return_value=False): + self.assertEqual(expected, lmetadata.combine(rmetadata)._asdict()) + self.assertEqual(expected, rmetadata.combine(lmetadata)._asdict()) + + def test_op_strict_different(self): + lmetadata = self.cls(**self.values) + right = self.values.copy() + right["long_name"] = self.dummy + rmetadata = self.cls(**right) + expected = self.values.copy() + expected["long_name"] = None + + with mock.patch("iris.common.metadata._LENIENT", return_value=False): + self.assertEqual(expected, lmetadata.combine(rmetadata)._asdict()) + self.assertEqual(expected, rmetadata.combine(lmetadata)._asdict()) + + def test_op_strict_different_measure(self): + lmetadata = self.cls(**self.values) + right = self.values.copy() + right["measure"] = self.dummy + rmetadata = self.cls(**right) + expected = self.values.copy() + expected["measure"] = None + + with mock.patch("iris.common.metadata._LENIENT", return_value=False): + self.assertEqual(expected, lmetadata.combine(rmetadata)._asdict()) + self.assertEqual(expected, rmetadata.combine(lmetadata)._asdict()) + + def test_op_strict_different_none(self): + lmetadata = self.cls(**self.values) + right = self.values.copy() + right["long_name"] = None + rmetadata = self.cls(**right) + expected = self.values.copy() + expected["long_name"] = None + + with mock.patch("iris.common.metadata._LENIENT", return_value=False): + self.assertEqual(expected, lmetadata.combine(rmetadata)._asdict()) + self.assertEqual(expected, rmetadata.combine(lmetadata)._asdict()) + + def test_op_strict_different_measure_none(self): + lmetadata = self.cls(**self.values) + right = self.values.copy() + right["measure"] = None + rmetadata = self.cls(**right) + expected = self.values.copy() + expected["measure"] = None + + with mock.patch("iris.common.metadata._LENIENT", return_value=False): + self.assertEqual(expected, lmetadata.combine(rmetadata)._asdict()) + self.assertEqual(expected, rmetadata.combine(lmetadata)._asdict()) + + +class Test_difference(tests.IrisTest): + def setUp(self): + self.values = dict( + standard_name=sentinel.standard_name, + long_name=sentinel.long_name, + var_name=sentinel.var_name, + units=sentinel.units, + attributes=sentinel.attributes, + measure=sentinel.measure, + ) + self.dummy = sentinel.dummy + self.cls = CellMeasureMetadata + self.none = self.cls(*(None,) * len(self.cls._fields)) + + def test_wraps_docstring(self): + self.assertEqual( + BaseMetadata.difference.__doc__, self.cls.difference.__doc__, + ) + + def test_lenient_service(self): + qualname_difference = _qualname(self.cls.difference) + self.assertIn(qualname_difference, _LENIENT) + self.assertTrue(_LENIENT[qualname_difference]) + self.assertTrue(_LENIENT[self.cls.difference]) + + def test_lenient_default(self): + other = sentinel.other + return_value = sentinel.return_value + with mock.patch.object( + BaseMetadata, "difference", return_value=return_value + ) as mocker: + result = self.none.difference(other) + + self.assertEqual(return_value, result) + self.assertEqual(1, mocker.call_count) + (arg,), kwargs = mocker.call_args + self.assertEqual(other, arg) + self.assertEqual(dict(lenient=None), kwargs) + + def test_lenient(self): + other = sentinel.other + lenient = sentinel.lenient + return_value = sentinel.return_value + with mock.patch.object( + BaseMetadata, "difference", return_value=return_value + ) as mocker: + result = self.none.difference(other, lenient=lenient) + + self.assertEqual(return_value, result) + self.assertEqual(1, mocker.call_count) + (arg,), kwargs = mocker.call_args + self.assertEqual(other, arg) + self.assertEqual(dict(lenient=lenient), kwargs) + + def test_op_lenient_same(self): + lmetadata = self.cls(**self.values) + rmetadata = self.cls(**self.values) + + with mock.patch("iris.common.metadata._LENIENT", return_value=True): + self.assertIsNone(lmetadata.difference(rmetadata)) + self.assertIsNone(rmetadata.difference(lmetadata)) + + def test_op_lenient_same_none(self): + lmetadata = self.cls(**self.values) + right = self.values.copy() + right["var_name"] = None + rmetadata = self.cls(**right) + + with mock.patch("iris.common.metadata._LENIENT", return_value=True): + self.assertIsNone(lmetadata.difference(rmetadata)) + self.assertIsNone(rmetadata.difference(lmetadata)) + + def test_op_lenient_same_measure_none(self): + lmetadata = self.cls(**self.values) + right = self.values.copy() + right["measure"] = None + rmetadata = self.cls(**right) + lexpected = deepcopy(self.none)._asdict() + lexpected["measure"] = (sentinel.measure, None) + rexpected = deepcopy(self.none)._asdict() + rexpected["measure"] = (None, sentinel.measure) + + with mock.patch("iris.common.metadata._LENIENT", return_value=True): + self.assertEqual( + lexpected, lmetadata.difference(rmetadata)._asdict() + ) + self.assertEqual( + rexpected, rmetadata.difference(lmetadata)._asdict() + ) + + def test_op_lenient_different(self): + left = self.values.copy() + lmetadata = self.cls(**left) + right = self.values.copy() + right["units"] = self.dummy + rmetadata = self.cls(**right) + lexpected = deepcopy(self.none)._asdict() + lexpected["units"] = (left["units"], right["units"]) + rexpected = deepcopy(self.none)._asdict() + rexpected["units"] = lexpected["units"][::-1] + + with mock.patch("iris.common.metadata._LENIENT", return_value=True): + self.assertEqual( + lexpected, lmetadata.difference(rmetadata)._asdict() + ) + self.assertEqual( + rexpected, rmetadata.difference(lmetadata)._asdict() + ) + + def test_op_lenient_different_measure(self): + left = self.values.copy() + lmetadata = self.cls(**left) + right = self.values.copy() + right["measure"] = self.dummy + rmetadata = self.cls(**right) + lexpected = deepcopy(self.none)._asdict() + lexpected["measure"] = (left["measure"], right["measure"]) + rexpected = deepcopy(self.none)._asdict() + rexpected["measure"] = lexpected["measure"][::-1] + + with mock.patch("iris.common.metadata._LENIENT", return_value=True): + self.assertEqual( + lexpected, lmetadata.difference(rmetadata)._asdict() + ) + self.assertEqual( + rexpected, rmetadata.difference(lmetadata)._asdict() + ) + + def test_op_strict_same(self): + lmetadata = self.cls(**self.values) + rmetadata = self.cls(**self.values) + + with mock.patch("iris.common.metadata._LENIENT", return_value=False): + self.assertIsNone(lmetadata.difference(rmetadata)) + self.assertIsNone(rmetadata.difference(lmetadata)) + + def test_op_strict_different(self): + left = self.values.copy() + lmetadata = self.cls(**left) + right = self.values.copy() + right["long_name"] = self.dummy + rmetadata = self.cls(**right) + lexpected = deepcopy(self.none)._asdict() + lexpected["long_name"] = (left["long_name"], right["long_name"]) + rexpected = deepcopy(self.none)._asdict() + rexpected["long_name"] = lexpected["long_name"][::-1] + + with mock.patch("iris.common.metadata._LENIENT", return_value=False): + self.assertEqual( + lexpected, lmetadata.difference(rmetadata)._asdict() + ) + self.assertEqual( + rexpected, rmetadata.difference(lmetadata)._asdict() + ) + + def test_op_strict_different_measure(self): + left = self.values.copy() + lmetadata = self.cls(**left) + right = self.values.copy() + right["measure"] = self.dummy + rmetadata = self.cls(**right) + lexpected = deepcopy(self.none)._asdict() + lexpected["measure"] = (left["measure"], right["measure"]) + rexpected = deepcopy(self.none)._asdict() + rexpected["measure"] = lexpected["measure"][::-1] + + with mock.patch("iris.common.metadata._LENIENT", return_value=False): + self.assertEqual( + lexpected, lmetadata.difference(rmetadata)._asdict() + ) + self.assertEqual( + rexpected, rmetadata.difference(lmetadata)._asdict() + ) + + def test_op_strict_different_none(self): + left = self.values.copy() + lmetadata = self.cls(**left) + right = self.values.copy() + right["long_name"] = None + rmetadata = self.cls(**right) + lexpected = deepcopy(self.none)._asdict() + lexpected["long_name"] = (left["long_name"], right["long_name"]) + rexpected = deepcopy(self.none)._asdict() + rexpected["long_name"] = lexpected["long_name"][::-1] + + with mock.patch("iris.common.metadata._LENIENT", return_value=False): + self.assertEqual( + lexpected, lmetadata.difference(rmetadata)._asdict() + ) + self.assertEqual( + rexpected, rmetadata.difference(lmetadata)._asdict() + ) + + def test_op_strict_different_measure_none(self): + left = self.values.copy() + lmetadata = self.cls(**left) + right = self.values.copy() + right["measure"] = None + rmetadata = self.cls(**right) + lexpected = deepcopy(self.none)._asdict() + lexpected["measure"] = (left["measure"], right["measure"]) + rexpected = deepcopy(self.none)._asdict() + rexpected["measure"] = lexpected["measure"][::-1] + + with mock.patch("iris.common.metadata._LENIENT", return_value=False): + self.assertEqual( + lexpected, lmetadata.difference(rmetadata)._asdict() + ) + self.assertEqual( + rexpected, rmetadata.difference(lmetadata)._asdict() + ) + + +class Test_equal(tests.IrisTest): + def setUp(self): + self.cls = CellMeasureMetadata + self.none = self.cls(*(None,) * len(self.cls._fields)) + + def test_wraps_docstring(self): + self.assertEqual(BaseMetadata.equal.__doc__, self.cls.equal.__doc__) + + def test_lenient_service(self): + qualname_equal = _qualname(self.cls.equal) + self.assertIn(qualname_equal, _LENIENT) + self.assertTrue(_LENIENT[qualname_equal]) + self.assertTrue(_LENIENT[self.cls.equal]) + + def test_lenient_default(self): + other = sentinel.other + return_value = sentinel.return_value + with mock.patch.object( + BaseMetadata, "equal", return_value=return_value + ) as mocker: + result = self.none.equal(other) + + self.assertEqual(return_value, result) + self.assertEqual(1, mocker.call_count) + (arg,), kwargs = mocker.call_args + self.assertEqual(other, arg) + self.assertEqual(dict(lenient=None), kwargs) + + def test_lenient(self): + other = sentinel.other + lenient = sentinel.lenient + return_value = sentinel.return_value + with mock.patch.object( + BaseMetadata, "equal", return_value=return_value + ) as mocker: + result = self.none.equal(other, lenient=lenient) + + self.assertEqual(return_value, result) + self.assertEqual(1, mocker.call_count) + (arg,), kwargs = mocker.call_args + self.assertEqual(other, arg) + self.assertEqual(dict(lenient=lenient), kwargs) + + +if __name__ == "__main__": + tests.main() diff --git a/lib/iris/tests/unit/common/metadata/test_CoordMetadata.py b/lib/iris/tests/unit/common/metadata/test_CoordMetadata.py new file mode 100644 index 0000000000..c37d33c62f --- /dev/null +++ b/lib/iris/tests/unit/common/metadata/test_CoordMetadata.py @@ -0,0 +1,724 @@ +# 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. +""" +Unit tests for the :class:`iris.common.metadata.CoordMetadata`. + +""" + +# Import iris.tests first so that some things can be initialised before +# importing anything else. +import iris.tests as tests + +from copy import deepcopy +import unittest.mock as mock +from unittest.mock import sentinel + +from iris.common.lenient import _LENIENT, _qualname +from iris.common.metadata import BaseMetadata, CoordMetadata + + +class Test(tests.IrisTest): + def setUp(self): + self.standard_name = mock.sentinel.standard_name + self.long_name = mock.sentinel.long_name + self.var_name = mock.sentinel.var_name + self.units = mock.sentinel.units + self.attributes = mock.sentinel.attributes + self.coord_system = mock.sentinel.coord_system + self.climatological = mock.sentinel.climatological + self.cls = CoordMetadata + + def test_repr(self): + metadata = self.cls( + standard_name=self.standard_name, + long_name=self.long_name, + var_name=self.var_name, + units=self.units, + attributes=self.attributes, + coord_system=self.coord_system, + climatological=self.climatological, + ) + fmt = ( + "CoordMetadata(standard_name={!r}, long_name={!r}, " + "var_name={!r}, units={!r}, attributes={!r}, coord_system={!r}, " + "climatological={!r})" + ) + expected = fmt.format( + self.standard_name, + self.long_name, + self.var_name, + self.units, + self.attributes, + self.coord_system, + self.climatological, + ) + self.assertEqual(expected, repr(metadata)) + + def test__fields(self): + expected = ( + "standard_name", + "long_name", + "var_name", + "units", + "attributes", + "coord_system", + "climatological", + ) + self.assertEqual(self.cls._fields, expected) + + def test_bases(self): + self.assertTrue(issubclass(self.cls, BaseMetadata)) + + +class Test___eq__(tests.IrisTest): + def setUp(self): + self.values = dict( + standard_name=sentinel.standard_name, + long_name=sentinel.long_name, + var_name=sentinel.var_name, + units=sentinel.units, + attributes=sentinel.attributes, + coord_system=sentinel.coord_system, + climatological=sentinel.climatological, + ) + self.dummy = sentinel.dummy + self.cls = CoordMetadata + + def test_wraps_docstring(self): + self.assertEqual( + BaseMetadata.__eq__.__doc__, self.cls.__eq__.__doc__, + ) + + def test_lenient_service(self): + qualname___eq__ = _qualname(self.cls.__eq__) + self.assertIn(qualname___eq__, _LENIENT) + self.assertTrue(_LENIENT[qualname___eq__]) + self.assertTrue(_LENIENT[self.cls.__eq__]) + + def test_call(self): + other = sentinel.other + return_value = sentinel.return_value + metadata = self.cls(*(None,) * len(self.cls._fields)) + with mock.patch.object( + BaseMetadata, "__eq__", return_value=return_value + ) as mocker: + result = metadata.__eq__(other) + + self.assertEqual(return_value, result) + self.assertEqual(1, mocker.call_count) + (arg,), kwargs = mocker.call_args + self.assertEqual(other, arg) + self.assertEqual(dict(), kwargs) + + def test_op_lenient_same(self): + lmetadata = self.cls(**self.values) + rmetadata = self.cls(**self.values) + + with mock.patch("iris.common.metadata._LENIENT", return_value=True): + self.assertTrue(lmetadata.__eq__(rmetadata)) + self.assertTrue(rmetadata.__eq__(lmetadata)) + + def test_op_lenient_same_none(self): + lmetadata = self.cls(**self.values) + right = self.values.copy() + right["var_name"] = None + rmetadata = self.cls(**right) + + with mock.patch("iris.common.metadata._LENIENT", return_value=True): + self.assertTrue(lmetadata.__eq__(rmetadata)) + self.assertTrue(rmetadata.__eq__(lmetadata)) + + def test_op_lenient_same_members_none(self): + for member in self.cls._members: + lmetadata = self.cls(**self.values) + right = self.values.copy() + right[member] = None + rmetadata = self.cls(**right) + + with mock.patch( + "iris.common.metadata._LENIENT", return_value=True + ): + self.assertFalse(lmetadata.__eq__(rmetadata)) + self.assertFalse(rmetadata.__eq__(lmetadata)) + + def test_op_lenient_different(self): + lmetadata = self.cls(**self.values) + right = self.values.copy() + right["units"] = self.dummy + rmetadata = self.cls(**right) + + with mock.patch("iris.common.metadata._LENIENT", return_value=True): + self.assertFalse(lmetadata.__eq__(rmetadata)) + self.assertFalse(rmetadata.__eq__(lmetadata)) + + def test_op_lenient_different_members(self): + for member in self.cls._members: + lmetadata = self.cls(**self.values) + right = self.values.copy() + right[member] = self.dummy + rmetadata = self.cls(**right) + + with mock.patch( + "iris.common.metadata._LENIENT", return_value=True + ): + self.assertFalse(lmetadata.__eq__(rmetadata)) + self.assertFalse(rmetadata.__eq__(lmetadata)) + + def test_op_strict_same(self): + lmetadata = self.cls(**self.values) + rmetadata = self.cls(**self.values) + + with mock.patch("iris.common.metadata._LENIENT", return_value=False): + self.assertTrue(lmetadata.__eq__(rmetadata)) + self.assertTrue(rmetadata.__eq__(lmetadata)) + + def test_op_strict_different(self): + lmetadata = self.cls(**self.values) + right = self.values.copy() + right["long_name"] = self.dummy + rmetadata = self.cls(**right) + + with mock.patch("iris.common.metadata._LENIENT", return_value=False): + self.assertFalse(lmetadata.__eq__(rmetadata)) + self.assertFalse(rmetadata.__eq__(lmetadata)) + + def test_op_strict_different_members(self): + for member in self.cls._members: + lmetadata = self.cls(**self.values) + right = self.values.copy() + right[member] = self.dummy + rmetadata = self.cls(**right) + + with mock.patch( + "iris.common.metadata._LENIENT", return_value=False + ): + self.assertFalse(lmetadata.__eq__(rmetadata)) + self.assertFalse(rmetadata.__eq__(lmetadata)) + + def test_op_strict_different_none(self): + lmetadata = self.cls(**self.values) + right = self.values.copy() + right["long_name"] = None + rmetadata = self.cls(**right) + + with mock.patch("iris.common.metadata._LENIENT", return_value=False): + self.assertFalse(lmetadata.__eq__(rmetadata)) + self.assertFalse(rmetadata.__eq__(lmetadata)) + + def test_op_strict_different_members_none(self): + for member in self.cls._members: + lmetadata = self.cls(**self.values) + right = self.values.copy() + right[member] = None + rmetadata = self.cls(**right) + + with mock.patch( + "iris.common.metadata._LENIENT", return_value=False + ): + self.assertFalse(lmetadata.__eq__(rmetadata)) + self.assertFalse(rmetadata.__eq__(lmetadata)) + + +class Test___lt__(tests.IrisTest): + def setUp(self): + self.cls = CoordMetadata + self.one = self.cls(1, 1, 1, 1, 1, 1, 1) + self.two = self.cls(1, 1, 1, 2, 1, 1, 1) + self.none = self.cls(1, 1, 1, None, 1, 1, 1) + self.attributes_cs = self.cls(1, 1, 1, 1, 10, 10, 1) + + def test__ascending_lt(self): + result = self.one < self.two + self.assertTrue(result) + + def test__descending_lt(self): + result = self.two < self.one + self.assertFalse(result) + + def test__none_rhs_operand(self): + result = self.one < self.none + self.assertFalse(result) + + def test__none_lhs_operand(self): + result = self.none < self.one + self.assertTrue(result) + + def test__ignore_attributes_coord_system(self): + result = self.one < self.attributes_cs + self.assertFalse(result) + result = self.attributes_cs < self.one + self.assertFalse(result) + + +class Test_combine(tests.IrisTest): + def setUp(self): + self.values = dict( + standard_name=sentinel.standard_name, + long_name=sentinel.long_name, + var_name=sentinel.var_name, + units=sentinel.units, + attributes=sentinel.attributes, + coord_system=sentinel.coord_system, + climatological=sentinel.climatological, + ) + self.dummy = sentinel.dummy + self.cls = CoordMetadata + self.none = self.cls(*(None,) * len(self.cls._fields)) + + def test_wraps_docstring(self): + self.assertEqual( + BaseMetadata.combine.__doc__, self.cls.combine.__doc__, + ) + + def test_lenient_service(self): + qualname_combine = _qualname(self.cls.combine) + self.assertIn(qualname_combine, _LENIENT) + self.assertTrue(_LENIENT[qualname_combine]) + self.assertTrue(_LENIENT[self.cls.combine]) + + def test_lenient_default(self): + other = sentinel.other + return_value = sentinel.return_value + with mock.patch.object( + BaseMetadata, "combine", return_value=return_value + ) as mocker: + result = self.none.combine(other) + + self.assertEqual(return_value, result) + self.assertEqual(1, mocker.call_count) + (arg,), kwargs = mocker.call_args + self.assertEqual(other, arg) + self.assertEqual(dict(lenient=None), kwargs) + + def test_lenient(self): + other = sentinel.other + lenient = sentinel.lenient + return_value = sentinel.return_value + with mock.patch.object( + BaseMetadata, "combine", return_value=return_value + ) as mocker: + result = self.none.combine(other, lenient=lenient) + + self.assertEqual(return_value, result) + self.assertEqual(1, mocker.call_count) + (arg,), kwargs = mocker.call_args + self.assertEqual(other, arg) + self.assertEqual(dict(lenient=lenient), kwargs) + + def test_op_lenient_same(self): + lmetadata = self.cls(**self.values) + rmetadata = self.cls(**self.values) + expected = self.values + + with mock.patch("iris.common.metadata._LENIENT", return_value=True): + self.assertEqual(expected, lmetadata.combine(rmetadata)._asdict()) + self.assertEqual(expected, rmetadata.combine(lmetadata)._asdict()) + + def test_op_lenient_same_none(self): + lmetadata = self.cls(**self.values) + right = self.values.copy() + right["var_name"] = None + rmetadata = self.cls(**right) + expected = self.values + + with mock.patch("iris.common.metadata._LENIENT", return_value=True): + self.assertEqual(expected, lmetadata.combine(rmetadata)._asdict()) + self.assertEqual(expected, rmetadata.combine(lmetadata)._asdict()) + + def test_op_lenient_same_members_none(self): + for member in self.cls._members: + lmetadata = self.cls(**self.values) + right = self.values.copy() + right[member] = None + rmetadata = self.cls(**right) + expected = right.copy() + + with mock.patch( + "iris.common.metadata._LENIENT", return_value=True + ): + self.assertTrue( + expected, lmetadata.combine(rmetadata)._asdict() + ) + self.assertTrue( + expected, rmetadata.combine(lmetadata)._asdict() + ) + + def test_op_lenient_different(self): + lmetadata = self.cls(**self.values) + right = self.values.copy() + right["units"] = self.dummy + rmetadata = self.cls(**right) + expected = self.values.copy() + expected["units"] = None + + with mock.patch("iris.common.metadata._LENIENT", return_value=True): + self.assertEqual(expected, lmetadata.combine(rmetadata)._asdict()) + self.assertEqual(expected, rmetadata.combine(lmetadata)._asdict()) + + def test_op_lenient_different_members(self): + for member in self.cls._members: + lmetadata = self.cls(**self.values) + right = self.values.copy() + right[member] = self.dummy + rmetadata = self.cls(**right) + expected = self.values.copy() + expected[member] = None + + with mock.patch( + "iris.common.metadata._LENIENT", return_value=True + ): + self.assertEqual( + expected, lmetadata.combine(rmetadata)._asdict() + ) + self.assertEqual( + expected, rmetadata.combine(lmetadata)._asdict() + ) + + def test_op_strict_same(self): + lmetadata = self.cls(**self.values) + rmetadata = self.cls(**self.values) + expected = self.values.copy() + + with mock.patch("iris.common.metadata._LENIENT", return_value=False): + self.assertEqual(expected, lmetadata.combine(rmetadata)._asdict()) + self.assertEqual(expected, rmetadata.combine(lmetadata)._asdict()) + + def test_op_strict_different(self): + lmetadata = self.cls(**self.values) + right = self.values.copy() + right["long_name"] = self.dummy + rmetadata = self.cls(**right) + expected = self.values.copy() + expected["long_name"] = None + + with mock.patch("iris.common.metadata._LENIENT", return_value=False): + self.assertEqual(expected, lmetadata.combine(rmetadata)._asdict()) + self.assertEqual(expected, rmetadata.combine(lmetadata)._asdict()) + + def test_op_strict_different_members(self): + for member in self.cls._members: + lmetadata = self.cls(**self.values) + right = self.values.copy() + right[member] = self.dummy + rmetadata = self.cls(**right) + expected = self.values.copy() + expected[member] = None + + with mock.patch( + "iris.common.metadata._LENIENT", return_value=False + ): + self.assertEqual( + expected, lmetadata.combine(rmetadata)._asdict() + ) + self.assertEqual( + expected, rmetadata.combine(lmetadata)._asdict() + ) + + def test_op_strict_different_none(self): + lmetadata = self.cls(**self.values) + right = self.values.copy() + right["long_name"] = None + rmetadata = self.cls(**right) + expected = self.values.copy() + expected["long_name"] = None + + with mock.patch("iris.common.metadata._LENIENT", return_value=False): + self.assertEqual(expected, lmetadata.combine(rmetadata)._asdict()) + self.assertEqual(expected, rmetadata.combine(lmetadata)._asdict()) + + def test_op_strict_different_members_none(self): + for member in self.cls._members: + lmetadata = self.cls(**self.values) + right = self.values.copy() + right[member] = None + rmetadata = self.cls(**right) + expected = self.values.copy() + expected[member] = None + + with mock.patch( + "iris.common.metadata._LENIENT", return_value=False + ): + self.assertEqual( + expected, lmetadata.combine(rmetadata)._asdict() + ) + self.assertEqual( + expected, rmetadata.combine(lmetadata)._asdict() + ) + + +class Test_difference(tests.IrisTest): + def setUp(self): + self.values = dict( + standard_name=sentinel.standard_name, + long_name=sentinel.long_name, + var_name=sentinel.var_name, + units=sentinel.units, + attributes=sentinel.attributes, + coord_system=sentinel.coord_system, + climatological=sentinel.climatological, + ) + self.dummy = sentinel.dummy + self.cls = CoordMetadata + self.none = self.cls(*(None,) * len(self.cls._fields)) + + def test_wraps_docstring(self): + self.assertEqual( + BaseMetadata.difference.__doc__, self.cls.difference.__doc__, + ) + + def test_lenient_service(self): + qualname_difference = _qualname(self.cls.difference) + self.assertIn(qualname_difference, _LENIENT) + self.assertTrue(_LENIENT[qualname_difference]) + self.assertTrue(_LENIENT[self.cls.difference]) + + def test_lenient_default(self): + other = sentinel.other + return_value = sentinel.return_value + with mock.patch.object( + BaseMetadata, "difference", return_value=return_value + ) as mocker: + result = self.none.difference(other) + + self.assertEqual(return_value, result) + self.assertEqual(1, mocker.call_count) + (arg,), kwargs = mocker.call_args + self.assertEqual(other, arg) + self.assertEqual(dict(lenient=None), kwargs) + + def test_lenient(self): + other = sentinel.other + lenient = sentinel.lenient + return_value = sentinel.return_value + with mock.patch.object( + BaseMetadata, "difference", return_value=return_value + ) as mocker: + result = self.none.difference(other, lenient=lenient) + + self.assertEqual(return_value, result) + self.assertEqual(1, mocker.call_count) + (arg,), kwargs = mocker.call_args + self.assertEqual(other, arg) + self.assertEqual(dict(lenient=lenient), kwargs) + + def test_op_lenient_same(self): + lmetadata = self.cls(**self.values) + rmetadata = self.cls(**self.values) + + with mock.patch("iris.common.metadata._LENIENT", return_value=True): + self.assertIsNone(lmetadata.difference(rmetadata)) + self.assertIsNone(rmetadata.difference(lmetadata)) + + def test_op_lenient_same_none(self): + lmetadata = self.cls(**self.values) + right = self.values.copy() + right["var_name"] = None + rmetadata = self.cls(**right) + + with mock.patch("iris.common.metadata._LENIENT", return_value=True): + self.assertIsNone(lmetadata.difference(rmetadata)) + self.assertIsNone(rmetadata.difference(lmetadata)) + + def test_op_lenient_same_members_none(self): + for member in self.cls._members: + lmetadata = self.cls(**self.values) + member_value = getattr(lmetadata, member) + right = self.values.copy() + right[member] = None + rmetadata = self.cls(**right) + lexpected = deepcopy(self.none)._asdict() + lexpected[member] = (member_value, None) + rexpected = deepcopy(self.none)._asdict() + rexpected[member] = (None, member_value) + + with mock.patch( + "iris.common.metadata._LENIENT", return_value=True + ): + self.assertEqual( + lexpected, lmetadata.difference(rmetadata)._asdict() + ) + self.assertEqual( + rexpected, rmetadata.difference(lmetadata)._asdict() + ) + + def test_op_lenient_different(self): + left = self.values.copy() + lmetadata = self.cls(**left) + right = self.values.copy() + right["units"] = self.dummy + rmetadata = self.cls(**right) + lexpected = deepcopy(self.none)._asdict() + lexpected["units"] = (left["units"], right["units"]) + rexpected = deepcopy(self.none)._asdict() + rexpected["units"] = lexpected["units"][::-1] + + with mock.patch("iris.common.metadata._LENIENT", return_value=True): + self.assertEqual( + lexpected, lmetadata.difference(rmetadata)._asdict() + ) + self.assertEqual( + rexpected, rmetadata.difference(lmetadata)._asdict() + ) + + def test_op_lenient_different_members(self): + for member in self.cls._members: + left = self.values.copy() + lmetadata = self.cls(**left) + right = self.values.copy() + right[member] = self.dummy + rmetadata = self.cls(**right) + lexpected = deepcopy(self.none)._asdict() + lexpected[member] = (left[member], right[member]) + rexpected = deepcopy(self.none)._asdict() + rexpected[member] = lexpected[member][::-1] + + with mock.patch( + "iris.common.metadata._LENIENT", return_value=True + ): + self.assertEqual( + lexpected, lmetadata.difference(rmetadata)._asdict() + ) + self.assertEqual( + rexpected, rmetadata.difference(lmetadata)._asdict() + ) + + def test_op_strict_same(self): + lmetadata = self.cls(**self.values) + rmetadata = self.cls(**self.values) + + with mock.patch("iris.common.metadata._LENIENT", return_value=False): + self.assertIsNone(lmetadata.difference(rmetadata)) + self.assertIsNone(rmetadata.difference(lmetadata)) + + def test_op_strict_different(self): + left = self.values.copy() + lmetadata = self.cls(**left) + right = self.values.copy() + right["long_name"] = self.dummy + rmetadata = self.cls(**right) + lexpected = deepcopy(self.none)._asdict() + lexpected["long_name"] = (left["long_name"], right["long_name"]) + rexpected = deepcopy(self.none)._asdict() + rexpected["long_name"] = lexpected["long_name"][::-1] + + with mock.patch("iris.common.metadata._LENIENT", return_value=False): + self.assertEqual( + lexpected, lmetadata.difference(rmetadata)._asdict() + ) + self.assertEqual( + rexpected, rmetadata.difference(lmetadata)._asdict() + ) + + def test_op_strict_different_members(self): + for member in self.cls._members: + left = self.values.copy() + lmetadata = self.cls(**left) + right = self.values.copy() + right[member] = self.dummy + rmetadata = self.cls(**right) + lexpected = deepcopy(self.none)._asdict() + lexpected[member] = (left[member], right[member]) + rexpected = deepcopy(self.none)._asdict() + rexpected[member] = lexpected[member][::-1] + + with mock.patch( + "iris.common.metadata._LENIENT", return_value=False + ): + self.assertEqual( + lexpected, lmetadata.difference(rmetadata)._asdict() + ) + self.assertEqual( + rexpected, rmetadata.difference(lmetadata)._asdict() + ) + + def test_op_strict_different_none(self): + left = self.values.copy() + lmetadata = self.cls(**left) + right = self.values.copy() + right["long_name"] = None + rmetadata = self.cls(**right) + lexpected = deepcopy(self.none)._asdict() + lexpected["long_name"] = (left["long_name"], right["long_name"]) + rexpected = deepcopy(self.none)._asdict() + rexpected["long_name"] = lexpected["long_name"][::-1] + + with mock.patch("iris.common.metadata._LENIENT", return_value=False): + self.assertEqual( + lexpected, lmetadata.difference(rmetadata)._asdict() + ) + self.assertEqual( + rexpected, rmetadata.difference(lmetadata)._asdict() + ) + + def test_op_strict_different_members_none(self): + for member in self.cls._members: + left = self.values.copy() + lmetadata = self.cls(**left) + right = self.values.copy() + right[member] = None + rmetadata = self.cls(**right) + lexpected = deepcopy(self.none)._asdict() + lexpected[member] = (left[member], right[member]) + rexpected = deepcopy(self.none)._asdict() + rexpected[member] = lexpected[member][::-1] + + with mock.patch( + "iris.common.metadata._LENIENT", return_value=False + ): + self.assertEqual( + lexpected, lmetadata.difference(rmetadata)._asdict() + ) + self.assertEqual( + rexpected, rmetadata.difference(lmetadata)._asdict() + ) + + +class Test_equal(tests.IrisTest): + def setUp(self): + self.cls = CoordMetadata + self.none = self.cls(*(None,) * len(self.cls._fields)) + + def test_wraps_docstring(self): + self.assertEqual(BaseMetadata.equal.__doc__, self.cls.equal.__doc__) + + def test_lenient_service(self): + qualname_equal = _qualname(self.cls.equal) + self.assertIn(qualname_equal, _LENIENT) + self.assertTrue(_LENIENT[qualname_equal]) + self.assertTrue(_LENIENT[self.cls.equal]) + + def test_lenient_default(self): + other = sentinel.other + return_value = sentinel.return_value + with mock.patch.object( + BaseMetadata, "equal", return_value=return_value + ) as mocker: + result = self.none.equal(other) + + self.assertEqual(return_value, result) + self.assertEqual(1, mocker.call_count) + (arg,), kwargs = mocker.call_args + self.assertEqual(other, arg) + self.assertEqual(dict(lenient=None), kwargs) + + def test_lenient(self): + other = sentinel.other + lenient = sentinel.lenient + return_value = sentinel.return_value + with mock.patch.object( + BaseMetadata, "equal", return_value=return_value + ) as mocker: + result = self.none.equal(other, lenient=lenient) + + self.assertEqual(return_value, result) + self.assertEqual(1, mocker.call_count) + (arg,), kwargs = mocker.call_args + self.assertEqual(other, arg) + self.assertEqual(dict(lenient=lenient), kwargs) + + +if __name__ == "__main__": + tests.main() diff --git a/lib/iris/tests/unit/common/metadata/test_CubeMetadata.py b/lib/iris/tests/unit/common/metadata/test_CubeMetadata.py new file mode 100644 index 0000000000..1636f85189 --- /dev/null +++ b/lib/iris/tests/unit/common/metadata/test_CubeMetadata.py @@ -0,0 +1,831 @@ +# 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. +""" +Unit tests for the :class:`iris.common.metadata.CubeMetadata`. + +""" + +# Import iris.tests first so that some things can be initialised before +# importing anything else. +import iris.tests as tests + +from copy import deepcopy +import unittest.mock as mock +from unittest.mock import sentinel + +from iris.common.lenient import _LENIENT, _qualname +from iris.common.metadata import BaseMetadata, CubeMetadata + + +def _make_metadata( + standard_name=None, + long_name=None, + var_name=None, + attributes=None, + force_mapping=True, +): + if force_mapping: + if attributes is None: + attributes = {} + else: + attributes = dict(STASH=attributes) + + return CubeMetadata( + standard_name=standard_name, + long_name=long_name, + var_name=var_name, + units=None, + attributes=attributes, + cell_methods=None, + ) + + +class Test(tests.IrisTest): + def setUp(self): + self.standard_name = mock.sentinel.standard_name + self.long_name = mock.sentinel.long_name + self.var_name = mock.sentinel.var_name + self.units = mock.sentinel.units + self.attributes = mock.sentinel.attributes + self.cell_methods = mock.sentinel.cell_methods + self.cls = CubeMetadata + + def test_repr(self): + metadata = self.cls( + standard_name=self.standard_name, + long_name=self.long_name, + var_name=self.var_name, + units=self.units, + attributes=self.attributes, + cell_methods=self.cell_methods, + ) + fmt = ( + "CubeMetadata(standard_name={!r}, long_name={!r}, var_name={!r}, " + "units={!r}, attributes={!r}, cell_methods={!r})" + ) + expected = fmt.format( + self.standard_name, + self.long_name, + self.var_name, + self.units, + self.attributes, + self.cell_methods, + ) + self.assertEqual(expected, repr(metadata)) + + def test__fields(self): + expected = ( + "standard_name", + "long_name", + "var_name", + "units", + "attributes", + "cell_methods", + ) + self.assertEqual(self.cls._fields, expected) + + def test_bases(self): + self.assertTrue(issubclass(self.cls, BaseMetadata)) + + +class Test___eq__(tests.IrisTest): + def setUp(self): + self.values = dict( + standard_name=sentinel.standard_name, + long_name=sentinel.long_name, + var_name=sentinel.var_name, + units=sentinel.units, + # Must be a mapping. + attributes=dict(), + cell_methods=sentinel.cell_methods, + ) + self.dummy = sentinel.dummy + self.cls = CubeMetadata + + def test_wraps_docstring(self): + self.assertEqual( + BaseMetadata.__eq__.__doc__, self.cls.__eq__.__doc__, + ) + + def test_lenient_service(self): + qualname___eq__ = _qualname(self.cls.__eq__) + self.assertIn(qualname___eq__, _LENIENT) + self.assertTrue(_LENIENT[qualname___eq__]) + self.assertTrue(_LENIENT[self.cls.__eq__]) + + def test_call(self): + other = sentinel.other + return_value = sentinel.return_value + metadata = self.cls(*(None,) * len(self.cls._fields)) + with mock.patch.object( + BaseMetadata, "__eq__", return_value=return_value + ) as mocker: + result = metadata.__eq__(other) + + self.assertEqual(return_value, result) + self.assertEqual(1, mocker.call_count) + (arg,), kwargs = mocker.call_args + self.assertEqual(other, arg) + self.assertEqual(dict(), kwargs) + + def test_op_lenient_same(self): + lmetadata = self.cls(**self.values) + rmetadata = self.cls(**self.values) + + with mock.patch("iris.common.metadata._LENIENT", return_value=True): + self.assertTrue(lmetadata.__eq__(rmetadata)) + self.assertTrue(rmetadata.__eq__(lmetadata)) + + def test_op_lenient_same_none(self): + lmetadata = self.cls(**self.values) + right = self.values.copy() + right["var_name"] = None + rmetadata = self.cls(**right) + + with mock.patch("iris.common.metadata._LENIENT", return_value=True): + self.assertTrue(lmetadata.__eq__(rmetadata)) + self.assertTrue(rmetadata.__eq__(lmetadata)) + + def test_op_lenient_same_cell_methods_none(self): + lmetadata = self.cls(**self.values) + right = self.values.copy() + right["cell_methods"] = None + rmetadata = self.cls(**right) + + with mock.patch("iris.common.metadata._LENIENT", return_value=True): + self.assertFalse(lmetadata.__eq__(rmetadata)) + self.assertFalse(rmetadata.__eq__(lmetadata)) + + def test_op_lenient_different(self): + lmetadata = self.cls(**self.values) + right = self.values.copy() + right["units"] = self.dummy + rmetadata = self.cls(**right) + + with mock.patch("iris.common.metadata._LENIENT", return_value=True): + self.assertFalse(lmetadata.__eq__(rmetadata)) + self.assertFalse(rmetadata.__eq__(lmetadata)) + + def test_op_lenient_different_cell_methods(self): + lmetadata = self.cls(**self.values) + right = self.values.copy() + right["cell_methods"] = self.dummy + rmetadata = self.cls(**right) + + with mock.patch("iris.common.metadata._LENIENT", return_value=True): + self.assertFalse(lmetadata.__eq__(rmetadata)) + self.assertFalse(rmetadata.__eq__(lmetadata)) + + def test_op_strict_same(self): + lmetadata = self.cls(**self.values) + rmetadata = self.cls(**self.values) + + with mock.patch("iris.common.metadata._LENIENT", return_value=False): + self.assertTrue(lmetadata.__eq__(rmetadata)) + self.assertTrue(rmetadata.__eq__(lmetadata)) + + def test_op_strict_different(self): + lmetadata = self.cls(**self.values) + right = self.values.copy() + right["long_name"] = self.dummy + rmetadata = self.cls(**right) + + with mock.patch("iris.common.metadata._LENIENT", return_value=False): + self.assertFalse(lmetadata.__eq__(rmetadata)) + self.assertFalse(rmetadata.__eq__(lmetadata)) + + def test_op_strict_different_cell_methods(self): + lmetadata = self.cls(**self.values) + right = self.values.copy() + right["cell_methods"] = self.dummy + rmetadata = self.cls(**right) + + with mock.patch("iris.common.metadata._LENIENT", return_value=False): + self.assertFalse(lmetadata.__eq__(rmetadata)) + self.assertFalse(rmetadata.__eq__(lmetadata)) + + def test_op_strict_different_none(self): + lmetadata = self.cls(**self.values) + right = self.values.copy() + right["long_name"] = None + rmetadata = self.cls(**right) + + with mock.patch("iris.common.metadata._LENIENT", return_value=False): + self.assertFalse(lmetadata.__eq__(rmetadata)) + self.assertFalse(rmetadata.__eq__(lmetadata)) + + def test_op_strict_different_measure_none(self): + lmetadata = self.cls(**self.values) + right = self.values.copy() + right["cell_methods"] = None + rmetadata = self.cls(**right) + + with mock.patch("iris.common.metadata._LENIENT", return_value=False): + self.assertFalse(lmetadata.__eq__(rmetadata)) + self.assertFalse(rmetadata.__eq__(lmetadata)) + + +class Test___lt__(tests.IrisTest): + def setUp(self): + self.cls = CubeMetadata + self.one = self.cls(1, 1, 1, 1, 1, 1) + self.two = self.cls(1, 1, 1, 2, 1, 1) + self.none = self.cls(1, 1, 1, None, 1, 1) + self.attributes_cm = self.cls(1, 1, 1, 1, 10, 10) + + def test__ascending_lt(self): + result = self.one < self.two + self.assertTrue(result) + + def test__descending_lt(self): + result = self.two < self.one + self.assertFalse(result) + + def test__none_rhs_operand(self): + result = self.one < self.none + self.assertFalse(result) + + def test__none_lhs_operand(self): + result = self.none < self.one + self.assertTrue(result) + + def test__ignore_attributes_cell_methods(self): + result = self.one < self.attributes_cm + self.assertFalse(result) + result = self.attributes_cm < self.one + self.assertFalse(result) + + +class Test_combine(tests.IrisTest): + def setUp(self): + self.values = dict( + standard_name=sentinel.standard_name, + long_name=sentinel.long_name, + var_name=sentinel.var_name, + units=sentinel.units, + attributes=sentinel.attributes, + cell_methods=sentinel.cell_methods, + ) + self.dummy = sentinel.dummy + self.cls = CubeMetadata + self.none = self.cls(*(None,) * len(self.cls._fields)) + + def test_wraps_docstring(self): + self.assertEqual( + BaseMetadata.combine.__doc__, self.cls.combine.__doc__, + ) + + def test_lenient_service(self): + qualname_combine = _qualname(self.cls.combine) + self.assertIn(qualname_combine, _LENIENT) + self.assertTrue(_LENIENT[qualname_combine]) + self.assertTrue(_LENIENT[self.cls.combine]) + + def test_lenient_default(self): + other = sentinel.other + return_value = sentinel.return_value + with mock.patch.object( + BaseMetadata, "combine", return_value=return_value + ) as mocker: + result = self.none.combine(other) + + self.assertEqual(return_value, result) + self.assertEqual(1, mocker.call_count) + (arg,), kwargs = mocker.call_args + self.assertEqual(other, arg) + self.assertEqual(dict(lenient=None), kwargs) + + def test_lenient(self): + other = sentinel.other + lenient = sentinel.lenient + return_value = sentinel.return_value + with mock.patch.object( + BaseMetadata, "combine", return_value=return_value + ) as mocker: + result = self.none.combine(other, lenient=lenient) + + self.assertEqual(return_value, result) + self.assertEqual(1, mocker.call_count) + (arg,), kwargs = mocker.call_args + self.assertEqual(other, arg) + self.assertEqual(dict(lenient=lenient), kwargs) + + def test_op_lenient_same(self): + lmetadata = self.cls(**self.values) + rmetadata = self.cls(**self.values) + expected = self.values + + with mock.patch("iris.common.metadata._LENIENT", return_value=True): + self.assertEqual(expected, lmetadata.combine(rmetadata)._asdict()) + self.assertEqual(expected, rmetadata.combine(lmetadata)._asdict()) + + def test_op_lenient_same_none(self): + lmetadata = self.cls(**self.values) + right = self.values.copy() + right["var_name"] = None + rmetadata = self.cls(**right) + expected = self.values + + with mock.patch("iris.common.metadata._LENIENT", return_value=True): + self.assertEqual(expected, lmetadata.combine(rmetadata)._asdict()) + self.assertEqual(expected, rmetadata.combine(lmetadata)._asdict()) + + def test_op_lenient_same_cell_methods_none(self): + lmetadata = self.cls(**self.values) + right = self.values.copy() + right["cell_methods"] = None + rmetadata = self.cls(**right) + expected = right.copy() + + with mock.patch("iris.common.metadata._LENIENT", return_value=True): + self.assertTrue(expected, lmetadata.combine(rmetadata)._asdict()) + self.assertTrue(expected, rmetadata.combine(lmetadata)._asdict()) + + def test_op_lenient_different(self): + lmetadata = self.cls(**self.values) + right = self.values.copy() + right["units"] = self.dummy + rmetadata = self.cls(**right) + expected = self.values.copy() + expected["units"] = None + + with mock.patch("iris.common.metadata._LENIENT", return_value=True): + self.assertEqual(expected, lmetadata.combine(rmetadata)._asdict()) + self.assertEqual(expected, rmetadata.combine(lmetadata)._asdict()) + + def test_op_lenient_different_cell_methods(self): + lmetadata = self.cls(**self.values) + right = self.values.copy() + right["cell_methods"] = self.dummy + rmetadata = self.cls(**right) + expected = self.values.copy() + expected["cell_methods"] = None + + with mock.patch("iris.common.metadata._LENIENT", return_value=True): + self.assertEqual(expected, lmetadata.combine(rmetadata)._asdict()) + self.assertEqual(expected, rmetadata.combine(lmetadata)._asdict()) + + def test_op_strict_same(self): + lmetadata = self.cls(**self.values) + rmetadata = self.cls(**self.values) + expected = self.values.copy() + + with mock.patch("iris.common.metadata._LENIENT", return_value=False): + self.assertEqual(expected, lmetadata.combine(rmetadata)._asdict()) + self.assertEqual(expected, rmetadata.combine(lmetadata)._asdict()) + + def test_op_strict_different(self): + lmetadata = self.cls(**self.values) + right = self.values.copy() + right["long_name"] = self.dummy + rmetadata = self.cls(**right) + expected = self.values.copy() + expected["long_name"] = None + + with mock.patch("iris.common.metadata._LENIENT", return_value=False): + self.assertEqual(expected, lmetadata.combine(rmetadata)._asdict()) + self.assertEqual(expected, rmetadata.combine(lmetadata)._asdict()) + + def test_op_strict_different_cell_methods(self): + lmetadata = self.cls(**self.values) + right = self.values.copy() + right["cell_methods"] = self.dummy + rmetadata = self.cls(**right) + expected = self.values.copy() + expected["cell_methods"] = None + + with mock.patch("iris.common.metadata._LENIENT", return_value=False): + self.assertEqual(expected, lmetadata.combine(rmetadata)._asdict()) + self.assertEqual(expected, rmetadata.combine(lmetadata)._asdict()) + + def test_op_strict_different_none(self): + lmetadata = self.cls(**self.values) + right = self.values.copy() + right["long_name"] = None + rmetadata = self.cls(**right) + expected = self.values.copy() + expected["long_name"] = None + + with mock.patch("iris.common.metadata._LENIENT", return_value=False): + self.assertEqual(expected, lmetadata.combine(rmetadata)._asdict()) + self.assertEqual(expected, rmetadata.combine(lmetadata)._asdict()) + + def test_op_strict_different_cell_methods_none(self): + lmetadata = self.cls(**self.values) + right = self.values.copy() + right["cell_methods"] = None + rmetadata = self.cls(**right) + expected = self.values.copy() + expected["cell_methods"] = None + + with mock.patch("iris.common.metadata._LENIENT", return_value=False): + self.assertEqual(expected, lmetadata.combine(rmetadata)._asdict()) + self.assertEqual(expected, rmetadata.combine(lmetadata)._asdict()) + + +class Test_difference(tests.IrisTest): + def setUp(self): + self.values = dict( + standard_name=sentinel.standard_name, + long_name=sentinel.long_name, + var_name=sentinel.var_name, + units=sentinel.units, + attributes=sentinel.attributes, + cell_methods=sentinel.cell_methods, + ) + self.dummy = sentinel.dummy + self.cls = CubeMetadata + self.none = self.cls(*(None,) * len(self.cls._fields)) + + def test_wraps_docstring(self): + self.assertEqual( + BaseMetadata.difference.__doc__, self.cls.difference.__doc__, + ) + + def test_lenient_service(self): + qualname_difference = _qualname(self.cls.difference) + self.assertIn(qualname_difference, _LENIENT) + self.assertTrue(_LENIENT[qualname_difference]) + self.assertTrue(_LENIENT[self.cls.difference]) + + def test_lenient_default(self): + other = sentinel.other + return_value = sentinel.return_value + with mock.patch.object( + BaseMetadata, "difference", return_value=return_value + ) as mocker: + result = self.none.difference(other) + + self.assertEqual(return_value, result) + self.assertEqual(1, mocker.call_count) + (arg,), kwargs = mocker.call_args + self.assertEqual(other, arg) + self.assertEqual(dict(lenient=None), kwargs) + + def test_lenient(self): + other = sentinel.other + lenient = sentinel.lenient + return_value = sentinel.return_value + with mock.patch.object( + BaseMetadata, "difference", return_value=return_value + ) as mocker: + result = self.none.difference(other, lenient=lenient) + + self.assertEqual(return_value, result) + self.assertEqual(1, mocker.call_count) + (arg,), kwargs = mocker.call_args + self.assertEqual(other, arg) + self.assertEqual(dict(lenient=lenient), kwargs) + + def test_op_lenient_same(self): + lmetadata = self.cls(**self.values) + rmetadata = self.cls(**self.values) + + with mock.patch("iris.common.metadata._LENIENT", return_value=True): + self.assertIsNone(lmetadata.difference(rmetadata)) + self.assertIsNone(rmetadata.difference(lmetadata)) + + def test_op_lenient_same_none(self): + lmetadata = self.cls(**self.values) + right = self.values.copy() + right["var_name"] = None + rmetadata = self.cls(**right) + + with mock.patch("iris.common.metadata._LENIENT", return_value=True): + self.assertIsNone(lmetadata.difference(rmetadata)) + self.assertIsNone(rmetadata.difference(lmetadata)) + + def test_op_lenient_same_cell_methods_none(self): + lmetadata = self.cls(**self.values) + right = self.values.copy() + right["cell_methods"] = None + rmetadata = self.cls(**right) + lexpected = deepcopy(self.none)._asdict() + lexpected["cell_methods"] = (sentinel.cell_methods, None) + rexpected = deepcopy(self.none)._asdict() + rexpected["cell_methods"] = (None, sentinel.cell_methods) + + with mock.patch("iris.common.metadata._LENIENT", return_value=True): + self.assertEqual( + lexpected, lmetadata.difference(rmetadata)._asdict() + ) + self.assertEqual( + rexpected, rmetadata.difference(lmetadata)._asdict() + ) + + def test_op_lenient_different(self): + left = self.values.copy() + lmetadata = self.cls(**left) + right = self.values.copy() + right["units"] = self.dummy + rmetadata = self.cls(**right) + lexpected = deepcopy(self.none)._asdict() + lexpected["units"] = (left["units"], right["units"]) + rexpected = deepcopy(self.none)._asdict() + rexpected["units"] = lexpected["units"][::-1] + + with mock.patch("iris.common.metadata._LENIENT", return_value=True): + self.assertEqual( + lexpected, lmetadata.difference(rmetadata)._asdict() + ) + self.assertEqual( + rexpected, rmetadata.difference(lmetadata)._asdict() + ) + + def test_op_lenient_different_cell_methods(self): + left = self.values.copy() + lmetadata = self.cls(**left) + right = self.values.copy() + right["cell_methods"] = self.dummy + rmetadata = self.cls(**right) + lexpected = deepcopy(self.none)._asdict() + lexpected["cell_methods"] = ( + left["cell_methods"], + right["cell_methods"], + ) + rexpected = deepcopy(self.none)._asdict() + rexpected["cell_methods"] = lexpected["cell_methods"][::-1] + + with mock.patch("iris.common.metadata._LENIENT", return_value=True): + self.assertEqual( + lexpected, lmetadata.difference(rmetadata)._asdict() + ) + self.assertEqual( + rexpected, rmetadata.difference(lmetadata)._asdict() + ) + + def test_op_strict_same(self): + lmetadata = self.cls(**self.values) + rmetadata = self.cls(**self.values) + + with mock.patch("iris.common.metadata._LENIENT", return_value=False): + self.assertIsNone(lmetadata.difference(rmetadata)) + self.assertIsNone(rmetadata.difference(lmetadata)) + + def test_op_strict_different(self): + left = self.values.copy() + lmetadata = self.cls(**left) + right = self.values.copy() + right["long_name"] = self.dummy + rmetadata = self.cls(**right) + lexpected = deepcopy(self.none)._asdict() + lexpected["long_name"] = (left["long_name"], right["long_name"]) + rexpected = deepcopy(self.none)._asdict() + rexpected["long_name"] = lexpected["long_name"][::-1] + + with mock.patch("iris.common.metadata._LENIENT", return_value=False): + self.assertEqual( + lexpected, lmetadata.difference(rmetadata)._asdict() + ) + self.assertEqual( + rexpected, rmetadata.difference(lmetadata)._asdict() + ) + + def test_op_strict_different_cell_methods(self): + left = self.values.copy() + lmetadata = self.cls(**left) + right = self.values.copy() + right["cell_methods"] = self.dummy + rmetadata = self.cls(**right) + lexpected = deepcopy(self.none)._asdict() + lexpected["cell_methods"] = ( + left["cell_methods"], + right["cell_methods"], + ) + rexpected = deepcopy(self.none)._asdict() + rexpected["cell_methods"] = lexpected["cell_methods"][::-1] + + with mock.patch("iris.common.metadata._LENIENT", return_value=False): + self.assertEqual( + lexpected, lmetadata.difference(rmetadata)._asdict() + ) + self.assertEqual( + rexpected, rmetadata.difference(lmetadata)._asdict() + ) + + def test_op_strict_different_none(self): + left = self.values.copy() + lmetadata = self.cls(**left) + right = self.values.copy() + right["long_name"] = None + rmetadata = self.cls(**right) + lexpected = deepcopy(self.none)._asdict() + lexpected["long_name"] = (left["long_name"], right["long_name"]) + rexpected = deepcopy(self.none)._asdict() + rexpected["long_name"] = lexpected["long_name"][::-1] + + with mock.patch("iris.common.metadata._LENIENT", return_value=False): + self.assertEqual( + lexpected, lmetadata.difference(rmetadata)._asdict() + ) + self.assertEqual( + rexpected, rmetadata.difference(lmetadata)._asdict() + ) + + def test_op_strict_different_measure_none(self): + left = self.values.copy() + lmetadata = self.cls(**left) + right = self.values.copy() + right["cell_methods"] = None + rmetadata = self.cls(**right) + lexpected = deepcopy(self.none)._asdict() + lexpected["cell_methods"] = ( + left["cell_methods"], + right["cell_methods"], + ) + rexpected = deepcopy(self.none)._asdict() + rexpected["cell_methods"] = lexpected["cell_methods"][::-1] + + with mock.patch("iris.common.metadata._LENIENT", return_value=False): + self.assertEqual( + lexpected, lmetadata.difference(rmetadata)._asdict() + ) + self.assertEqual( + rexpected, rmetadata.difference(lmetadata)._asdict() + ) + + +class Test_equal(tests.IrisTest): + def setUp(self): + self.cls = CubeMetadata + self.none = self.cls(*(None,) * len(self.cls._fields)) + + def test_wraps_docstring(self): + self.assertEqual(BaseMetadata.equal.__doc__, self.cls.equal.__doc__) + + def test_lenient_service(self): + qualname_equal = _qualname(self.cls.equal) + self.assertIn(qualname_equal, _LENIENT) + self.assertTrue(_LENIENT[qualname_equal]) + self.assertTrue(_LENIENT[self.cls.equal]) + + def test_lenient_default(self): + other = sentinel.other + return_value = sentinel.return_value + with mock.patch.object( + BaseMetadata, "equal", return_value=return_value + ) as mocker: + result = self.none.equal(other) + + self.assertEqual(return_value, result) + self.assertEqual(1, mocker.call_count) + (arg,), kwargs = mocker.call_args + self.assertEqual(other, arg) + self.assertEqual(dict(lenient=None), kwargs) + + def test_lenient(self): + other = sentinel.other + lenient = sentinel.lenient + return_value = sentinel.return_value + with mock.patch.object( + BaseMetadata, "equal", return_value=return_value + ) as mocker: + result = self.none.equal(other, lenient=lenient) + + self.assertEqual(return_value, result) + self.assertEqual(1, mocker.call_count) + (arg,), kwargs = mocker.call_args + self.assertEqual(other, arg) + self.assertEqual(dict(lenient=lenient), kwargs) + + +class Test_name(tests.IrisTest): + def setUp(self): + self.default = CubeMetadata.DEFAULT_NAME + + def test_standard_name(self): + token = "standard_name" + metadata = _make_metadata(standard_name=token) + result = metadata.name() + self.assertEqual(result, token) + result = metadata.name(token=True) + self.assertEqual(result, token) + + def test_standard_name__invalid_token(self): + token = "nope nope" + metadata = _make_metadata(standard_name=token) + result = metadata.name() + self.assertEqual(result, token) + result = metadata.name(token=True) + self.assertEqual(result, self.default) + + def test_long_name(self): + token = "long_name" + metadata = _make_metadata(long_name=token) + result = metadata.name() + self.assertEqual(result, token) + result = metadata.name(token=True) + self.assertEqual(result, token) + + def test_long_name__invalid_token(self): + token = "nope nope" + metadata = _make_metadata(long_name=token) + result = metadata.name() + self.assertEqual(result, token) + result = metadata.name(token=True) + self.assertEqual(result, self.default) + + def test_var_name(self): + token = "var_name" + metadata = _make_metadata(var_name=token) + result = metadata.name() + self.assertEqual(result, token) + result = metadata.name(token=True) + self.assertEqual(result, token) + + def test_var_name__invalid_token(self): + token = "nope nope" + metadata = _make_metadata(var_name=token) + result = metadata.name() + self.assertEqual(result, token) + result = metadata.name(token=True) + self.assertEqual(result, self.default) + + def test_attributes(self): + token = "stash" + metadata = _make_metadata(attributes=token) + result = metadata.name() + self.assertEqual(result, token) + result = metadata.name(token=True) + self.assertEqual(result, token) + + def test_attributes__invalid_token(self): + token = "nope nope" + metadata = _make_metadata(attributes=token) + result = metadata.name() + self.assertEqual(result, token) + result = metadata.name(token=True) + self.assertEqual(result, self.default) + + def test_attributes__non_mapping(self): + metadata = _make_metadata(force_mapping=False) + self.assertIsNone(metadata.attributes) + emsg = "Invalid 'CubeMetadata.attributes' member, must be a mapping." + with self.assertRaisesRegex(AttributeError, emsg): + _ = metadata.name() + + def test_default(self): + metadata = _make_metadata() + result = metadata.name() + self.assertEqual(result, self.default) + result = metadata.name(token=True) + self.assertEqual(result, self.default) + + def test_default__invalid_token(self): + token = "nope nope" + metadata = _make_metadata() + result = metadata.name(default=token) + self.assertEqual(result, token) + emsg = "Cannot retrieve a valid name token" + with self.assertRaisesRegex(ValueError, emsg): + _ = metadata.name(default=token, token=True) + + +class Test__names(tests.IrisTest): + def test_standard_name(self): + token = "standard_name" + metadata = _make_metadata(standard_name=token) + expected = (token, None, None, None) + result = metadata._names + self.assertEqual(expected, result) + + def test_long_name(self): + token = "long_name" + metadata = _make_metadata(long_name=token) + expected = (None, token, None, None) + result = metadata._names + self.assertEqual(expected, result) + + def test_var_name(self): + token = "var_name" + metadata = _make_metadata(var_name=token) + expected = (None, None, token, None) + result = metadata._names + self.assertEqual(expected, result) + + def test_attributes(self): + token = "stash" + metadata = _make_metadata(attributes=token) + expected = (None, None, None, token) + result = metadata._names + self.assertEqual(expected, result) + + def test_attributes__non_mapping(self): + metadata = _make_metadata(force_mapping=False) + self.assertIsNone(metadata.attributes) + emsg = "Invalid 'CubeMetadata.attributes' member, must be a mapping." + with self.assertRaisesRegex(AttributeError, emsg): + _ = metadata._names + + def test_None(self): + metadata = _make_metadata() + expected = (None, None, None, None) + result = metadata._names + self.assertEqual(expected, result) + + +if __name__ == "__main__": + tests.main() diff --git a/lib/iris/tests/unit/common/metadata/test__NamedTupleMeta.py b/lib/iris/tests/unit/common/metadata/test__NamedTupleMeta.py new file mode 100644 index 0000000000..72b3c1bc8f --- /dev/null +++ b/lib/iris/tests/unit/common/metadata/test__NamedTupleMeta.py @@ -0,0 +1,148 @@ +# 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. +""" +Unit tests for the :class:`iris.common.metadata._NamedTupleMeta`. + +""" + +# Import iris.tests first so that some things can be initialised before +# importing anything else. +import iris.tests as tests + +from abc import abstractmethod + +from iris.common.metadata import _NamedTupleMeta + + +class Test(tests.IrisTest): + @staticmethod + def names(classes): + return [cls.__name__ for cls in classes] + + @staticmethod + def emsg_generate(members): + if isinstance(members, str): + members = (members,) + emsg = ".* missing {} required positional argument{}: {}" + args = ", ".join([f"{member!r}" for member in members[:-1]]) + count = len(members) + if count == 1: + args += f"{members[-1]!r}" + elif count == 2: + args += f" and {members[-1]!r}" + else: + args += f", and {members[-1]!r}" + plural = "s" if count > 1 else "" + return emsg.format(len(members), plural, args) + + def test__no_bases_with_abstract_members_property(self): + class Metadata(metaclass=_NamedTupleMeta): + @property + @abstractmethod + def _members(self): + pass + + expected = ["object"] + self.assertEqual(self.names(Metadata.__bases__), expected) + expected = ["Metadata", "object"] + self.assertEqual(self.names(Metadata.__mro__), expected) + emsg = ( + "Can't instantiate abstract class .* with abstract " + "methods _members" + ) + with self.assertRaisesRegex(TypeError, emsg): + _ = Metadata() + + def test__no_bases_single_member(self): + member = "arg_one" + + class Metadata(metaclass=_NamedTupleMeta): + _members = member + + expected = ["MetadataNamedtuple"] + self.assertEqual(self.names(Metadata.__bases__), expected) + expected = ["Metadata", "MetadataNamedtuple", "tuple", "object"] + self.assertEqual(self.names(Metadata.__mro__), expected) + emsg = self.emsg_generate(member) + with self.assertRaisesRegex(TypeError, emsg): + _ = Metadata() + metadata = Metadata(1) + self.assertEqual(metadata._fields, (member,)) + self.assertEqual(metadata.arg_one, 1) + + def test__no_bases_multiple_members(self): + members = ("arg_one", "arg_two") + + class Metadata(metaclass=_NamedTupleMeta): + _members = members + + expected = ["MetadataNamedtuple"] + self.assertEqual(self.names(Metadata.__bases__), expected) + expected = ["Metadata", "MetadataNamedtuple", "tuple", "object"] + self.assertEqual(self.names(Metadata.__mro__), expected) + emsg = self.emsg_generate(members) + with self.assertRaisesRegex(TypeError, emsg): + _ = Metadata() + values = range(len(members)) + metadata = Metadata(*values) + self.assertEqual(metadata._fields, members) + expected = dict(zip(members, values)) + self.assertEqual(metadata._asdict(), expected) + + def test__multiple_bases_multiple_members(self): + members_parent = ("arg_one", "arg_two") + members_child = ("arg_three", "arg_four") + + class MetadataParent(metaclass=_NamedTupleMeta): + _members = members_parent + + class MetadataChild(MetadataParent): + _members = members_child + + # Check the parent class... + expected = ["MetadataParentNamedtuple"] + self.assertEqual(self.names(MetadataParent.__bases__), expected) + expected = [ + "MetadataParent", + "MetadataParentNamedtuple", + "tuple", + "object", + ] + self.assertEqual(self.names(MetadataParent.__mro__), expected) + emsg = self.emsg_generate(members_parent) + with self.assertRaisesRegex(TypeError, emsg): + _ = MetadataParent() + values_parent = range(len(members_parent)) + metadata_parent = MetadataParent(*values_parent) + self.assertEqual(metadata_parent._fields, members_parent) + expected = dict(zip(members_parent, values_parent)) + self.assertEqual(metadata_parent._asdict(), expected) + + # Check the dependant child class... + expected = ["MetadataChildNamedtuple", "MetadataParent"] + self.assertEqual(self.names(MetadataChild.__bases__), expected) + expected = [ + "MetadataChild", + "MetadataChildNamedtuple", + "MetadataParent", + "MetadataParentNamedtuple", + "tuple", + "object", + ] + self.assertEqual(self.names(MetadataChild.__mro__), expected) + emsg = self.emsg_generate((*members_parent, *members_child)) + with self.assertRaisesRegex(TypeError, emsg): + _ = MetadataChild() + fields_child = (*members_parent, *members_child) + values_child = range(len(fields_child)) + metadata_child = MetadataChild(*values_child) + self.assertEqual(metadata_child._fields, fields_child) + expected = dict(zip(fields_child, values_child)) + self.assertEqual(metadata_child._asdict(), expected) + + +if __name__ == "__main__": + tests.main() diff --git a/lib/iris/tests/unit/common/metadata/test__hexdigest.py b/lib/iris/tests/unit/common/metadata/test__hexdigest.py new file mode 100644 index 0000000000..798f71bcd0 --- /dev/null +++ b/lib/iris/tests/unit/common/metadata/test__hexdigest.py @@ -0,0 +1,179 @@ +# 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. +""" +Unit tests for the :func:`iris.common.metadata._hexdigest`. + +""" + +# Import iris.tests first so that some things can be initialised before +# importing anything else. +import iris.tests as tests + +from unittest import mock + +import numpy.ma as ma +import numpy as np +from xxhash import xxh64, xxh64_hexdigest + +from iris.common.metadata import _hexdigest as hexdigest + + +class TestBytesLikeObject(tests.IrisTest): + def setUp(self): + self.hasher = xxh64() + self.hasher.reset() + + @staticmethod + def _ndarray(value): + parts = str((value.shape, xxh64_hexdigest(value))) + return xxh64_hexdigest(parts) + + @staticmethod + def _masked(value): + parts = str( + ( + value.shape, + xxh64_hexdigest(value.data), + xxh64_hexdigest(value.mask), + ) + ) + return xxh64_hexdigest(parts) + + def test_string(self): + value = "hello world" + self.hasher.update(value) + expected = self.hasher.hexdigest() + self.assertEqual(expected, hexdigest(value)) + + def test_numpy_array_int(self): + value = np.arange(10, dtype=np.int) + expected = self._ndarray(value) + self.assertEqual(expected, hexdigest(value)) + + def test_numpy_array_float(self): + value = np.arange(10, dtype=np.float) + expected = self._ndarray(value) + self.assertEqual(expected, hexdigest(value)) + + def test_numpy_array_float_not_int(self): + ivalue = np.arange(10, dtype=np.int) + fvalue = np.arange(10, dtype=np.float) + expected = self._ndarray(ivalue) + self.assertNotEqual(expected, hexdigest(fvalue)) + + def test_numpy_array_reshape(self): + value = np.arange(10).reshape(2, 5) + expected = self._ndarray(value) + self.assertEqual(expected, hexdigest(value)) + + def test_numpy_array_reshape_not_flat(self): + value = np.arange(10).reshape(2, 5) + expected = self._ndarray(value) + self.assertNotEqual(expected, hexdigest(value.flatten())) + + def test_masked_array_int(self): + value = ma.arange(10, dtype=np.int) + expected = self._masked(value) + self.assertEqual(expected, hexdigest(value)) + + value[0] = ma.masked + self.assertNotEqual(expected, hexdigest(value)) + expected = self._masked(value) + self.assertEqual(expected, hexdigest(value)) + + def test_masked_array_float(self): + value = ma.arange(10, dtype=np.float) + expected = self._masked(value) + self.assertEqual(expected, hexdigest(value)) + + value[0] = ma.masked + self.assertNotEqual(expected, hexdigest(value)) + expected = self._masked(value) + self.assertEqual(expected, hexdigest(value)) + + def test_masked_array_float_not_int(self): + ivalue = ma.arange(10, dtype=np.int) + fvalue = ma.arange(10, dtype=np.float) + expected = self._masked(ivalue) + self.assertNotEqual(expected, hexdigest(fvalue)) + + def test_masked_array_not_array(self): + value = ma.arange(10) + expected = self._masked(value) + self.assertNotEqual(expected, hexdigest(value.data)) + + def test_masked_array_reshape(self): + value = ma.arange(10).reshape(2, 5) + expected = self._masked(value) + self.assertEqual(expected, hexdigest(value)) + + def test_masked_array_reshape_not_flat(self): + value = ma.arange(10).reshape(2, 5) + expected = self._masked(value) + self.assertNotEqual(expected, hexdigest(value.flatten())) + + +class TestNotBytesLikeObject(tests.IrisTest): + def _expected(self, value): + parts = str((type(value), value)) + return xxh64_hexdigest(parts) + + def test_int(self): + value = 123 + expected = self._expected(value) + self.assertEqual(expected, hexdigest(value)) + + def test_numpy_int(self): + value = np.int(123) + expected = self._expected(value) + self.assertEqual(expected, hexdigest(value)) + + def test_float(self): + value = 123.4 + expected = self._expected(value) + self.assertEqual(expected, hexdigest(value)) + + def test_numpy_float(self): + value = np.float(123.4) + expected = self._expected(value) + self.assertEqual(expected, hexdigest(value)) + + def test_list(self): + value = [1, 2, 3] + expected = self._expected(value) + self.assertEqual(expected, hexdigest(value)) + + def test_tuple(self): + value = (1, 2, 3) + expected = self._expected(value) + self.assertEqual(expected, hexdigest(value)) + + def test_dict(self): + value = dict(one=1, two=2, three=3) + expected = self._expected(value) + self.assertEqual(expected, hexdigest(value)) + + def test_sentinel(self): + value = mock.sentinel.value + expected = self._expected(value) + self.assertEqual(expected, hexdigest(value)) + + def test_instance(self): + class Dummy: + pass + + value = Dummy() + expected = self._expected(value) + self.assertEqual(expected, hexdigest(value)) + + def test_int_not_str(self): + value = 123 + expected = self._expected(value) + self.assertNotEqual(expected, hexdigest(str(value))) + + +if __name__ == "__main__": + tests.main() diff --git a/lib/iris/tests/unit/common/metadata/test_metadata_manager_factory.py b/lib/iris/tests/unit/common/metadata/test_metadata_manager_factory.py new file mode 100644 index 0000000000..6678aca446 --- /dev/null +++ b/lib/iris/tests/unit/common/metadata/test_metadata_manager_factory.py @@ -0,0 +1,210 @@ +# 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. +""" +Unit tests for the :func:`iris.common.metadata.metadata_manager_factory`. + +""" + +# Import iris.tests first so that some things can be initialised before +# importing anything else. +import iris.tests as tests + +import pickle +import unittest.mock as mock + +from cf_units import Unit + +from iris.common.metadata import ( + AncillaryVariableMetadata, + BaseMetadata, + CellMeasureMetadata, + CoordMetadata, + CubeMetadata, + metadata_manager_factory, +) + + +BASES = [ + AncillaryVariableMetadata, + BaseMetadata, + CellMeasureMetadata, + CoordMetadata, + CubeMetadata, +] + + +class Test_factory(tests.IrisTest): + def test__subclass_invalid(self): + class Other: + pass + + emsg = "Require a subclass of 'BaseMetadata'" + with self.assertRaisesRegex(TypeError, emsg): + _ = metadata_manager_factory(Other) + + def test__kwargs_invalid(self): + emsg = "Invalid 'BaseMetadata' field parameters, got 'wibble'." + with self.assertRaisesRegex(ValueError, emsg): + metadata_manager_factory(BaseMetadata, wibble="nope") + + +class Test_instance(tests.IrisTest): + def setUp(self): + self.bases = BASES + + def test__namespace(self): + namespace = [ + "DEFAULT_NAME", + "__init__", + "__eq__", + "__getstate__", + "__ne__", + "__reduce__", + "__repr__", + "__setstate__", + "fields", + "name", + "token", + "values", + ] + for base in self.bases: + metadata = metadata_manager_factory(base) + for name in namespace: + self.assertTrue(hasattr(metadata, name)) + if base is CubeMetadata: + self.assertTrue(hasattr(metadata, "_names")) + self.assertIs(metadata.cls, base) + + def test__kwargs_default(self): + for base in self.bases: + kwargs = dict(zip(base._fields, [None] * len(base._fields))) + metadata = metadata_manager_factory(base) + self.assertEqual(metadata.values._asdict(), kwargs) + + def test__kwargs(self): + for base in self.bases: + kwargs = dict(zip(base._fields, range(len(base._fields)))) + metadata = metadata_manager_factory(base, **kwargs) + self.assertEqual(metadata.values._asdict(), kwargs) + + +class Test_instance___eq__(tests.IrisTest): + def setUp(self): + self.metadata = metadata_manager_factory(BaseMetadata) + + def test__not_implemented(self): + self.assertNotEqual(self.metadata, 1) + + def test__not_is_cls(self): + base = BaseMetadata + other = metadata_manager_factory(base) + self.assertIs(other.cls, base) + other.cls = CoordMetadata + self.assertNotEqual(self.metadata, other) + + def test__not_values(self): + standard_name = mock.sentinel.standard_name + other = metadata_manager_factory( + BaseMetadata, standard_name=standard_name + ) + self.assertEqual(other.standard_name, standard_name) + self.assertIsNone(other.long_name) + self.assertIsNone(other.var_name) + self.assertIsNone(other.units) + self.assertIsNone(other.attributes) + self.assertNotEqual(self.metadata, other) + + def test__same_default(self): + other = metadata_manager_factory(BaseMetadata) + self.assertEqual(self.metadata, other) + + def test__same(self): + kwargs = dict( + standard_name=1, long_name=2, var_name=3, units=4, attributes=5 + ) + metadata = metadata_manager_factory(BaseMetadata, **kwargs) + other = metadata_manager_factory(BaseMetadata, **kwargs) + self.assertEqual(metadata.values._asdict(), kwargs) + self.assertEqual(metadata, other) + + +class Test_instance____repr__(tests.IrisTest): + def setUp(self): + self.metadata = metadata_manager_factory(BaseMetadata) + + def test(self): + standard_name = mock.sentinel.standard_name + long_name = mock.sentinel.long_name + var_name = mock.sentinel.var_name + units = mock.sentinel.units + attributes = mock.sentinel.attributes + values = (standard_name, long_name, var_name, units, attributes) + + for field, value in zip(self.metadata.fields, values): + setattr(self.metadata, field, value) + + result = repr(self.metadata) + expected = ( + "MetadataManager(standard_name={!r}, long_name={!r}, var_name={!r}, " + "units={!r}, attributes={!r})" + ) + self.assertEqual(result, expected.format(*values)) + + +class Test_instance__pickle(tests.IrisTest): + def setUp(self): + self.standard_name = "standard_name" + self.long_name = "long_name" + self.var_name = "var_name" + self.units = Unit("1") + self.attributes = dict(hello="world") + values = ( + self.standard_name, + self.long_name, + self.var_name, + self.units, + self.attributes, + ) + self.kwargs = dict(zip(BaseMetadata._fields, values)) + self.metadata = metadata_manager_factory(BaseMetadata, **self.kwargs) + + def test_pickle(self): + for protocol in range(pickle.HIGHEST_PROTOCOL + 1): + with self.temp_filename(suffix=".pkl") as fname: + with open(fname, "wb") as fo: + pickle.dump(self.metadata, fo, protocol=protocol) + with open(fname, "rb") as fi: + metadata = pickle.load(fi) + self.assertEqual(metadata, self.metadata) + + +class Test_instance__fields(tests.IrisTest): + def setUp(self): + self.bases = BASES + + def test(self): + for base in self.bases: + fields = base._fields + metadata = metadata_manager_factory(base) + self.assertEqual(metadata.fields, fields) + for field in fields: + hasattr(metadata, field) + + +class Test_instance__values(tests.IrisTest): + def setUp(self): + self.bases = BASES + + def test(self): + for base in self.bases: + metadata = metadata_manager_factory(base) + result = metadata.values + self.assertIsInstance(result, base) + self.assertEqual(result._fields, base._fields) + + +if __name__ == "__main__": + tests.main() diff --git a/lib/iris/tests/unit/common/mixin/__init__.py b/lib/iris/tests/unit/common/mixin/__init__.py new file mode 100644 index 0000000000..493e140626 --- /dev/null +++ b/lib/iris/tests/unit/common/mixin/__init__.py @@ -0,0 +1,6 @@ +# 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. +"""Unit tests for the :mod:`iris.common.mixin` package.""" diff --git a/lib/iris/tests/unit/common/mixin/test_CFVariableMixin.py b/lib/iris/tests/unit/common/mixin/test_CFVariableMixin.py new file mode 100644 index 0000000000..5ac9361e4f --- /dev/null +++ b/lib/iris/tests/unit/common/mixin/test_CFVariableMixin.py @@ -0,0 +1,364 @@ +# 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. +""" +Unit tests for the :class:`iris.common.mixin.CFVariableMixin`. + +""" + +# Import iris.tests first so that some things can be initialised before +# importing anything else. +import iris.tests as tests + +from collections import OrderedDict, namedtuple +from unittest import mock + +from cf_units import Unit + +from iris.common.metadata import ( + AncillaryVariableMetadata, + BaseMetadata, + CellMeasureMetadata, + CoordMetadata, + CubeMetadata, +) +from iris.common.mixin import CFVariableMixin, LimitedAttributeDict + + +class Test__getter(tests.IrisTest): + def setUp(self): + self.standard_name = mock.sentinel.standard_name + self.long_name = mock.sentinel.long_name + self.var_name = mock.sentinel.var_name + self.units = mock.sentinel.units + self.attributes = mock.sentinel.attributes + self.metadata = mock.sentinel.metadata + + metadata = mock.MagicMock( + standard_name=self.standard_name, + long_name=self.long_name, + var_name=self.var_name, + units=self.units, + attributes=self.attributes, + values=self.metadata, + ) + + self.item = CFVariableMixin() + self.item._metadata_manager = metadata + + def test_standard_name(self): + self.assertEqual(self.item.standard_name, self.standard_name) + + def test_long_name(self): + self.assertEqual(self.item.long_name, self.long_name) + + def test_var_name(self): + self.assertEqual(self.item.var_name, self.var_name) + + def test_units(self): + self.assertEqual(self.item.units, self.units) + + def test_attributes(self): + self.assertEqual(self.item.attributes, self.attributes) + + def test_metadata(self): + self.assertEqual(self.item.metadata, self.metadata) + + +class Test__setter(tests.IrisTest): + def setUp(self): + metadata = mock.MagicMock( + standard_name=mock.sentinel.standard_name, + long_name=mock.sentinel.long_name, + var_name=mock.sentinel.var_name, + units=mock.sentinel.units, + attributes=mock.sentinel.attributes, + token=lambda name: name, + ) + + self.item = CFVariableMixin() + self.item._metadata_manager = metadata + + def test_standard_name__valid(self): + standard_name = "air_temperature" + self.item.standard_name = standard_name + self.assertEqual( + self.item._metadata_manager.standard_name, standard_name + ) + + def test_standard_name__none(self): + self.item.standard_name = None + self.assertIsNone(self.item._metadata_manager.standard_name) + + def test_standard_name__invalid(self): + standard_name = "nope nope" + emsg = f"{standard_name!r} is not a valid standard_name" + with self.assertRaisesRegex(ValueError, emsg): + self.item.standard_name = standard_name + + def test_long_name(self): + long_name = "long_name" + self.item.long_name = long_name + self.assertEqual(self.item._metadata_manager.long_name, long_name) + + def test_long_name__none(self): + self.item.long_name = None + self.assertIsNone(self.item._metadata_manager.long_name) + + def test_var_name(self): + var_name = "var_name" + self.item.var_name = var_name + self.assertEqual(self.item._metadata_manager.var_name, var_name) + + def test_var_name__none(self): + self.item.var_name = None + self.assertIsNone(self.item._metadata_manager.var_name) + + def test_var_name__invalid_token(self): + var_name = "nope nope" + self.item._metadata_manager.token = lambda name: None + emsg = f"{var_name!r} is not a valid NetCDF variable name." + with self.assertRaisesRegex(ValueError, emsg): + self.item.var_name = var_name + + def test_attributes(self): + attributes = dict(hello="world") + self.item.attributes = attributes + self.assertEqual(self.item._metadata_manager.attributes, attributes) + self.assertIsNot(self.item._metadata_manager.attributes, attributes) + self.assertIsInstance( + self.item._metadata_manager.attributes, LimitedAttributeDict + ) + + def test_attributes__none(self): + self.item.attributes = None + self.assertEqual(self.item._metadata_manager.attributes, {}) + + +class Test__metadata_setter(tests.IrisTest): + def setUp(self): + class Metadata: + def __init__(self): + self.cls = BaseMetadata + self.fields = BaseMetadata._fields + self.standard_name = mock.sentinel.standard_name + self.long_name = mock.sentinel.long_name + self.var_name = mock.sentinel.var_name + self.units = mock.sentinel.units + self.attributes = mock.sentinel.attributes + self.token = lambda name: name + + @property + def values(self): + return dict( + standard_name=self.standard_name, + long_name=self.long_name, + var_name=self.var_name, + units=self.units, + attributes=self.attributes, + ) + + metadata = Metadata() + self.item = CFVariableMixin() + self.item._metadata_manager = metadata + self.attributes = dict(one=1, two=2, three=3) + self.args = OrderedDict( + standard_name="air_temperature", + long_name="long_name", + var_name="var_name", + units=Unit("1"), + attributes=self.attributes, + ) + + def test_dict(self): + metadata = dict(**self.args) + self.item.metadata = metadata + self.assertEqual(self.item._metadata_manager.values, metadata) + self.assertIsNot( + self.item._metadata_manager.attributes, self.attributes + ) + + def test_dict__partial(self): + metadata = dict(**self.args) + del metadata["standard_name"] + self.item.metadata = metadata + metadata["standard_name"] = mock.sentinel.standard_name + self.assertEqual(self.item._metadata_manager.values, metadata) + self.assertIsNot( + self.item._metadata_manager.attributes, self.attributes + ) + + def test_ordereddict(self): + metadata = self.args + self.item.metadata = metadata + self.assertEqual(self.item._metadata_manager.values, metadata) + self.assertIsNot( + self.item._metadata_manager.attributes, self.attributes + ) + + def test_ordereddict__partial(self): + metadata = self.args + del metadata["long_name"] + del metadata["units"] + self.item.metadata = metadata + metadata["long_name"] = mock.sentinel.long_name + metadata["units"] = mock.sentinel.units + self.assertEqual(self.item._metadata_manager.values, metadata) + + def test_tuple(self): + metadata = tuple(self.args.values()) + self.item.metadata = metadata + result = tuple( + [ + getattr(self.item._metadata_manager, field) + for field in self.item._metadata_manager.fields + ] + ) + self.assertEqual(result, metadata) + self.assertIsNot( + self.item._metadata_manager.attributes, self.attributes + ) + + def test_tuple__missing(self): + metadata = list(self.args.values()) + del metadata[2] + emsg = "Invalid .* metadata, require .* to be specified." + with self.assertRaisesRegex(TypeError, emsg): + self.item.metadata = tuple(metadata) + + def test_namedtuple(self): + Metadata = namedtuple( + "Metadata", + ("standard_name", "long_name", "var_name", "units", "attributes"), + ) + metadata = Metadata(**self.args) + self.item.metadata = metadata + self.assertEqual( + self.item._metadata_manager.values, metadata._asdict() + ) + self.assertIsNot( + self.item._metadata_manager.attributes, metadata.attributes + ) + + def test_namedtuple__partial(self): + Metadata = namedtuple( + "Metadata", ("standard_name", "long_name", "var_name", "units") + ) + del self.args["attributes"] + metadata = Metadata(**self.args) + self.item.metadata = metadata + expected = metadata._asdict() + expected.update(dict(attributes=mock.sentinel.attributes)) + self.assertEqual(self.item._metadata_manager.values, expected) + + def test_class_ancillaryvariablemetadata(self): + metadata = AncillaryVariableMetadata(**self.args) + self.item.metadata = metadata + self.assertEqual( + self.item._metadata_manager.values, metadata._asdict() + ) + self.assertIsNot( + self.item._metadata_manager.attributes, metadata.attributes + ) + + def test_class_basemetadata(self): + metadata = BaseMetadata(**self.args) + self.item.metadata = metadata + self.assertEqual( + self.item._metadata_manager.values, metadata._asdict() + ) + self.assertIsNot( + self.item._metadata_manager.attributes, metadata.attributes + ) + + def test_class_cellmeasuremetadata(self): + self.args["measure"] = None + metadata = CellMeasureMetadata(**self.args) + self.item.metadata = metadata + expected = metadata._asdict() + del expected["measure"] + self.assertEqual(self.item._metadata_manager.values, expected) + self.assertIsNot( + self.item._metadata_manager.attributes, metadata.attributes + ) + + def test_class_coordmetadata(self): + self.args.update(dict(coord_system=None, climatological=False)) + metadata = CoordMetadata(**self.args) + self.item.metadata = metadata + expected = metadata._asdict() + del expected["coord_system"] + del expected["climatological"] + self.assertEqual(self.item._metadata_manager.values, expected) + self.assertIsNot( + self.item._metadata_manager.attributes, metadata.attributes + ) + + def test_class_cubemetadata(self): + self.args["cell_methods"] = None + metadata = CubeMetadata(**self.args) + self.item.metadata = metadata + expected = metadata._asdict() + del expected["cell_methods"] + self.assertEqual(self.item._metadata_manager.values, expected) + self.assertIsNot( + self.item._metadata_manager.attributes, metadata.attributes + ) + + +class Test_rename(tests.IrisTest): + def setUp(self): + metadata = mock.MagicMock( + standard_name=mock.sentinel.standard_name, + long_name=mock.sentinel.long_name, + var_name=mock.sentinel.var_name, + units=mock.sentinel.units, + attributes=mock.sentinel.attributes, + values=mock.sentinel.metadata, + token=lambda name: name, + ) + + self.item = CFVariableMixin() + self.item._metadata_manager = metadata + + def test__valid_standard_name(self): + name = "air_temperature" + self.item.rename(name) + self.assertEqual(self.item._metadata_manager.standard_name, name) + self.assertIsNone(self.item._metadata_manager.long_name) + self.assertIsNone(self.item._metadata_manager.var_name) + + def test__invalid_standard_name(self): + name = "nope nope" + self.item.rename(name) + self.assertIsNone(self.item._metadata_manager.standard_name) + self.assertEqual(self.item._metadata_manager.long_name, name) + self.assertIsNone(self.item._metadata_manager.var_name) + + +class Test_name(tests.IrisTest): + def setUp(self): + class Metadata: + def __init__(self, name): + self.name = mock.MagicMock(return_value=name) + + self.name = mock.sentinel.name + metadata = Metadata(self.name) + + self.item = CFVariableMixin() + self.item._metadata_manager = metadata + + def test(self): + default = mock.sentinel.default + token = mock.sentinel.token + result = self.item.name(default=default, token=token) + self.assertEqual(result, self.name) + self.item._metadata_manager.name.assert_called_with( + default=default, token=token + ) + + +if __name__ == "__main__": + tests.main() diff --git a/lib/iris/tests/unit/common/mixin/test_LimitedAttributeDict.py b/lib/iris/tests/unit/common/mixin/test_LimitedAttributeDict.py new file mode 100644 index 0000000000..bfaeae2daf --- /dev/null +++ b/lib/iris/tests/unit/common/mixin/test_LimitedAttributeDict.py @@ -0,0 +1,69 @@ +# 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. +""" +Unit tests for the :class:`iris.common.mixin.LimitedAttributeDict`. + +""" + +# Import iris.tests first so that some things can be initialised before +# importing anything else. +import iris.tests as tests + +from unittest import mock +import numpy as np + +from iris.common.mixin import LimitedAttributeDict + + +class Test(tests.IrisTest): + def setUp(self): + self.forbidden_keys = LimitedAttributeDict._forbidden_keys + self.emsg = "{!r} is not a permitted attribute" + + def test__invalid_keys(self): + for key in self.forbidden_keys: + with self.assertRaisesRegex(ValueError, self.emsg.format(key)): + _ = LimitedAttributeDict(**{key: None}) + + def test___eq__(self): + values = dict( + one=mock.sentinel.one, + two=mock.sentinel.two, + three=mock.sentinel.three, + ) + left = LimitedAttributeDict(**values) + right = LimitedAttributeDict(**values) + self.assertEqual(left, right) + self.assertEqual(left, values) + + def test___eq___numpy(self): + values = dict(one=np.arange(1), two=np.arange(2), three=np.arange(3),) + left = LimitedAttributeDict(**values) + right = LimitedAttributeDict(**values) + self.assertEqual(left, right) + self.assertEqual(left, values) + values = dict(one=np.arange(1), two=np.arange(1), three=np.arange(1),) + left = LimitedAttributeDict(dict(one=0, two=0, three=0)) + right = LimitedAttributeDict(**values) + self.assertEqual(left, right) + self.assertEqual(left, values) + + def test___setitem__(self): + for key in self.forbidden_keys: + item = LimitedAttributeDict() + with self.assertRaisesRegex(ValueError, self.emsg.format(key)): + item[key] = None + + def test_update(self): + for key in self.forbidden_keys: + item = LimitedAttributeDict() + with self.assertRaisesRegex(ValueError, self.emsg.format(key)): + other = {key: None} + item.update(other) + + +if __name__ == "__main__": + tests.main() diff --git a/lib/iris/tests/unit/cube_coord_common/test_get_valid_standard_name.py b/lib/iris/tests/unit/common/mixin/test__get_valid_standard_name.py similarity index 70% rename from lib/iris/tests/unit/cube_coord_common/test_get_valid_standard_name.py rename to lib/iris/tests/unit/common/mixin/test__get_valid_standard_name.py index ae084f33e4..6d6dcb182e 100644 --- a/lib/iris/tests/unit/cube_coord_common/test_get_valid_standard_name.py +++ b/lib/iris/tests/unit/common/mixin/test__get_valid_standard_name.py @@ -4,7 +4,7 @@ # See COPYING and COPYING.LESSER in the root of the repository for full # licensing details. """ -Unit tests for the :func:`iris._cube_coord_common.get_valid_standard_name`. +Unit tests for the :func:`iris.common.mixin._get_valid_standard_name`. """ @@ -12,7 +12,7 @@ # importing anything else. import iris.tests as tests -from iris._cube_coord_common import get_valid_standard_name +from iris.common.mixin import _get_valid_standard_name class Test(tests.IrisTest): @@ -21,51 +21,51 @@ def setUp(self): def test_pass_thru_none(self): name = None - self.assertEqual(get_valid_standard_name(name), name) + self.assertEqual(_get_valid_standard_name(name), name) def test_pass_thru_empty(self): name = "" - self.assertEqual(get_valid_standard_name(name), name) + self.assertEqual(_get_valid_standard_name(name), name) def test_pass_thru_whitespace(self): name = " " - self.assertEqual(get_valid_standard_name(name), name) + self.assertEqual(_get_valid_standard_name(name), name) def test_valid_standard_name(self): name = "air_temperature" - self.assertEqual(get_valid_standard_name(name), name) + self.assertEqual(_get_valid_standard_name(name), name) def test_standard_name_alias(self): name = "atmosphere_optical_thickness_due_to_pm1_ambient_aerosol" - self.assertEqual(get_valid_standard_name(name), name) + self.assertEqual(_get_valid_standard_name(name), name) def test_invalid_standard_name(self): name = "not_a_standard_name" with self.assertRaisesRegex(ValueError, self.emsg.format(name)): - get_valid_standard_name(name) + _get_valid_standard_name(name) def test_valid_standard_name_valid_modifier(self): name = "air_temperature standard_error" - self.assertEqual(get_valid_standard_name(name), name) + self.assertEqual(_get_valid_standard_name(name), name) def test_valid_standard_name_valid_modifier_extra_spaces(self): name = "air_temperature standard_error" - self.assertEqual(get_valid_standard_name(name), name) + self.assertEqual(_get_valid_standard_name(name), name) def test_invalid_standard_name_valid_modifier(self): name = "not_a_standard_name standard_error" with self.assertRaisesRegex(ValueError, self.emsg.format(name)): - get_valid_standard_name(name) + _get_valid_standard_name(name) def test_valid_standard_invalid_name_modifier(self): name = "air_temperature extra_names standard_error" with self.assertRaisesRegex(ValueError, self.emsg.format(name)): - get_valid_standard_name(name) + _get_valid_standard_name(name) def test_valid_standard_valid_name_modifier_extra_names(self): name = "air_temperature standard_error extra words" with self.assertRaisesRegex(ValueError, self.emsg.format(name)): - get_valid_standard_name(name) + _get_valid_standard_name(name) if __name__ == "__main__": diff --git a/lib/iris/tests/unit/coords/test_CellMethod.py b/lib/iris/tests/unit/coords/test_CellMethod.py index 3014823f9f..530c39cf6d 100644 --- a/lib/iris/tests/unit/coords/test_CellMethod.py +++ b/lib/iris/tests/unit/coords/test_CellMethod.py @@ -11,7 +11,7 @@ # importing anything else. import iris.tests as tests -from iris._cube_coord_common import CFVariableMixin +from iris.common import BaseMetadata from iris.coords import CellMethod, AuxCoord @@ -21,7 +21,7 @@ def setUp(self): def _check(self, token, coord, default=False): result = CellMethod(self.method, coords=coord) - token = token if not default else CFVariableMixin._DEFAULT_NAME + token = token if not default else BaseMetadata.DEFAULT_NAME expected = "{}: {}".format(self.method, token) self.assertEqual(str(result), expected) @@ -54,7 +54,7 @@ def test_coord_var_name_fail(self): def test_coord_stash(self): token = "stash" coord = AuxCoord(1, attributes=dict(STASH=token)) - self._check(token, coord) + self._check(token, coord, default=True) def test_coord_stash_default(self): token = "_stash" # includes leading underscore diff --git a/lib/iris/tests/unit/coords/test_Coord.py b/lib/iris/tests/unit/coords/test_Coord.py index b3fdd215d6..b7fa7a5ce7 100644 --- a/lib/iris/tests/unit/coords/test_Coord.py +++ b/lib/iris/tests/unit/coords/test_Coord.py @@ -1010,6 +1010,17 @@ def test_remove_bounds(self): coord.bounds = None self.assertFalse(coord.climatological) + def test_change_units(self): + coord = AuxCoord( + points=[0, 1], + bounds=[[0, 1], [1, 2]], + units="days since 1970-01-01", + climatological=True, + ) + self.assertTrue(coord.climatological) + coord.units = "K" + self.assertFalse(coord.climatological) + class Test___init____abstractmethod(tests.IrisTest): def test(self): diff --git a/lib/iris/tests/unit/cube_coord_common/test_CFVariableMixin.py b/lib/iris/tests/unit/cube_coord_common/test_CFVariableMixin.py deleted file mode 100644 index 0f08d397cb..0000000000 --- a/lib/iris/tests/unit/cube_coord_common/test_CFVariableMixin.py +++ /dev/null @@ -1,199 +0,0 @@ -# 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. -""" -Unit tests for the :class:`iris._cube_coord_common.CFVariableMixin`. -""" - -# Import iris.tests first so that some things can be initialised before -# importing anything else. -import iris.tests as tests - -from iris._cube_coord_common import CFVariableMixin - - -class Test_token(tests.IrisTest): - def test_passthru_None(self): - result = CFVariableMixin.token(None) - self.assertIsNone(result) - - def test_fail_leading_underscore(self): - result = CFVariableMixin.token("_nope") - self.assertIsNone(result) - - def test_fail_leading_dot(self): - result = CFVariableMixin.token(".nope") - self.assertIsNone(result) - - def test_fail_leading_plus(self): - result = CFVariableMixin.token("+nope") - self.assertIsNone(result) - - def test_fail_leading_at(self): - result = CFVariableMixin.token("@nope") - self.assertIsNone(result) - - def test_fail_space(self): - result = CFVariableMixin.token("nope nope") - self.assertIsNone(result) - - def test_fail_colon(self): - result = CFVariableMixin.token("nope:") - self.assertIsNone(result) - - def test_pass_simple(self): - token = "simple" - result = CFVariableMixin.token(token) - self.assertEqual(result, token) - - def test_pass_leading_digit(self): - token = "123simple" - result = CFVariableMixin.token(token) - self.assertEqual(result, token) - - def test_pass_mixture(self): - token = "S.imple@one+two_3" - result = CFVariableMixin.token(token) - self.assertEqual(result, token) - - -class Test_name(tests.IrisTest): - def setUp(self): - # None token CFVariableMixin - self.cf_var = CFVariableMixin() - self.cf_var.standard_name = None - self.cf_var.long_name = None - self.cf_var.var_name = None - self.cf_var.attributes = {} - self.default = CFVariableMixin._DEFAULT_NAME - # bad token CFVariableMixin - self.cf_bad = CFVariableMixin() - self.cf_bad.standard_name = None - self.cf_bad.long_name = "nope nope" - self.cf_bad.var_name = None - self.cf_bad.attributes = {"STASH": "nope nope"} - - def test_standard_name(self): - token = "air_temperature" - self.cf_var.standard_name = token - result = self.cf_var.name() - self.assertEqual(result, token) - - def test_long_name(self): - token = "long_name" - self.cf_var.long_name = token - result = self.cf_var.name() - self.assertEqual(result, token) - - def test_var_name(self): - token = "var_name" - self.cf_var.var_name = token - result = self.cf_var.name() - self.assertEqual(result, token) - - def test_stash(self): - token = "stash" - self.cf_var.attributes["STASH"] = token - result = self.cf_var.name() - self.assertEqual(result, token) - - def test_default(self): - result = self.cf_var.name() - self.assertEqual(result, self.default) - - def test_token_long_name(self): - token = "long_name" - self.cf_bad.long_name = token - result = self.cf_bad.name(token=True) - self.assertEqual(result, token) - - def test_token_var_name(self): - token = "var_name" - self.cf_bad.var_name = token - result = self.cf_bad.name(token=True) - self.assertEqual(result, token) - - def test_token_stash(self): - token = "stash" - self.cf_bad.attributes["STASH"] = token - result = self.cf_bad.name(token=True) - self.assertEqual(result, token) - - def test_token_default(self): - result = self.cf_var.name(token=True) - self.assertEqual(result, self.default) - - def test_fail_token_default(self): - emsg = "Cannot retrieve a valid name token" - with self.assertRaisesRegex(ValueError, emsg): - self.cf_var.name(default="_nope", token=True) - - -class Test_names(tests.IrisTest): - def setUp(self): - self.cf_var = CFVariableMixin() - self.cf_var.standard_name = None - self.cf_var.long_name = None - self.cf_var.var_name = None - self.cf_var.attributes = dict() - - def test_standard_name(self): - standard_name = "air_temperature" - self.cf_var.standard_name = standard_name - expected = (standard_name, None, None, None) - result = self.cf_var.names - self.assertEqual(expected, result) - self.assertEqual(result.standard_name, standard_name) - - def test_long_name(self): - long_name = "air temperature" - self.cf_var.long_name = long_name - expected = (None, long_name, None, None) - result = self.cf_var.names - self.assertEqual(expected, result) - self.assertEqual(result.long_name, long_name) - - def test_var_name(self): - var_name = "atemp" - self.cf_var.var_name = var_name - expected = (None, None, var_name, None) - result = self.cf_var.names - self.assertEqual(expected, result) - self.assertEqual(result.var_name, var_name) - - def test_STASH(self): - stash = "m01s16i203" - self.cf_var.attributes = dict(STASH=stash) - expected = (None, None, None, stash) - result = self.cf_var.names - self.assertEqual(expected, result) - self.assertEqual(result.STASH, stash) - - def test_None(self): - expected = (None, None, None, None) - result = self.cf_var.names - self.assertEqual(expected, result) - - -class Test_standard_name__setter(tests.IrisTest): - def test_valid_standard_name(self): - cf_var = CFVariableMixin() - cf_var.standard_name = "air_temperature" - self.assertEqual(cf_var.standard_name, "air_temperature") - - def test_invalid_standard_name(self): - cf_var = CFVariableMixin() - emsg = "'not_a_standard_name' is not a valid standard_name" - with self.assertRaisesRegex(ValueError, emsg): - cf_var.standard_name = "not_a_standard_name" - - def test_none_standard_name(self): - cf_var = CFVariableMixin() - cf_var.standard_name = None - self.assertIsNone(cf_var.standard_name) - - -if __name__ == "__main__": - tests.main() diff --git a/lib/iris/tests/unit/experimental/stratify/test_relevel.py b/lib/iris/tests/unit/experimental/stratify/test_relevel.py index 8746625f7e..aa8a363895 100644 --- a/lib/iris/tests/unit/experimental/stratify/test_relevel.py +++ b/lib/iris/tests/unit/experimental/stratify/test_relevel.py @@ -79,7 +79,10 @@ def test_static_level(self): def test_coord_input(self): source = AuxCoord(self.src_levels.data) - source.metadata = self.src_levels.metadata + metadata = self.src_levels.metadata._asdict() + metadata["coord_system"] = None + metadata["climatological"] = None + source.metadata = metadata for axis in self.axes: result = relevel(self.cube, source, [0, 12, 13], axis=axis) diff --git a/lib/iris/tests/unit/fileformats/netcdf/test__load_aux_factory.py b/lib/iris/tests/unit/fileformats/netcdf/test__load_aux_factory.py index 3bbac6b309..609f7d097a 100644 --- a/lib/iris/tests/unit/fileformats/netcdf/test__load_aux_factory.py +++ b/lib/iris/tests/unit/fileformats/netcdf/test__load_aux_factory.py @@ -23,7 +23,9 @@ class TestAtmosphereHybridSigmaPressureCoordinate(tests.IrisTest): def setUp(self): standard_name = "atmosphere_hybrid_sigma_pressure_coordinate" self.requires = dict(formula_type=standard_name) - coordinates = [(mock.sentinel.b, "b"), (mock.sentinel.ps, "ps")] + self.ap = mock.MagicMock(units="units") + self.ps = mock.MagicMock(units="units") + coordinates = [(mock.sentinel.b, "b"), (self.ps, "ps")] self.provides = dict(coordinates=coordinates) self.engine = mock.Mock(requires=self.requires, provides=self.provides) self.cube = mock.create_autospec(Cube, spec_set=True, instance=True) @@ -34,7 +36,7 @@ def setUp(self): self.addCleanup(patcher.stop) def test_formula_terms_ap(self): - self.provides["coordinates"].append((mock.sentinel.ap, "ap")) + self.provides["coordinates"].append((self.ap, "ap")) self.requires["formula_terms"] = dict(ap="ap", b="b", ps="ps") _load_aux_factory(self.engine, self.cube) # Check cube.add_aux_coord method. @@ -44,9 +46,9 @@ def test_formula_terms_ap(self): args, _ = self.cube.add_aux_factory.call_args self.assertEqual(len(args), 1) factory = args[0] - self.assertEqual(factory.delta, mock.sentinel.ap) + self.assertEqual(factory.delta, self.ap) self.assertEqual(factory.sigma, mock.sentinel.b) - self.assertEqual(factory.surface_air_pressure, mock.sentinel.ps) + self.assertEqual(factory.surface_air_pressure, self.ps) def test_formula_terms_a_p0(self): coord_a = DimCoord(np.arange(5), units="Pa") @@ -78,7 +80,7 @@ def test_formula_terms_a_p0(self): factory = args[0] self.assertEqual(factory.delta, coord_expected) self.assertEqual(factory.sigma, mock.sentinel.b) - self.assertEqual(factory.surface_air_pressure, mock.sentinel.ps) + self.assertEqual(factory.surface_air_pressure, self.ps) def test_formula_terms_p0_non_scalar(self): coord_p0 = DimCoord(np.arange(5)) @@ -113,7 +115,7 @@ def _check_no_delta(self): # Check that the factory has no delta term self.assertEqual(factory.delta, None) self.assertEqual(factory.sigma, mock.sentinel.b) - self.assertEqual(factory.surface_air_pressure, mock.sentinel.ps) + self.assertEqual(factory.surface_air_pressure, self.ps) def test_formula_terms_ap_missing_coords(self): self.requires["formula_terms"] = dict(ap="ap", b="b", ps="ps") diff --git a/lib/iris/util.py b/lib/iris/util.py index 3212eba4a5..95afb251a5 100644 --- a/lib/iris/util.py +++ b/lib/iris/util.py @@ -1218,7 +1218,7 @@ def as_compatible_shape(src_cube, target_cube): dimension coordinates where necessary. It operates by matching coordinate metadata to infer the dimensions that need modifying, so the provided cubes must have coordinates with the same metadata - (see :class:`iris.coords.CoordDefn`). + (see :class:`iris.common.CoordMetadata`). .. note:: This function will load and copy the data payload of `src_cube`. diff --git a/requirements/core.txt b/requirements/core.txt index dbc0333d7c..56544d1926 100644 --- a/requirements/core.txt +++ b/requirements/core.txt @@ -12,3 +12,4 @@ matplotlib<3.3 netcdf4 numpy>=1.14 scipy +xxhash #conda: python-xxhash