Equality Testers¶
This page describes the
coola.equality.tester package, which provides the registry-based
system for dispatching equality comparisons to type-specific testers.
Prerequisites: You'll need to know a bit of Python and understand the basics of the coola.equality package. For a refresher on Python, see the Python tutorial.
Overview¶
The coola.equality.tester package provides a registry system that manages equality testers for
different types. When you call objects_are_equal() or objects_are_allclose(), the registry
automatically selects the appropriate tester based on the type of the objects being compared.
Key components:
BaseEqualityTester: Abstract base class for all equality testersEqualityTesterRegistry: Registry that dispatches to appropriate testers by type- Type-specific testers: Specialized testers for Python built-ins and third-party libraries
- Default registry: Pre-configured registry with testers for common types
A tester never recurses: it returns a verdict or the child pairs that must all be equal, and the
Engine compares them with an explicit stack.
Key Concepts¶
Equality Testers¶
An equality tester is responsible for comparing objects of a specific type. Each tester:
- Checks if it can handle the given type
- Returns
Trueif the objects are equal,Falseif they are different (ctx.fail(reason)), or the child pairs that must all be equal
>>> from coola.equality import objects_are_equal
>>> from coola.equality.config import EqualityConfig
>>> from coola.equality.engine import Engine
>>> from coola.equality.tester import SequenceEqualityTester, get_default_registry
>>> engine = Engine(EqualityConfig())
>>> tester = SequenceEqualityTester()
>>> list(tester.objects_are_equal([1.0, 2.0], [1.0, 3.0], engine))
[(1.0, 1.0, 0), (2.0, 3.0, 1)]
>>> engine.run([1, 2, 3], [1, 2, 3])
True
>>> engine.run([1, 2, 3], [1, 2, 4])
False
Registry System¶
!!! tip "Import collisions"
`get_default_registry` exists in several `coola` subpackages with different return types. Import it
from `coola.equality.tester`, or import the module and call `tester.get_default_registry()`.
The registry maintains a mapping from types to testers and automatically selects the right tester:
>>> from coola.equality.config import EqualityConfig
>>> from coola.equality.tester import get_default_registry
>>> registry = get_default_registry()
>>> config = EqualityConfig()
>>> # Registry automatically selects SequenceEqualityTester for lists
>>> registry.objects_are_equal([1, 2, 3], [1, 2, 3], config)
True
>>> # Registry automatically selects MappingEqualityTester for dicts
>>> registry.objects_are_equal({"a": 1}, {"a": 1}, config)
True
The registry uses Python's Method Resolution Order (MRO) to find the most specific tester for a given type, which means it can handle inheritance correctly.
Built-in Testers¶
Collection Testers¶
SequenceEqualityTester¶
Handles sequence types like list, tuple, and deque:
>>> from coola.equality.config import EqualityConfig
>>> from coola.equality.engine import Engine
>>> from coola.equality.tester import get_default_registry
>>> config = EqualityConfig()
>>> engine = Engine(config)
>>> engine.run([1, 2, 3], [1, 2, 3])
True
>>> engine.run([1, 2, 3], [1, 2, 4])
False
>>> # Works with nested structures
>>> engine.run(
... [1, {"a": 2}, [3, 4]],
... [1, {"a": 2}, [3, 4]],
... )
True
The tester recursively compares elements using the registry, so nested structures are handled correctly.
SetEqualityTester¶
Handles set and frozenset. Unlike ==, the elements are matched by hash and type, so
equal_nan applies to them (the tolerance does not):
>>> from coola.equality.config import EqualityConfig
>>> from coola.equality.engine import Engine
>>> from coola.equality.tester import get_default_registry
>>> config = EqualityConfig(equal_nan=True)
>>> engine = Engine(config)
>>> engine.run({1.0, float("nan")}, {1.0, float("nan")})
True
>>> engine.run({1.0, 2.0}, {1.0, 3.0})
False
MappingEqualityTester¶
Handles mapping types like dict:
>>> from coola.equality.config import EqualityConfig
>>> from coola.equality.engine import Engine
>>> from coola.equality.tester import get_default_registry
>>> config = EqualityConfig()
>>> engine = Engine(config)
>>> engine.run({"a": 1, "b": 2}, {"a": 1, "b": 2})
True
>>> engine.run({"a": 1, "b": 2}, {"a": 1, "b": 3})
False
>>> # Works with nested structures
>>> engine.run(
... {"x": [1, 2], "y": {"z": 3}},
... {"x": [1, 2], "y": {"z": 3}},
... )
True
OrderedMappingEqualityTester¶
Like MappingEqualityTester, and it also requires the keys to be in the same order. It is
registered for collections.OrderedDict, whose order is meaningful (Python's == also compares it
between two OrderedDicts). The other mappings ignore the order of the keys:
>>> from collections import OrderedDict
>>> from coola.equality.config import EqualityConfig
>>> from coola.equality.engine import Engine
>>> from coola.equality.tester import get_default_registry
>>> config = EqualityConfig()
>>> engine = Engine(config)
>>> engine.run(OrderedDict(a=1, b=2), OrderedDict(a=1, b=2))
True
>>> engine.run(OrderedDict(a=1, b=2), OrderedDict(b=2, a=1))
False
Scalar Testers¶
ScalarEqualityTester¶
Handles scalar types like int, float, and bool:
>>> from coola.equality import Tolerance
>>> from coola.equality.config import EqualityConfig
>>> from coola.equality.engine import Engine
>>> from coola.equality.tester import get_default_registry, ScalarEqualityTester
>>> config = EqualityConfig()
>>> engine = Engine(config)
>>> tester = ScalarEqualityTester()
>>> tester.objects_are_equal(42, 42, engine)
True
>>> tester.objects_are_equal(3.14, 3.14, engine)
True
>>> # With tolerance
>>> engine_tol = Engine(EqualityConfig(tolerance=Tolerance(atol=0.01)))
>>> tester.objects_are_equal(1.0, 1.005, engine_tol)
True
EqualEqualityTester¶
Generic tester for objects that have an equal(other) method. The objects are equal if
actual.equal(expected) is true; an object without an equal method is not equal. It is not
registered by default: register it for your own types:
>>> from coola.equality.config import EqualityConfig
>>> from coola.equality.tester import EqualEqualityTester
>>> class MyList(list):
... def equal(self, other: object) -> bool:
... return self == other
>>> config = EqualityConfig()
>>> engine = Engine(config)
>>> tester = EqualEqualityTester()
>>> tester.objects_are_equal(MyList([1, 2, 3]), MyList([1, 2, 3]), engine)
True
>>> tester.objects_are_equal(MyList([1, 2, 3]), MyList([1, 2, 4]), engine)
False
DifferenceCloseEqualityTester¶
decimal.Decimal, fractions.Fraction and datetime.timedelta have no allclose method, so this
tester compares their difference with the tolerance. A Decimal NaN is equal to a NaN only if
equal_nan is True, and the atol of a timedelta is a number of seconds. It is registered for
these three types in the default registry:
>>> from decimal import Decimal
>>> from coola.equality import Tolerance
>>> from coola.equality.config import EqualityConfig
>>> from coola.equality.engine import Engine
>>> from coola.equality.tester import DifferenceCloseEqualityTester
>>> tester = DifferenceCloseEqualityTester()
>>> tester.objects_are_equal(Decimal("1.0"), Decimal("1.00001"), Engine(EqualityConfig()))
False
>>> config = EqualityConfig(tolerance=Tolerance(atol=1e-3))
>>> tester.objects_are_equal(Decimal("1.0"), Decimal("1.00001"), Engine(config))
True
NumPy Testers¶
NumpyArrayEqualityTester¶
Handles NumPy arrays:
>>> from coola.equality import Tolerance
>>> import numpy as np
>>> from coola.equality.config import EqualityConfig
>>> from coola.equality.engine import Engine
>>> from coola.equality.tester import get_default_registry, NumpyArrayEqualityTester
>>> config = EqualityConfig()
>>> engine = Engine(config)
>>> tester = NumpyArrayEqualityTester()
>>> tester.objects_are_equal(
... np.array([1, 2, 3]),
... np.array([1, 2, 3]),
... engine,
... )
True
>>> tester.objects_are_equal(
... np.array([1, 2, 3]),
... np.array([1, 2, 4]),
... engine,
... )
False
>>> # With tolerance
>>> engine_tol = Engine(EqualityConfig(tolerance=Tolerance(atol=0.01)))
>>> tester.objects_are_equal(
... np.array([1.0, 2.0, 3.0]),
... np.array([1.0, 2.0, 3.001]),
... engine_tol,
... )
True
NumpyMaskedArrayEqualityTester¶
Handles NumPy masked arrays:
>>> import numpy as np
>>> from coola.equality.config import EqualityConfig
>>> from coola.equality.engine import Engine
>>> from coola.equality.tester import get_default_registry, NumpyMaskedArrayEqualityTester
>>> config = EqualityConfig()
>>> engine = Engine(config)
>>> tester = NumpyMaskedArrayEqualityTester()
>>> arr1 = np.ma.array([1, 2, 3], mask=[0, 0, 1])
>>> arr2 = np.ma.array([1, 2, 99], mask=[0, 0, 1]) # Masked value differs
>>> engine.run(arr1, arr2)
False
NumpyTimeScalarEqualityTester¶
Handles numpy.timedelta64 and numpy.datetime64 scalars. NaT is equal to NaT only if
equal_nan is True. A timedelta64 is a duration, so the tolerances apply (atol is a number
of seconds), whereas a datetime64 is an instant and is compared exactly:
>>> import numpy as np
>>> from coola.equality import Tolerance
>>> from coola.equality.config import EqualityConfig
>>> from coola.equality.engine import Engine
>>> from coola.equality.tester import NumpyTimeScalarEqualityTester
>>> tester = NumpyTimeScalarEqualityTester()
>>> engine = Engine(EqualityConfig(tolerance=Tolerance(atol=1.0)))
>>> tester.objects_are_equal(np.timedelta64(1, "s"), np.timedelta64(2000, "ms"), engine)
True
>>> tester.objects_are_equal(np.datetime64("2020-01-01"), np.datetime64("2020-01-02"), engine)
False
PyTorch Testers¶
TorchTensorEqualityTester¶
Handles PyTorch tensors:
>>> from coola.equality import Tolerance
>>> import torch
>>> from coola.equality.config import EqualityConfig
>>> from coola.equality.engine import Engine
>>> from coola.equality.tester import get_default_registry, TorchTensorEqualityTester
>>> config = EqualityConfig()
>>> engine = Engine(config)
>>> tester = TorchTensorEqualityTester()
>>> tester.objects_are_equal(
... torch.tensor([1, 2, 3]),
... torch.tensor([1, 2, 3]),
... engine,
... )
True
>>> # With tolerance
>>> engine_tol = Engine(EqualityConfig(tolerance=Tolerance(atol=0.01)))
>>> tester.objects_are_equal(
... torch.tensor([1.0, 2.0, 3.0]),
... torch.tensor([1.0, 2.0, 3.001]),
... engine_tol,
... )
True
TorchPackedSequenceEqualityTester¶
Handles PyTorch PackedSequence objects:
>>> import torch
>>> from torch.nn.utils.rnn import pack_padded_sequence
>>> from coola.equality.config import EqualityConfig
>>> from coola.equality.engine import Engine
>>> from coola.equality.tester import get_default_registry, TorchPackedSequenceEqualityTester
>>> config = EqualityConfig()
>>> engine = Engine(config)
>>> tester = TorchPackedSequenceEqualityTester()
>>> ps1 = pack_padded_sequence(
... torch.arange(10).view(2, 5).float(),
... torch.tensor([5, 3], dtype=torch.long),
... batch_first=True,
... )
>>> ps2 = pack_padded_sequence(
... torch.arange(10).view(2, 5).float(),
... torch.tensor([5, 3], dtype=torch.long),
... batch_first=True,
... )
>>> engine.run(ps1, ps2)
True
Table Testers¶
pandas, polars and PyArrow run the same algorithm, written once in TableEqualityTester: the
structure is compared first (shape, names, data types, index), then the columns one by one, each by
its kind, and the comparison stops at the first column that differs. An adapter converts an object
of a library to a table. The default registry registers one TableEqualityTester per type:
| Library | Types |
|---|---|
| pandas | DataFrame, Series, Index (also MultiIndex), extension arrays |
| polars | DataFrame, Series |
| PyArrow | Array, ChunkedArray, RecordBatch, Table |
The numbers (integers, floats, durations) go through the numeric kernel: the tolerance and
equal_nan apply and the null values must be at the same positions. The other columns (booleans,
strings, categories, instants, decimals, nested values and object columns) are compared by the
library (equals), which never sees the tolerance or equal_nan.
Without NumPy, the PyArrow columns are compared by Arrow alone: the tolerance and equal_nan cannot
be applied, so a RuntimeWarning says that they are ignored (once per comparison, through
ctx.warn_once).
A table is one leaf for the engine: compare reports it as different, with the name of the first
column that differs and no row. Use the tools of the library to find the row.
>>> import pandas as pd
>>> from coola.equality import compare
>>> df1 = pd.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]})
>>> df2 = pd.DataFrame({"a": [1, 2, 3], "b": [4, 5, 7]})
>>> result = compare({"prices": df1}, {"prices": df2})
>>> result.path
('prices',)
>>> print(result.reason)
tables have different values: column 'b' differs
To support another library, write an adapter: a function that returns an object with a kind_name
(the plural word used in the reasons), a same_structure(other, ctx) method and a columns()
method that yields Columns, and register TableEqualityTester(adapter) for the type.
Testers for Objects with Methods¶
DataclassEqualityTester: Compare two instances of the same dataclass. The fields are compared through the registry, so they support the tolerance andequal_nanEqualNanEqualityTester: Compare objects that have anequal(other, equal_nan)methodTolerantEqualEqualityTester: Compare objects that have both anallclosemethod and anequalmethod.allcloseis used whenatolorrtolis not zero, otherwiseequal
Other Library Testers¶
JaxArrayEqualityTester: Compare JAX arraysXarrayDataArrayEqualityTester: Compare xarray DataArraysXarrayDatasetEqualityTester: Compare xarray DatasetsXarrayVariableEqualityTester: Compare xarray VariablesPydanticModelEqualityTester: Compare pydantic models. The models must have the same type, the same fields and the same extra values. The private attributes are not compared, and the location of a difference is the name of the field (ormodel_extra)ScipySparseEqualityTester: Comparescipy.sparsematrices and arrays. The class (so the format), the shape, the dtype and the non-zero positions must be equal, then the stored values are compared with the tolerance andequal_nan. Duplicate entries are summed and explicitly stored zeros are ignored, so two objects that represent the same matrix are equal
Tester for Objects Without Value Semantics¶
ObjectAttributesEqualityTester¶
A plain class that does not define __eq__, an exception such as ValueError("a"), and a
functools.partial are compared by identity: two equal-looking instances are different, unlike a
dataclass or a pydantic model, which are compared field by field. This is the rule of "equal means
==", so the tester is not registered by default. Register it for the classes that must be
compared by their attributes. It compares the entries of vars(obj), the slots of the class and of its
parent classes, the args of an exception and the func, args and keywords of a partial, through
the registry (so the tolerance and equal_nan apply), and reports the attribute that differs:
>>> from coola.equality import objects_are_equal
>>> from coola.equality.tester import (
... DefaultEqualityTester,
... EqualityTesterRegistry,
... ObjectAttributesEqualityTester,
... )
>>> class Point:
... def __init__(self, x):
... self.x = x
>>> registry = EqualityTesterRegistry({object: DefaultEqualityTester()})
>>> objects_are_equal(Point(1), Point(1), registry=registry)
False
>>> registry.register(Point, ObjectAttributesEqualityTester())
>>> objects_are_equal(Point(1), Point(1), registry=registry)
True
To change the default registry, use register_equality_testers, for example
register_equality_testers({BaseException: ObjectAttributesEqualityTester()}) to compare all the
exceptions by their args.
Default Tester¶
DefaultEqualityTester¶
The fallback tester used when no type-specific tester is registered:
>>> from coola.equality.config import EqualityConfig
>>> from coola.equality.tester import DefaultEqualityTester
>>> config = EqualityConfig()
>>> tester = DefaultEqualityTester()
>>> # Works with any type using == comparison
>>> tester.objects_are_equal("hello", "hello", config)
True
>>> tester.objects_are_equal(42, 42, config)
True
Using the Registry¶
Default Registry¶
Get the default pre-configured registry:
>>> from coola.equality.tester import get_default_registry
>>> from coola.equality.config import EqualityConfig
>>> registry = get_default_registry()
>>> config = EqualityConfig()
>>> # Use the registry to compare objects
>>> registry.objects_are_equal([1, 2, 3], [1, 2, 3], config)
True
The default registry includes testers for all supported types.
Registering Custom Testers¶
You can register custom testers for your own types:
>>> from coola.equality.tester import register_equality_testers, DefaultEqualityTester
>>> # Create a custom class
>>> class MyClass:
... def __init__(self, value):
... self.value = value
>>> # Register a tester for MyClass (modifies the global default registry)
>>> # Note: This is skipped in doctests to avoid side effects on the global state
>>> register_equality_testers({MyClass: DefaultEqualityTester()}) # doctest: +SKIP
For more complex types, you would create a custom tester class (see "Creating Custom Testers" section below).
Creating Custom Registries¶
You can create a custom registry with specific testers:
>>> from coola.equality.tester import (
... EqualityTesterRegistry,
... SequenceEqualityTester,
... MappingEqualityTester,
... DefaultEqualityTester,
... )
>>> from coola.equality.config import EqualityConfig
>>> registry = EqualityTesterRegistry(
... {
... list: SequenceEqualityTester(),
... dict: MappingEqualityTester(),
... object: DefaultEqualityTester(),
... }
... )
>>> config = EqualityConfig(registry=registry)
>>> registry.objects_are_equal([1, 2], [1, 2], config)
True
Creating Custom Testers¶
To create a custom tester, inherit from BaseEqualityTester and implement objects_are_equal. It
receives two objects of the same type and the Engine (ctx). For a leaf, return a verdict:
>>> from coola.equality import objects_are_equal
>>> from coola.equality.tester import BaseEqualityTester, register_equality_testers
>>> class Version:
... def __init__(self, major: int, minor: int) -> None:
... self.major = major
... self.minor = minor
>>> class VersionEqualityTester(BaseEqualityTester[Version]):
... def objects_are_equal(self, actual: Version, expected: Version, ctx) -> bool:
... return (actual.major, actual.minor) == (expected.major, expected.minor) or ctx.fail(
... lambda: (
... f"versions differ: {actual.major}.{actual.minor} vs {expected.major}.{expected.minor}"
... )
... )
ctx.fail(reason, *path) records the reason and returns False. A reason can be a callable: it is
called only when the difference is explained (compare, assert_*), so objects_are_equal formats
nothing. For a container, return the child pairs (actual, expected, path part); they are produced
lazily and in the order the first difference is wanted. The path part is a key or an index
(0, "a"), an AttributeName for an attribute, or an Element for an element of a set; it is
what compare reports in result.path. The engine compares them, applies the
tolerance, detects the cycles and locates the difference:
>>> from coola.equality import AttributeName, compare
>>> class Point:
... def __init__(self, x: float, y: float) -> None:
... self.x = x
... self.y = y
>>> class PointEqualityTester(BaseEqualityTester[Point]):
... def objects_are_equal(self, actual: Point, expected: Point, ctx):
... return (
... (actual.x, expected.x, AttributeName("x")),
... (actual.y, expected.y, AttributeName("y")),
... )
>>> register_equality_testers({Point: PointEqualityTester()}) # doctest: +SKIP
Two testers are equal if they have the same type and the same attributes (==); a tester has no
state but its configuration.
Advanced Usage¶
Type Resolution with MRO¶
The registry uses Python's Method Resolution Order (MRO) to find testers:
>>> from coola.equality.tester import get_default_registry
>>> from coola.equality.config import EqualityConfig
>>> from collections import OrderedDict
>>> registry = get_default_registry()
>>> config = EqualityConfig()
>>> # OrderedDict inherits from dict, but it has its own, more specific tester
>>> # And the tester checks that both objects have the same type
>>> od = OrderedDict([("a", 1), ("b", 2)])
>>> registry.objects_are_equal(od, {"a": 1, "b": 2}, config)
False
>>> # Comparing two OrderedDicts works (same type, same order)
>>> registry.objects_are_equal(od, OrderedDict([("a", 1), ("b", 2)]), config)
True
>>> registry.objects_are_equal(od, OrderedDict([("b", 2), ("a", 1)]), config)
False
This means you can register a tester for a base class and it will work for all subclasses.
Recursive Comparison¶
A tester does not recurse: a tester for a container returns the child pairs, and the Engine
compares them with the registry, so the nested values get the same tolerance and equal_nan:
>>> from coola.equality.tester import get_default_registry
>>> from coola.equality.config import EqualityConfig
>>> registry = get_default_registry()
>>> config = EqualityConfig()
>>> # Nested structure: list containing dict containing list
>>> nested1 = [1, {"a": [2, 3]}, 4]
>>> nested2 = [1, {"a": [2, 3]}, 4]
>>> registry.objects_are_equal(nested1, nested2, config)
True
The nesting depth is limited by the max_depth parameter in EqualityConfig: a deeper structure
raises a MaxDepthExceededError.
Performance Considerations¶
The registry caches the result of the type lookups (a bounded cache that is emptied when the registry changes) to optimize performance:
>>> from coola.equality.tester import get_default_registry
>>> registry = get_default_registry()
>>> # First lookup for a type will cache the result
>>> # Subsequent lookups for the same type will be faster
>>> # This is automatically managed internally
Common Use Cases¶
Testing Framework Integration¶
Testers are commonly used in testing frameworks. For the common case, assert_objects_equal and
assert_objects_allclose already raise an AssertionError with the path and the reason of the
first difference. To build your own assertion from a registry:
>>> from coola.equality.tester import get_default_registry
>>> from coola.equality.config import EqualityConfig
>>> def assert_equal(actual, expected):
... registry = get_default_registry()
... config = EqualityConfig()
... if not registry.objects_are_equal(actual, expected, config):
... raise AssertionError(f"{actual} != {expected}")
>>> assert_equal([1, 2, 3], [1, 2, 3]) # Passes
>>> # assert_equal([1, 2, 3], [1, 2, 4]) # Would raise AssertionError
Custom Type Support¶
Add support for your custom types:
>>> from coola.equality.tester import BaseEqualityTester, register_equality_testers
>>> class Point:
... def __init__(self, x, y):
... self.x = x
... self.y = y
>>> class PointEqualityTester(BaseEqualityTester[Point]):
... def objects_are_equal(self, actual, expected, ctx):
... return ((actual.x, expected.x, 0), (actual.y, expected.y, 1))
>>> # Register the tester (modifies global state, so skipped in doctests)
>>> register_equality_testers({Point: PointEqualityTester()}) # doctest: +SKIP
Tolerance-Based Comparisons¶
Use testers with tolerance configurations:
>>> from coola.equality import Tolerance
>>> import numpy as np
>>> from coola.equality.tester import get_default_registry
>>> from coola.equality.config import EqualityConfig
>>> registry = get_default_registry()
>>> config = EqualityConfig(tolerance=Tolerance(atol=1e-5, rtol=1e-5))
>>> registry.objects_are_equal(
... np.array([1.0, 2.0, 3.0]),
... np.array([1.00001, 2.00001, 3.00001]),
... config,
... )
True
Design Principles¶
The tester system is designed around several key principles:
-
Type-based dispatch: Automatically selects the right tester based on object type
-
Extensibility: Easy to add support for new types by registering custom testers
-
One extension point: A tester is a class with one method, and it never recurses
-
Iterative comparison: Nested structures are compared with an explicit stack, so there is no
RecursionError -
Performance: Caching of type lookups for efficient repeated comparisons
-
Configurability:
EqualityConfigprovides fine-grained control over comparison behavior
See Also¶
coola.equality: Main equality comparison functionscoola.registry: General registry pattern used by the tester system