From e602ad80c30941fc6f1a3ca0374e6e9e985cb5c3 Mon Sep 17 00:00:00 2001 From: Bill Little Date: Wed, 27 Nov 2019 10:37:06 +0000 Subject: [PATCH 01/10] common metadata api --- .flake8 | 4 + lib/iris/_concatenate.py | 12 +- lib/iris/_cube_coord_common.py | 50 ++++++- lib/iris/_merge.py | 23 ++-- lib/iris/analysis/__init__.py | 2 +- lib/iris/aux_factory.py | 26 ++-- lib/iris/common/__init__.py | 8 ++ lib/iris/common/metadata.py | 126 ++++++++++++++++++ lib/iris/coords.py | 101 ++------------ lib/iris/cube.py | 100 +++----------- lib/iris/iterate.py | 9 +- lib/iris/plot.py | 4 +- lib/iris/tests/test_coord_api.py | 10 +- .../experimental/stratify/test_relevel.py | 5 +- lib/iris/util.py | 2 +- 15 files changed, 259 insertions(+), 223 deletions(-) create mode 100644 lib/iris/common/__init__.py create mode 100644 lib/iris/common/metadata.py diff --git a/.flake8 b/.flake8 index 257b9b3d62..e49401c7fa 100644 --- a/.flake8 +++ b/.flake8 @@ -38,3 +38,7 @@ exclude = # ignore third-party files # gitwash_dumper.py, + # + # convenience imports + # + lib/iris/common/__init__.py diff --git a/lib/iris/_concatenate.py b/lib/iris/_concatenate.py index 6dabb1d264..100451d8f9 100644 --- a/lib/iris/_concatenate.py +++ b/lib/iris/_concatenate.py @@ -69,7 +69,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: @@ -86,7 +86,7 @@ class _CoordMetaData( """ - def __new__(cls, coord, dims): + def __new__(mcs, coord, dims): """ Create a new :class:`_CoordMetaData` instance. @@ -102,7 +102,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 = {} @@ -121,7 +121,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 @@ -331,11 +331,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 metadata, then by dimensions, in ascending order. def key_func(coord): return ( axes.get(guess_coord_axis(coord), len(axes) + 1), - coord._as_defn(), + coord.metadata, cube.coord_dims(coord), ) diff --git a/lib/iris/_cube_coord_common.py b/lib/iris/_cube_coord_common.py index 0a3dfd12ca..424e0971c3 100644 --- a/lib/iris/_cube_coord_common.py +++ b/lib/iris/_cube_coord_common.py @@ -5,7 +5,7 @@ # licensing details. -from collections import namedtuple +from collections import Iterable, namedtuple import re import cf_units @@ -281,3 +281,51 @@ def attributes(self): @attributes.setter def attributes(self, attributes): self._attributes = LimitedAttributeDict(attributes or {}) + + @property + def metadata(self): + fields = { + field: getattr(self, field) for field in self._METADATA._fields + } + return self._METADATA(**fields) + + @metadata.setter + def metadata(self, metadata): + try: + # Try dict-like initialisation... + metadata = self._METADATA(**metadata) + except TypeError: + try: + # Try iterator/namedtuple-like initialisation... + metadata = self._METADATA(*metadata) + except TypeError: + if hasattr(metadata, "_asdict"): + metadata = metadata._asdict() + fields = self._METADATA._fields + + if isinstance(metadata, Iterable): + missing = [ + field for field in fields if field not in metadata + ] + else: + 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(self.__class__.__name__, missing) + ) + + for field in self._METADATA._fields: + if hasattr(metadata, field): + value = getattr(metadata, field) + else: + value = metadata[field] + setattr(self, field, value) diff --git a/lib/iris/_merge.py b/lib/iris/_merge.py index f6b03882d7..9f6a6c996f 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: @@ -1460,9 +1461,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(): @@ -1542,7 +1541,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) ) @@ -1576,7 +1575,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 @@ -1767,7 +1766,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 @@ -1778,14 +1777,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(). @@ -1817,14 +1816,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 c0962ec568..71d1a0122f 100644 --- a/lib/iris/analysis/__init__.py +++ b/lib/iris/analysis/__init__.py @@ -318,7 +318,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/aux_factory.py b/lib/iris/aux_factory.py index 11148188fa..2f1aef5006 100644 --- a/lib/iris/aux_factory.py +++ b/lib/iris/aux_factory.py @@ -15,6 +15,7 @@ import numpy as np from iris._cube_coord_common import CFVariableMixin +from iris.common import CoordMetadata import iris.coords @@ -32,6 +33,8 @@ class AuxCoordFactory(CFVariableMixin, metaclass=ABCMeta): """ + _METADATA = CoordMetadata + def __init__(self): #: Descriptive name of the coordinate made by the factory self.long_name = None @@ -42,6 +45,15 @@ def __init__(self): #: Coordinate system (if any) of the coordinate made by the factory self.coord_system = None + @property + def climatological(self): + """ + Always returns False, as a factory can never have points/bounds and + therefore can never be climatological. + + """ + return False + @property @abstractmethod def dependencies(self): @@ -51,20 +63,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): """ diff --git a/lib/iris/common/__init__.py b/lib/iris/common/__init__.py new file mode 100644 index 0000000000..12acb4f77c --- /dev/null +++ b/lib/iris/common/__init__.py @@ -0,0 +1,8 @@ +# 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 .metadata import * diff --git a/lib/iris/common/metadata.py b/lib/iris/common/metadata.py new file mode 100644 index 0000000000..519c911c27 --- /dev/null +++ b/lib/iris/common/metadata.py @@ -0,0 +1,126 @@ +# 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 + + +__all__ = [ + "BaseMetadata", + "CellMeasureMetadata", + "CoordMetadata", + "CubeMetadata", +] + + +class _BaseMeta(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): + if "_names" in namespace and not getattr( + namespace["_names"], "__isabstractmethod__", False + ): + namespace_names = namespace["_names"] + names = [] + for base in bases: + if hasattr(base, "_names"): + base_names = base._names + 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 (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=_BaseMeta): + """Container for common metadata.""" + + _names = ( + "standard_name", + "long_name", + "var_name", + "units", + "attributes", + ) + + __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): + # + # 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: + value = getattr(item, field) + keys.extend((value is not None, value)) + return tuple(keys) + + return _sort_key(self) < _sort_key(other) + + +class CellMeasureMetadata(BaseMetadata): + """Metadata for a :class:`~iris.coords.CellMeasure`.""" + + _names = "measure" + + __slots__ = () + + +class CoordMetadata(BaseMetadata): + """Metadata for a :class:`~iris.coords.Coord`.""" + + _names = ("coord_system", "climatological") + + __slots__ = () + + +class CubeMetadata(BaseMetadata): + """Metadata for a :class:`~iris.cube.Cube`.""" + + _names = "cell_methods" + + __slots__ = () diff --git a/lib/iris/coords.py b/lib/iris/coords.py index e491a65c91..5ca3fc4be6 100644 --- a/lib/iris/coords.py +++ b/lib/iris/coords.py @@ -25,6 +25,7 @@ from iris._data_manager import DataManager import iris._lazy_data as _lazy import iris.aux_factory +from iris.common import BaseMetadata, CellMeasureMetadata, CoordMetadata import iris.exceptions import iris.time import iris.util @@ -39,6 +40,7 @@ class _DimensionalMetadata(CFVariableMixin, metaclass=ABCMeta): """ + _METADATA = BaseMetadata _MODE_ADD = 1 _MODE_SUB = 2 _MODE_MUL = 3 @@ -340,9 +342,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 +369,6 @@ def __ne__(self, other): result = not result return result - def _as_defn(self): - defn = ( - 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 @@ -778,6 +769,8 @@ class CellMeasure(AncillaryVariable): """ + _METADATA = CellMeasureMetadata + def __init__( self, data, @@ -860,17 +853,6 @@ def __repr__(self): ) return result - def _as_defn(self): - defn = ( - 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. @@ -881,61 +863,6 @@ def cube_dims(self, cube): return cube.cell_measure_dims(self) -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 CoordExtent( namedtuple( "_CoordExtent", @@ -1320,6 +1247,8 @@ class Coord(_DimensionalMetadata): """ + _METADATA = CoordMetadata + @abstractmethod def __init__( self, @@ -1609,18 +1538,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 @@ -1874,7 +1791,7 @@ def is_compatible(self, other, ignore=None): * other: An instance of :class:`iris.coords.Coord` or - :class:`iris.coords.CoordDefn`. + :class:`iris.common.CoordMetadata`. * ignore: A single attribute key or iterable of attribute keys to ignore when comparing the coordinates. Default is None. To ignore all diff --git a/lib/iris/cube.py b/lib/iris/cube.py index f58a2ce607..b76e82697d 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, @@ -40,45 +40,14 @@ from iris.analysis.cartography import wrap_lons import iris.analysis.maths import iris.aux_factory +from iris.common import CoordMetadata, CubeMetadata 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 @@ -687,6 +656,8 @@ class Cube(CFVariableMixin): """ + _METADATA = CubeMetadata + #: Indicates to client code that the object supports #: "orthogonal indexing", which means that slices that are 1d arrays #: or lists slice along each dimension independently. This behavior @@ -840,45 +811,6 @@ def __init__( for ancillary_variable, dims in ancillary_variables_and_dims: self.add_ancillary_variable(ancillary_variable, dims) - @property - def metadata(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`. - - """ - 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)) - def is_compatible(self, other, ignore=None): """ Return whether the cube is compatible with another. @@ -1097,7 +1029,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): @@ -1128,7 +1060,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): @@ -1336,11 +1268,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 = [ @@ -1502,7 +1434,7 @@ def coords( the desired coordinates. Accepts either a :class:`iris.coords.DimCoord`, :class:`iris.coords.AuxCoord`, :class:`iris.aux_factory.AuxCoordFactory` - or :class:`iris.coords.CoordDefn`. + or :class:`iris.common.CoordMetadata`. * standard_name The CF standard name of the desired coordinate. If None, does not check for standard name. @@ -1620,14 +1552,14 @@ def attr_filter(coord_): ] if coord is not None: - if isinstance(coord, iris.coords.CoordDefn): - defn = coord + if isinstance(coord, CoordMetadata): + 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: @@ -3958,7 +3890,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 " @@ -4074,7 +4006,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/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/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/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/util.py b/lib/iris/util.py index 3bda110a07..0ef5a01bff 100644 --- a/lib/iris/util.py +++ b/lib/iris/util.py @@ -1157,7 +1157,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`. From a586604c076516e7dc8ee8df06f2fd773c12c781 Mon Sep 17 00:00:00 2001 From: Bill Little Date: Thu, 28 Nov 2019 16:26:43 +0000 Subject: [PATCH 02/10] rationalise _cube_coord_common into common --- lib/iris/aux_factory.py | 3 +-- lib/iris/common/__init__.py | 1 + lib/iris/common/metadata.py | 21 +++++++++++++++---- .../mixin.py} | 10 +++++++-- lib/iris/coords.py | 12 ++++++----- lib/iris/cube.py | 12 +++++------ .../fileformats/_pyke_rules/fc_rules_cf.krb | 6 +++--- .../{cube_coord_common => common}/__init__.py | 2 +- lib/iris/tests/unit/common/mixin/__init__.py | 6 ++++++ .../mixin}/test_CFVariableMixin.py | 4 ++-- .../mixin}/test_get_valid_standard_name.py | 18 ++++++++-------- lib/iris/tests/unit/coords/test_CellMethod.py | 2 +- 12 files changed, 61 insertions(+), 36 deletions(-) rename lib/iris/{_cube_coord_common.py => common/mixin.py} (98%) rename lib/iris/tests/unit/{cube_coord_common => common}/__init__.py (75%) create mode 100644 lib/iris/tests/unit/common/mixin/__init__.py rename lib/iris/tests/unit/{cube_coord_common => common/mixin}/test_CFVariableMixin.py (98%) rename lib/iris/tests/unit/{cube_coord_common => common/mixin}/test_get_valid_standard_name.py (75%) diff --git a/lib/iris/aux_factory.py b/lib/iris/aux_factory.py index 2f1aef5006..b36f22dcdc 100644 --- a/lib/iris/aux_factory.py +++ b/lib/iris/aux_factory.py @@ -14,8 +14,7 @@ import dask.array as da import numpy as np -from iris._cube_coord_common import CFVariableMixin -from iris.common import CoordMetadata +from iris.common import CFVariableMixin, CoordMetadata import iris.coords diff --git a/lib/iris/common/__init__.py b/lib/iris/common/__init__.py index 12acb4f77c..3f25865a01 100644 --- a/lib/iris/common/__init__.py +++ b/lib/iris/common/__init__.py @@ -6,3 +6,4 @@ from .metadata import * +from .mixin import * diff --git a/lib/iris/common/metadata.py b/lib/iris/common/metadata.py index 519c911c27..2508e3d9ac 100644 --- a/lib/iris/common/metadata.py +++ b/lib/iris/common/metadata.py @@ -61,7 +61,10 @@ def __new__(mcs, name, bases, namespace): class BaseMetadata(metaclass=_BaseMeta): - """Container for common metadata.""" + """ + Container for common metadata. + + """ _names = ( "standard_name", @@ -74,6 +77,7 @@ class BaseMetadata(metaclass=_BaseMeta): __slots__ = () def name(self, default="unknown"): + """ Returns a human-readable name. @@ -103,7 +107,10 @@ def _sort_key(item): class CellMeasureMetadata(BaseMetadata): - """Metadata for a :class:`~iris.coords.CellMeasure`.""" + """ + Metadata container for a :class:`~iris.coords.CellMeasure`. + + """ _names = "measure" @@ -111,7 +118,10 @@ class CellMeasureMetadata(BaseMetadata): class CoordMetadata(BaseMetadata): - """Metadata for a :class:`~iris.coords.Coord`.""" + """ + Metadata container for a :class:`~iris.coords.Coord`. + + """ _names = ("coord_system", "climatological") @@ -119,7 +129,10 @@ class CoordMetadata(BaseMetadata): class CubeMetadata(BaseMetadata): - """Metadata for a :class:`~iris.cube.Cube`.""" + """ + Metadata container for a :class:`~iris.cube.Cube`. + + """ _names = "cell_methods" diff --git a/lib/iris/_cube_coord_common.py b/lib/iris/common/mixin.py similarity index 98% rename from lib/iris/_cube_coord_common.py rename to lib/iris/common/mixin.py index 424e0971c3..b764fd2f1c 100644 --- a/lib/iris/_cube_coord_common.py +++ b/lib/iris/common/mixin.py @@ -13,6 +13,12 @@ import iris.std_names +__all__ = [ + "CFVariableMixin", + "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\.\+\-@]*$""") @@ -41,7 +47,7 @@ class Names( __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 @@ -249,7 +255,7 @@ def standard_name(self): @standard_name.setter def standard_name(self, name): - self._standard_name = get_valid_standard_name(name) + self._standard_name = _get_valid_standard_name(name) @property def units(self): diff --git a/lib/iris/coords.py b/lib/iris/coords.py index 5ca3fc4be6..71b023df71 100644 --- a/lib/iris/coords.py +++ b/lib/iris/coords.py @@ -25,14 +25,16 @@ from iris._data_manager import DataManager import iris._lazy_data as _lazy import iris.aux_factory -from iris.common import BaseMetadata, CellMeasureMetadata, CoordMetadata +from iris.common import ( + CFVariableMixin, + BaseMetadata, + CellMeasureMetadata, + CoordMetadata, +) 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): """ @@ -2254,7 +2256,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 diff --git a/lib/iris/cube.py b/lib/iris/cube.py index b76e82697d..7172b98179 100644 --- a/lib/iris/cube.py +++ b/lib/iris/cube.py @@ -29,18 +29,16 @@ 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 CoordMetadata, CubeMetadata +from iris.common import CFVariableMixin, CoordMetadata, CubeMetadata import iris.coord_systems import iris.coords import iris.exceptions @@ -1176,7 +1174,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. @@ -1428,7 +1426,7 @@ 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 @@ -1725,7 +1723,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. @@ -1808,7 +1806,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. 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/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/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/cube_coord_common/test_CFVariableMixin.py b/lib/iris/tests/unit/common/mixin/test_CFVariableMixin.py similarity index 98% rename from lib/iris/tests/unit/cube_coord_common/test_CFVariableMixin.py rename to lib/iris/tests/unit/common/mixin/test_CFVariableMixin.py index 0f08d397cb..a1dd5e6e93 100644 --- a/lib/iris/tests/unit/cube_coord_common/test_CFVariableMixin.py +++ b/lib/iris/tests/unit/common/mixin/test_CFVariableMixin.py @@ -4,14 +4,14 @@ # 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`. +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 iris._cube_coord_common import CFVariableMixin +from iris.common.mixin import CFVariableMixin class Test_token(tests.IrisTest): 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 75% 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 460f3bd338..a6530deb4b 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,35 +21,35 @@ def setUp(self): 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_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 88906dd905..25e12b0236 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 CFVariableMixin from iris.coords import CellMethod, AuxCoord From e2fef55590cc2fbaf77789b6b4bfa8ebebffdde6 Mon Sep 17 00:00:00 2001 From: Bill Little Date: Tue, 3 Dec 2019 10:18:52 +0000 Subject: [PATCH 03/10] move state into metadata --- lib/iris/_constraints.py | 2 +- lib/iris/aux_factory.py | 104 ++--- lib/iris/common/metadata.py | 290 +++++++++++-- lib/iris/common/mixin.py | 191 +++------ lib/iris/coords.py | 55 ++- lib/iris/cube.py | 29 +- .../integration/fast_load/test_fast_load.py | 2 +- .../tests/unit/common/metadata/__init__.py | 6 + .../test_AncillaryVariableMetadata.py | 64 +++ .../unit/common/metadata/test_BaseMetadata.py | 180 ++++++++ .../metadata/test_CellMeasureMetadata.py | 68 +++ .../common/metadata/test_CoordMetadata.py | 73 ++++ .../unit/common/metadata/test_CubeMetadata.py | 220 ++++++++++ .../unit/common/mixin/test_CFVariableMixin.py | 398 +++++++++++------- lib/iris/tests/unit/coords/test_CellMethod.py | 6 +- .../netcdf/test__load_aux_factory.py | 14 +- 16 files changed, 1293 insertions(+), 409 deletions(-) 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 diff --git a/lib/iris/_constraints.py b/lib/iris/_constraints.py index ab213dfa7b..d74aaf8f42 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/aux_factory.py b/lib/iris/aux_factory.py index b36f22dcdc..93664e2f59 100644 --- a/lib/iris/aux_factory.py +++ b/lib/iris/aux_factory.py @@ -14,7 +14,7 @@ import dask.array as da import numpy as np -from iris.common import CFVariableMixin, CoordMetadata +from iris.common import CFVariableMixin, CoordMetadata, MetadataFactory import iris.coords @@ -32,26 +32,41 @@ class AuxCoordFactory(CFVariableMixin, metaclass=ABCMeta): """ - _METADATA = CoordMetadata - def __init__(self): + # Configure the metadata manager. + if not hasattr(self, "_metadata"): + self._metadata = MetadataFactory(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.climatological = False + + @property + def coord_system(self): + """ + The coordinate-system (if any) of the coordinate made by the factory. + + """ + return self._metadata.coord_system + + @coord_system.setter + def coord_system(self, value): + self._metadata.coord_system = value @property def climatological(self): """ - Always returns False, as a factory can never have points/bounds and - therefore can never be climatological. + Always returns False, as a factory itself can never have points/bounds + and therefore can never be climatological by definition. """ - return False + return self._metadata.climatological @property @abstractmethod @@ -369,6 +384,8 @@ def __init__(self, delta=None, sigma=None, orography=None): The coordinate providing the `orog` term. """ + # Configure the metadata manager. + self._metadata = MetadataFactory(CoordMetadata) super().__init__() if delta and delta.nbounds not in (0, 2): @@ -392,21 +409,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 @@ -553,10 +573,13 @@ def __init__(self, delta=None, sigma=None, surface_air_pressure=None): The coordinate providing the `ps` term. """ + # Configure the metadata manager. + self._metadata = MetadataFactory(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 @@ -565,20 +588,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) @@ -750,7 +765,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) * @@ -763,10 +778,13 @@ def __init__( either `eta`, or 'sigma' and `depth` and `depth_c` coordinates. """ + # Configure the metadata manager. + self._metadata = MetadataFactory(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 @@ -778,16 +796,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.") @@ -1065,10 +1079,13 @@ def __init__(self, sigma=None, eta=None, depth=None): (depth(j, i) + eta(n, j, i)) """ + # Configure the metadata manager. + self._metadata = MetadataFactory(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 @@ -1077,10 +1094,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. @@ -1249,10 +1262,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 = MetadataFactory(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 @@ -1263,10 +1279,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. @@ -1473,10 +1485,13 @@ def __init__( b * [tanh(a * (s(k) + 0.5)) / (2 * tanh(0.5*a)) - 0.5] """ + # Configure the metadata manager. + self._metadata = MetadataFactory(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 @@ -1488,10 +1503,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. @@ -1692,10 +1703,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 = MetadataFactory(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 @@ -1706,10 +1720,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/metadata.py b/lib/iris/common/metadata.py index 2508e3d9ac..bf45d5a815 100644 --- a/lib/iris/common/metadata.py +++ b/lib/iris/common/metadata.py @@ -6,17 +6,25 @@ from abc import ABCMeta from collections import namedtuple -from collections.abc import Iterable +from collections.abc import Iterable, Mapping +from functools import wraps +import re __all__ = [ + "AncillaryVariableMetadata", "BaseMetadata", "CellMeasureMetadata", "CoordMetadata", "CubeMetadata", + "MetadataFactory", ] +# 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\.\+\-@]*$""") + + class _BaseMeta(ABCMeta): """ Meta-class to support the convenience of creating a namedtuple from @@ -25,23 +33,26 @@ class _BaseMeta(ABCMeta): """ def __new__(mcs, name, bases, namespace): - if "_names" in namespace and not getattr( - namespace["_names"], "__isabstractmethod__", False + token = "_members" + names = [] + + for base in bases: + if hasattr(base, token): + base_names = getattr(base, token) + 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 token in namespace and not getattr( + namespace[token], "__isabstractmethod__", False ): - namespace_names = namespace["_names"] - names = [] - for base in bases: - if hasattr(base, "_names"): - base_names = base._names - 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) + namespace_names = namespace[token] if (not isinstance(namespace_names, Iterable)) or isinstance( namespace_names, str @@ -50,12 +61,12 @@ def __new__(mcs, name, bases, namespace): 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) + 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) @@ -66,7 +77,9 @@ class BaseMetadata(metaclass=_BaseMeta): """ - _names = ( + DEFAULT_NAME = "unknown" # the fall-back name for metadata identity + + _members = ( "standard_name", "long_name", "var_name", @@ -76,17 +89,67 @@ class BaseMetadata(metaclass=_BaseMeta): __slots__ = () - def name(self, default="unknown"): + @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 + + def name(self, default=None, token=False): """ - Returns a human-readable name. + 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. - 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'). + Returns: + String. """ - return self.standard_name or self.long_name or self.var_name or default + + 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 def __lt__(self, other): # @@ -106,13 +169,22 @@ def _sort_key(item): return _sort_key(self) < _sort_key(other) +class AncillaryVariableMetadata(BaseMetadata): + """ + Metadata container for a :class:`~iris.coords.AncillaryVariableMetadata`. + + """ + + __slots__ = () + + class CellMeasureMetadata(BaseMetadata): """ Metadata container for a :class:`~iris.coords.CellMeasure`. """ - _names = "measure" + _members = "measure" __slots__ = () @@ -123,7 +195,7 @@ class CoordMetadata(BaseMetadata): """ - _names = ("coord_system", "climatological") + _members = ("coord_system", "climatological") __slots__ = () @@ -134,6 +206,158 @@ class CubeMetadata(BaseMetadata): """ - _names = "cell_methods" + _members = "cell_methods" __slots__ = () + + @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 + + @property + def _names(self): + """ + A tuple containing the value of each name participating in the identify + 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) + + +def MetadataFactory(cls): + def __init__(self, cls): + """ + 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. + + """ + # 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 + + #: The metadata class members. + self.fields = cls._fields + + # Initialise the metadata class fields in the instance. + for field in self.fields: + setattr(self, field, None) + + def __getstate__(self): + """Return the instance state to be pickled.""" + return {field: getattr(self, field) for field in self.fields} + + 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 (MetadataFactory, (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 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)) + + # Define the name, (inheritance) bases and namespace of the dynamic class. + name = "Metadata" + bases = () + namespace = { + "DEFAULT_NAME": cls.DEFAULT_NAME, + "__init__": __init__, + "__getstate__": __getstate__, + "__reduce__": __reduce__, + "__repr__": __repr__, + "__setstate__": __setstate__, + "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) + + return metadata diff --git a/lib/iris/common/mixin.py b/lib/iris/common/mixin.py index b764fd2f1c..18a91a83cd 100644 --- a/lib/iris/common/mixin.py +++ b/lib/iris/common/mixin.py @@ -5,46 +5,17 @@ # licensing details. -from collections import Iterable, namedtuple +from collections.abc import Mapping +from functools import wraps import re import cf_units +from iris.common import BaseMetadata import iris.std_names -__all__ = [ - "CFVariableMixin", - "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\.\+\-@]*$""") - - -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__ = () +__all__ = ["CFVariableMixin"] def _get_valid_standard_name(name): @@ -105,7 +76,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. @@ -126,7 +97,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): @@ -142,92 +113,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.name(default=default, token=token) def rename(self, name): """ @@ -250,70 +144,80 @@ 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.standard_name @standard_name.setter def standard_name(self, name): - self._standard_name = _get_valid_standard_name(name) + self._metadata.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.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.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.var_name @var_name.setter def var_name(self, name): if name is not None: - result = self.token(name) + result = self._metadata.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.var_name = name + + @property + def units(self): + """The S.I. unit of the object.""" + return self._metadata.units + + @units.setter + def units(self, unit): + self._metadata.units = cf_units.as_unit(unit) @property def attributes(self): - return self._attributes + return self._metadata.attributes @attributes.setter def attributes(self, attributes): - self._attributes = LimitedAttributeDict(attributes or {}) + self._metadata.attributes = LimitedAttributeDict(attributes or {}) @property def metadata(self): - fields = { - field: getattr(self, field) for field in self._METADATA._fields - } - return self._METADATA(**fields) + return self._metadata.values @metadata.setter def metadata(self, metadata): + cls = self._metadata.cls + fields = self._metadata.fields + arg = metadata + try: # Try dict-like initialisation... - metadata = self._METADATA(**metadata) + metadata = cls(**metadata) except TypeError: try: # Try iterator/namedtuple-like initialisation... - metadata = self._METADATA(*metadata) + metadata = cls(*metadata) except TypeError: if hasattr(metadata, "_asdict"): metadata = metadata._asdict() - fields = self._METADATA._fields - if isinstance(metadata, Iterable): + if isinstance(metadata, Mapping): missing = [ field for field in fields if field not in metadata ] else: + # Generic iterable/container with no associated keys. missing = [ field for field in fields @@ -325,13 +229,14 @@ def metadata(self, metadata): map(lambda i: "{!r}".format(i), missing) ) emsg = "Invalid {!r} metadata, require {} to be specified." - raise TypeError( - emsg.format(self.__class__.__name__, missing) - ) + raise TypeError(emsg.format(type(arg), missing)) - for field in self._METADATA._fields: + 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/coords.py b/lib/iris/coords.py index 71b023df71..3cef39c443 100644 --- a/lib/iris/coords.py +++ b/lib/iris/coords.py @@ -26,10 +26,12 @@ import iris._lazy_data as _lazy import iris.aux_factory from iris.common import ( - CFVariableMixin, + AncillaryVariableMetadata, BaseMetadata, + CFVariableMixin, CellMeasureMetadata, CoordMetadata, + MetadataFactory, ) import iris.exceptions import iris.time @@ -42,7 +44,6 @@ class _DimensionalMetadata(CFVariableMixin, metaclass=ABCMeta): """ - _METADATA = BaseMetadata _MODE_ADD = 1 _MODE_SUB = 2 _MODE_MUL = 3 @@ -96,6 +97,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"): + self._metadata = MetadataFactory(BaseMetadata) + #: CF standard name of the quantity that the metadata represents. self.standard_name = standard_name @@ -703,6 +708,10 @@ def __init__( A dictionary containing other cf and user-defined attributes. """ + # Configure the metadata manager. + if not hasattr(self, "_metadata"): + self._metadata = MetadataFactory(AncillaryVariableMetadata) + super().__init__( values=data, standard_name=standard_name, @@ -771,8 +780,6 @@ class CellMeasure(AncillaryVariable): """ - _METADATA = CellMeasureMetadata - def __init__( self, data, @@ -812,6 +819,9 @@ def __init__( 'area' and 'volume'. The default is 'area'. """ + # Configure the metadata manager. + self._metadata = MetadataFactory(CellMeasureMetadata) + super().__init__( data=data, standard_name=standard_name, @@ -829,14 +839,14 @@ def __init__( @property def measure(self): - return self._measure + return self._metadata.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.measure = measure def __str__(self): result = repr(self) @@ -1249,8 +1259,6 @@ class Coord(_DimensionalMetadata): """ - _METADATA = CoordMetadata - @abstractmethod def __init__( self, @@ -1308,7 +1316,11 @@ 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. + self._metadata = MetadataFactory(CoordMetadata) + super().__init__( values=points, standard_name=standard_name, @@ -1407,7 +1419,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]: @@ -1423,6 +1435,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.coord_system + + @coord_system.setter + def coord_system(self, value): + self._metadata.coord_system = value + @property def climatological(self): """ @@ -1433,8 +1454,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.climatological = False + if not self.units.is_time_reference(): + self._metadata.climatological = False + return self._metadata.climatological @climatological.setter def climatological(self, value): @@ -1452,7 +1478,7 @@ def climatological(self, value): emsg = "Cannot set climatological coordinate, no bounds exist." raise ValueError(emsg) - self._climatological = value + self._metadata.climatological = value def lazy_points(self): """ @@ -2593,19 +2619,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 7172b98179..18eedada58 100644 --- a/lib/iris/cube.py +++ b/lib/iris/cube.py @@ -38,7 +38,12 @@ from iris.analysis.cartography import wrap_lons import iris.analysis.maths import iris.aux_factory -from iris.common import CFVariableMixin, CoordMetadata, CubeMetadata +from iris.common import ( + CFVariableMixin, + CoordMetadata, + CubeMetadata, + MetadataFactory, +) import iris.coord_systems import iris.coords import iris.exceptions @@ -654,8 +659,6 @@ class Cube(CFVariableMixin): """ - _METADATA = CubeMetadata - #: Indicates to client code that the object supports #: "orthogonal indexing", which means that slices that are 1d arrays #: or lists slice along each dimension independently. This behavior @@ -744,6 +747,9 @@ def __init__( if isinstance(data, str): raise TypeError("Invalid data type: {!r}.".format(data)) + # Configure the metadata manager. + self._metadata = MetadataFactory(CubeMetadata) + # Initialise the cube data manager. self._data_manager = DataManager(data) @@ -809,6 +815,17 @@ def __init__( for ancillary_variable, dims in ancillary_variables_and_dims: self.add_ancillary_variable(ancillary_variable, dims) + @property + def _names(self): + """ + A tuple containing the value of each name participating in the identify + of a :class:`iris.cube.Cube`. This includes the standard name, + long name, NetCDF variable name, and the STASH from the attributes + dictionary. + + """ + return self._metadata._names + def is_compatible(self, other, ignore=None): """ Return whether the cube is compatible with another. @@ -1887,11 +1904,13 @@ def cell_methods(self): done on the phenomenon. """ - return self._cell_methods + return self._metadata.cell_methods @cell_methods.setter def cell_methods(self, cell_methods): - self._cell_methods = tuple(cell_methods) if cell_methods else tuple() + self._metadata.cell_methods = ( + tuple(cell_methods) if cell_methods else tuple() + ) def core_data(self): """ 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..1aa781ebf1 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 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..0fa0cd56bf --- /dev/null +++ b/lib/iris/tests/unit/common/metadata/test_AncillaryVariableMetadata.py @@ -0,0 +1,64 @@ +# 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 + +import unittest.mock as mock + +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 + + def test_repr(self): + metadata = AncillaryVariableMetadata( + 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(AncillaryVariableMetadata._fields, expected) + + def test_bases(self): + self.assertTrue(issubclass(AncillaryVariableMetadata, BaseMetadata)) + + +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..52c68ffbac --- /dev/null +++ b/lib/iris/tests/unit/common/metadata/test_BaseMetadata.py @@ -0,0 +1,180 @@ +# 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 + +import unittest.mock as mock + +from iris.common.metadata import BaseMetadata + + +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 + + def test_repr(self): + metadata = BaseMetadata( + 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(BaseMetadata._fields, expected) + + +class Test_token(tests.IrisTest): + def test_passthru_None(self): + result = BaseMetadata.token(None) + self.assertIsNone(result) + + def test_fail_leading_underscore(self): + result = BaseMetadata.token("_nope") + self.assertIsNone(result) + + def test_fail_leading_dot(self): + result = BaseMetadata.token(".nope") + self.assertIsNone(result) + + def test_fail_leading_plus(self): + result = BaseMetadata.token("+nope") + self.assertIsNone(result) + + def test_fail_leading_at(self): + result = BaseMetadata.token("@nope") + self.assertIsNone(result) + + def test_fail_space(self): + result = BaseMetadata.token("nope nope") + self.assertIsNone(result) + + def test_fail_colon(self): + result = BaseMetadata.token("nope:") + self.assertIsNone(result) + + def test_pass_simple(self): + token = "simple" + result = BaseMetadata.token(token) + self.assertEqual(result, token) + + def test_pass_leading_digit(self): + token = "123simple" + result = BaseMetadata.token(token) + self.assertEqual(result, token) + + def test_pass_mixture(self): + token = "S.imple@one+two_3" + result = BaseMetadata.token(token) + self.assertEqual(result, token) + + +class Test_name(tests.IrisTest): + def setUp(self): + self.default = BaseMetadata.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(result, token) + result = metadata.name(token=True) + self.assertEqual(result, token) + + token = "nope nope" + metadata = self._make(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 = self._make(long_name=token) + result = metadata.name() + self.assertEqual(result, token) + result = metadata.name(token=True) + self.assertEqual(result, token) + + token = "nope nope" + metadata = self._make(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 = self._make(var_name=token) + result = metadata.name() + self.assertEqual(result, token) + result = metadata.name(token=True) + self.assertEqual(result, token) + + token = "nope nope" + metadata = self._make(var_name=token) + result = metadata.name() + self.assertEqual(result, token) + result = metadata.name(token=True) + self.assertEqual(result, self.default) + + def test_default(self): + metadata = self._make() + result = metadata.name() + self.assertEqual(result, self.default) + result = metadata.name(token=True) + self.assertEqual(result, self.default) + + token = "nope nope" + 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) + + +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..6a2ffbd70c --- /dev/null +++ b/lib/iris/tests/unit/common/metadata/test_CellMeasureMetadata.py @@ -0,0 +1,68 @@ +# 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 + +import unittest.mock as mock + +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 + + def test_repr(self): + metadata = CellMeasureMetadata( + 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(CellMeasureMetadata._fields, expected) + + def test_bases(self): + self.assertTrue(issubclass(CellMeasureMetadata, BaseMetadata)) + + +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..07f350bed7 --- /dev/null +++ b/lib/iris/tests/unit/common/metadata/test_CoordMetadata.py @@ -0,0 +1,73 @@ +# 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 + +import unittest.mock as mock + +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 + + def test_repr(self): + metadata = CoordMetadata( + 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(CoordMetadata._fields, expected) + + def test_bases(self): + self.assertTrue(issubclass(CoordMetadata, BaseMetadata)) + + +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..c6533cbed5 --- /dev/null +++ b/lib/iris/tests/unit/common/metadata/test_CubeMetadata.py @@ -0,0 +1,220 @@ +# 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 + +import unittest.mock as mock + +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 + + def test_repr(self): + metadata = CubeMetadata( + 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(CubeMetadata._fields, expected) + + def test_bases(self): + self.assertTrue(issubclass(CubeMetadata, BaseMetadata)) + + +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) + + 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) + + 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) + + 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) + + 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) + + token = "nope nope" + 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/mixin/test_CFVariableMixin.py b/lib/iris/tests/unit/common/mixin/test_CFVariableMixin.py index a1dd5e6e93..830cc2ee2f 100644 --- a/lib/iris/tests/unit/common/mixin/test_CFVariableMixin.py +++ b/lib/iris/tests/unit/common/mixin/test_CFVariableMixin.py @@ -5,194 +5,280 @@ # 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 iris.common.mixin import CFVariableMixin - +from collections import OrderedDict, namedtuple +from unittest import mock -class Test_token(tests.IrisTest): - def test_passthru_None(self): - result = CFVariableMixin.token(None) - self.assertIsNone(result) +from cf_units import Unit - def test_fail_leading_underscore(self): - result = CFVariableMixin.token("_nope") - self.assertIsNone(result) +from iris.common.metadata import BaseMetadata +from iris.common.mixin import CFVariableMixin, LimitedAttributeDict - 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) +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 = metadata - def test_fail_leading_at(self): - result = CFVariableMixin.token("@nope") - self.assertIsNone(result) + def test_standard_name(self): + self.assertEqual(self.item.standard_name, self.standard_name) - def test_fail_space(self): - result = CFVariableMixin.token("nope nope") - self.assertIsNone(result) + def test_long_name(self): + self.assertEqual(self.item.long_name, self.long_name) - def test_fail_colon(self): - result = CFVariableMixin.token("nope:") - self.assertIsNone(result) + def test_var_name(self): + self.assertEqual(self.item.var_name, self.var_name) - def test_pass_simple(self): - token = "simple" - result = CFVariableMixin.token(token) - self.assertEqual(result, token) + def test_units(self): + self.assertEqual(self.item.units, self.units) - def test_pass_leading_digit(self): - token = "123simple" - result = CFVariableMixin.token(token) - self.assertEqual(result, token) + def test_attributes(self): + self.assertEqual(self.item.attributes, self.attributes) - def test_pass_mixture(self): - token = "S.imple@one+two_3" - result = CFVariableMixin.token(token) - self.assertEqual(result, token) + def test_metadata(self): + self.assertEqual(self.item.metadata, self.metadata) -class Test_name(tests.IrisTest): +class Test__setter(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"} + 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 = metadata def test_standard_name(self): - token = "air_temperature" - self.cf_var.standard_name = token - result = self.cf_var.name() - self.assertEqual(result, token) + standard_name = "air_temperature" + self.item.standard_name = standard_name + self.assertEqual(self.item._metadata.standard_name, standard_name) + + self.item.standard_name = None + self.assertIsNone(self.item._metadata.standard_name) + + 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): - token = "long_name" - self.cf_var.long_name = token - result = self.cf_var.name() - self.assertEqual(result, token) + long_name = "long_name" + self.item.long_name = long_name + self.assertEqual(self.item._metadata.long_name, long_name) + + self.item.long_name = None + self.assertIsNone(self.item._metadata.long_name) 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" + var_name = "var_name" + self.item.var_name = var_name + self.assertEqual(self.item._metadata.var_name, var_name) + + self.item.var_name = None + self.assertIsNone(self.item._metadata.var_name) + + var_name = "nope nope" + self.item._metadata.token = lambda name: None + emsg = f"{var_name!r} is not a valid NetCDF variable name." with self.assertRaisesRegex(ValueError, emsg): - self.cf_var.name(default="_nope", token=True) + self.item.var_name = var_name + def test_attributes(self): + attributes = dict(hello="world") + self.item.attributes = attributes + self.assertEqual(self.item._metadata.attributes, attributes) + self.assertIsNot(self.item._metadata.attributes, attributes) + self.assertIsInstance( + self.item._metadata.attributes, LimitedAttributeDict + ) -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() + self.item.attributes = None + self.assertEqual(self.item._metadata.attributes, {}) - 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) +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 = 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.values, metadata) + self.assertIsNot(self.item._metadata.attributes, self.attributes) + + def test_dict__missing(self): + metadata = dict(**self.args) + del metadata["standard_name"] + emsg = "Invalid .* metadata, require 'standard_name' to be specified." + with self.assertRaisesRegex(TypeError, emsg): + self.item.metadata = metadata + + def test_ordereddict(self): + metadata = self.args + self.item.metadata = metadata + self.assertEqual(self.item._metadata.values, metadata) + self.assertIsNot(self.item._metadata.attributes, self.attributes) + + def test_ordereddict__missing(self): + metadata = self.args + del metadata["long_name"] + del metadata["units"] + emsg = "Invalid .* metadata, require 'long_name', 'units' to be specified." + with self.assertRaisesRegex(TypeError, emsg): + self.item.metadata = metadata + + def test_tuple(self): + metadata = tuple(self.args.values()) + self.item.metadata = metadata + result = tuple( + [ + getattr(self.item._metadata, field) + for field in self.item._metadata.fields + ] + ) + self.assertEqual(result, metadata) + self.assertIsNot(self.item._metadata.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.values, metadata._asdict()) + self.assertIsNot(self.item._metadata.attributes, metadata.attributes) + + def test_namedtuple__missing(self): + Metadata = namedtuple( + "Metadata", ("standard_name", "long_name", "var_name", "units") + ) + metadata = Metadata(standard_name=1, long_name=2, var_name=3, units=4) + emsg = "Invalid .* metadata, require 'attributes' to be specified." + with self.assertRaisesRegex(TypeError, emsg): + self.item.metadata = metadata + + def test_class(self): + metadata = BaseMetadata(**self.args) + self.item.metadata = metadata + self.assertEqual(self.item._metadata.values, metadata._asdict()) + self.assertIsNot(self.item._metadata.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 = metadata + + def test(self): + name = "air_temperature" + self.item.rename(name) + self.assertEqual(self.item._metadata.standard_name, name) + self.assertIsNone(self.item._metadata.long_name) + self.assertIsNone(self.item._metadata.var_name) + + name = "nope nope" + self.item.rename(name) + self.assertIsNone(self.item._metadata.standard_name) + self.assertEqual(self.item._metadata.long_name, name) + self.assertIsNone(self.item._metadata.var_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) +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 = 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.name.assert_called_with( + default=default, token=token + ) if __name__ == "__main__": diff --git a/lib/iris/tests/unit/coords/test_CellMethod.py b/lib/iris/tests/unit/coords/test_CellMethod.py index 25e12b0236..609a459d2d 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.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/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") From e0a8d2ac87884afb5e42ea9dc39d99f264c955e4 Mon Sep 17 00:00:00 2001 From: Bill Little Date: Thu, 5 Dec 2019 16:31:56 +0000 Subject: [PATCH 04/10] MetadataFactory test coverage --- lib/iris/common/metadata.py | 71 ++++-- .../common/metadata/test_MetadataFactory.py | 208 ++++++++++++++++++ 2 files changed, 263 insertions(+), 16 deletions(-) create mode 100644 lib/iris/tests/unit/common/metadata/test_MetadataFactory.py diff --git a/lib/iris/common/metadata.py b/lib/iris/common/metadata.py index bf45d5a815..4de795c7be 100644 --- a/lib/iris/common/metadata.py +++ b/lib/iris/common/metadata.py @@ -267,22 +267,29 @@ def _names(self): return (standard_name, long_name, var_name, stash_name) -def MetadataFactory(cls): - def __init__(self, cls): - """ - A class instance factory function responsible for manufacturing - metadata instances dynamically at runtime. +def MetadataFactory(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. + The factory instances returned by the factory are capable of managing + their metadata state, which can be proxied by the owning container. - Args: + Args: - * cls: - A subclass of :class:`~iris.common.metadata.BaseMetadata`, defining - the metadata to be managed. + * 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}." @@ -291,17 +298,33 @@ def __init__(self, cls): #: The metadata class to be manufactured by this factory. self.cls = cls - #: The metadata class members. - self.fields = cls._fields - # 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 @@ -326,6 +349,11 @@ def __setstate__(self, state): for field, value in state.items(): setattr(self, field, value) + @property + def fields(self): + """Return the name of the metadata members.""" + return self.cls._fields + @property def values(self): fields = {field: getattr(self, field) for field in self.fields} @@ -336,16 +364,27 @@ def values(self): 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 = "Metadata" 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, @@ -358,6 +397,6 @@ def values(self): # Dynamically create the class. Metadata = type(name, bases, namespace) # Now manufacture an instance of that class. - metadata = Metadata(cls) + metadata = Metadata(cls, **kwargs) return metadata diff --git a/lib/iris/tests/unit/common/metadata/test_MetadataFactory.py b/lib/iris/tests/unit/common/metadata/test_MetadataFactory.py new file mode 100644 index 0000000000..68efda64c5 --- /dev/null +++ b/lib/iris/tests/unit/common/metadata/test_MetadataFactory.py @@ -0,0 +1,208 @@ +# 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.MetadataFactory`. + +""" + +# 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, + MetadataFactory, +) + + +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): + _ = MetadataFactory(Other) + + def test__kwargs_invalid(self): + emsg = "Invalid 'BaseMetadata' field parameters, got 'wibble'." + with self.assertRaisesRegex(ValueError, emsg): + MetadataFactory(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 = MetadataFactory(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 = MetadataFactory(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 = MetadataFactory(base, **kwargs) + self.assertEqual(metadata.values._asdict(), kwargs) + + +class Test_instance___eq__(tests.IrisTest): + def setUp(self): + self.metadata = MetadataFactory(BaseMetadata) + + def test__not_implemented(self): + self.assertNotEqual(self.metadata, 1) + + def test__not_is_cls(self): + base = BaseMetadata + other = MetadataFactory(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 = MetadataFactory(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 = MetadataFactory(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 = MetadataFactory(BaseMetadata, **kwargs) + other = MetadataFactory(BaseMetadata, **kwargs) + self.assertEqual(metadata.values._asdict(), kwargs) + self.assertEqual(metadata, other) + + +class Test_instance____repr__(tests.IrisTest): + def setUp(self): + self.metadata = MetadataFactory(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 = ( + "Metadata(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 = MetadataFactory(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 = MetadataFactory(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 = MetadataFactory(base) + result = metadata.values + self.assertIsInstance(result, base) + self.assertEqual(result._fields, base._fields) + + +if __name__ == "__main__": + tests.main() From 154222c7017a358bebf1e7e2f9d75d9edce6570a Mon Sep 17 00:00:00 2001 From: Bill Little Date: Fri, 6 Dec 2019 07:49:40 +0000 Subject: [PATCH 05/10] temporarily pin back iris-grib --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 2c9c33b4cb..b0bff878af 100644 --- a/.travis.yml +++ b/.travis.yml @@ -106,7 +106,7 @@ install: - > if [[ "${TEST_MINIMAL}" != true ]]; then conda install --quiet -n ${ENV_NAME} python-eccodes>=0.9.1,<2; - conda install --quiet -n ${ENV_NAME} --no-deps iris-grib; + conda install --quiet -n ${ENV_NAME} --no-deps "iris-grib<0.15"; fi script: From 6133af561e6782000af2f80f7f14da1f1a244f3d Mon Sep 17 00:00:00 2001 From: Bill Little Date: Fri, 6 Dec 2019 10:25:44 +0000 Subject: [PATCH 06/10] test coverage for iris.common.metadata._BaseMeta --- .../unit/common/metadata/test__BaseMeta.py | 148 ++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 lib/iris/tests/unit/common/metadata/test__BaseMeta.py diff --git a/lib/iris/tests/unit/common/metadata/test__BaseMeta.py b/lib/iris/tests/unit/common/metadata/test__BaseMeta.py new file mode 100644 index 0000000000..27a1c9f1d4 --- /dev/null +++ b/lib/iris/tests/unit/common/metadata/test__BaseMeta.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._BaseMeta`. + +""" + +# 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 _BaseMeta + + +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=_BaseMeta): + @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=_BaseMeta): + _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=_BaseMeta): + _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=_BaseMeta): + _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() From 9a463720f063f9eae1aad8521036321aee3355b4 Mon Sep 17 00:00:00 2001 From: Bill Little Date: Fri, 6 Dec 2019 11:53:28 +0000 Subject: [PATCH 07/10] test coverage for iris.common.mixin.LimitedAttributeDict --- .../common/mixin/test_LimitedAttributeDict.py | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 lib/iris/tests/unit/common/mixin/test_LimitedAttributeDict.py 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() From 137d4a2fef02cd8b90fd3270b322183f33f5ac58 Mon Sep 17 00:00:00 2001 From: Bill Little Date: Fri, 6 Dec 2019 14:31:39 +0000 Subject: [PATCH 08/10] remove temporary iris-grib pin --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index b0bff878af..2c9c33b4cb 100644 --- a/.travis.yml +++ b/.travis.yml @@ -106,7 +106,7 @@ install: - > if [[ "${TEST_MINIMAL}" != true ]]; then conda install --quiet -n ${ENV_NAME} python-eccodes>=0.9.1,<2; - conda install --quiet -n ${ENV_NAME} --no-deps "iris-grib<0.15"; + conda install --quiet -n ${ENV_NAME} --no-deps iris-grib; fi script: From 59dba91886556130304fe8cedd17405808e92648 Mon Sep 17 00:00:00 2001 From: Bill Little Date: Thu, 19 Dec 2019 13:40:15 +0000 Subject: [PATCH 09/10] review actions --- lib/iris/aux_factory.py | 28 +++--- lib/iris/common/metadata.py | 17 ++-- lib/iris/common/mixin.py | 32 ++++--- lib/iris/coords.py | 32 ++++--- lib/iris/cube.py | 12 +-- .../unit/common/metadata/test_BaseMetadata.py | 28 ++++++ .../unit/common/metadata/test_CubeMetadata.py | 8 +- ...tory.py => test_MetadataManagerFactory.py} | 38 ++++---- ...t__BaseMeta.py => test__NamedTupleMeta.py} | 12 +-- .../unit/common/mixin/test_CFVariableMixin.py | 95 ++++++++++++------- ...me.py => test__get_valid_standard_name.py} | 2 +- lib/iris/tests/unit/coords/test_Coord.py | 11 +++ 12 files changed, 195 insertions(+), 120 deletions(-) rename lib/iris/tests/unit/common/metadata/{test_MetadataFactory.py => test_MetadataManagerFactory.py} (83%) rename lib/iris/tests/unit/common/metadata/{test__BaseMeta.py => test__NamedTupleMeta.py} (93%) rename lib/iris/tests/unit/common/mixin/{test_get_valid_standard_name.py => test__get_valid_standard_name.py} (96%) diff --git a/lib/iris/aux_factory.py b/lib/iris/aux_factory.py index 93664e2f59..1f134f65c0 100644 --- a/lib/iris/aux_factory.py +++ b/lib/iris/aux_factory.py @@ -14,7 +14,7 @@ import dask.array as da import numpy as np -from iris.common import CFVariableMixin, CoordMetadata, MetadataFactory +from iris.common import CFVariableMixin, CoordMetadata, MetadataManagerFactory import iris.coords @@ -34,8 +34,8 @@ class AuxCoordFactory(CFVariableMixin, metaclass=ABCMeta): def __init__(self): # Configure the metadata manager. - if not hasattr(self, "_metadata"): - self._metadata = MetadataFactory(CoordMetadata) + if not hasattr(self, "_metadata_manager"): + self._metadata_manager = MetadataManagerFactory(CoordMetadata) #: Descriptive name of the coordinate made by the factory self.long_name = None @@ -45,7 +45,7 @@ def __init__(self): self.coord_system = None # See the climatological property getter. - self._metadata.climatological = False + self._metadata_manager.climatological = False @property def coord_system(self): @@ -53,11 +53,11 @@ def coord_system(self): The coordinate-system (if any) of the coordinate made by the factory. """ - return self._metadata.coord_system + return self._metadata_manager.coord_system @coord_system.setter def coord_system(self, value): - self._metadata.coord_system = value + self._metadata_manager.coord_system = value @property def climatological(self): @@ -66,7 +66,7 @@ def climatological(self): and therefore can never be climatological by definition. """ - return self._metadata.climatological + return self._metadata_manager.climatological @property @abstractmethod @@ -385,7 +385,7 @@ def __init__(self, delta=None, sigma=None, orography=None): """ # Configure the metadata manager. - self._metadata = MetadataFactory(CoordMetadata) + self._metadata_manager = MetadataManagerFactory(CoordMetadata) super().__init__() if delta and delta.nbounds not in (0, 2): @@ -574,7 +574,7 @@ def __init__(self, delta=None, sigma=None, surface_air_pressure=None): """ # Configure the metadata manager. - self._metadata = MetadataFactory(CoordMetadata) + self._metadata_manager = MetadataManagerFactory(CoordMetadata) super().__init__() # Check that provided coords meet necessary conditions. @@ -779,7 +779,7 @@ def __init__( """ # Configure the metadata manager. - self._metadata = MetadataFactory(CoordMetadata) + self._metadata_manager = MetadataManagerFactory(CoordMetadata) super().__init__() # Check that provided coordinates meet necessary conditions. @@ -1080,7 +1080,7 @@ def __init__(self, sigma=None, eta=None, depth=None): """ # Configure the metadata manager. - self._metadata = MetadataFactory(CoordMetadata) + self._metadata_manager = MetadataManagerFactory(CoordMetadata) super().__init__() # Check that provided coordinates meet necessary conditions. @@ -1263,7 +1263,7 @@ def __init__(self, s=None, c=None, eta=None, depth=None, depth_c=None): """ # Configure the metadata manager. - self._metadata = MetadataFactory(CoordMetadata) + self._metadata_manager = MetadataManagerFactory(CoordMetadata) super().__init__() # Check that provided coordinates meet necessary conditions. @@ -1486,7 +1486,7 @@ def __init__( """ # Configure the metadata manager. - self._metadata = MetadataFactory(CoordMetadata) + self._metadata_manager = MetadataManagerFactory(CoordMetadata) super().__init__() # Check that provided coordinates meet necessary conditions. @@ -1704,7 +1704,7 @@ def __init__(self, s=None, c=None, eta=None, depth=None, depth_c=None): """ # Configure the metadata manager. - self._metadata = MetadataFactory(CoordMetadata) + self._metadata_manager = MetadataManagerFactory(CoordMetadata) super().__init__() # Check that provided coordinates meet necessary conditions. diff --git a/lib/iris/common/metadata.py b/lib/iris/common/metadata.py index 4de795c7be..e51e34e8f8 100644 --- a/lib/iris/common/metadata.py +++ b/lib/iris/common/metadata.py @@ -17,7 +17,7 @@ "CellMeasureMetadata", "CoordMetadata", "CubeMetadata", - "MetadataFactory", + "MetadataManagerFactory", ] @@ -25,7 +25,7 @@ _TOKEN_PARSE = re.compile(r"""^[a-zA-Z0-9][\w\.\+\-@]*$""") -class _BaseMeta(ABCMeta): +class _NamedTupleMeta(ABCMeta): """ Meta-class to support the convenience of creating a namedtuple from names/members of the metadata class hierarchy. @@ -71,7 +71,7 @@ def __new__(mcs, name, bases, namespace): return super().__new__(mcs, name, bases, namespace) -class BaseMetadata(metaclass=_BaseMeta): +class BaseMetadata(metaclass=_NamedTupleMeta): """ Container for common metadata. @@ -154,7 +154,8 @@ def _check(item): def __lt__(self, other): # # Support Python2 behaviour for a "<" operation involving a - # "NoneType" operand. + # "NoneType" operand. Require to at least implement this comparison + # operator to support sorting of instances. # if not isinstance(other, self.__class__): return NotImplemented @@ -242,7 +243,7 @@ def _check(item): @property def _names(self): """ - A tuple containing the value of each name participating in the identify + 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. @@ -267,7 +268,7 @@ def _names(self): return (standard_name, long_name, var_name, stash_name) -def MetadataFactory(cls, **kwargs): +def MetadataManagerFactory(cls, **kwargs): """ A class instance factory function responsible for manufacturing metadata instances dynamically at runtime. @@ -333,7 +334,7 @@ def __reduce__(self): instance, and dump and load instance state successfully. """ - return (MetadataFactory, (self.cls,), self.__getstate__()) + return (MetadataManagerFactory, (self.cls,), self.__getstate__()) def __repr__(self): args = ", ".join( @@ -373,7 +374,7 @@ def values(self): raise ValueError(emsg.format(cls.__name__, bad)) # Define the name, (inheritance) bases and namespace of the dynamic class. - name = "Metadata" + name = "MetadataManager" bases = () namespace = { "DEFAULT_NAME": cls.DEFAULT_NAME, diff --git a/lib/iris/common/mixin.py b/lib/iris/common/mixin.py index 18a91a83cd..8133bb50a4 100644 --- a/lib/iris/common/mixin.py +++ b/lib/iris/common/mixin.py @@ -121,7 +121,7 @@ def update(self, other, **kwargs): class CFVariableMixin: @wraps(BaseMetadata.name) def name(self, default=None, token=None): - return self._metadata.name(default=default, token=token) + return self._metadata_manager.name(default=default, token=token) def rename(self, name): """ @@ -145,60 +145,62 @@ def rename(self, name): @property def standard_name(self): """The CF Metadata standard name for the object.""" - return self._metadata.standard_name + return self._metadata_manager.standard_name @standard_name.setter def standard_name(self, name): - self._metadata.standard_name = _get_valid_standard_name(name) + self._metadata_manager.standard_name = _get_valid_standard_name(name) @property def long_name(self): """The CF Metadata long name for the object.""" - return self._metadata.long_name + return self._metadata_manager.long_name @long_name.setter def long_name(self, name): - self._metadata.long_name = name + self._metadata_manager.long_name = name @property def var_name(self): """The NetCDF variable name for the object.""" - return self._metadata.var_name + return self._metadata_manager.var_name @var_name.setter def var_name(self, name): if name is not None: - result = self._metadata.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._metadata.var_name = name + self._metadata_manager.var_name = name @property def units(self): """The S.I. unit of the object.""" - return self._metadata.units + return self._metadata_manager.units @units.setter def units(self, unit): - self._metadata.units = cf_units.as_unit(unit) + self._metadata_manager.units = cf_units.as_unit(unit) @property def attributes(self): - return self._metadata.attributes + return self._metadata_manager.attributes @attributes.setter def attributes(self, attributes): - self._metadata.attributes = LimitedAttributeDict(attributes or {}) + self._metadata_manager.attributes = LimitedAttributeDict( + attributes or {} + ) @property def metadata(self): - return self._metadata.values + return self._metadata_manager.values @metadata.setter def metadata(self, metadata): - cls = self._metadata.cls - fields = self._metadata.fields + cls = self._metadata_manager.cls + fields = self._metadata_manager.fields arg = metadata try: diff --git a/lib/iris/coords.py b/lib/iris/coords.py index 3cef39c443..e5574b3388 100644 --- a/lib/iris/coords.py +++ b/lib/iris/coords.py @@ -31,7 +31,7 @@ CFVariableMixin, CellMeasureMetadata, CoordMetadata, - MetadataFactory, + MetadataManagerFactory, ) import iris.exceptions import iris.time @@ -98,8 +98,8 @@ def __init__( # it provides is a 'has_bounds()' method, which always returns False. # Configure the metadata manager. - if not hasattr(self, "_metadata"): - self._metadata = MetadataFactory(BaseMetadata) + if not hasattr(self, "_metadata_manager"): + self._metadata_manager = MetadataManagerFactory(BaseMetadata) #: CF standard name of the quantity that the metadata represents. self.standard_name = standard_name @@ -709,8 +709,10 @@ def __init__( """ # Configure the metadata manager. - if not hasattr(self, "_metadata"): - self._metadata = MetadataFactory(AncillaryVariableMetadata) + if not hasattr(self, "_metadata_manager"): + self._metadata_manager = MetadataManagerFactory( + AncillaryVariableMetadata + ) super().__init__( values=data, @@ -820,7 +822,7 @@ def __init__( """ # Configure the metadata manager. - self._metadata = MetadataFactory(CellMeasureMetadata) + self._metadata_manager = MetadataManagerFactory(CellMeasureMetadata) super().__init__( data=data, @@ -839,14 +841,14 @@ def __init__( @property def measure(self): - return self._metadata.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._metadata.measure = measure + self._metadata_manager.measure = measure def __str__(self): result = repr(self) @@ -1319,7 +1321,7 @@ def __init__( """ # Configure the metadata manager. - self._metadata = MetadataFactory(CoordMetadata) + self._metadata_manager = MetadataManagerFactory(CoordMetadata) super().__init__( values=points, @@ -1438,11 +1440,11 @@ def bounds(self, bounds): @property def coord_system(self): """The coordinate-system of the coordinate.""" - return self._metadata.coord_system + return self._metadata_manager.coord_system @coord_system.setter def coord_system(self, value): - self._metadata.coord_system = value + self._metadata_manager.coord_system = value @property def climatological(self): @@ -1457,10 +1459,10 @@ def climatological(self): """ if not self.has_bounds(): - self._metadata.climatological = False + self._metadata_manager.climatological = False if not self.units.is_time_reference(): - self._metadata.climatological = False - return self._metadata.climatological + self._metadata_manager.climatological = False + return self._metadata_manager.climatological @climatological.setter def climatological(self, value): @@ -1478,7 +1480,7 @@ def climatological(self, value): emsg = "Cannot set climatological coordinate, no bounds exist." raise ValueError(emsg) - self._metadata.climatological = value + self._metadata_manager.climatological = value def lazy_points(self): """ diff --git a/lib/iris/cube.py b/lib/iris/cube.py index 18eedada58..a1359911fe 100644 --- a/lib/iris/cube.py +++ b/lib/iris/cube.py @@ -42,7 +42,7 @@ CFVariableMixin, CoordMetadata, CubeMetadata, - MetadataFactory, + MetadataManagerFactory, ) import iris.coord_systems import iris.coords @@ -748,7 +748,7 @@ def __init__( raise TypeError("Invalid data type: {!r}.".format(data)) # Configure the metadata manager. - self._metadata = MetadataFactory(CubeMetadata) + self._metadata_manager = MetadataManagerFactory(CubeMetadata) # Initialise the cube data manager. self._data_manager = DataManager(data) @@ -818,13 +818,13 @@ def __init__( @property def _names(self): """ - A tuple containing the value of each name participating in the identify + 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 self._metadata._names + return self._metadata_manager._names def is_compatible(self, other, ignore=None): """ @@ -1904,11 +1904,11 @@ def cell_methods(self): done on the phenomenon. """ - return self._metadata.cell_methods + return self._metadata_manager.cell_methods @cell_methods.setter def cell_methods(self, cell_methods): - self._metadata.cell_methods = ( + self._metadata_manager.cell_methods = ( tuple(cell_methods) if cell_methods else tuple() ) diff --git a/lib/iris/tests/unit/common/metadata/test_BaseMetadata.py b/lib/iris/tests/unit/common/metadata/test_BaseMetadata.py index 52c68ffbac..a0a8cd336d 100644 --- a/lib/iris/tests/unit/common/metadata/test_BaseMetadata.py +++ b/lib/iris/tests/unit/common/metadata/test_BaseMetadata.py @@ -57,6 +57,29 @@ def test__fields(self): self.assertEqual(BaseMetadata._fields, expected) +class Test___le__(tests.IrisTest): + def setUp(self): + self.one = BaseMetadata(1, 1, 1, 1, 1) + self.two = BaseMetadata(1, 1, 1, 1, 2) + self.none = BaseMetadata(1, 1, 1, 1, None) + + 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) + + class Test_token(tests.IrisTest): def test_passthru_None(self): result = BaseMetadata.token(None) @@ -124,6 +147,7 @@ def test_standard_name(self): result = metadata.name(token=True) self.assertEqual(result, token) + def test_standard_name__invalid_token(self): token = "nope nope" metadata = self._make(standard_name=token) result = metadata.name() @@ -139,6 +163,7 @@ def test_long_name(self): result = metadata.name(token=True) self.assertEqual(result, token) + def test_long_name__invalid_token(self): token = "nope nope" metadata = self._make(long_name=token) result = metadata.name() @@ -154,6 +179,7 @@ def test_var_name(self): result = metadata.name(token=True) self.assertEqual(result, token) + def test_var_name__invalid_token(self): token = "nope nope" metadata = self._make(var_name=token) result = metadata.name() @@ -168,7 +194,9 @@ def test_default(self): result = metadata.name(token=True) self.assertEqual(result, self.default) + def test_default__invalid_token(self): token = "nope nope" + metadata = self._make() result = metadata.name(default=token) self.assertEqual(result, token) emsg = "Cannot retrieve a valid name token" diff --git a/lib/iris/tests/unit/common/metadata/test_CubeMetadata.py b/lib/iris/tests/unit/common/metadata/test_CubeMetadata.py index c6533cbed5..5d3f39f570 100644 --- a/lib/iris/tests/unit/common/metadata/test_CubeMetadata.py +++ b/lib/iris/tests/unit/common/metadata/test_CubeMetadata.py @@ -99,6 +99,7 @@ def test_standard_name(self): 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() @@ -114,6 +115,7 @@ def test_long_name(self): 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() @@ -129,6 +131,7 @@ def test_var_name(self): 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() @@ -144,6 +147,7 @@ def test_attributes(self): 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() @@ -165,7 +169,9 @@ def test_default(self): 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" @@ -173,7 +179,7 @@ def test_default(self): _ = metadata.name(default=token, token=True) -class Test_names(tests.IrisTest): +class Test__names(tests.IrisTest): def test_standard_name(self): token = "standard_name" metadata = _make_metadata(standard_name=token) diff --git a/lib/iris/tests/unit/common/metadata/test_MetadataFactory.py b/lib/iris/tests/unit/common/metadata/test_MetadataManagerFactory.py similarity index 83% rename from lib/iris/tests/unit/common/metadata/test_MetadataFactory.py rename to lib/iris/tests/unit/common/metadata/test_MetadataManagerFactory.py index 68efda64c5..bfc777cb0c 100644 --- a/lib/iris/tests/unit/common/metadata/test_MetadataFactory.py +++ b/lib/iris/tests/unit/common/metadata/test_MetadataManagerFactory.py @@ -4,7 +4,7 @@ # See COPYING and COPYING.LESSER in the root of the repository for full # licensing details. """ -Unit tests for the :class:`iris.common.metadata.MetadataFactory`. +Unit tests for the :class:`iris.common.metadata.MetadataManagerFactory`. """ @@ -23,7 +23,7 @@ CellMeasureMetadata, CoordMetadata, CubeMetadata, - MetadataFactory, + MetadataManagerFactory, ) @@ -43,12 +43,12 @@ class Other: emsg = "Require a subclass of 'BaseMetadata'" with self.assertRaisesRegex(TypeError, emsg): - _ = MetadataFactory(Other) + _ = MetadataManagerFactory(Other) def test__kwargs_invalid(self): emsg = "Invalid 'BaseMetadata' field parameters, got 'wibble'." with self.assertRaisesRegex(ValueError, emsg): - MetadataFactory(BaseMetadata, wibble="nope") + MetadataManagerFactory(BaseMetadata, wibble="nope") class Test_instance(tests.IrisTest): @@ -71,7 +71,7 @@ def test__namespace(self): "values", ] for base in self.bases: - metadata = MetadataFactory(base) + metadata = MetadataManagerFactory(base) for name in namespace: self.assertTrue(hasattr(metadata, name)) if base is CubeMetadata: @@ -81,33 +81,35 @@ def test__namespace(self): def test__kwargs_default(self): for base in self.bases: kwargs = dict(zip(base._fields, [None] * len(base._fields))) - metadata = MetadataFactory(base) + metadata = MetadataManagerFactory(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 = MetadataFactory(base, **kwargs) + metadata = MetadataManagerFactory(base, **kwargs) self.assertEqual(metadata.values._asdict(), kwargs) class Test_instance___eq__(tests.IrisTest): def setUp(self): - self.metadata = MetadataFactory(BaseMetadata) + self.metadata = MetadataManagerFactory(BaseMetadata) def test__not_implemented(self): self.assertNotEqual(self.metadata, 1) def test__not_is_cls(self): base = BaseMetadata - other = MetadataFactory(base) + other = MetadataManagerFactory(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 = MetadataFactory(BaseMetadata, standard_name=standard_name) + other = MetadataManagerFactory( + BaseMetadata, standard_name=standard_name + ) self.assertEqual(other.standard_name, standard_name) self.assertIsNone(other.long_name) self.assertIsNone(other.var_name) @@ -116,22 +118,22 @@ def test__not_values(self): self.assertNotEqual(self.metadata, other) def test__same_default(self): - other = MetadataFactory(BaseMetadata) + other = MetadataManagerFactory(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 = MetadataFactory(BaseMetadata, **kwargs) - other = MetadataFactory(BaseMetadata, **kwargs) + metadata = MetadataManagerFactory(BaseMetadata, **kwargs) + other = MetadataManagerFactory(BaseMetadata, **kwargs) self.assertEqual(metadata.values._asdict(), kwargs) self.assertEqual(metadata, other) class Test_instance____repr__(tests.IrisTest): def setUp(self): - self.metadata = MetadataFactory(BaseMetadata) + self.metadata = MetadataManagerFactory(BaseMetadata) def test(self): standard_name = mock.sentinel.standard_name @@ -146,7 +148,7 @@ def test(self): result = repr(self.metadata) expected = ( - "Metadata(standard_name={!r}, long_name={!r}, var_name={!r}, " + "MetadataManager(standard_name={!r}, long_name={!r}, var_name={!r}, " "units={!r}, attributes={!r})" ) self.assertEqual(result, expected.format(*values)) @@ -167,7 +169,7 @@ def setUp(self): self.attributes, ) self.kwargs = dict(zip(BaseMetadata._fields, values)) - self.metadata = MetadataFactory(BaseMetadata, **self.kwargs) + self.metadata = MetadataManagerFactory(BaseMetadata, **self.kwargs) def test_pickle(self): for protocol in range(pickle.HIGHEST_PROTOCOL + 1): @@ -186,7 +188,7 @@ def setUp(self): def test(self): for base in self.bases: fields = base._fields - metadata = MetadataFactory(base) + metadata = MetadataManagerFactory(base) self.assertEqual(metadata.fields, fields) for field in fields: hasattr(metadata, field) @@ -198,7 +200,7 @@ def setUp(self): def test(self): for base in self.bases: - metadata = MetadataFactory(base) + metadata = MetadataManagerFactory(base) result = metadata.values self.assertIsInstance(result, base) self.assertEqual(result._fields, base._fields) diff --git a/lib/iris/tests/unit/common/metadata/test__BaseMeta.py b/lib/iris/tests/unit/common/metadata/test__NamedTupleMeta.py similarity index 93% rename from lib/iris/tests/unit/common/metadata/test__BaseMeta.py rename to lib/iris/tests/unit/common/metadata/test__NamedTupleMeta.py index 27a1c9f1d4..72b3c1bc8f 100644 --- a/lib/iris/tests/unit/common/metadata/test__BaseMeta.py +++ b/lib/iris/tests/unit/common/metadata/test__NamedTupleMeta.py @@ -4,7 +4,7 @@ # See COPYING and COPYING.LESSER in the root of the repository for full # licensing details. """ -Unit tests for the :class:`iris.common.metadata._BaseMeta`. +Unit tests for the :class:`iris.common.metadata._NamedTupleMeta`. """ @@ -14,7 +14,7 @@ from abc import abstractmethod -from iris.common.metadata import _BaseMeta +from iris.common.metadata import _NamedTupleMeta class Test(tests.IrisTest): @@ -39,7 +39,7 @@ def emsg_generate(members): return emsg.format(len(members), plural, args) def test__no_bases_with_abstract_members_property(self): - class Metadata(metaclass=_BaseMeta): + class Metadata(metaclass=_NamedTupleMeta): @property @abstractmethod def _members(self): @@ -59,7 +59,7 @@ def _members(self): def test__no_bases_single_member(self): member = "arg_one" - class Metadata(metaclass=_BaseMeta): + class Metadata(metaclass=_NamedTupleMeta): _members = member expected = ["MetadataNamedtuple"] @@ -76,7 +76,7 @@ class Metadata(metaclass=_BaseMeta): def test__no_bases_multiple_members(self): members = ("arg_one", "arg_two") - class Metadata(metaclass=_BaseMeta): + class Metadata(metaclass=_NamedTupleMeta): _members = members expected = ["MetadataNamedtuple"] @@ -96,7 +96,7 @@ def test__multiple_bases_multiple_members(self): members_parent = ("arg_one", "arg_two") members_child = ("arg_three", "arg_four") - class MetadataParent(metaclass=_BaseMeta): + class MetadataParent(metaclass=_NamedTupleMeta): _members = members_parent class MetadataChild(MetadataParent): diff --git a/lib/iris/tests/unit/common/mixin/test_CFVariableMixin.py b/lib/iris/tests/unit/common/mixin/test_CFVariableMixin.py index 830cc2ee2f..334c908e20 100644 --- a/lib/iris/tests/unit/common/mixin/test_CFVariableMixin.py +++ b/lib/iris/tests/unit/common/mixin/test_CFVariableMixin.py @@ -40,7 +40,7 @@ def setUp(self): ) self.item = CFVariableMixin() - self.item._metadata = metadata + self.item._metadata_manager = metadata def test_standard_name(self): self.assertEqual(self.item.standard_name, self.standard_name) @@ -73,16 +73,20 @@ def setUp(self): ) self.item = CFVariableMixin() - self.item._metadata = metadata + self.item._metadata_manager = metadata - def test_standard_name(self): + def test_standard_name__valid(self): standard_name = "air_temperature" self.item.standard_name = standard_name - self.assertEqual(self.item._metadata.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.standard_name) + 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): @@ -91,21 +95,24 @@ def test_standard_name(self): def test_long_name(self): long_name = "long_name" self.item.long_name = long_name - self.assertEqual(self.item._metadata.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.long_name) + 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.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.var_name) + self.assertIsNone(self.item._metadata_manager.var_name) + def test_var_name__invalid_token(self): var_name = "nope nope" - self.item._metadata.token = lambda name: None + 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 @@ -113,14 +120,15 @@ def test_var_name(self): def test_attributes(self): attributes = dict(hello="world") self.item.attributes = attributes - self.assertEqual(self.item._metadata.attributes, attributes) - self.assertIsNot(self.item._metadata.attributes, attributes) + self.assertEqual(self.item._metadata_manager.attributes, attributes) + self.assertIsNot(self.item._metadata_manager.attributes, attributes) self.assertIsInstance( - self.item._metadata.attributes, LimitedAttributeDict + self.item._metadata_manager.attributes, LimitedAttributeDict ) + def test_attributes__none(self): self.item.attributes = None - self.assertEqual(self.item._metadata.attributes, {}) + self.assertEqual(self.item._metadata_manager.attributes, {}) class Test__metadata_setter(tests.IrisTest): @@ -148,7 +156,7 @@ def values(self): metadata = Metadata() self.item = CFVariableMixin() - self.item._metadata = metadata + self.item._metadata_manager = metadata self.attributes = dict(one=1, two=2, three=3) self.args = OrderedDict( standard_name="air_temperature", @@ -161,8 +169,10 @@ def values(self): def test_dict(self): metadata = dict(**self.args) self.item.metadata = metadata - self.assertEqual(self.item._metadata.values, metadata) - self.assertIsNot(self.item._metadata.attributes, self.attributes) + self.assertEqual(self.item._metadata_manager.values, metadata) + self.assertIsNot( + self.item._metadata_manager.attributes, self.attributes + ) def test_dict__missing(self): metadata = dict(**self.args) @@ -174,8 +184,10 @@ def test_dict__missing(self): def test_ordereddict(self): metadata = self.args self.item.metadata = metadata - self.assertEqual(self.item._metadata.values, metadata) - self.assertIsNot(self.item._metadata.attributes, self.attributes) + self.assertEqual(self.item._metadata_manager.values, metadata) + self.assertIsNot( + self.item._metadata_manager.attributes, self.attributes + ) def test_ordereddict__missing(self): metadata = self.args @@ -190,12 +202,14 @@ def test_tuple(self): self.item.metadata = metadata result = tuple( [ - getattr(self.item._metadata, field) - for field in self.item._metadata.fields + getattr(self.item._metadata_manager, field) + for field in self.item._metadata_manager.fields ] ) self.assertEqual(result, metadata) - self.assertIsNot(self.item._metadata.attributes, self.attributes) + self.assertIsNot( + self.item._metadata_manager.attributes, self.attributes + ) def test_tuple__missing(self): metadata = list(self.args.values()) @@ -211,8 +225,12 @@ def test_namedtuple(self): ) metadata = Metadata(**self.args) self.item.metadata = metadata - self.assertEqual(self.item._metadata.values, metadata._asdict()) - self.assertIsNot(self.item._metadata.attributes, metadata.attributes) + self.assertEqual( + self.item._metadata_manager.values, metadata._asdict() + ) + self.assertIsNot( + self.item._metadata_manager.attributes, metadata.attributes + ) def test_namedtuple__missing(self): Metadata = namedtuple( @@ -226,8 +244,12 @@ def test_namedtuple__missing(self): def test_class(self): metadata = BaseMetadata(**self.args) self.item.metadata = metadata - self.assertEqual(self.item._metadata.values, metadata._asdict()) - self.assertIsNot(self.item._metadata.attributes, metadata.attributes) + self.assertEqual( + self.item._metadata_manager.values, metadata._asdict() + ) + self.assertIsNot( + self.item._metadata_manager.attributes, metadata.attributes + ) class Test_rename(tests.IrisTest): @@ -243,20 +265,21 @@ def setUp(self): ) self.item = CFVariableMixin() - self.item._metadata = metadata + self.item._metadata_manager = metadata - def test(self): + def test__valid_standard_name(self): name = "air_temperature" self.item.rename(name) - self.assertEqual(self.item._metadata.standard_name, name) - self.assertIsNone(self.item._metadata.long_name) - self.assertIsNone(self.item._metadata.var_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.standard_name) - self.assertEqual(self.item._metadata.long_name, name) - self.assertIsNone(self.item._metadata.var_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): @@ -269,14 +292,14 @@ def __init__(self, name): metadata = Metadata(self.name) self.item = CFVariableMixin() - self.item._metadata = metadata + 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.name.assert_called_with( + self.item._metadata_manager.name.assert_called_with( default=default, token=token ) diff --git a/lib/iris/tests/unit/common/mixin/test_get_valid_standard_name.py b/lib/iris/tests/unit/common/mixin/test__get_valid_standard_name.py similarity index 96% rename from lib/iris/tests/unit/common/mixin/test_get_valid_standard_name.py rename to lib/iris/tests/unit/common/mixin/test__get_valid_standard_name.py index a6530deb4b..672372b0aa 100644 --- a/lib/iris/tests/unit/common/mixin/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.common.mixin.get_valid_standard_name`. +Unit tests for the :func:`iris.common.mixin._get_valid_standard_name`. """ 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): From fae0c89e681212e211c4cb58a78f41836e38607e Mon Sep 17 00:00:00 2001 From: Bill Little Date: Thu, 19 Dec 2019 15:12:46 +0000 Subject: [PATCH 10/10] Update lib/iris/tests/unit/common/metadata/test_BaseMetadata.py Co-Authored-By: lbdreyer --- lib/iris/tests/unit/common/metadata/test_BaseMetadata.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/iris/tests/unit/common/metadata/test_BaseMetadata.py b/lib/iris/tests/unit/common/metadata/test_BaseMetadata.py index a0a8cd336d..104a220370 100644 --- a/lib/iris/tests/unit/common/metadata/test_BaseMetadata.py +++ b/lib/iris/tests/unit/common/metadata/test_BaseMetadata.py @@ -57,7 +57,7 @@ def test__fields(self): self.assertEqual(BaseMetadata._fields, expected) -class Test___le__(tests.IrisTest): +class Test___lt__(tests.IrisTest): def setUp(self): self.one = BaseMetadata(1, 1, 1, 1, 1) self.two = BaseMetadata(1, 1, 1, 1, 2)