Source code for iris.common.mixin

# Copyright Iris contributors
#
# This file is part of Iris and is released under the BSD license.
# See LICENSE in the root of the repository for full licensing details.
"""Provides common metadata mixin behaviour."""

from __future__ import annotations

from collections.abc import Mapping
from datetime import timedelta
from functools import wraps
from typing import Any
import warnings

import cf_units
import numpy as np

import iris.std_names

from .metadata import BaseMetadata

__all__ = ["CFVariableMixin", "LimitedAttributeDict"]


def _get_valid_standard_name(name):
    # Standard names are optionally followed by a standard name
    # modifier, separated by one or more blank spaces

    if name is not None:
        # Supported standard name modifiers. Ref: [CF] Appendix C.
        valid_std_name_modifiers = [
            "detection_minimum",
            "number_of_observations",
            "standard_error",
            "status_flag",
        ]

        name_groups = name.split(maxsplit=1)
        if name_groups:
            std_name = name_groups[0]
            name_is_valid = std_name in iris.std_names.STD_NAMES
            try:
                std_name_modifier = name_groups[1]
            except IndexError:
                pass  # No modifier
            else:
                name_is_valid &= std_name_modifier in valid_std_name_modifiers

            if not name_is_valid:
                raise ValueError("{!r} is not a valid standard_name".format(name))

    return name


