diff --git a/docs/src/whatsnew/latest.rst b/docs/src/whatsnew/latest.rst index 06443cd344..641a8cb491 100644 --- a/docs/src/whatsnew/latest.rst +++ b/docs/src/whatsnew/latest.rst @@ -73,6 +73,13 @@ This document explains the changes made to Iris for this release a new error class: :class:`~iris.exceptions.CannotAddError` (subclass of :class:`ValueError`). (:pull:`5054`) +#. `@pp-mo`_ implemented == and != comparisons for :class:`~iris.Constraint` s. + A simple constraint is now == to another one constructed in the same way. + However, equality is limited for more complex cases : value-matching functions must + be the same identical function, and for &-combinations order is significant, + i.e. ``(c1 & c2) != (c2 & c1)``. + (:issue:`3616`, :pull:`3749`). + 🐛 Bugs Fixed ============= diff --git a/lib/iris/_constraints.py b/lib/iris/_constraints.py index 4e23793e1d..bfd4865f56 100644 --- a/lib/iris/_constraints.py +++ b/lib/iris/_constraints.py @@ -131,6 +131,30 @@ def latitude_bands(cell): _CoordConstraint(coord_name, coord_thing) ) + def __eq__(self, other): + # Equivalence is defined, but is naturally limited for any Constraints + # based on callables, i.e. "cube_func", or value functions for + # attributes/names/coords : These can only be == if they contain the + # *same* callable object (i.e. same object identity). + eq = ( + type(other) == Constraint + and self._name == other._name + and self._cube_func == other._cube_func + and self._coord_constraints == other._coord_constraints + ) + # NOTE: theoretically, you could compare coord constraints as a *set*, + # as order should not affect matching. + # Not totally sure, so for now let's not. + return eq + + def __hash__(self): + # We want constraints to have hashes, so they can act as e.g. + # dictionary keys or tuple elements. + # So, we *must* provide this, as overloading '__eq__' automatically + # disables it. + # Just use basic object identity. + return id(self) + def __repr__(self): args = [] if self._name: @@ -218,6 +242,19 @@ def __init__(self, lhs, rhs, operator): self.rhs = rhs_constraint self.operator = operator + def __eq__(self, other): + eq = ( + type(other) == ConstraintCombination + and self.lhs == other.lhs + and self.rhs == other.rhs + and self.operator == other.operator + ) + return eq + + def __hash__(self): + # Must re-define if you overload __eq__ : Use object identity. + return id(self) + def _coordless_match(self, cube): return self.operator( self.lhs._coordless_match(cube), self.rhs._coordless_match(cube) @@ -261,6 +298,18 @@ def __repr__(self): self._coord_thing, ) + def __eq__(self, other): + eq = ( + type(other) == _CoordConstraint + and self.coord_name == other.coord_name + and self._coord_thing == other._coord_thing + ) + return eq + + def __hash__(self): + # Must re-define if you overload __eq__ : Use object identity. + return id(self) + def extract(self, cube): """ Returns the the column based indices of the given cube which @@ -493,6 +542,17 @@ def __init__(self, **attributes): self._attributes = attributes super().__init__(cube_func=self._cube_func) + def __eq__(self, other): + eq = ( + type(other) == AttributeConstraint + and self._attributes == other._attributes + ) + return eq + + def __hash__(self): + # Must re-define if you overload __eq__ : Use object identity. + return id(self) + def _cube_func(self, cube): match = True for name, value in self._attributes.items(): @@ -577,6 +637,17 @@ def __init__( self._names = ("standard_name", "long_name", "var_name", "STASH") super().__init__(cube_func=self._cube_func) + def __eq__(self, other): + eq = type(other) == NameConstraint and all( + getattr(self, attname) == getattr(other, attname) + for attname in self._names + ) + return eq + + def __hash__(self): + # Must re-define if you overload __eq__ : Use object identity. + return id(self) + def _cube_func(self, cube): def matcher(target, value): if callable(value): diff --git a/lib/iris/tests/unit/constraints/test_Constraint_equality.py b/lib/iris/tests/unit/constraints/test_Constraint_equality.py new file mode 100644 index 0000000000..01e61b70a7 --- /dev/null +++ b/lib/iris/tests/unit/constraints/test_Constraint_equality.py @@ -0,0 +1,274 @@ +# 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 equality testing of different constraint types.""" + +# Import iris.tests first so that some things can be initialised before +# importing anything else. +import iris.tests as tests # isort:skip + +from iris._constraints import AttributeConstraint, Constraint, NameConstraint + + +class Test_Constraint__hash__(tests.IrisTest): + def test_empty(self): + c1 = Constraint() + c2 = Constraint() + self.assertEqual(hash(c1), hash(c1)) + self.assertNotEqual(hash(c1), hash(c2)) + + +class Test_Constraint__eq__(tests.IrisTest): + def test_empty_same(self): + c1 = Constraint() + c2 = Constraint() + self.assertEqual(c1, c2) + self.assertIsNot(c1, c2) + + def test_emptyname_same(self): + c1 = Constraint("") + c2 = Constraint("") + self.assertEqual(c1, c2) + + def test_empty_emptyname_differ(self): + c1 = Constraint() + c2 = Constraint("") + self.assertNotEqual(c1, c2) + + def test_names_same(self): + c1 = Constraint("a") + c2 = Constraint("a") + self.assertEqual(c1, c2) + + def test_names_differ(self): + c1 = Constraint("a") + c2 = Constraint("b") + self.assertNotEqual(c1, c2) + + def test_funcs_same(self): + # *Same* functions match + def func(cube): + return False + + c1 = Constraint(cube_func=func) + c2 = Constraint(cube_func=func) + self.assertEqual(c1, c2) + + def test_funcs_differ(self): + # Identical but different funcs do not match. + c1 = Constraint(cube_func=lambda c: False) + c2 = Constraint(cube_func=lambda c: False) + self.assertNotEqual(c1, c2) + + def test_coord_names_same(self): + c1 = Constraint(some_coordname=3) + c2 = Constraint(some_coordname=3) + self.assertEqual(c1, c2) + + def test_coord_names_differ(self): + c1 = Constraint(some_coordname_A=3) + c2 = Constraint(some_coordname_B=3) + self.assertNotEqual(c1, c2) + + def test_coord_values_differ(self): + c1 = Constraint(some_coordname=3) + c2 = Constraint(some_coordname=4) + self.assertNotEqual(c1, c2) + + def test_coord_orders_differ(self): + # We *could* maybe ignore Coordinate order, but at present we don't. + c1 = Constraint(coordname_1=1, coordname_2=2) + c2 = Constraint(coordname_2=2, coordname_1=1) + self.assertNotEqual(c1, c2) + + def test_coord_values_functions_same(self): + def func(coord): + return False + + c1 = Constraint(some_coordname=func) + c2 = Constraint(some_coordname=func) + self.assertEqual(c1, c2) + + def test_coord_values_functions_differ(self): + # Identical functions are not the same. + c1 = Constraint(some_coordname=lambda c: True) + c2 = Constraint(some_coordname=lambda c: True) + self.assertNotEqual(c1, c2) + + def test_coord_values_and_keys_same(self): + # **kwargs and 'coord_values=' are combined without distinction. + c1 = Constraint(coord_values={"a": [2, 3]}) + c2 = Constraint(a=[2, 3]) + self.assertEqual(c1, c2) + + +class Test_AttributeConstraint__hash__(tests.IrisTest): + def test_empty(self): + c1 = AttributeConstraint() + c2 = AttributeConstraint() + self.assertEqual(hash(c1), hash(c1)) + self.assertNotEqual(hash(c1), hash(c2)) + + +class Test_AttributeConstraint__eq__(tests.IrisTest): + def test_empty_same(self): + c1 = AttributeConstraint() + c2 = AttributeConstraint() + self.assertEqual(c1, c2) + self.assertIsNot(c1, c2) + + def test_attribute_plain_empty_diff(self): + c1 = AttributeConstraint() + c2 = Constraint() + self.assertNotEqual(c1, c2) + + def test_names_same(self): + c1 = AttributeConstraint(a=1) + c2 = AttributeConstraint(a=1) + self.assertEqual(c1, c2) + + def test_names_diff(self): + c1 = AttributeConstraint(a=1) + c2 = AttributeConstraint(a=1, b=1) + self.assertNotEqual(c1, c2) + + def test_values_diff(self): + c1 = AttributeConstraint(a=1, b=1) + c2 = AttributeConstraint(a=1, b=2) + self.assertNotEqual(c1, c2) + + def test_func_same(self): + def func(attrs): + return False + + c1 = AttributeConstraint(a=func) + c2 = AttributeConstraint(a=func) + self.assertEqual(c1, c2) + + def test_func_diff(self): + c1 = AttributeConstraint(a=lambda a: False) + c2 = AttributeConstraint(a=lambda a: False) + self.assertNotEqual(c1, c2) + + +class Test_NameConstraint__hash__(tests.IrisTest): + def test_empty(self): + c1 = NameConstraint() + c2 = NameConstraint() + self.assertEqual(hash(c1), hash(c1)) + self.assertNotEqual(hash(c1), hash(c2)) + + +class Test_NameConstraint__eq__(tests.IrisTest): + def test_empty_same(self): + c1 = NameConstraint() + c2 = NameConstraint() + self.assertEqual(c1, c2) + self.assertIsNot(c1, c2) + + def test_attribute_plain_empty_diff(self): + c1 = NameConstraint() + c2 = Constraint() + self.assertNotEqual(c1, c2) + + def test_names_same(self): + c1 = NameConstraint(standard_name="air_temperature") + c2 = NameConstraint(standard_name="air_temperature") + self.assertEqual(c1, c2) + + def test_full_same(self): + c1 = NameConstraint( + standard_name="air_temperature", + long_name="temp", + var_name="tair", + STASH="m01s02i003", + ) + c2 = NameConstraint( + standard_name="air_temperature", + long_name="temp", + var_name="tair", + STASH="m01s02i003", + ) + self.assertEqual(c1, c2) + + def test_missing_diff(self): + c1 = NameConstraint(standard_name="air_temperature", var_name="tair") + c2 = NameConstraint(standard_name="air_temperature") + self.assertNotEqual(c1, c2) + + def test_standard_name_diff(self): + c1 = NameConstraint(standard_name="air_temperature") + c2 = NameConstraint(standard_name="height") + self.assertNotEqual(c1, c2) + + def test_long_name_diff(self): + c1 = NameConstraint(long_name="temp") + c2 = NameConstraint(long_name="t3") + self.assertNotEqual(c1, c2) + + def test_var_name_diff(self): + c1 = NameConstraint(var_name="tair") + c2 = NameConstraint(var_name="xxx") + self.assertNotEqual(c1, c2) + + def test_stash_diff(self): + c1 = NameConstraint(STASH="m01s02i003") + c2 = NameConstraint(STASH="m01s02i777") + self.assertNotEqual(c1, c2) + + def test_func_same(self): + def func(name): + return True + + c1 = NameConstraint(STASH="m01s02i003", long_name=func) + c2 = NameConstraint(STASH="m01s02i003", long_name=func) + self.assertEqual(c1, c2) + + def test_func_diff(self): + c1 = NameConstraint(STASH="m01s02i003", long_name=lambda n: True) + c2 = NameConstraint(STASH="m01s02i003", long_name=lambda n: True) + self.assertNotEqual(c1, c2) + + +class Test_ConstraintCombination__hash__(tests.IrisTest): + def test_empty(self): + c1 = Constraint() & Constraint() + c2 = Constraint() & Constraint() + self.assertEqual(hash(c1), hash(c1)) + self.assertNotEqual(hash(c1), hash(c2)) + + def test_identical_construction(self): + c1, c2 = Constraint(a=1), Constraint(b=1) + cc1 = c1 & c2 + cc2 = c1 & c2 + self.assertNotEqual(hash(cc1), hash(cc2)) + + +class Test_ConstraintCombination__eq__(tests.IrisTest): + def test_empty_same(self): + c1 = Constraint() & Constraint() + c2 = Constraint() & Constraint() + self.assertEqual(c1, c2) + self.assertIsNot(c1, c2) + + def test_multi_components_same(self): + c1 = Constraint("a") & Constraint(b=1) + c2 = Constraint("a") & Constraint(b=1) + self.assertEqual(c1, c2) + + def test_multi_components_diff(self): + c1 = Constraint("a") & Constraint(b=1, c=2) + c2 = Constraint("a") & Constraint(b=1) + self.assertNotEqual(c1, c2) + + def test_different_component_order(self): + c1, c2 = Constraint("a"), Constraint(b=1) + cc1 = c1 & c2 + cc2 = c2 & c1 + self.assertNotEqual(cc1, cc2) + + +if __name__ == "__main__": + tests.main()