From 516a9defd9600f2d783fe66104d264b73ff0a520 Mon Sep 17 00:00:00 2001 From: Martin Yeo Date: Wed, 20 Aug 2025 12:34:14 +0100 Subject: [PATCH 01/29] Protect _constraints from elementwise equality. --- lib/iris/_constraints.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/iris/_constraints.py b/lib/iris/_constraints.py index 765a975651..8465481aed 100644 --- a/lib/iris/_constraints.py +++ b/lib/iris/_constraints.py @@ -9,6 +9,7 @@ import numpy as np +from iris.common.metadata import hexdigest import iris.exceptions @@ -531,10 +532,9 @@ def __init__(self, **attributes): super().__init__(cube_func=self._cube_func) def __eq__(self, other): - eq = ( - isinstance(other, AttributeConstraint) - and self._attributes == other._attributes - ) + hex_self = {(k, hexdigest(v)) for k, v in self._attributes.items()} + hex_other = {(k, hexdigest(v)) for k, v in other._attributes.items()} + eq = isinstance(other, AttributeConstraint) and hex_self == hex_other return eq def __hash__(self): From 199c9675c6ccf46f00f0b84a4ebf3f91416d0e16 Mon Sep 17 00:00:00 2001 From: Martin Yeo Date: Wed, 20 Aug 2025 12:34:42 +0100 Subject: [PATCH 02/29] Protect cube.py from elementwise equality. --- lib/iris/cube.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/iris/cube.py b/lib/iris/cube.py index 77191c3a9a..2291acbb38 100644 --- a/lib/iris/cube.py +++ b/lib/iris/cube.py @@ -3386,8 +3386,8 @@ def dim_coord_subset(): # Condition1: The two blocks don't themselves wrap # (inside_indices is contiguous). # Condition2: Are we chunked at either extreme edge. - edge_wrap = ( - index_of_second_chunk == inside_indices[end_of_first_chunk] + 1 + edge_wrap = np.array_equal( + index_of_second_chunk, inside_indices[end_of_first_chunk] + 1 ) and index_of_second_chunk in (final_index, 1) subsets = None if edge_wrap: From a036073f06f547bf51e9565b42cac6c2a1190c45 Mon Sep 17 00:00:00 2001 From: Martin Yeo Date: Wed, 20 Aug 2025 12:49:16 +0100 Subject: [PATCH 03/29] Fix dumb Constraint eq mistake. --- lib/iris/_constraints.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/iris/_constraints.py b/lib/iris/_constraints.py index 8465481aed..1d2bde49c2 100644 --- a/lib/iris/_constraints.py +++ b/lib/iris/_constraints.py @@ -532,9 +532,11 @@ def __init__(self, **attributes): super().__init__(cube_func=self._cube_func) def __eq__(self, other): - hex_self = {(k, hexdigest(v)) for k, v in self._attributes.items()} - hex_other = {(k, hexdigest(v)) for k, v in other._attributes.items()} - eq = isinstance(other, AttributeConstraint) and hex_self == hex_other + eq = isinstance(other, AttributeConstraint) + if eq: + hex_self = {(k, hexdigest(v)) for k, v in self._attributes.items()} + hex_other = {(k, hexdigest(v)) for k, v in other._attributes.items()} + eq = hex_self == hex_other return eq def __hash__(self): From 209133c43b3114140de1860a6014d2c384f82336 Mon Sep 17 00:00:00 2001 From: Martin Yeo Date: Wed, 20 Aug 2025 12:54:24 +0100 Subject: [PATCH 04/29] Protect util from elementwise equality. --- lib/iris/util.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/iris/util.py b/lib/iris/util.py index e0d2fa5183..ce2bb324c5 100644 --- a/lib/iris/util.py +++ b/lib/iris/util.py @@ -28,6 +28,7 @@ from iris._shapefiles import create_shapefile_mask from iris.common import SERVICES from iris.common.lenient import _lenient_client +from iris.common.metadata import hexdigest import iris.exceptions import iris.warnings @@ -427,7 +428,7 @@ def _masked_array_equal( else: ignore |= nanmask - eqs = ma.getdata(array1) == ma.getdata(array2) + eqs = np.array_equal(ma.getdata(array1), ma.getdata(array2)) if ignore is not None: eqs = np.where(ignore, True, eqs) @@ -2106,10 +2107,12 @@ def equalise_attributes(cubes): for attrs in cube_attrs[1:]: cube_keys = list(attrs.keys()) keys_to_remove.update(cube_keys) + hex_attrs = {k: hexdigest(v) for k, v in attrs.items()} + hex_cube_attrs_0 = {k: hexdigest(v) for k, v in cube_attrs[0].items()} common_keys = [ key for key in common_keys - if (key in cube_keys and np.all(attrs[key] == cube_attrs[0][key])) + if (key in cube_keys and hex_attrs[key] == hex_cube_attrs_0[key]) ] keys_to_remove.difference_update(common_keys) From 912e41081a94cdbd44531e68cbe2aaa80305a022 Mon Sep 17 00:00:00 2001 From: Martin Yeo Date: Wed, 20 Aug 2025 13:41:13 +0100 Subject: [PATCH 05/29] Correct return type for masked_array_equal. --- lib/iris/util.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/iris/util.py b/lib/iris/util.py index ce2bb324c5..a63f0129c9 100644 --- a/lib/iris/util.py +++ b/lib/iris/util.py @@ -428,7 +428,12 @@ def _masked_array_equal( else: ignore |= nanmask - eqs = np.array_equal(ma.getdata(array1), ma.getdata(array2)) + try: + eqs = ma.getdata(array1) == ma.getdata(array2) + except ValueError: + # In case of broadcasting errors. + eqs = np.zeros(array1.shape, dtype=bool) + if ignore is not None: eqs = np.where(ignore, True, eqs) From 7787980095e222ad487848b07cdf5ff123e82bd0 Mon Sep 17 00:00:00 2001 From: Martin Yeo Date: Wed, 20 Aug 2025 13:47:07 +0100 Subject: [PATCH 06/29] Protect _structured_array_identitication from elementwise equality. --- lib/iris/fileformats/_structured_array_identification.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/iris/fileformats/_structured_array_identification.py b/lib/iris/fileformats/_structured_array_identification.py index 0f386e9815..a9a95ea516 100644 --- a/lib/iris/fileformats/_structured_array_identification.py +++ b/lib/iris/fileformats/_structured_array_identification.py @@ -111,7 +111,9 @@ def __eq__(self, other): result = NotImplemented if stride is not None or arr is not None: - result = stride == self.stride and np.all(self.unique_ordered_values == arr) + result = stride == self.stride and np.array_equal( + self.unique_ordered_values, arr + ) return result def __ne__(self, other): @@ -284,7 +286,7 @@ def from_array(cls, arr): # Do one last sanity check - does the array we've just described # actually compute the correct array? constructed_array = structure.construct_array(arr.size) - if not np.all(constructed_array == arr): + if not np.array_equal(constructed_array, arr): structure = None return structure From b5da9a18ea7d4087ffc72d98af044f1a3b22dd83 Mon Sep 17 00:00:00 2001 From: ukmo-ccbunney Date: Wed, 20 Aug 2025 14:36:18 +0100 Subject: [PATCH 07/29] Fixes for saver.py --- lib/iris/fileformats/netcdf/saver.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/lib/iris/fileformats/netcdf/saver.py b/lib/iris/fileformats/netcdf/saver.py index 4a2474ba9b..71ecef2c0d 100644 --- a/lib/iris/fileformats/netcdf/saver.py +++ b/lib/iris/fileformats/netcdf/saver.py @@ -41,6 +41,7 @@ OceanSigmaFactory, OceanSigmaZFactory, ) +from iris.common import hexdigest import iris.config import iris.coord_systems import iris.coords @@ -2590,12 +2591,7 @@ def save( def attr_values_equal(val1, val2): # An equality test which also works when some values are numpy arrays (!) # As done in :meth:`iris.common.mixin.LimitedAttributeDict.__eq__`. - match = val1 == val2 - try: - match = bool(match) - except ValueError: - match = match.all() - return match + return hexdigest(val1) == hexdigest(val2) cube0 = cubes[0] invalid_globals = set( @@ -2682,7 +2678,7 @@ def attr_values_equal(val1, val2): common_keys.intersection_update(keys) different_value_keys = [] for key in common_keys: - if np.any(attributes[key] != cube.attributes[key]): + if hexdigest(attributes[key]) != hexdigest(cube.attributes[key]): different_value_keys.append(key) common_keys.difference_update(different_value_keys) local_keys.update(different_value_keys) From 864f0d7c68fbd8e06eef90790789b46827101234 Mon Sep 17 00:00:00 2001 From: Martin Yeo Date: Wed, 20 Aug 2025 14:46:34 +0100 Subject: [PATCH 08/29] Protect pp.py from elementwise equality. --- lib/iris/fileformats/pp.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/iris/fileformats/pp.py b/lib/iris/fileformats/pp.py index 5f3b74de52..baf5502c3a 100644 --- a/lib/iris/fileformats/pp.py +++ b/lib/iris/fileformats/pp.py @@ -1453,7 +1453,7 @@ def __eq__(self, other): if all(attrs): self_attr = getattr(self, attr) other_attr = getattr(other, attr) - if not np.all(self_attr == other_attr): + if not np.array_equal(self_attr, other_attr): result = False break elif any(attrs): From 37cb9668b63b66d426b002cdabf69582517e71d6 Mon Sep 17 00:00:00 2001 From: Martin Yeo Date: Wed, 20 Aug 2025 14:47:04 +0100 Subject: [PATCH 09/29] Protect pp_load_rules from elementwise equality. --- lib/iris/fileformats/pp_load_rules.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/lib/iris/fileformats/pp_load_rules.py b/lib/iris/fileformats/pp_load_rules.py index 71540fe74a..4a6ad46ef2 100644 --- a/lib/iris/fileformats/pp_load_rules.py +++ b/lib/iris/fileformats/pp_load_rules.py @@ -161,13 +161,18 @@ def _convert_vertical_coords( coords_and_dims.append((coord, dim)) # Depth - unbound and bound (mixed). + try: + svd_lev_eq = brsvd1 == brlev + except ValueError: + # In case of broadcasting errors. + svd_lev_eq = False if ( (len(lbcode) != 5) and (lbvc == 2) - and (np.any(brsvd1 == brlev) and np.any(brsvd1 != brlev)) + and (np.any(svd_lev_eq) and np.any(~svd_lev_eq)) ): - lower = np.where(brsvd1 == brlev, blev, brsvd1) - upper = np.where(brsvd1 == brlev, blev, brlev) + lower = np.where(svd_lev_eq, blev, brsvd1) + upper = np.where(svd_lev_eq, blev, brlev) coord = _dim_or_aux( blev, standard_name="depth", From 4a0eb4cdaa4189dd8459c2257d68c6756b1e7a84 Mon Sep 17 00:00:00 2001 From: Martin Yeo Date: Wed, 20 Aug 2025 15:32:47 +0100 Subject: [PATCH 10/29] Revert "Protect pp.py from elementwise equality." This reverts commit 864f0d7c68fbd8e06eef90790789b46827101234. --- lib/iris/fileformats/pp.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/iris/fileformats/pp.py b/lib/iris/fileformats/pp.py index baf5502c3a..5f3b74de52 100644 --- a/lib/iris/fileformats/pp.py +++ b/lib/iris/fileformats/pp.py @@ -1453,7 +1453,7 @@ def __eq__(self, other): if all(attrs): self_attr = getattr(self, attr) other_attr = getattr(other, attr) - if not np.array_equal(self_attr, other_attr): + if not np.all(self_attr == other_attr): result = False break elif any(attrs): From 60029084c0103e9b7a9ddb4e0f98531aa0e3a4dd Mon Sep 17 00:00:00 2001 From: Martin Yeo Date: Wed, 20 Aug 2025 15:50:05 +0100 Subject: [PATCH 11/29] Protect _concatenate from elementwise NOT equality. --- lib/iris/_concatenate.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/iris/_concatenate.py b/lib/iris/_concatenate.py index 6caee79c4f..186ad7700b 100644 --- a/lib/iris/_concatenate.py +++ b/lib/iris/_concatenate.py @@ -16,6 +16,7 @@ from xxhash import xxh3_64 from iris._lazy_data import concatenate as concatenate_arrays +from iris.common.metadata import hexdigest import iris.coords from iris.coords import AncillaryVariable, AuxCoord, CellMeasure, DimCoord import iris.cube @@ -786,7 +787,7 @@ def _coordinate_differences(self, other, attr, reason="metadata"): diff_names = [] for self_key, self_value in self_dict.items(): other_value = other_dict[self_key] - if self_value != other_value: + if hexdigest(self_value) != hexdigest(other_value): diff_names.append(self_key) result = ( " " + reason, From 25b8604a8bf9f9530af14b4d3557fd607216c681 Mon Sep 17 00:00:00 2001 From: Martin Yeo Date: Wed, 20 Aug 2025 15:52:05 +0100 Subject: [PATCH 12/29] Protect _constraints from elementwise NOT equality. --- lib/iris/_constraints.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/iris/_constraints.py b/lib/iris/_constraints.py index 1d2bde49c2..a64a50308b 100644 --- a/lib/iris/_constraints.py +++ b/lib/iris/_constraints.py @@ -555,7 +555,7 @@ def _cube_func(self, cube): match = False break else: - if cube_attr != value: + if hexdigest(cube_attr) != hexdigest(value): match = False break else: From 6d0287cfd01d5fd2feda820bf131bd67a2408c24 Mon Sep 17 00:00:00 2001 From: Martin Yeo Date: Wed, 20 Aug 2025 15:53:56 +0100 Subject: [PATCH 13/29] Protect coords.py from elementwise NOT equality. --- lib/iris/coords.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/iris/coords.py b/lib/iris/coords.py index ca73dcb729..3c18d7d2f4 100644 --- a/lib/iris/coords.py +++ b/lib/iris/coords.py @@ -29,6 +29,7 @@ DimCoordMetadata, metadata_manager_factory, ) +from iris.common.metadata import hexdigest import iris.exceptions import iris.time import iris.util @@ -760,7 +761,7 @@ def is_compatible(self, other, ignore=None): ignore = (ignore,) common_keys = common_keys.difference(ignore) for key in common_keys: - if np.any(self.attributes[key] != other.attributes[key]): + if hexdigest(self.attributes[key]) != hexdigest(other.attributes[key]): compatible = False break From 4dfacf743664d752edd8640ad9b3926cad28c662 Mon Sep 17 00:00:00 2001 From: Martin Yeo Date: Wed, 20 Aug 2025 15:55:30 +0100 Subject: [PATCH 14/29] Protect cube.py from elementwise NOT equality. --- lib/iris/cube.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/iris/cube.py b/lib/iris/cube.py index 2291acbb38..2896b88565 100644 --- a/lib/iris/cube.py +++ b/lib/iris/cube.py @@ -40,7 +40,7 @@ import iris.aux_factory from iris.aux_factory import AuxCoordFactory from iris.common import CFVariableMixin, CubeMetadata, metadata_manager_factory -from iris.common.metadata import CoordMetadata, metadata_filter +from iris.common.metadata import CoordMetadata, hexdigest, metadata_filter from iris.common.mixin import LimitedAttributeDict import iris.coord_systems import iris.coords @@ -1435,7 +1435,7 @@ def is_compatible( ignore = (ignore,) common_keys = common_keys.difference(ignore) for key in common_keys: - if np.any(self.attributes[key] != other.attributes[key]): + if hexdigest(self.attributes[key]) != hexdigest(other.attributes[key]): compatible = False break From 6208f4ca447cea34d0fa0958fd6f8aa00b9f23a7 Mon Sep 17 00:00:00 2001 From: Martin Yeo Date: Wed, 20 Aug 2025 15:56:48 +0100 Subject: [PATCH 15/29] Protect util from elementwise NOT equality. --- lib/iris/util.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/iris/util.py b/lib/iris/util.py index a63f0129c9..794f8a16b4 100644 --- a/lib/iris/util.py +++ b/lib/iris/util.py @@ -233,7 +233,7 @@ def describe_diff(cube_a, cube_b, output_file=None): else: common_keys = set(cube_a.attributes).intersection(cube_b.attributes) for key in common_keys: - if np.any(cube_a.attributes[key] != cube_b.attributes[key]): + if hexdigest(cube_a.attributes[key]) != hexdigest(cube_b.attributes[key]): output_file.write( '"%s" cube_a attribute value "%s" is not ' "compatible with cube_b " From fcce10e6445fed4b568723ed9f3342e0d6513f07 Mon Sep 17 00:00:00 2001 From: Martin Yeo Date: Wed, 20 Aug 2025 16:35:09 +0100 Subject: [PATCH 16/29] Add structured array test for NumPy elementwise broadcasting error. --- .../structured_array_identification/test_ArrayStructure.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lib/iris/tests/unit/fileformats/structured_array_identification/test_ArrayStructure.py b/lib/iris/tests/unit/fileformats/structured_array_identification/test_ArrayStructure.py index bf23b39a5c..d8b3171e74 100644 --- a/lib/iris/tests/unit/fileformats/structured_array_identification/test_ArrayStructure.py +++ b/lib/iris/tests/unit/fileformats/structured_array_identification/test_ArrayStructure.py @@ -93,6 +93,13 @@ def test_irregular_3d(self): a[0, 0, 0] = 5 assert self.struct_from_arr(a) is None + def test_irregular_1d(self): + # Note this tests a unique scenario where NumPy raises a broadcasting + # error (having previously allowed elementwise comparison in earlier + # versions). + a = np.array([0, 0, 0, 0, 1, 1, 1, 1, 2]) + assert self.struct_from_arr(a) is None + def test_repeated_3d(self): sub = np.array([-1, 3, 1, 2]) a = construct_nd(sub, 2, (3, 2, 4)) From b2e0566e974d653d2cbf8747d8c007d3c9fa6a7e Mon Sep 17 00:00:00 2001 From: ukmo-ccbunney Date: Wed, 20 Aug 2025 16:40:38 +0100 Subject: [PATCH 17/29] Extra updates for pp_load_rules.py --- lib/iris/fileformats/pp_load_rules.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/lib/iris/fileformats/pp_load_rules.py b/lib/iris/fileformats/pp_load_rules.py index 4a6ad46ef2..59e0f31d17 100644 --- a/lib/iris/fileformats/pp_load_rules.py +++ b/lib/iris/fileformats/pp_load_rules.py @@ -139,8 +139,15 @@ def _convert_vertical_coords( ) coords_and_dims.append((coord, dim)) + # Common calc for Depth + try: + svd_lev_eq = brsvd1 == brlev + except ValueError: + # In case of broadcasting errors. + svd_lev_eq = False + # Depth - unbound. - if (len(lbcode) != 5) and (lbvc == 2) and np.all(brsvd1 == brlev): + if (len(lbcode) != 5) and (lbvc == 2) and np.all(svd_lev_eq): coord = _dim_or_aux( blev, standard_name="depth", @@ -150,7 +157,7 @@ def _convert_vertical_coords( coords_and_dims.append((coord, dim)) # Depth - bound. - if (len(lbcode) != 5) and (lbvc == 2) and np.all(brsvd1 != brlev): + if (len(lbcode) != 5) and (lbvc == 2) and np.all(~svd_lev_eq): coord = _dim_or_aux( blev, standard_name="depth", @@ -161,11 +168,6 @@ def _convert_vertical_coords( coords_and_dims.append((coord, dim)) # Depth - unbound and bound (mixed). - try: - svd_lev_eq = brsvd1 == brlev - except ValueError: - # In case of broadcasting errors. - svd_lev_eq = False if ( (len(lbcode) != 5) and (lbvc == 2) @@ -194,7 +196,7 @@ def _convert_vertical_coords( units="1", ) coords_and_dims.append((coord, dim)) - elif np.any(brsvd1 != brlev): + elif np.any(~svd_lev_eq): # UM populates metadata CORRECTLY, # so treat it as the expected (bounded) soil depth. coord = _dim_or_aux( From f74311fd6e8935306dd0a68314447d7b9ded9010 Mon Sep 17 00:00:00 2001 From: Martin Yeo Date: Thu, 21 Aug 2025 10:14:32 +0100 Subject: [PATCH 18/29] What's New entry. --- docs/src/whatsnew/3.12.rst | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/src/whatsnew/3.12.rst b/docs/src/whatsnew/3.12.rst index 2b6ca8ab17..b2e3ba5baa 100644 --- a/docs/src/whatsnew/3.12.rst +++ b/docs/src/whatsnew/3.12.rst @@ -215,6 +215,12 @@ v3.12.2 (09 May 2025) #. `@trexfeathers`_ refactored Iris loading and saving to make it compatible with Dask version ``2025.4.0`` and above. (:pull:`6451`) +#. `@trexfeathers`_ and `@ukmo-ccbunney`_ adapted array comparison in response + to NumPy v1.25 deprecating comparison of un-broadcastable arrays. It is + hoped that users will see no difference in behaviour, but please get in touch + if you notice anything. See `NumPy v1.25 expired deprecations`_ and + `numpy#22707`_ for more. (:pull:`6665`) + 📚 Documentation ================ @@ -271,3 +277,5 @@ v3.12.2 (09 May 2025) .. _SPEC 0: https://scientific-python.org/specs/spec-0000/ .. _Running setuptools commands: https://setuptools.pypa.io/en/latest/deprecated/commands.html +.. _NumPy v1.25 expired deprecations: https://numpy.org/doc/stable/release/1.25.0-notes.html#expired-deprecations +.. _numpy#22707: https://github.com/numpy/numpy/pull/22707 From f31ed1b69baaea28b514ae044651ed254ca9bac0 Mon Sep 17 00:00:00 2001 From: Martin Yeo Date: Thu, 21 Aug 2025 12:25:37 +0100 Subject: [PATCH 19/29] Expose users to array comparison errors when requested directly, including helpful errors. --- lib/iris/_constraints.py | 17 +++--- .../constraints/test_AttributeConstraint.py | 57 +++++++++++++++++++ .../tests/unit/util/test_describe_diff.py | 7 +++ .../unit/util/test_equalise_attributes.py | 12 ++++ lib/iris/util.py | 24 +++++--- 5 files changed, 101 insertions(+), 16 deletions(-) create mode 100644 lib/iris/tests/unit/constraints/test_AttributeConstraint.py diff --git a/lib/iris/_constraints.py b/lib/iris/_constraints.py index a64a50308b..4180c1d083 100644 --- a/lib/iris/_constraints.py +++ b/lib/iris/_constraints.py @@ -9,7 +9,6 @@ import numpy as np -from iris.common.metadata import hexdigest import iris.exceptions @@ -532,11 +531,10 @@ def __init__(self, **attributes): super().__init__(cube_func=self._cube_func) def __eq__(self, other): - eq = isinstance(other, AttributeConstraint) - if eq: - hex_self = {(k, hexdigest(v)) for k, v in self._attributes.items()} - hex_other = {(k, hexdigest(v)) for k, v in other._attributes.items()} - eq = hex_self == hex_other + eq = ( + isinstance(other, AttributeConstraint) + and self._attributes == other._attributes + ) return eq def __hash__(self): @@ -555,7 +553,12 @@ def _cube_func(self, cube): match = False break else: - if hexdigest(cube_attr) != hexdigest(value): + try: + eq = np.all(cube_attr == value) + except ValueError as err: + message = f"Error comparing {name} attributes: {err}" + raise ValueError(message) + if not eq: match = False break else: diff --git a/lib/iris/tests/unit/constraints/test_AttributeConstraint.py b/lib/iris/tests/unit/constraints/test_AttributeConstraint.py new file mode 100644 index 0000000000..932dc0a084 --- /dev/null +++ b/lib/iris/tests/unit/constraints/test_AttributeConstraint.py @@ -0,0 +1,57 @@ +# 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. +"""Unit tests for the `iris._constraints.AttributeConstraint` class.""" + +# TODO: migrate AttributeConstraint content from iris/tests/test_constraints.py + +import numpy as np +import pytest + +from iris._constraints import AttributeConstraint +from iris.tests import stock + + +@pytest.fixture +def simple_1d(): + return stock.simple_1d() + + +@pytest.fixture +def cube_w_numpy_attribute(simple_1d): + # Guarantee the new attribute is the only attribute. + assert simple_1d.attributes == {} + attr_name = "numpy_attr" + attr_value = np.array([1, 2, 3]) + simple_1d.attributes[attr_name] = attr_value + return simple_1d + + +def test_numpy_attribute_match(cube_w_numpy_attribute): + attr = cube_w_numpy_attribute.attributes + constraint = AttributeConstraint(**attr) + assert cube_w_numpy_attribute.extract(constraint) == cube_w_numpy_attribute + + +def test_numpy_attribute_mismatch(cube_w_numpy_attribute): + attr = cube_w_numpy_attribute.attributes + attr = {key: value + 1 for key, value in attr.items()} + constraint = AttributeConstraint(**attr) + assert cube_w_numpy_attribute.extract(constraint) is None + + +def test_numpy_attribute_against_str(cube_w_numpy_attribute): + # Should not error. + attr = cube_w_numpy_attribute.attributes + attr = {key: "foo" for key, value in attr.items()} + constraint = AttributeConstraint(**attr) + assert cube_w_numpy_attribute.extract(constraint) is None + + +def test_numpy_attribute_incompatible(cube_w_numpy_attribute): + attr = cube_w_numpy_attribute.attributes + attr = {key: value[:-1] for key, value in attr.items()} + constraint = AttributeConstraint(**attr) + with pytest.raises(ValueError, match="Error comparing numpy_attr attributes"): + _ = cube_w_numpy_attribute.extract(constraint) diff --git a/lib/iris/tests/unit/util/test_describe_diff.py b/lib/iris/tests/unit/util/test_describe_diff.py index 0263a0de27..e99c47f417 100644 --- a/lib/iris/tests/unit/util/test_describe_diff.py +++ b/lib/iris/tests/unit/util/test_describe_diff.py @@ -58,3 +58,10 @@ def test_different_array_attributes(self): "incompatible_array_attrs.str.txt", ], ) + + def test_incompatible_array_attributes(self): + # test incompatible array attribute + self.cube_a.attributes["test_array"] = np.array([1, 2, 3]) + self.cube_b.attributes["test_array"] = np.array([1, 2]) + with pytest.raises(ValueError, match="Error comparing test_array attributes"): + _ = self._compare_result(self.cube_a, self.cube_b) diff --git a/lib/iris/tests/unit/util/test_equalise_attributes.py b/lib/iris/tests/unit/util/test_equalise_attributes.py index 1392f9cff8..6aa33558d7 100644 --- a/lib/iris/tests/unit/util/test_equalise_attributes.py +++ b/lib/iris/tests/unit/util/test_equalise_attributes.py @@ -129,6 +129,18 @@ def test_array_same(self): cubes = [self.cube_a1b5v1, self.cube_a1b6v1] self._test(cubes, {"a": 1, "v": self.v1}, [{"b": 5}, {"b": 6}]) + @pytest.fixture + def make_a1b6v2_incompatible(self): + v_array = self.cube_a1b6v2.attributes["v"] + self.cube_a1b6v2.attributes["v"] = np.repeat(v_array, 2) + + def test_array_incompatible(self, make_a1b6v2_incompatible): + cubes = [self.cube_a1b5v1, self.cube_a1b6v2] + with pytest.raises( + ValueError, match=r"Error comparing \('local', 'v'\) attributes" + ): + self._test(cubes, {}, []) + @_shared_utils.skip_data def test_complex_nonecommon(self): # Example with cell methods and factories, but no common attributes. diff --git a/lib/iris/util.py b/lib/iris/util.py index 794f8a16b4..d3411c621b 100644 --- a/lib/iris/util.py +++ b/lib/iris/util.py @@ -28,7 +28,6 @@ from iris._shapefiles import create_shapefile_mask from iris.common import SERVICES from iris.common.lenient import _lenient_client -from iris.common.metadata import hexdigest import iris.exceptions import iris.warnings @@ -233,7 +232,12 @@ def describe_diff(cube_a, cube_b, output_file=None): else: common_keys = set(cube_a.attributes).intersection(cube_b.attributes) for key in common_keys: - if hexdigest(cube_a.attributes[key]) != hexdigest(cube_b.attributes[key]): + try: + eq = np.any(cube_a.attributes[key] == cube_b.attributes[key]) + except ValueError as err: + message = f"Error comparing {key} attributes: {err}" + raise ValueError(message) + if not eq: output_file.write( '"%s" cube_a attribute value "%s" is not ' "compatible with cube_b " @@ -2112,13 +2116,15 @@ def equalise_attributes(cubes): for attrs in cube_attrs[1:]: cube_keys = list(attrs.keys()) keys_to_remove.update(cube_keys) - hex_attrs = {k: hexdigest(v) for k, v in attrs.items()} - hex_cube_attrs_0 = {k: hexdigest(v) for k, v in cube_attrs[0].items()} - common_keys = [ - key - for key in common_keys - if (key in cube_keys and hex_attrs[key] == hex_cube_attrs_0[key]) - ] + + def eq(key): + try: + return np.all(attrs[key] == cube_attrs[0][key]) + except ValueError as err: + message = f"Error comparing {key} attributes: {err}" + raise ValueError(message) + + common_keys = [key for key in common_keys if (key in cube_keys and eq(key))] keys_to_remove.difference_update(common_keys) # Convert back from the resulting 'paired' keys set, extracting just the From 6dc67d8e85e185cf4e869953ae59ea2e28755e7a Mon Sep 17 00:00:00 2001 From: Martin Yeo Date: Thu, 21 Aug 2025 12:37:15 +0100 Subject: [PATCH 20/29] Back out new support for NumPy arrays in AttributeConstraint - inappropriate for patch release. --- lib/iris/_constraints.py | 11 ++-- .../constraints/test_AttributeConstraint.py | 57 ------------------- 2 files changed, 5 insertions(+), 63 deletions(-) delete mode 100644 lib/iris/tests/unit/constraints/test_AttributeConstraint.py diff --git a/lib/iris/_constraints.py b/lib/iris/_constraints.py index 4180c1d083..c0ba2f120f 100644 --- a/lib/iris/_constraints.py +++ b/lib/iris/_constraints.py @@ -531,6 +531,8 @@ def __init__(self, **attributes): super().__init__(cube_func=self._cube_func) def __eq__(self, other): + # Note: equality means that NumPy arrays are not supported for + # AttributeConstraints (get the truth ambiguity error). eq = ( isinstance(other, AttributeConstraint) and self._attributes == other._attributes @@ -553,12 +555,9 @@ def _cube_func(self, cube): match = False break else: - try: - eq = np.all(cube_attr == value) - except ValueError as err: - message = f"Error comparing {name} attributes: {err}" - raise ValueError(message) - if not eq: + # Note: equality means that NumPy arrays are not supported + # for AttributeConstraints (get the truth ambiguity error). + if cube_attr != value: match = False break else: diff --git a/lib/iris/tests/unit/constraints/test_AttributeConstraint.py b/lib/iris/tests/unit/constraints/test_AttributeConstraint.py deleted file mode 100644 index 932dc0a084..0000000000 --- a/lib/iris/tests/unit/constraints/test_AttributeConstraint.py +++ /dev/null @@ -1,57 +0,0 @@ -# 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. -"""Unit tests for the `iris._constraints.AttributeConstraint` class.""" - -# TODO: migrate AttributeConstraint content from iris/tests/test_constraints.py - -import numpy as np -import pytest - -from iris._constraints import AttributeConstraint -from iris.tests import stock - - -@pytest.fixture -def simple_1d(): - return stock.simple_1d() - - -@pytest.fixture -def cube_w_numpy_attribute(simple_1d): - # Guarantee the new attribute is the only attribute. - assert simple_1d.attributes == {} - attr_name = "numpy_attr" - attr_value = np.array([1, 2, 3]) - simple_1d.attributes[attr_name] = attr_value - return simple_1d - - -def test_numpy_attribute_match(cube_w_numpy_attribute): - attr = cube_w_numpy_attribute.attributes - constraint = AttributeConstraint(**attr) - assert cube_w_numpy_attribute.extract(constraint) == cube_w_numpy_attribute - - -def test_numpy_attribute_mismatch(cube_w_numpy_attribute): - attr = cube_w_numpy_attribute.attributes - attr = {key: value + 1 for key, value in attr.items()} - constraint = AttributeConstraint(**attr) - assert cube_w_numpy_attribute.extract(constraint) is None - - -def test_numpy_attribute_against_str(cube_w_numpy_attribute): - # Should not error. - attr = cube_w_numpy_attribute.attributes - attr = {key: "foo" for key, value in attr.items()} - constraint = AttributeConstraint(**attr) - assert cube_w_numpy_attribute.extract(constraint) is None - - -def test_numpy_attribute_incompatible(cube_w_numpy_attribute): - attr = cube_w_numpy_attribute.attributes - attr = {key: value[:-1] for key, value in attr.items()} - constraint = AttributeConstraint(**attr) - with pytest.raises(ValueError, match="Error comparing numpy_attr attributes"): - _ = cube_w_numpy_attribute.extract(constraint) From bf98c77d3f4fb3ca72281d9e3b3e7a7519cb167e Mon Sep 17 00:00:00 2001 From: Martin Yeo Date: Thu, 21 Aug 2025 13:39:03 +0100 Subject: [PATCH 21/29] Revert change to intersection edge_wrap - will never be comparing 2 arrays. --- lib/iris/cube.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/iris/cube.py b/lib/iris/cube.py index 2896b88565..27b7c06737 100644 --- a/lib/iris/cube.py +++ b/lib/iris/cube.py @@ -3386,8 +3386,8 @@ def dim_coord_subset(): # Condition1: The two blocks don't themselves wrap # (inside_indices is contiguous). # Condition2: Are we chunked at either extreme edge. - edge_wrap = np.array_equal( - index_of_second_chunk, inside_indices[end_of_first_chunk] + 1 + edge_wrap = ( + index_of_second_chunk == inside_indices[end_of_first_chunk] + 1 ) and index_of_second_chunk in (final_index, 1) subsets = None if edge_wrap: From d5752b9802b5ca83499fb1472a37b747c646a9f8 Mon Sep 17 00:00:00 2001 From: Martin Yeo Date: Thu, 21 Aug 2025 13:50:00 +0100 Subject: [PATCH 22/29] Add test coverage for structured array eq with incompatible shapes. --- .../structured_array_identification/test_ArrayStructure.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/iris/tests/unit/fileformats/structured_array_identification/test_ArrayStructure.py b/lib/iris/tests/unit/fileformats/structured_array_identification/test_ArrayStructure.py index d8b3171e74..bc461f84ee 100644 --- a/lib/iris/tests/unit/fileformats/structured_array_identification/test_ArrayStructure.py +++ b/lib/iris/tests/unit/fileformats/structured_array_identification/test_ArrayStructure.py @@ -128,6 +128,11 @@ def test_multi_dim_array(self): with pytest.raises(ValueError): ArrayStructure.from_array(np.arange(12).reshape(3, 4)) + def test_eq_incompatible_shapes(self): + struct1 = ArrayStructure(1, np.array([1, 2])) + struct2 = ArrayStructure(1, np.array([1, 2, 3])) + assert struct1 != struct2 + class TestNdarrayAndDimsCases: """Defines the test functionality for nd_array_and_dims. This class From ac9eab32bcc99897a966c8dacccbc202bd8c0ef1 Mon Sep 17 00:00:00 2001 From: Martin Yeo Date: Thu, 21 Aug 2025 14:43:13 +0100 Subject: [PATCH 23/29] Test coverage for broadcasting errors in pp_load_rules. --- .../test__convert_vertical_coords.py | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/lib/iris/tests/unit/fileformats/pp_load_rules/test__convert_vertical_coords.py b/lib/iris/tests/unit/fileformats/pp_load_rules/test__convert_vertical_coords.py index 7f6270e9f3..a5c239a37d 100644 --- a/lib/iris/tests/unit/fileformats/pp_load_rules/test__convert_vertical_coords.py +++ b/lib/iris/tests/unit/fileformats/pp_load_rules/test__convert_vertical_coords.py @@ -8,6 +8,7 @@ """ import numpy as np +import pytest from iris.aux_factory import HybridHeightFactory, HybridPressureFactory from iris.coords import AuxCoord, DimCoord @@ -280,6 +281,29 @@ def test_unbounded__vector_no_depth(self): dim=0, ) + def test_unbounded_incompatible_vectors(self): + # Confirm this is not vulnerable to the non-broadcastable error. + lblev = [1, 2, 3] + blev = [10, 20, 30] + brsvd1 = [5, 15, 25, 35] + brlev = [5, 15, 25] + avoided_error = "operands could not be broadcast together" + try: + self._check_depth( + _lbcode(1), + lblev=lblev, + blev=blev, + brsvd1=brsvd1, + brlev=brlev, + expect_bounds=False, + dim=1, + ) + except ValueError as err: + if avoided_error in str(err): + message = f'Test failed to avoid specified error: "{err}"' + pytest.fail(message) + pass + def test_bounded(self): self._check_depth( _lbcode(1), lblev=23.0, brlev=22.5, brsvd1=23.5, expect_bounds=True @@ -300,6 +324,29 @@ def test_bounded__vector(self): dim=1, ) + def test_bounded_incompatible_vectors(self): + # Confirm this is not vulnerable to the non-broadcastable error. + lblev = [1, 2, 3] + blev = [10, 20, 30] + brsvd1 = [5, 15, 25, 35] + brlev = [15, 25, 35] + avoided_error = "operands could not be broadcast together" + try: + self._check_depth( + _lbcode(1), + lblev=lblev, + blev=blev, + brsvd1=brsvd1, + brlev=brlev, + expect_bounds=True, + dim=1, + ) + except ValueError as err: + if avoided_error in str(err): + message = f'Test failed to avoid specified error: "{err}"' + pytest.fail(message) + pass + def test_cross_section(self): self._check_depth(_lbcode(ix=1, iy=2), lblev=23.0, expect_match=False) @@ -360,6 +407,37 @@ def test_normal__vector(self): lblev = np.arange(10) self._check_soil_level(_lbcode(0), lblev=lblev, dim=0) + def test_normal_incompatible_vectors(self): + # Confirm this is not vulnerable to the non-broadcastable error. + lbvc = 6 + stash = STASH(1, 1, 1) + lbcode = _lbcode(0) + lblev = np.arange(10) + brsvd1 = [1] * len(lblev) + brlev = brsvd1 + [1] + blev, bhlev, bhrlev, brsvd2 = None, None, None, None + + avoided_error = "operands could not be broadcast together" + try: + _ = _convert_vertical_coords( + lbcode=lbcode, + lbvc=lbvc, + blev=blev, + lblev=lblev, + stash=stash, + bhlev=bhlev, + bhrlev=bhrlev, + brsvd1=brsvd1, + brsvd2=brsvd2, + brlev=brlev, + dim=0, + ) + except ValueError as err: + if avoided_error in str(err): + message = f'Test failed to avoid specified error: "{err}"' + pytest.fail(message) + pass + def test_cross_section(self): self._check_soil_level(_lbcode(ix=1, iy=2), expect_match=False) From 18f89993d5ea3412c514dc891e951b62d55b2b66 Mon Sep 17 00:00:00 2001 From: Martin Yeo Date: Thu, 21 Aug 2025 15:04:48 +0100 Subject: [PATCH 24/29] Common code for comparing attributes in case np arrays. --- lib/iris/common/mixin.py | 3 +++ lib/iris/coords.py | 5 +++-- lib/iris/cube.py | 6 ++++-- lib/iris/fileformats/netcdf/saver.py | 8 ++++---- lib/iris/util.py | 20 +++++++++++++++++++- 5 files changed, 33 insertions(+), 9 deletions(-) diff --git a/lib/iris/common/mixin.py b/lib/iris/common/mixin.py index dae10abc9b..bbb806f331 100644 --- a/lib/iris/common/mixin.py +++ b/lib/iris/common/mixin.py @@ -115,6 +115,9 @@ def __eq__(self, other): match = set(self.keys()) == set(other.keys()) if match: for key, value in self.items(): + # TODO: should this use the iris.common.metadata approach of + # using hexdigest? Might be a breaking change for some corner + # cases, so would need a major release. match = np.array_equal( np.array(value, ndmin=1), np.array(other[key], ndmin=1) ) diff --git a/lib/iris/coords.py b/lib/iris/coords.py index 3c18d7d2f4..7c5cbdfdea 100644 --- a/lib/iris/coords.py +++ b/lib/iris/coords.py @@ -29,7 +29,6 @@ DimCoordMetadata, metadata_manager_factory, ) -from iris.common.metadata import hexdigest import iris.exceptions import iris.time import iris.util @@ -761,7 +760,9 @@ def is_compatible(self, other, ignore=None): ignore = (ignore,) common_keys = common_keys.difference(ignore) for key in common_keys: - if hexdigest(self.attributes[key]) != hexdigest(other.attributes[key]): + if not iris.util._attribute_equal( + self.attributes[key], other.attributes[key] + ): compatible = False break diff --git a/lib/iris/cube.py b/lib/iris/cube.py index 27b7c06737..51a6eea5c6 100644 --- a/lib/iris/cube.py +++ b/lib/iris/cube.py @@ -40,7 +40,7 @@ import iris.aux_factory from iris.aux_factory import AuxCoordFactory from iris.common import CFVariableMixin, CubeMetadata, metadata_manager_factory -from iris.common.metadata import CoordMetadata, hexdigest, metadata_filter +from iris.common.metadata import CoordMetadata, metadata_filter from iris.common.mixin import LimitedAttributeDict import iris.coord_systems import iris.coords @@ -1435,7 +1435,9 @@ def is_compatible( ignore = (ignore,) common_keys = common_keys.difference(ignore) for key in common_keys: - if hexdigest(self.attributes[key]) != hexdigest(other.attributes[key]): + if not iris.util._attribute_equal( + self.attributes[key], other.attributes[key] + ): compatible = False break diff --git a/lib/iris/fileformats/netcdf/saver.py b/lib/iris/fileformats/netcdf/saver.py index 71ecef2c0d..ef8c844764 100644 --- a/lib/iris/fileformats/netcdf/saver.py +++ b/lib/iris/fileformats/netcdf/saver.py @@ -41,7 +41,6 @@ OceanSigmaFactory, OceanSigmaZFactory, ) -from iris.common import hexdigest import iris.config import iris.coord_systems import iris.coords @@ -2590,8 +2589,7 @@ def save( # Fnd any global attributes which are not the same on *all* cubes. def attr_values_equal(val1, val2): # An equality test which also works when some values are numpy arrays (!) - # As done in :meth:`iris.common.mixin.LimitedAttributeDict.__eq__`. - return hexdigest(val1) == hexdigest(val2) + return iris.util._attribute_equal(val1, val2) cube0 = cubes[0] invalid_globals = set( @@ -2678,7 +2676,9 @@ def attr_values_equal(val1, val2): common_keys.intersection_update(keys) different_value_keys = [] for key in common_keys: - if hexdigest(attributes[key]) != hexdigest(cube.attributes[key]): + if not iris.util._attribute_equal( + attributes[key], cube.attributes[key] + ): different_value_keys.append(key) common_keys.difference_update(different_value_keys) local_keys.update(different_value_keys) diff --git a/lib/iris/util.py b/lib/iris/util.py index d3411c621b..2bdc3e5a33 100644 --- a/lib/iris/util.py +++ b/lib/iris/util.py @@ -15,7 +15,7 @@ import os.path import sys import tempfile -from typing import TYPE_CHECKING, List, Literal +from typing import TYPE_CHECKING, Any, List, Literal from warnings import warn import cf_units @@ -395,6 +395,24 @@ def _rolling_window(array): return rw +def _attribute_equal( + attr1: Any, + attr2: Any, +) -> bool: + """Compare two attribute values, including np arrays. + + If either attribute is a NumPy array, :func:`numpy.array_equal` is used, to + avoid broadcastability errors in case of mismatches. + """ + # TODO: at next major release replace uses of this with hexdigest + # comparisons, in alignment with iris.common.metadata (consider calling + # a routine in iris.common.metadata). + if isinstance(attr1, np.ndarray) or isinstance(attr2, np.ndarray): + return np.array_equal(attr1, attr2) + else: + return attr1 == attr2 + + def _masked_array_equal( array1: np.ndarray, array2: np.ndarray, From 3336c8a3c508ee4fb76dc21855493e8985e7ddc6 Mon Sep 17 00:00:00 2001 From: Martin Yeo Date: Thu, 21 Aug 2025 15:22:59 +0100 Subject: [PATCH 25/29] Temporary fix for TestLicenseHeaders. --- lib/iris/tests/test_coding_standards.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/iris/tests/test_coding_standards.py b/lib/iris/tests/test_coding_standards.py index 3c7e524e1e..1e71aad3b3 100644 --- a/lib/iris/tests/test_coding_standards.py +++ b/lib/iris/tests/test_coding_standards.py @@ -223,8 +223,10 @@ def last_change_by_fname(): # Call "git whatchanged" to get the details of all the files and when # they were last changed. + # TODO: whatchanged is deprecated, find an alternative Git command. output = subprocess.check_output( - ["git", "whatchanged", "--pretty=TIME:%ct"], cwd=IRIS_REPO_DIRPATH + ["git", "whatchanged", "--pretty=TIME:%ct", "--i-still-use-this"], + cwd=IRIS_REPO_DIRPATH, ) output = output.decode().split("\n") From 226dcf5dc28dde84d5ea697cbb872442515ed88c Mon Sep 17 00:00:00 2001 From: Martin Yeo Date: Thu, 21 Aug 2025 15:25:23 +0100 Subject: [PATCH 26/29] Don't create a file when testing describe_diff. --- lib/iris/tests/unit/util/test_describe_diff.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/iris/tests/unit/util/test_describe_diff.py b/lib/iris/tests/unit/util/test_describe_diff.py index e99c47f417..f6593d04f2 100644 --- a/lib/iris/tests/unit/util/test_describe_diff.py +++ b/lib/iris/tests/unit/util/test_describe_diff.py @@ -64,4 +64,4 @@ def test_incompatible_array_attributes(self): self.cube_a.attributes["test_array"] = np.array([1, 2, 3]) self.cube_b.attributes["test_array"] = np.array([1, 2]) with pytest.raises(ValueError, match="Error comparing test_array attributes"): - _ = self._compare_result(self.cube_a, self.cube_b) + describe_diff(self.cube_a, self.cube_b) From 1d36b22be7c16af8636c7d32cb0f5a2577b06d6c Mon Sep 17 00:00:00 2001 From: Martin Yeo Date: Thu, 21 Aug 2025 15:42:15 +0100 Subject: [PATCH 27/29] Final tests for attribute comparison. --- lib/iris/tests/unit/coords/test_Coord.py | 6 ++++++ lib/iris/tests/unit/cube/test_Cube.py | 6 ++++++ .../unit/fileformats/netcdf/saver/test_save.py | 15 +++++++++++++++ 3 files changed, 27 insertions(+) diff --git a/lib/iris/tests/unit/coords/test_Coord.py b/lib/iris/tests/unit/coords/test_Coord.py index 97429f58f8..f81b1cc706 100644 --- a/lib/iris/tests/unit/coords/test_Coord.py +++ b/lib/iris/tests/unit/coords/test_Coord.py @@ -831,6 +831,12 @@ def test_different_array_attrs_incompatible(self): self.other_coord.attributes["array_test"] = np.array([1.0, 2, 777.7]) self.assertFalse(self.test_coord.is_compatible(self.other_coord)) + def test_misshaped_array_attrs_incompatible(self): + # Comparison should avoid broadcast failures and return False. + self.test_coord.attributes["array_test"] = np.array([1.0, 2, 3]) + self.other_coord.attributes["array_test"] = np.array([1.0, 2]) + self.assertFalse(self.test_coord.is_compatible(self.other_coord)) + class Test_contiguous_bounds(tests.IrisTest): def test_1d_coord_no_bounds_warning(self): diff --git a/lib/iris/tests/unit/cube/test_Cube.py b/lib/iris/tests/unit/cube/test_Cube.py index 21fa27d2f9..2a52478c45 100644 --- a/lib/iris/tests/unit/cube/test_Cube.py +++ b/lib/iris/tests/unit/cube/test_Cube.py @@ -876,6 +876,12 @@ def test_different_array_attrs_incompatible(self): self.other_cube.attributes["array_test"] = np.array([1.0, 2, 777.7]) assert not self.test_cube.is_compatible(self.other_cube) + def test_misshaped_array_attrs_incompatible(self): + # Comparison should avoid broadcast failures and return False. + self.test_cube.attributes["array_test"] = np.array([1.0, 2, 3]) + self.other_cube.attributes["array_test"] = np.array([1.0, 2]) + assert not self.test_cube.is_compatible(self.other_cube) + class Test_rolling_window: @pytest.fixture(autouse=True) diff --git a/lib/iris/tests/unit/fileformats/netcdf/saver/test_save.py b/lib/iris/tests/unit/fileformats/netcdf/saver/test_save.py index 860da84e6b..b4b06c8c33 100644 --- a/lib/iris/tests/unit/fileformats/netcdf/saver/test_save.py +++ b/lib/iris/tests/unit/fileformats/netcdf/saver/test_save.py @@ -79,6 +79,21 @@ def test_attributes_arrays(self): ds.close() self.assertArrayEqual(res, np.arange(2)) + def test_attributes_arrays_incompatible_shapes(self): + # Ensure successful comparison without raising a broadcast error. + c1 = Cube([1], attributes={"bar": np.arange(2)}) + c2 = Cube([2], attributes={"bar": np.arange(3)}) + + with self.temp_filename("foo.nc") as nc_out: + save([c1, c2], nc_out) + ds = _thread_safe_nc.DatasetWrapper(nc_out) + with pytest.raises(AttributeError): + _ = ds.getncattr("bar") + for var in ds.variables.values(): + res = var.getncattr("bar") + self.assertIsInstance(res, np.ndarray) + ds.close() + def test_no_special_attribute_clash(self): # Ensure that saving multiple cubes with netCDF4 protected attributes # works as expected. From aae364ac16e4d88b1ba3f458fe7e387c15fe801d Mon Sep 17 00:00:00 2001 From: Martin Yeo Date: Thu, 21 Aug 2025 15:58:16 +0100 Subject: [PATCH 28/29] Fix any versus all confusion. --- lib/iris/util.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/iris/util.py b/lib/iris/util.py index 2bdc3e5a33..1070cb088f 100644 --- a/lib/iris/util.py +++ b/lib/iris/util.py @@ -233,7 +233,7 @@ def describe_diff(cube_a, cube_b, output_file=None): common_keys = set(cube_a.attributes).intersection(cube_b.attributes) for key in common_keys: try: - eq = np.any(cube_a.attributes[key] == cube_b.attributes[key]) + eq = np.all(cube_a.attributes[key] == cube_b.attributes[key]) except ValueError as err: message = f"Error comparing {key} attributes: {err}" raise ValueError(message) From 46f04fa044797981cc4cf4cb500ae0a4e046ba91 Mon Sep 17 00:00:00 2001 From: Martin Yeo <40734014+trexfeathers@users.noreply.github.com> Date: Fri, 2 May 2025 10:08:13 +0100 Subject: [PATCH 29/29] Temporary Nox negation pin - see wntrblm/nox#961. (#6441) --- .github/workflows/benchmarks_run.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/benchmarks_run.yml b/.github/workflows/benchmarks_run.yml index f32d42caaf..287d84123f 100644 --- a/.github/workflows/benchmarks_run.yml +++ b/.github/workflows/benchmarks_run.yml @@ -78,7 +78,7 @@ jobs: - name: Install Nox run: | - pip install nox + pip install nox!=2025.05.01 - name: Cache environment directories id: cache-env-dir