[docs] class LimitedAttributeDict(dict): """A specialised 'dict' subclass, which forbids (errors) certain attribute names. Used for the attribute dictionaries of all Iris data objects (that is, :class:`CFVariableMixin` and its subclasses). The "excluded" attributes are those which either :mod:`netCDF4` or Iris intpret and control with special meaning, which therefore should *not* be defined as custom 'user' attributes on Iris data objects such as cubes. For example : "coordinates", "grid_mapping", "scale_factor". The 'forbidden' attributes are those listed in :data:`iris.common.mixin.LimitedAttributeDict.CF_ATTRS_FORBIDDEN` . All the forbidden attributes are amongst those listed in `Appendix A of the CF Conventions: <https://cfconventions.org/Data/cf-conventions/cf-conventions-1.10/cf-conventions.html#attribute-appendix>`_ -- however, not *all* of them, since not all are interpreted by Iris. """ CF_ATTRS_FORBIDDEN = ( "standard_name", "long_name", "units", "bounds", "axis", "calendar", "leap_month", "leap_year", "month_lengths", "coordinates", "grid_mapping", "climatology", "cell_methods", "formula_terms", "compress", "add_offset", "scale_factor", "_FillValue", ) """Attributes with special CF meaning, forbidden in Iris attribute dictionaries.""" IRIS_RAW = "IRIS_RAW" """Key used by Iris to store ALL attributes when problems are encountered during loading. See Also -------- iris.loading.LOAD_PROBLEMS: The destination for captured loading problems. """ def __init__(self, *args, **kwargs): dict.__init__(self, *args, **kwargs) # Check validity of keys for key in self.keys(): if key in self.CF_ATTRS_FORBIDDEN: raise ValueError(f"{key!r} is not a permitted attribute") def __eq__(self, other): # Extend equality to allow for NumPy arrays. match = set(self.keys()) == set(other.keys()) if match: for key, value in self.items(): match = np.array_equal( np.array(value, ndmin=1), np.array(other[key], ndmin=1) ) if not match: break return match def __ne__(self, other): return not self == other def __setitem__(self, key, value): if key in self.CF_ATTRS_FORBIDDEN: raise ValueError(f"{key!r} is not a permitted attribute") dict.__setitem__(self, key, value)
[docs] def update(self, other, **kwargs): """Perform standard ``dict.update()`` operation.""" # Gather incoming keys keys = [] if hasattr(other, "keys"): keys += list(other.keys()) else: keys += [k for k, v in other] keys += list(kwargs.keys()) # Check validity of keys for key in keys: if key in self.CF_ATTRS_FORBIDDEN: raise ValueError(f"{key!r} is not a permitted attribute") dict.update(self, other, **kwargs)
class Unit(cf_units.Unit): # TODO: remove this subclass once FUTURE.date_microseconds is removed. @classmethod def from_unit(cls, unit: cf_units.Unit): """Cast a :class:`cf_units.Unit` to an :class:`Unit`.""" if isinstance(unit, Unit): result = unit elif isinstance(unit, cf_units.Unit): result = cls.__new__(cls) result.__dict__.update(unit.__dict__) else: message = f"Expected a cf_units.Unit, got {type(unit)}" raise TypeError(message) return result def num2date( self, time_value, only_use_cftime_datetimes=True, only_use_python_datetimes=False, ): # Used to patch the cf_units.Unit.num2date method to round to the # nearest second, which was the legacy behaviour. This is under a FUTURE # flag - users will need to adapt to microsecond precision eventually, # which may involve floating point issues. from iris import FUTURE def _round(date): if date.microsecond == 0: return date elif date.microsecond < 500000: return date - timedelta(microseconds=date.microsecond) else: return ( date + timedelta(seconds=1) - timedelta(microseconds=date.microsecond) ) result = super().num2date( time_value, only_use_cftime_datetimes, only_use_python_datetimes ) if FUTURE.date_microseconds is False: message = ( "You are using legacy date precision for Iris units - max " "precision is seconds. In future, Iris will use microsecond " "precision - available since cf-units version 3.3 - which may " "affect core behaviour. To opt-in to the " "new behaviour, set `iris.FUTURE.date_microseconds = True`." ) warnings.warn(message, category=FutureWarning) if hasattr(result, "shape"): vfunc = np.vectorize(_round) result = vfunc(result) else: result = _round(result) return result
[docs] class CFVariableMixin: _metadata_manager: Any @wraps(BaseMetadata.name) def name( self, default: str | None = None, token: bool | None = None, ) -> str: return self._metadata_manager.name(default=default, token=token)
[docs] def rename(self, name: str | None) -> None: """Change the human-readable name. If 'name' is a valid standard name it will assign it to :attr:`standard_name`, otherwise it will assign it to :attr:`long_name`. """ try: self.standard_name = name self.long_name = None except ValueError: self.standard_name = None self.long_name = str(name) # Always clear var_name when renaming. self.var_name = None
@property def standard_name(self) -> str | None: """The CF Metadata standard name for the object.""" return self._metadata_manager.standard_name @standard_name.setter def standard_name(self, name: str | None) -> None: self._metadata_manager.standard_name = _get_valid_standard_name(name) @property def long_name(self) -> str | None: """The CF Metadata long name for the object.""" return self._metadata_manager.long_name @long_name.setter def long_name(self, name: str | None) -> None: self._metadata_manager.long_name = name @property def var_name(self) -> str | None: """The NetCDF variable name for the object.""" return self._metadata_manager.var_name @var_name.setter def var_name(self, name: str | None) -> None: if name is not None: result = self._metadata_manager.token(name) if result is None or not name: emsg = "{!r} is not a valid NetCDF variable name." raise ValueError(emsg.format(name)) self._metadata_manager.var_name = name @property def units(self) -> cf_units.Unit: """The S.I. unit of the object.""" return self._metadata_manager.units @units.setter def units(self, unit: cf_units.Unit | str | None) -> None: unit = cf_units.as_unit(unit) self._metadata_manager.units = Unit.from_unit(unit) @property def attributes(self) -> LimitedAttributeDict: return self._metadata_manager.attributes @attributes.setter def attributes(self, attributes: Mapping) -> None: self._metadata_manager.attributes = LimitedAttributeDict(attributes or {}) @property def metadata(self): return self._metadata_manager.values @metadata.setter def metadata(self, metadata): cls = self._metadata_manager.cls fields = self._metadata_manager.fields arg = metadata try: # Try dict-like initialisation... metadata = cls(**metadata) except TypeError: try: # Try iterator/namedtuple-like initialisation... metadata = cls(*metadata) except TypeError: if hasattr(metadata, "_asdict"): metadata = metadata._asdict() if isinstance(metadata, Mapping): fields = [field for field in fields if field in metadata] else: # Generic iterable/container with no associated keys. missing = [ field for field in fields if not hasattr(metadata, field) ] if missing: missing = ", ".join(map(lambda i: "{!r}".format(i), missing)) emsg = "Invalid {!r} metadata, require {} to be specified." raise TypeError(emsg.format(type(arg), missing)) for field in fields: if hasattr(metadata, field): value = getattr(metadata, field) else: value = metadata[field] # Ensure to always set state through the individual mixin/container # setter functions. setattr(self, field, value)