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/_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/_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..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._cube_coord_common import CFVariableMixin +from iris.common import CFVariableMixin, CoordMetadata, MetadataManagerFactory import iris.coords @@ -33,14 +33,40 @@ class AuxCoordFactory(CFVariableMixin, metaclass=ABCMeta): """ def __init__(self): + # Configure the metadata manager. + if not hasattr(self, "_metadata_manager"): + self._metadata_manager = MetadataManagerFactory(CoordMetadata) + #: Descriptive name of the coordinate made by the factory self.long_name = None #: netCDF variable name for the coordinate made by the factory self.var_name = None - #: Coordinate system (if any) of the coordinate made by the factory self.coord_system = None + # See the climatological property getter. + self._metadata_manager.climatological = False + + @property + def coord_system(self): + """ + The coordinate-system (if any) of the coordinate made by the factory. + + """ + return self._metadata_manager.coord_system + + @coord_system.setter + def coord_system(self, value): + self._metadata_manager.coord_system = value + + @property + def climatological(self): + """ + Always returns False, as a factory itself can never have points/bounds + and therefore can never be climatological by definition. + + """ + return self._metadata_manager.climatological @property @abstractmethod @@ -51,20 +77,6 @@ def dependencies(self): """ - def _as_defn(self): - defn = iris.coords.CoordDefn( - self.standard_name, - self.long_name, - self.var_name, - self.units, - self.attributes, - self.coord_system, - # Slot for Coord 'climatological' property, which this - # doesn't have. - False, - ) - return defn - @abstractmethod def make_coord(self, coord_dims_func): """ @@ -372,6 +384,8 @@ def __init__(self, delta=None, sigma=None, orography=None): The coordinate providing the `orog` term. """ + # Configure the metadata manager. + self._metadata_manager = MetadataManagerFactory(CoordMetadata) super().__init__() if delta and delta.nbounds not in (0, 2): @@ -395,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 @@ -556,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_manager = MetadataManagerFactory(CoordMetadata) super().__init__() # Check that provided coords meet necessary conditions. self._check_dependencies(delta, sigma, surface_air_pressure) + self.units = (delta and delta.units) or surface_air_pressure.units self.delta = delta self.sigma = sigma @@ -568,20 +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) @@ -753,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) * @@ -766,10 +778,13 @@ def __init__( either `eta`, or 'sigma' and `depth` and `depth_c` coordinates. """ + # Configure the metadata manager. + self._metadata_manager = MetadataManagerFactory(CoordMetadata) super().__init__() # Check that provided coordinates meet necessary conditions. self._check_dependencies(sigma, eta, depth, depth_c, nsigma, zlev) + self.units = zlev.units self.sigma = sigma self.eta = eta @@ -781,16 +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.") @@ -1068,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_manager = MetadataManagerFactory(CoordMetadata) super().__init__() # Check that provided coordinates meet necessary conditions. self._check_dependencies(sigma, eta, depth) + self.units = depth.units self.sigma = sigma self.eta = eta @@ -1080,10 +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. @@ -1252,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_manager = MetadataManagerFactory(CoordMetadata) super().__init__() # Check that provided coordinates meet necessary conditions. self._check_dependencies(s, c, eta, depth, depth_c) + self.units = depth.units self.s = s self.c = c @@ -1266,10 +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. @@ -1476,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_manager = MetadataManagerFactory(CoordMetadata) super().__init__() # Check that provided coordinates meet necessary conditions. self._check_dependencies(s, eta, depth, a, b, depth_c) + self.units = depth.units self.s = s self.eta = eta @@ -1491,10 +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. @@ -1695,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_manager = MetadataManagerFactory(CoordMetadata) super().__init__() # Check that provided coordinates meet necessary conditions. self._check_dependencies(s, c, eta, depth, depth_c) + self.units = depth.units self.s = s self.c = c @@ -1709,10 +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/tests/unit/cube_coord_common/__init__.py b/lib/iris/common/__init__.py similarity index 75% rename from lib/iris/tests/unit/cube_coord_common/__init__.py rename to lib/iris/common/__init__.py index 4390f95921..3f25865a01 100644 --- a/lib/iris/tests/unit/cube_coord_common/__init__.py +++ b/lib/iris/common/__init__.py @@ -3,4 +3,7 @@ # 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.""" + + +from .metadata import * +from .mixin import * diff --git a/lib/iris/common/metadata.py b/lib/iris/common/metadata.py new file mode 100644 index 0000000000..e51e34e8f8 --- /dev/null +++ b/lib/iris/common/metadata.py @@ -0,0 +1,403 @@ +# Copyright Iris contributors +# +# This file is part of Iris and is released under the LGPL license. +# See COPYING and COPYING.LESSER in the root of the repository for full +# licensing details. + +from abc import ABCMeta +from collections import namedtuple +from collections.abc import Iterable, Mapping +from functools import wraps +import re + + +__all__ = [ + "AncillaryVariableMetadata", + "BaseMetadata", + "CellMeasureMetadata", + "CoordMetadata", + "CubeMetadata", + "MetadataManagerFactory", +] + + +# 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 _NamedTupleMeta(ABCMeta): + """ + Meta-class to support the convenience of creating a namedtuple from + names/members of the metadata class hierarchy. + + """ + + def __new__(mcs, name, bases, namespace): + 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[token] + + if (not isinstance(namespace_names, Iterable)) or isinstance( + namespace_names, str + ): + namespace_names = (namespace_names,) + + names.extend(namespace_names) + + if names: + item = namedtuple(f"{name}Namedtuple", names) + bases = list(bases) + # Influence the appropriate MRO. + bases.insert(0, item) + bases = tuple(bases) + + return super().__new__(mcs, name, bases, namespace) + + +class BaseMetadata(metaclass=_NamedTupleMeta): + """ + Container for common metadata. + + """ + + DEFAULT_NAME = "unknown" # the fall-back name for metadata identity + + _members = ( + "standard_name", + "long_name", + "var_name", + "units", + "attributes", + ) + + __slots__ = () + + @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 string name representing the identity of the metadata. + + First it tries standard name, then it tries the long name, then + the NetCDF variable name, before falling-back to a default value, + which itself defaults to the string 'unknown'. + + Kwargs: + + * default: + The fall-back string representing the default name. Defaults to + the string 'unknown'. + * token: + If True, ensures that the name returned satisfies the criteria for + the characters required by a valid NetCDF name. If it is not + possible to return a valid name, then a ValueError exception is + raised. Defaults to False. + + Returns: + String. + + """ + + def _check(item): + return self.token(item) if token else item + + default = self.DEFAULT_NAME if default is None else default + + result = ( + _check(self.standard_name) + or _check(self.long_name) + or _check(self.var_name) + or _check(default) + ) + + if token and result is None: + emsg = "Cannot retrieve a valid name token from {!r}" + raise ValueError(emsg.format(self)) + + return result + + def __lt__(self, other): + # + # Support Python2 behaviour for a "<" operation involving a + # "NoneType" operand. Require to at least implement this comparison + # operator to support sorting of instances. + # + 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 AncillaryVariableMetadata(BaseMetadata): + """ + Metadata container for a :class:`~iris.coords.AncillaryVariableMetadata`. + + """ + + __slots__ = () + + +class CellMeasureMetadata(BaseMetadata): + """ + Metadata container for a :class:`~iris.coords.CellMeasure`. + + """ + + _members = "measure" + + __slots__ = () + + +class CoordMetadata(BaseMetadata): + """ + Metadata container for a :class:`~iris.coords.Coord`. + + """ + + _members = ("coord_system", "climatological") + + __slots__ = () + + +class CubeMetadata(BaseMetadata): + """ + Metadata container for a :class:`~iris.cube.Cube`. + + """ + + _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 identity + of a :class:`iris.cube.Cube`. This includes the standard name, + long name, NetCDF variable name, and the STASH from the attributes + dictionary. + + """ + standard_name = self.standard_name + long_name = self.long_name + var_name = self.var_name + + # Defensive enforcement of attributes being a dictionary. + if not isinstance(self.attributes, Mapping): + try: + self.attributes = dict() + except AttributeError: + emsg = "Invalid '{}.attributes' member, must be a mapping." + raise AttributeError(emsg.format(self.__class__.__name__)) + + stash_name = self.attributes.get("STASH") + if stash_name is not None: + stash_name = str(stash_name) + + return (standard_name, long_name, var_name, stash_name) + + +def MetadataManagerFactory(cls, **kwargs): + """ + A class instance factory function responsible for manufacturing + metadata instances dynamically at runtime. + + The factory instances returned by the factory are capable of managing + their metadata state, which can be proxied by the owning container. + + Args: + + * cls: + A subclass of :class:`~iris.common.metadata.BaseMetadata`, defining + the metadata to be managed. + + Kwargs: + + * kwargs: + Initial values for the manufactured metadata instance. Unspecified + fields will default to a value of 'None'. + + """ + + def __init__(self, cls, **kwargs): + # Restrict to only dealing with appropriate metadata classes. + if not issubclass(cls, BaseMetadata): + emsg = "Require a subclass of {!r}, got {!r}." + raise TypeError(emsg.format(BaseMetadata.__name__, cls)) + + #: The metadata class to be manufactured by this factory. + self.cls = cls + + # Initialise the metadata class fields in the instance. + for field in self.fields: + setattr(self, field, None) + + # Populate with provided kwargs, which have already been verified + # by the factory. + for field, value in kwargs.items(): + setattr(self, field, value) + + def __eq__(self, other): + if not hasattr(other, "cls"): + return NotImplemented + match = self.cls is other.cls + if match: + match = self.values == other.values + return match + + def __getstate__(self): + """Return the instance state to be pickled.""" + return {field: getattr(self, field) for field in self.fields} + + def __ne__(self, other): + match = self.__eq__(other) + if match is not NotImplemented: + match = not match + return match + + def __reduce__(self): + """ + Dynamically created classes at runtime cannot be pickled, due to not + being defined at the top level of a module. As a result, we require to + use the __reduce__ interface to allow 'pickle' to recreate this class + instance, and dump and load instance state successfully. + + """ + return (MetadataManagerFactory, (self.cls,), self.__getstate__()) + + def __repr__(self): + args = ", ".join( + [ + "{}={!r}".format(field, getattr(self, field)) + for field in self.fields + ] + ) + return "{}({})".format(self.__class__.__name__, args) + + def __setstate__(self, state): + """Set the instance state when unpickling.""" + for field, value in state.items(): + setattr(self, field, value) + + @property + def fields(self): + """Return the name of the metadata members.""" + return self.cls._fields + + @property + def values(self): + fields = {field: getattr(self, field) for field in self.fields} + return self.cls(**fields) + + # Restrict factory to appropriate metadata classes only. + if not issubclass(cls, BaseMetadata): + emsg = "Require a subclass of {!r}, got {!r}." + raise TypeError(emsg.format(BaseMetadata.__name__, cls)) + + # Check whether kwargs have valid fields for the specified metadata. + if kwargs: + extra = [field for field in kwargs.keys() if field not in cls._fields] + if extra: + bad = ", ".join(map(lambda field: "{!r}".format(field), extra)) + emsg = "Invalid {!r} field parameters, got {}." + raise ValueError(emsg.format(cls.__name__, bad)) + + # Define the name, (inheritance) bases and namespace of the dynamic class. + name = "MetadataManager" + bases = () + namespace = { + "DEFAULT_NAME": cls.DEFAULT_NAME, + "__init__": __init__, + "__eq__": __eq__, + "__getstate__": __getstate__, + "__ne__": __ne__, + "__reduce__": __reduce__, + "__repr__": __repr__, + "__setstate__": __setstate__, + "fields": fields, + "name": cls.name, + "token": cls.token, + "values": values, + } + + # Account for additional "CubeMetadata" specialised class behaviour. + if cls is CubeMetadata: + namespace["_names"] = cls._names + + # Dynamically create the class. + Metadata = type(name, bases, namespace) + # Now manufacture an instance of that class. + metadata = Metadata(cls, **kwargs) + + return metadata diff --git a/lib/iris/_cube_coord_common.py b/lib/iris/common/mixin.py similarity index 51% rename from lib/iris/_cube_coord_common.py rename to lib/iris/common/mixin.py index 0a3dfd12ca..8133bb50a4 100644 --- a/lib/iris/_cube_coord_common.py +++ b/lib/iris/common/mixin.py @@ -5,43 +5,20 @@ # licensing details. -from collections import namedtuple +from collections.abc import Mapping +from functools import wraps import re import cf_units +from iris.common import BaseMetadata import iris.std_names -# https://www.unidata.ucar.edu/software/netcdf/docs/netcdf_data_set_components.html#object_name -_TOKEN_PARSE = re.compile(r"""^[a-zA-Z0-9][\w\.\+\-@]*$""") +__all__ = ["CFVariableMixin"] -class Names( - namedtuple("Names", ["standard_name", "long_name", "var_name", "STASH"]) -): - """ - Immutable container for name metadata. - - Args: - - * standard_name: - A string representing the CF Conventions and Metadata standard name, or - None. - * long_name: - A string representing the CF Conventions and Metadata long name, or - None - * var_name: - A string representing the associated NetCDF variable name, or None. - * STASH: - A string representing the `~iris.fileformats.pp.STASH` code, or None. - - """ - - __slots__ = () - - -def get_valid_standard_name(name): +def _get_valid_standard_name(name): # Standard names are optionally followed by a standard name # modifier, separated by one or more blank spaces @@ -99,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. @@ -120,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): @@ -136,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_manager.name(default=default, token=token) def rename(self, name): """ @@ -244,40 +144,101 @@ def rename(self, name): @property def standard_name(self): - """The standard name for the Cube's data.""" - return self._standard_name + """The CF Metadata standard name for the object.""" + return self._metadata_manager.standard_name @standard_name.setter def standard_name(self, name): - self._standard_name = get_valid_standard_name(name) + self._metadata_manager.standard_name = _get_valid_standard_name(name) @property - def units(self): - """The :mod:`~cf_units.Unit` instance of the object.""" - return self._units + def long_name(self): + """The CF Metadata long name for the object.""" + return self._metadata_manager.long_name - @units.setter - def units(self, unit): - self._units = cf_units.as_unit(unit) + @long_name.setter + def long_name(self, name): + self._metadata_manager.long_name = name @property def var_name(self): - """The netCDF variable name for the object.""" - return self._var_name + """The NetCDF variable name for the object.""" + return self._metadata_manager.var_name @var_name.setter def var_name(self, name): if name is not None: - result = self.token(name) + result = self._metadata_manager.token(name) if result is None or not name: emsg = "{!r} is not a valid NetCDF variable name." raise ValueError(emsg.format(name)) - self._var_name = name + self._metadata_manager.var_name = name + + @property + def units(self): + """The S.I. unit of the object.""" + return self._metadata_manager.units + + @units.setter + def units(self, unit): + self._metadata_manager.units = cf_units.as_unit(unit) @property def attributes(self): - return self._attributes + return self._metadata_manager.attributes @attributes.setter def attributes(self, attributes): - self._attributes = LimitedAttributeDict(attributes or {}) + self._metadata_manager.attributes = LimitedAttributeDict( + attributes or {} + ) + + @property + def metadata(self): + return self._metadata_manager.values + + @metadata.setter + def metadata(self, metadata): + cls = self._metadata_manager.cls + fields = self._metadata_manager.fields + arg = metadata + + try: + # Try dict-like initialisation... + metadata = cls(**metadata) + except TypeError: + try: + # Try iterator/namedtuple-like initialisation... + metadata = cls(*metadata) + except TypeError: + if hasattr(metadata, "_asdict"): + metadata = metadata._asdict() + + if isinstance(metadata, Mapping): + 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 + if not hasattr(metadata, field) + ] + + if missing: + missing = ", ".join( + map(lambda i: "{!r}".format(i), missing) + ) + emsg = "Invalid {!r} metadata, require {} to be specified." + raise TypeError(emsg.format(type(arg), missing)) + + for field in fields: + if hasattr(metadata, field): + value = getattr(metadata, field) + else: + value = metadata[field] + + # Ensure to always set state through the individual mixin/container + # setter functions. + setattr(self, field, value) diff --git a/lib/iris/coords.py b/lib/iris/coords.py index e491a65c91..e5574b3388 100644 --- a/lib/iris/coords.py +++ b/lib/iris/coords.py @@ -25,13 +25,18 @@ from iris._data_manager import DataManager import iris._lazy_data as _lazy import iris.aux_factory +from iris.common import ( + AncillaryVariableMetadata, + BaseMetadata, + CFVariableMixin, + CellMeasureMetadata, + CoordMetadata, + MetadataManagerFactory, +) import iris.exceptions import iris.time import iris.util -from iris._cube_coord_common import CFVariableMixin -from iris.util import points_step - class _DimensionalMetadata(CFVariableMixin, metaclass=ABCMeta): """ @@ -92,6 +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_manager"): + self._metadata_manager = MetadataManagerFactory(BaseMetadata) + #: CF standard name of the quantity that the metadata represents. self.standard_name = standard_name @@ -340,9 +349,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 +376,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 @@ -710,6 +708,12 @@ def __init__( A dictionary containing other cf and user-defined attributes. """ + # Configure the metadata manager. + if not hasattr(self, "_metadata_manager"): + self._metadata_manager = MetadataManagerFactory( + AncillaryVariableMetadata + ) + super().__init__( values=data, standard_name=standard_name, @@ -817,6 +821,9 @@ def __init__( 'area' and 'volume'. The default is 'area'. """ + # Configure the metadata manager. + self._metadata_manager = MetadataManagerFactory(CellMeasureMetadata) + super().__init__( data=data, standard_name=standard_name, @@ -834,14 +841,14 @@ def __init__( @property def measure(self): - return self._measure + return self._metadata_manager.measure @measure.setter def measure(self, measure): if measure not in ["area", "volume"]: emsg = f"measure must be 'area' or 'volume', got {measure!r}" raise ValueError(emsg) - self._measure = measure + self._metadata_manager.measure = measure def __str__(self): result = repr(self) @@ -860,17 +867,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 +877,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", @@ -1377,7 +1318,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_manager = MetadataManagerFactory(CoordMetadata) + super().__init__( values=points, standard_name=standard_name, @@ -1476,7 +1421,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]: @@ -1492,6 +1437,15 @@ def bounds(self, bounds): else: self._bounds_dm.data = bounds + @property + def coord_system(self): + """The coordinate-system of the coordinate.""" + return self._metadata_manager.coord_system + + @coord_system.setter + def coord_system(self, value): + self._metadata_manager.coord_system = value + @property def climatological(self): """ @@ -1502,8 +1456,13 @@ def climatological(self): Always reads as False if there are no bounds. On set, the input value is cast to a boolean, exceptions raised if units are not time units or if there are no bounds. + """ - return self._climatological if self.has_bounds() else False + if not self.has_bounds(): + self._metadata_manager.climatological = False + if not self.units.is_time_reference(): + self._metadata_manager.climatological = False + return self._metadata_manager.climatological @climatological.setter def climatological(self, value): @@ -1521,7 +1480,7 @@ def climatological(self, value): emsg = "Cannot set climatological coordinate, no bounds exist." raise ValueError(emsg) - self._climatological = value + self._metadata_manager.climatological = value def lazy_points(self): """ @@ -1609,18 +1568,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 +1821,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 @@ -2337,7 +2284,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 @@ -2674,19 +2621,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 f58a2ce607..a1359911fe 100644 --- a/lib/iris/cube.py +++ b/lib/iris/cube.py @@ -9,7 +9,7 @@ """ -from collections import namedtuple, OrderedDict +from collections import OrderedDict from collections.abc import ( Iterable, Container, @@ -29,56 +29,28 @@ import numpy as np import numpy.ma as ma -from iris._cube_coord_common import CFVariableMixin import iris._concatenate import iris._constraints from iris._data_manager import DataManager import iris._lazy_data as _lazy - import iris._merge import iris.analysis from iris.analysis.cartography import wrap_lons import iris.analysis.maths import iris.aux_factory +from iris.common import ( + CFVariableMixin, + CoordMetadata, + CubeMetadata, + MetadataManagerFactory, +) 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 @@ -775,6 +747,9 @@ def __init__( if isinstance(data, str): raise TypeError("Invalid data type: {!r}.".format(data)) + # Configure the metadata manager. + self._metadata_manager = MetadataManagerFactory(CubeMetadata) + # Initialise the cube data manager. self._data_manager = DataManager(data) @@ -841,43 +816,15 @@ def __init__( self.add_ancillary_variable(ancillary_variable, dims) @property - def metadata(self): + def _names(self): """ - An instance of :class:`CubeMetadata` describing the phenomenon. - - This property can be updated with any of: - - another :class:`CubeMetadata` instance, - - a tuple/dict which can be used to make a :class:`CubeMetadata`, - - or any object providing the attributes exposed by - :class:`CubeMetadata`. + A tuple containing the value of each name participating in the identity + of a :class:`iris.cube.Cube`. This includes the standard name, + long name, NetCDF variable name, and the STASH from the attributes + dictionary. """ - return CubeMetadata( - self.standard_name, - self.long_name, - self.var_name, - self.units, - self.attributes, - self.cell_methods, - ) - - @metadata.setter - def metadata(self, value): - try: - value = CubeMetadata(**value) - except TypeError: - try: - value = CubeMetadata(*value) - except TypeError: - missing_attrs = [ - field - for field in CubeMetadata._fields - if not hasattr(value, field) - ] - if missing_attrs: - raise TypeError("Invalid/incomplete metadata") - for name in CubeMetadata._fields: - setattr(self, name, getattr(value, name)) + return self._metadata_manager._names def is_compatible(self, other, ignore=None): """ @@ -1097,7 +1044,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 +1075,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): @@ -1244,7 +1191,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. @@ -1336,11 +1283,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 = [ @@ -1496,13 +1443,13 @@ def coords( (a) a :attr:`standard_name`, :attr:`long_name`, or :attr:`var_name`. Defaults to value of `default` (which itself defaults to `unknown`) as defined in - :class:`iris._cube_coord_common.CFVariableMixin`. + :class:`iris.common.CFVariableMixin`. (b) a coordinate instance with metadata equal to that of the desired coordinates. Accepts either a :class:`iris.coords.DimCoord`, :class:`iris.coords.AuxCoord`, :class:`iris.aux_factory.AuxCoordFactory` - or :class:`iris.coords.CoordDefn`. + 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 +1567,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: @@ -1793,7 +1740,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. @@ -1876,7 +1823,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. @@ -1957,11 +1904,13 @@ def cell_methods(self): done on the phenomenon. """ - return self._cell_methods + return self._metadata_manager.cell_methods @cell_methods.setter def cell_methods(self, cell_methods): - self._cell_methods = tuple(cell_methods) if cell_methods else tuple() + self._metadata_manager.cell_methods = ( + tuple(cell_methods) if cell_methods else tuple() + ) def core_data(self): """ @@ -3958,7 +3907,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 +4023,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/fileformats/_pyke_rules/fc_rules_cf.krb b/lib/iris/fileformats/_pyke_rules/fc_rules_cf.krb index 5ecfeb77b1..ad2c181b0b 100644 --- a/lib/iris/fileformats/_pyke_rules/fc_rules_cf.krb +++ b/lib/iris/fileformats/_pyke_rules/fc_rules_cf.krb @@ -1173,6 +1173,7 @@ fc_extras import numpy.ma as ma import iris.aux_factory + from iris.common.mixin import _get_valid_standard_name import iris.coords import iris.coord_systems import iris.fileformats.cf as cf @@ -1182,7 +1183,6 @@ fc_extras import iris.exceptions import iris.std_names import iris.util - from iris._cube_coord_common import get_valid_standard_name from iris._lazy_data import as_lazy_data @@ -1298,7 +1298,7 @@ fc_extras if standard_name is not None: try: - cube.standard_name = get_valid_standard_name(standard_name) + cube.standard_name = _get_valid_standard_name(standard_name) except ValueError: if cube.long_name is not None: cube.attributes['invalid_standard_name'] = standard_name @@ -1693,7 +1693,7 @@ fc_extras if standard_name is not None: try: - standard_name = get_valid_standard_name(standard_name) + standard_name = _get_valid_standard_name(standard_name) except ValueError: if long_name is not None: attributes['invalid_standard_name'] = standard_name diff --git a/lib/iris/iterate.py b/lib/iris/iterate.py index 6cca135d21..ea2d939280 100644 --- a/lib/iris/iterate.py +++ b/lib/iris/iterate.py @@ -302,12 +302,13 @@ def __init__(self, coord): self._coord = coord # Methods of contained class we need to expose/use. - def _as_defn(self): - return self._coord._as_defn() + @property + def metadata(self): + return self._coord.metadata - # Methods of contained class we want to overide/customise. + # Methods of contained class we want to override/customise. def __eq__(self, other): - return self._coord._as_defn() == other._as_defn() + return self._coord.metadata == other.metadata # Force use of __eq__ for set operations. def __hash__(self): diff --git a/lib/iris/plot.py b/lib/iris/plot.py index 9dff582bc4..36afe906dc 100644 --- a/lib/iris/plot.py +++ b/lib/iris/plot.py @@ -168,7 +168,7 @@ def guess_axis(coord): if isinstance(coord, iris.coords.DimCoord) ] if aux_coords: - aux_coords.sort(key=lambda coord: coord._as_defn()) + aux_coords.sort(key=lambda coord: coord.metadata) coords[dim] = aux_coords[0] # If plotting a 2 dimensional plot, check for 2d coordinates @@ -183,7 +183,7 @@ def guess_axis(coord): coord for coord in two_dim_coords if coord.ndim == 2 ] if len(two_dim_coords) >= 2: - two_dim_coords.sort(key=lambda coord: coord._as_defn()) + two_dim_coords.sort(key=lambda coord: coord.metadata) coords = two_dim_coords[:2] if mode == iris.coords.POINT_MODE: diff --git a/lib/iris/tests/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/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/common/__init__.py b/lib/iris/tests/unit/common/__init__.py new file mode 100644 index 0000000000..5380785042 --- /dev/null +++ b/lib/iris/tests/unit/common/__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` module.""" 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..104a220370 --- /dev/null +++ b/lib/iris/tests/unit/common/metadata/test_BaseMetadata.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.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___lt__(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) + 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) + + def test_standard_name__invalid_token(self): + 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) + + def test_long_name__invalid_token(self): + 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) + + def test_var_name__invalid_token(self): + 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) + + 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" + 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..5d3f39f570 --- /dev/null +++ b/lib/iris/tests/unit/common/metadata/test_CubeMetadata.py @@ -0,0 +1,226 @@ +# 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) + + def test_standard_name__invalid_token(self): + token = "nope nope" + metadata = _make_metadata(standard_name=token) + result = metadata.name() + self.assertEqual(result, token) + result = metadata.name(token=True) + self.assertEqual(result, self.default) + + def test_long_name(self): + token = "long_name" + metadata = _make_metadata(long_name=token) + result = metadata.name() + self.assertEqual(result, token) + result = metadata.name(token=True) + self.assertEqual(result, token) + + def test_long_name__invalid_token(self): + token = "nope nope" + metadata = _make_metadata(long_name=token) + result = metadata.name() + self.assertEqual(result, token) + result = metadata.name(token=True) + self.assertEqual(result, self.default) + + def test_var_name(self): + token = "var_name" + metadata = _make_metadata(var_name=token) + result = metadata.name() + self.assertEqual(result, token) + result = metadata.name(token=True) + self.assertEqual(result, token) + + def test_var_name__invalid_token(self): + token = "nope nope" + metadata = _make_metadata(var_name=token) + result = metadata.name() + self.assertEqual(result, token) + result = metadata.name(token=True) + self.assertEqual(result, self.default) + + def test_attributes(self): + token = "stash" + metadata = _make_metadata(attributes=token) + result = metadata.name() + self.assertEqual(result, token) + result = metadata.name(token=True) + self.assertEqual(result, token) + + def test_attributes__invalid_token(self): + token = "nope nope" + metadata = _make_metadata(attributes=token) + result = metadata.name() + self.assertEqual(result, token) + result = metadata.name(token=True) + self.assertEqual(result, self.default) + + def test_attributes__non_mapping(self): + metadata = _make_metadata(force_mapping=False) + self.assertIsNone(metadata.attributes) + emsg = "Invalid 'CubeMetadata.attributes' member, must be a mapping." + with self.assertRaisesRegex(AttributeError, emsg): + _ = metadata.name() + + def test_default(self): + metadata = _make_metadata() + result = metadata.name() + self.assertEqual(result, self.default) + result = metadata.name(token=True) + self.assertEqual(result, self.default) + + def test_default__invalid_token(self): + token = "nope nope" + metadata = _make_metadata() + result = metadata.name(default=token) + self.assertEqual(result, token) + emsg = "Cannot retrieve a valid name token" + with self.assertRaisesRegex(ValueError, emsg): + _ = metadata.name(default=token, token=True) + + +class Test__names(tests.IrisTest): + def test_standard_name(self): + token = "standard_name" + metadata = _make_metadata(standard_name=token) + expected = (token, None, None, None) + result = metadata._names + self.assertEqual(expected, result) + + def test_long_name(self): + token = "long_name" + metadata = _make_metadata(long_name=token) + expected = (None, token, None, None) + result = metadata._names + self.assertEqual(expected, result) + + def test_var_name(self): + token = "var_name" + metadata = _make_metadata(var_name=token) + expected = (None, None, token, None) + result = metadata._names + self.assertEqual(expected, result) + + def test_attributes(self): + token = "stash" + metadata = _make_metadata(attributes=token) + expected = (None, None, None, token) + result = metadata._names + self.assertEqual(expected, result) + + def test_attributes__non_mapping(self): + metadata = _make_metadata(force_mapping=False) + self.assertIsNone(metadata.attributes) + emsg = "Invalid 'CubeMetadata.attributes' member, must be a mapping." + with self.assertRaisesRegex(AttributeError, emsg): + _ = metadata._names + + def test_None(self): + metadata = _make_metadata() + expected = (None, None, None, None) + result = metadata._names + self.assertEqual(expected, result) + + +if __name__ == "__main__": + tests.main() diff --git a/lib/iris/tests/unit/common/metadata/test_MetadataManagerFactory.py b/lib/iris/tests/unit/common/metadata/test_MetadataManagerFactory.py new file mode 100644 index 0000000000..bfc777cb0c --- /dev/null +++ b/lib/iris/tests/unit/common/metadata/test_MetadataManagerFactory.py @@ -0,0 +1,210 @@ +# Copyright Iris contributors +# +# This file is part of Iris and is released under the LGPL license. +# See COPYING and COPYING.LESSER in the root of the repository for full +# licensing details. +""" +Unit tests for the :class:`iris.common.metadata.MetadataManagerFactory`. + +""" + +# 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, + MetadataManagerFactory, +) + + +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): + _ = MetadataManagerFactory(Other) + + def test__kwargs_invalid(self): + emsg = "Invalid 'BaseMetadata' field parameters, got 'wibble'." + with self.assertRaisesRegex(ValueError, emsg): + MetadataManagerFactory(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 = MetadataManagerFactory(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 = 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 = MetadataManagerFactory(base, **kwargs) + self.assertEqual(metadata.values._asdict(), kwargs) + + +class Test_instance___eq__(tests.IrisTest): + def setUp(self): + self.metadata = MetadataManagerFactory(BaseMetadata) + + def test__not_implemented(self): + self.assertNotEqual(self.metadata, 1) + + def test__not_is_cls(self): + base = BaseMetadata + 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 = MetadataManagerFactory( + 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 = 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 = 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 = MetadataManagerFactory(BaseMetadata) + + def test(self): + standard_name = mock.sentinel.standard_name + long_name = mock.sentinel.long_name + var_name = mock.sentinel.var_name + units = mock.sentinel.units + attributes = mock.sentinel.attributes + values = (standard_name, long_name, var_name, units, attributes) + + for field, value in zip(self.metadata.fields, values): + setattr(self.metadata, field, value) + + result = repr(self.metadata) + expected = ( + "MetadataManager(standard_name={!r}, long_name={!r}, var_name={!r}, " + "units={!r}, attributes={!r})" + ) + self.assertEqual(result, expected.format(*values)) + + +class Test_instance__pickle(tests.IrisTest): + def setUp(self): + self.standard_name = "standard_name" + self.long_name = "long_name" + self.var_name = "var_name" + self.units = Unit("1") + self.attributes = dict(hello="world") + values = ( + self.standard_name, + self.long_name, + self.var_name, + self.units, + self.attributes, + ) + self.kwargs = dict(zip(BaseMetadata._fields, values)) + self.metadata = MetadataManagerFactory(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 = MetadataManagerFactory(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 = MetadataManagerFactory(base) + result = metadata.values + self.assertIsInstance(result, base) + self.assertEqual(result._fields, base._fields) + + +if __name__ == "__main__": + tests.main() diff --git a/lib/iris/tests/unit/common/metadata/test__NamedTupleMeta.py b/lib/iris/tests/unit/common/metadata/test__NamedTupleMeta.py new file mode 100644 index 0000000000..72b3c1bc8f --- /dev/null +++ b/lib/iris/tests/unit/common/metadata/test__NamedTupleMeta.py @@ -0,0 +1,148 @@ +# Copyright Iris contributors +# +# This file is part of Iris and is released under the LGPL license. +# See COPYING and COPYING.LESSER in the root of the repository for full +# licensing details. +""" +Unit tests for the :class:`iris.common.metadata._NamedTupleMeta`. + +""" + +# Import iris.tests first so that some things can be initialised before +# importing anything else. +import iris.tests as tests + +from abc import abstractmethod + +from iris.common.metadata import _NamedTupleMeta + + +class Test(tests.IrisTest): + @staticmethod + def names(classes): + return [cls.__name__ for cls in classes] + + @staticmethod + def emsg_generate(members): + if isinstance(members, str): + members = (members,) + emsg = ".* missing {} required positional argument{}: {}" + args = ", ".join([f"{member!r}" for member in members[:-1]]) + count = len(members) + if count == 1: + args += f"{members[-1]!r}" + elif count == 2: + args += f" and {members[-1]!r}" + else: + args += f", and {members[-1]!r}" + plural = "s" if count > 1 else "" + return emsg.format(len(members), plural, args) + + def test__no_bases_with_abstract_members_property(self): + class Metadata(metaclass=_NamedTupleMeta): + @property + @abstractmethod + def _members(self): + pass + + expected = ["object"] + self.assertEqual(self.names(Metadata.__bases__), expected) + expected = ["Metadata", "object"] + self.assertEqual(self.names(Metadata.__mro__), expected) + emsg = ( + "Can't instantiate abstract class .* with abstract " + "methods _members" + ) + with self.assertRaisesRegex(TypeError, emsg): + _ = Metadata() + + def test__no_bases_single_member(self): + member = "arg_one" + + class Metadata(metaclass=_NamedTupleMeta): + _members = member + + expected = ["MetadataNamedtuple"] + self.assertEqual(self.names(Metadata.__bases__), expected) + expected = ["Metadata", "MetadataNamedtuple", "tuple", "object"] + self.assertEqual(self.names(Metadata.__mro__), expected) + emsg = self.emsg_generate(member) + with self.assertRaisesRegex(TypeError, emsg): + _ = Metadata() + metadata = Metadata(1) + self.assertEqual(metadata._fields, (member,)) + self.assertEqual(metadata.arg_one, 1) + + def test__no_bases_multiple_members(self): + members = ("arg_one", "arg_two") + + class Metadata(metaclass=_NamedTupleMeta): + _members = members + + expected = ["MetadataNamedtuple"] + self.assertEqual(self.names(Metadata.__bases__), expected) + expected = ["Metadata", "MetadataNamedtuple", "tuple", "object"] + self.assertEqual(self.names(Metadata.__mro__), expected) + emsg = self.emsg_generate(members) + with self.assertRaisesRegex(TypeError, emsg): + _ = Metadata() + values = range(len(members)) + metadata = Metadata(*values) + self.assertEqual(metadata._fields, members) + expected = dict(zip(members, values)) + self.assertEqual(metadata._asdict(), expected) + + def test__multiple_bases_multiple_members(self): + members_parent = ("arg_one", "arg_two") + members_child = ("arg_three", "arg_four") + + class MetadataParent(metaclass=_NamedTupleMeta): + _members = members_parent + + class MetadataChild(MetadataParent): + _members = members_child + + # Check the parent class... + expected = ["MetadataParentNamedtuple"] + self.assertEqual(self.names(MetadataParent.__bases__), expected) + expected = [ + "MetadataParent", + "MetadataParentNamedtuple", + "tuple", + "object", + ] + self.assertEqual(self.names(MetadataParent.__mro__), expected) + emsg = self.emsg_generate(members_parent) + with self.assertRaisesRegex(TypeError, emsg): + _ = MetadataParent() + values_parent = range(len(members_parent)) + metadata_parent = MetadataParent(*values_parent) + self.assertEqual(metadata_parent._fields, members_parent) + expected = dict(zip(members_parent, values_parent)) + self.assertEqual(metadata_parent._asdict(), expected) + + # Check the dependant child class... + expected = ["MetadataChildNamedtuple", "MetadataParent"] + self.assertEqual(self.names(MetadataChild.__bases__), expected) + expected = [ + "MetadataChild", + "MetadataChildNamedtuple", + "MetadataParent", + "MetadataParentNamedtuple", + "tuple", + "object", + ] + self.assertEqual(self.names(MetadataChild.__mro__), expected) + emsg = self.emsg_generate((*members_parent, *members_child)) + with self.assertRaisesRegex(TypeError, emsg): + _ = MetadataChild() + fields_child = (*members_parent, *members_child) + values_child = range(len(fields_child)) + metadata_child = MetadataChild(*values_child) + self.assertEqual(metadata_child._fields, fields_child) + expected = dict(zip(fields_child, values_child)) + self.assertEqual(metadata_child._asdict(), expected) + + +if __name__ == "__main__": + tests.main() diff --git a/lib/iris/tests/unit/common/mixin/__init__.py b/lib/iris/tests/unit/common/mixin/__init__.py new file mode 100644 index 0000000000..493e140626 --- /dev/null +++ b/lib/iris/tests/unit/common/mixin/__init__.py @@ -0,0 +1,6 @@ +# Copyright Iris contributors +# +# This file is part of Iris and is released under the LGPL license. +# See COPYING and COPYING.LESSER in the root of the repository for full +# licensing details. +"""Unit tests for the :mod:`iris.common.mixin` package.""" diff --git a/lib/iris/tests/unit/common/mixin/test_CFVariableMixin.py b/lib/iris/tests/unit/common/mixin/test_CFVariableMixin.py new file mode 100644 index 0000000000..334c908e20 --- /dev/null +++ b/lib/iris/tests/unit/common/mixin/test_CFVariableMixin.py @@ -0,0 +1,308 @@ +# Copyright Iris contributors +# +# This file is part of Iris and is released under the LGPL license. +# See COPYING and COPYING.LESSER in the root of the repository for full +# licensing details. +""" +Unit tests for the :class:`iris.common.mixin.CFVariableMixin`. + +""" + +# Import iris.tests first so that some things can be initialised before +# importing anything else. +import iris.tests as tests + +from collections import OrderedDict, namedtuple +from unittest import mock + +from cf_units import Unit + +from iris.common.metadata import BaseMetadata +from iris.common.mixin import CFVariableMixin, LimitedAttributeDict + + +class Test__getter(tests.IrisTest): + def setUp(self): + self.standard_name = mock.sentinel.standard_name + self.long_name = mock.sentinel.long_name + self.var_name = mock.sentinel.var_name + self.units = mock.sentinel.units + self.attributes = mock.sentinel.attributes + self.metadata = mock.sentinel.metadata + + metadata = mock.MagicMock( + standard_name=self.standard_name, + long_name=self.long_name, + var_name=self.var_name, + units=self.units, + attributes=self.attributes, + values=self.metadata, + ) + + self.item = CFVariableMixin() + self.item._metadata_manager = metadata + + def test_standard_name(self): + self.assertEqual(self.item.standard_name, self.standard_name) + + def test_long_name(self): + self.assertEqual(self.item.long_name, self.long_name) + + def test_var_name(self): + self.assertEqual(self.item.var_name, self.var_name) + + def test_units(self): + self.assertEqual(self.item.units, self.units) + + def test_attributes(self): + self.assertEqual(self.item.attributes, self.attributes) + + def test_metadata(self): + self.assertEqual(self.item.metadata, self.metadata) + + +class Test__setter(tests.IrisTest): + def setUp(self): + metadata = mock.MagicMock( + standard_name=mock.sentinel.standard_name, + long_name=mock.sentinel.long_name, + var_name=mock.sentinel.var_name, + units=mock.sentinel.units, + attributes=mock.sentinel.attributes, + token=lambda name: name, + ) + + self.item = CFVariableMixin() + self.item._metadata_manager = metadata + + def test_standard_name__valid(self): + standard_name = "air_temperature" + self.item.standard_name = standard_name + self.assertEqual( + self.item._metadata_manager.standard_name, standard_name + ) + + def test_standard_name__none(self): + self.item.standard_name = None + self.assertIsNone(self.item._metadata_manager.standard_name) + + def test_standard_name__invalid(self): + standard_name = "nope nope" + emsg = f"{standard_name!r} is not a valid standard_name" + with self.assertRaisesRegex(ValueError, emsg): + self.item.standard_name = standard_name + + def test_long_name(self): + long_name = "long_name" + self.item.long_name = long_name + self.assertEqual(self.item._metadata_manager.long_name, long_name) + + def test_long_name__none(self): + self.item.long_name = None + self.assertIsNone(self.item._metadata_manager.long_name) + + def test_var_name(self): + var_name = "var_name" + self.item.var_name = var_name + self.assertEqual(self.item._metadata_manager.var_name, var_name) + + def test_var_name__none(self): + self.item.var_name = None + self.assertIsNone(self.item._metadata_manager.var_name) + + def test_var_name__invalid_token(self): + var_name = "nope nope" + self.item._metadata_manager.token = lambda name: None + emsg = f"{var_name!r} is not a valid NetCDF variable name." + with self.assertRaisesRegex(ValueError, emsg): + self.item.var_name = var_name + + def test_attributes(self): + attributes = dict(hello="world") + self.item.attributes = attributes + self.assertEqual(self.item._metadata_manager.attributes, attributes) + self.assertIsNot(self.item._metadata_manager.attributes, attributes) + self.assertIsInstance( + self.item._metadata_manager.attributes, LimitedAttributeDict + ) + + def test_attributes__none(self): + self.item.attributes = None + self.assertEqual(self.item._metadata_manager.attributes, {}) + + +class Test__metadata_setter(tests.IrisTest): + def setUp(self): + class Metadata: + def __init__(self): + self.cls = BaseMetadata + self.fields = BaseMetadata._fields + self.standard_name = mock.sentinel.standard_name + self.long_name = mock.sentinel.long_name + self.var_name = mock.sentinel.var_name + self.units = mock.sentinel.units + self.attributes = mock.sentinel.attributes + self.token = lambda name: name + + @property + def values(self): + return dict( + standard_name=self.standard_name, + long_name=self.long_name, + var_name=self.var_name, + units=self.units, + attributes=self.attributes, + ) + + metadata = Metadata() + self.item = CFVariableMixin() + self.item._metadata_manager = metadata + self.attributes = dict(one=1, two=2, three=3) + self.args = OrderedDict( + standard_name="air_temperature", + long_name="long_name", + var_name="var_name", + units=Unit("1"), + attributes=self.attributes, + ) + + def test_dict(self): + metadata = dict(**self.args) + self.item.metadata = metadata + self.assertEqual(self.item._metadata_manager.values, metadata) + self.assertIsNot( + self.item._metadata_manager.attributes, self.attributes + ) + + def test_dict__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_manager.values, metadata) + self.assertIsNot( + self.item._metadata_manager.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_manager, field) + for field in self.item._metadata_manager.fields + ] + ) + self.assertEqual(result, metadata) + self.assertIsNot( + self.item._metadata_manager.attributes, self.attributes + ) + + def test_tuple__missing(self): + metadata = list(self.args.values()) + del metadata[2] + emsg = "Invalid .* metadata, require .* to be specified." + with self.assertRaisesRegex(TypeError, emsg): + self.item.metadata = tuple(metadata) + + def test_namedtuple(self): + Metadata = namedtuple( + "Metadata", + ("standard_name", "long_name", "var_name", "units", "attributes"), + ) + metadata = Metadata(**self.args) + self.item.metadata = metadata + self.assertEqual( + self.item._metadata_manager.values, metadata._asdict() + ) + self.assertIsNot( + self.item._metadata_manager.attributes, metadata.attributes + ) + + def test_namedtuple__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_manager.values, metadata._asdict() + ) + self.assertIsNot( + self.item._metadata_manager.attributes, metadata.attributes + ) + + +class Test_rename(tests.IrisTest): + def setUp(self): + metadata = mock.MagicMock( + standard_name=mock.sentinel.standard_name, + long_name=mock.sentinel.long_name, + var_name=mock.sentinel.var_name, + units=mock.sentinel.units, + attributes=mock.sentinel.attributes, + values=mock.sentinel.metadata, + token=lambda name: name, + ) + + self.item = CFVariableMixin() + self.item._metadata_manager = metadata + + def test__valid_standard_name(self): + name = "air_temperature" + self.item.rename(name) + self.assertEqual(self.item._metadata_manager.standard_name, name) + self.assertIsNone(self.item._metadata_manager.long_name) + self.assertIsNone(self.item._metadata_manager.var_name) + + def test__invalid_standard_name(self): + name = "nope nope" + self.item.rename(name) + self.assertIsNone(self.item._metadata_manager.standard_name) + self.assertEqual(self.item._metadata_manager.long_name, name) + self.assertIsNone(self.item._metadata_manager.var_name) + + +class Test_name(tests.IrisTest): + def setUp(self): + class Metadata: + def __init__(self, name): + self.name = mock.MagicMock(return_value=name) + + self.name = mock.sentinel.name + metadata = Metadata(self.name) + + self.item = CFVariableMixin() + self.item._metadata_manager = metadata + + def test(self): + default = mock.sentinel.default + token = mock.sentinel.token + result = self.item.name(default=default, token=token) + self.assertEqual(result, self.name) + self.item._metadata_manager.name.assert_called_with( + default=default, token=token + ) + + +if __name__ == "__main__": + tests.main() diff --git a/lib/iris/tests/unit/common/mixin/test_LimitedAttributeDict.py b/lib/iris/tests/unit/common/mixin/test_LimitedAttributeDict.py new file mode 100644 index 0000000000..bfaeae2daf --- /dev/null +++ b/lib/iris/tests/unit/common/mixin/test_LimitedAttributeDict.py @@ -0,0 +1,69 @@ +# Copyright Iris contributors +# +# This file is part of Iris and is released under the LGPL license. +# See COPYING and COPYING.LESSER in the root of the repository for full +# licensing details. +""" +Unit tests for the :class:`iris.common.mixin.LimitedAttributeDict`. + +""" + +# Import iris.tests first so that some things can be initialised before +# importing anything else. +import iris.tests as tests + +from unittest import mock +import numpy as np + +from iris.common.mixin import LimitedAttributeDict + + +class Test(tests.IrisTest): + def setUp(self): + self.forbidden_keys = LimitedAttributeDict._forbidden_keys + self.emsg = "{!r} is not a permitted attribute" + + def test__invalid_keys(self): + for key in self.forbidden_keys: + with self.assertRaisesRegex(ValueError, self.emsg.format(key)): + _ = LimitedAttributeDict(**{key: None}) + + def test___eq__(self): + values = dict( + one=mock.sentinel.one, + two=mock.sentinel.two, + three=mock.sentinel.three, + ) + left = LimitedAttributeDict(**values) + right = LimitedAttributeDict(**values) + self.assertEqual(left, right) + self.assertEqual(left, values) + + def test___eq___numpy(self): + values = dict(one=np.arange(1), two=np.arange(2), three=np.arange(3),) + left = LimitedAttributeDict(**values) + right = LimitedAttributeDict(**values) + self.assertEqual(left, right) + self.assertEqual(left, values) + values = dict(one=np.arange(1), two=np.arange(1), three=np.arange(1),) + left = LimitedAttributeDict(dict(one=0, two=0, three=0)) + right = LimitedAttributeDict(**values) + self.assertEqual(left, right) + self.assertEqual(left, values) + + def test___setitem__(self): + for key in self.forbidden_keys: + item = LimitedAttributeDict() + with self.assertRaisesRegex(ValueError, self.emsg.format(key)): + item[key] = None + + def test_update(self): + for key in self.forbidden_keys: + item = LimitedAttributeDict() + with self.assertRaisesRegex(ValueError, self.emsg.format(key)): + other = {key: None} + item.update(other) + + +if __name__ == "__main__": + tests.main() diff --git a/lib/iris/tests/unit/cube_coord_common/test_get_valid_standard_name.py b/lib/iris/tests/unit/common/mixin/test__get_valid_standard_name.py similarity index 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..672372b0aa 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..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._cube_coord_common import CFVariableMixin +from iris.common import BaseMetadata from iris.coords import CellMethod, AuxCoord @@ -21,7 +21,7 @@ def setUp(self): def _check(self, token, coord, default=False): result = CellMethod(self.method, coords=coord) - token = token if not default else CFVariableMixin._DEFAULT_NAME + token = token if not default else BaseMetadata.DEFAULT_NAME expected = "{}: {}".format(self.method, token) self.assertEqual(str(result), expected) @@ -54,7 +54,7 @@ def test_coord_var_name_fail(self): def test_coord_stash(self): token = "stash" coord = AuxCoord(1, attributes=dict(STASH=token)) - self._check(token, coord) + self._check(token, coord, default=True) def test_coord_stash_default(self): token = "_stash" # includes leading underscore diff --git a/lib/iris/tests/unit/coords/test_Coord.py b/lib/iris/tests/unit/coords/test_Coord.py index b3fdd215d6..b7fa7a5ce7 100644 --- a/lib/iris/tests/unit/coords/test_Coord.py +++ b/lib/iris/tests/unit/coords/test_Coord.py @@ -1010,6 +1010,17 @@ def test_remove_bounds(self): coord.bounds = None self.assertFalse(coord.climatological) + def test_change_units(self): + coord = AuxCoord( + points=[0, 1], + bounds=[[0, 1], [1, 2]], + units="days since 1970-01-01", + climatological=True, + ) + self.assertTrue(coord.climatological) + coord.units = "K" + self.assertFalse(coord.climatological) + class Test___init____abstractmethod(tests.IrisTest): def test(self): diff --git a/lib/iris/tests/unit/cube_coord_common/test_CFVariableMixin.py b/lib/iris/tests/unit/cube_coord_common/test_CFVariableMixin.py deleted file mode 100644 index 0f08d397cb..0000000000 --- a/lib/iris/tests/unit/cube_coord_common/test_CFVariableMixin.py +++ /dev/null @@ -1,199 +0,0 @@ -# Copyright Iris contributors -# -# This file is part of Iris and is released under the LGPL license. -# See COPYING and COPYING.LESSER in the root of the repository for full -# licensing details. -""" -Unit tests for the :class:`iris._cube_coord_common.CFVariableMixin`. -""" - -# Import iris.tests first so that some things can be initialised before -# importing anything else. -import iris.tests as tests - -from iris._cube_coord_common import CFVariableMixin - - -class Test_token(tests.IrisTest): - def test_passthru_None(self): - result = CFVariableMixin.token(None) - self.assertIsNone(result) - - def test_fail_leading_underscore(self): - result = CFVariableMixin.token("_nope") - self.assertIsNone(result) - - def test_fail_leading_dot(self): - result = CFVariableMixin.token(".nope") - self.assertIsNone(result) - - def test_fail_leading_plus(self): - result = CFVariableMixin.token("+nope") - self.assertIsNone(result) - - def test_fail_leading_at(self): - result = CFVariableMixin.token("@nope") - self.assertIsNone(result) - - def test_fail_space(self): - result = CFVariableMixin.token("nope nope") - self.assertIsNone(result) - - def test_fail_colon(self): - result = CFVariableMixin.token("nope:") - self.assertIsNone(result) - - def test_pass_simple(self): - token = "simple" - result = CFVariableMixin.token(token) - self.assertEqual(result, token) - - def test_pass_leading_digit(self): - token = "123simple" - result = CFVariableMixin.token(token) - self.assertEqual(result, token) - - def test_pass_mixture(self): - token = "S.imple@one+two_3" - result = CFVariableMixin.token(token) - self.assertEqual(result, token) - - -class Test_name(tests.IrisTest): - def setUp(self): - # None token CFVariableMixin - self.cf_var = CFVariableMixin() - self.cf_var.standard_name = None - self.cf_var.long_name = None - self.cf_var.var_name = None - self.cf_var.attributes = {} - self.default = CFVariableMixin._DEFAULT_NAME - # bad token CFVariableMixin - self.cf_bad = CFVariableMixin() - self.cf_bad.standard_name = None - self.cf_bad.long_name = "nope nope" - self.cf_bad.var_name = None - self.cf_bad.attributes = {"STASH": "nope nope"} - - def test_standard_name(self): - token = "air_temperature" - self.cf_var.standard_name = token - result = self.cf_var.name() - self.assertEqual(result, token) - - def test_long_name(self): - token = "long_name" - self.cf_var.long_name = token - result = self.cf_var.name() - self.assertEqual(result, token) - - def test_var_name(self): - token = "var_name" - self.cf_var.var_name = token - result = self.cf_var.name() - self.assertEqual(result, token) - - def test_stash(self): - token = "stash" - self.cf_var.attributes["STASH"] = token - result = self.cf_var.name() - self.assertEqual(result, token) - - def test_default(self): - result = self.cf_var.name() - self.assertEqual(result, self.default) - - def test_token_long_name(self): - token = "long_name" - self.cf_bad.long_name = token - result = self.cf_bad.name(token=True) - self.assertEqual(result, token) - - def test_token_var_name(self): - token = "var_name" - self.cf_bad.var_name = token - result = self.cf_bad.name(token=True) - self.assertEqual(result, token) - - def test_token_stash(self): - token = "stash" - self.cf_bad.attributes["STASH"] = token - result = self.cf_bad.name(token=True) - self.assertEqual(result, token) - - def test_token_default(self): - result = self.cf_var.name(token=True) - self.assertEqual(result, self.default) - - def test_fail_token_default(self): - emsg = "Cannot retrieve a valid name token" - with self.assertRaisesRegex(ValueError, emsg): - self.cf_var.name(default="_nope", token=True) - - -class Test_names(tests.IrisTest): - def setUp(self): - self.cf_var = CFVariableMixin() - self.cf_var.standard_name = None - self.cf_var.long_name = None - self.cf_var.var_name = None - self.cf_var.attributes = dict() - - def test_standard_name(self): - standard_name = "air_temperature" - self.cf_var.standard_name = standard_name - expected = (standard_name, None, None, None) - result = self.cf_var.names - self.assertEqual(expected, result) - self.assertEqual(result.standard_name, standard_name) - - def test_long_name(self): - long_name = "air temperature" - self.cf_var.long_name = long_name - expected = (None, long_name, None, None) - result = self.cf_var.names - self.assertEqual(expected, result) - self.assertEqual(result.long_name, long_name) - - def test_var_name(self): - var_name = "atemp" - self.cf_var.var_name = var_name - expected = (None, None, var_name, None) - result = self.cf_var.names - self.assertEqual(expected, result) - self.assertEqual(result.var_name, var_name) - - def test_STASH(self): - stash = "m01s16i203" - self.cf_var.attributes = dict(STASH=stash) - expected = (None, None, None, stash) - result = self.cf_var.names - self.assertEqual(expected, result) - self.assertEqual(result.STASH, stash) - - def test_None(self): - expected = (None, None, None, None) - result = self.cf_var.names - self.assertEqual(expected, result) - - -class Test_standard_name__setter(tests.IrisTest): - def test_valid_standard_name(self): - cf_var = CFVariableMixin() - cf_var.standard_name = "air_temperature" - self.assertEqual(cf_var.standard_name, "air_temperature") - - def test_invalid_standard_name(self): - cf_var = CFVariableMixin() - emsg = "'not_a_standard_name' is not a valid standard_name" - with self.assertRaisesRegex(ValueError, emsg): - cf_var.standard_name = "not_a_standard_name" - - def test_none_standard_name(self): - cf_var = CFVariableMixin() - cf_var.standard_name = None - self.assertIsNone(cf_var.standard_name) - - -if __name__ == "__main__": - tests.main() diff --git a/lib/iris/tests/unit/experimental/stratify/test_relevel.py b/lib/iris/tests/unit/experimental/stratify/test_relevel.py index 8746625f7e..aa8a363895 100644 --- a/lib/iris/tests/unit/experimental/stratify/test_relevel.py +++ b/lib/iris/tests/unit/experimental/stratify/test_relevel.py @@ -79,7 +79,10 @@ def test_static_level(self): def test_coord_input(self): source = AuxCoord(self.src_levels.data) - source.metadata = self.src_levels.metadata + metadata = self.src_levels.metadata._asdict() + metadata["coord_system"] = None + metadata["climatological"] = None + source.metadata = metadata for axis in self.axes: result = relevel(self.cube, source, [0, 12, 13], axis=axis) diff --git a/lib/iris/tests/unit/fileformats/netcdf/test__load_aux_factory.py b/lib/iris/tests/unit/fileformats/netcdf/test__load_aux_factory.py index 3bbac6b309..609f7d097a 100644 --- a/lib/iris/tests/unit/fileformats/netcdf/test__load_aux_factory.py +++ b/lib/iris/tests/unit/fileformats/netcdf/test__load_aux_factory.py @@ -23,7 +23,9 @@ class TestAtmosphereHybridSigmaPressureCoordinate(tests.IrisTest): def setUp(self): standard_name = "atmosphere_hybrid_sigma_pressure_coordinate" self.requires = dict(formula_type=standard_name) - coordinates = [(mock.sentinel.b, "b"), (mock.sentinel.ps, "ps")] + self.ap = mock.MagicMock(units="units") + self.ps = mock.MagicMock(units="units") + coordinates = [(mock.sentinel.b, "b"), (self.ps, "ps")] self.provides = dict(coordinates=coordinates) self.engine = mock.Mock(requires=self.requires, provides=self.provides) self.cube = mock.create_autospec(Cube, spec_set=True, instance=True) @@ -34,7 +36,7 @@ def setUp(self): self.addCleanup(patcher.stop) def test_formula_terms_ap(self): - self.provides["coordinates"].append((mock.sentinel.ap, "ap")) + self.provides["coordinates"].append((self.ap, "ap")) self.requires["formula_terms"] = dict(ap="ap", b="b", ps="ps") _load_aux_factory(self.engine, self.cube) # Check cube.add_aux_coord method. @@ -44,9 +46,9 @@ def test_formula_terms_ap(self): args, _ = self.cube.add_aux_factory.call_args self.assertEqual(len(args), 1) factory = args[0] - self.assertEqual(factory.delta, mock.sentinel.ap) + self.assertEqual(factory.delta, self.ap) self.assertEqual(factory.sigma, mock.sentinel.b) - self.assertEqual(factory.surface_air_pressure, mock.sentinel.ps) + self.assertEqual(factory.surface_air_pressure, self.ps) def test_formula_terms_a_p0(self): coord_a = DimCoord(np.arange(5), units="Pa") @@ -78,7 +80,7 @@ def test_formula_terms_a_p0(self): factory = args[0] self.assertEqual(factory.delta, coord_expected) self.assertEqual(factory.sigma, mock.sentinel.b) - self.assertEqual(factory.surface_air_pressure, mock.sentinel.ps) + self.assertEqual(factory.surface_air_pressure, self.ps) def test_formula_terms_p0_non_scalar(self): coord_p0 = DimCoord(np.arange(5)) @@ -113,7 +115,7 @@ def _check_no_delta(self): # Check that the factory has no delta term self.assertEqual(factory.delta, None) self.assertEqual(factory.sigma, mock.sentinel.b) - self.assertEqual(factory.surface_air_pressure, mock.sentinel.ps) + self.assertEqual(factory.surface_air_pressure, self.ps) def test_formula_terms_ap_missing_coords(self): self.requires["formula_terms"] = dict(ap="ap", b="b", ps="ps") diff --git a/lib/iris/util.py b/lib/iris/util.py index 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`.