pytest: approx() and nested containers
Merged into pytest. approx() refused to descend into a nested container and said so clearly, but only when that container matched the type of the one holding it. A dict inside a list slipped past and was compared exactly, so the tolerance was ignored without warning. Reported in 2022 and still reproducing on main.
What was wrong
Both nesting guards tested the child against the type of its parent: isinstance(value, type(expected)) in ApproxMapping, and the same shape in ApproxSequenceLike. A list inside a list matched and raised the intended error. A dict inside a list did not match, so approx treated it as a leaf and compared it with ==.
That comparison is exact, which makes the failure quiet. [{"a": 0.1 + 1e-9}] == approx([{"a": 0.1}]) returned False even though the values sit well inside the default tolerance. Passing rel= explicitly produced a third behaviour: an error from a lower layer that never mentions nesting at all.
The change
Ask whether the value is a container at all, rather than whether it matches the parent type. A single helper tests for Collection and excludes str, bytes, and bytearray, which approx treats as leaves on purpose.
An earlier attempt in 2022 patched only ApproxSequenceLike, which would have left approx({"a": [1.0]}) quietly wrong. This one covers both sides and leaves both existing error messages untouched, so each container still reports in its own wording.
Behaviour change worth flagging
A numpy array nested inside a list or dict now raises instead of returning a result. That case was already broken: it returned False for values inside the tolerance. The change turns a quiet wrong answer into a clear error. A top-level array is unaffected, because ApproxNumpy handles it and does its own nesting.
Tests
- Extended the type-error test with every cross-kind combination: list-of-tuple, list-of-set, list-of-dict, tuple-of-dict, dict-of-list, dict-of-tuple, dict-of-set.
- Added a test that pins the actual bug, where values inside the default tolerance used to compare unequal instead of raising.
- Extended the non-numeric equality test with bytes leaves, so the str/bytes exclusion is held by a test rather than by the implementation.
- Eleven of these failed before the change. After it, 149 passed in the approx suite and 4,483 passed across the full run, with ruff and mypy clean on both touched files.