From 01281a16a79e8cade6e0d3d23de30eb387b30205 Mon Sep 17 00:00:00 2001 From: deepak7lal Date: Mon, 14 Sep 2026 12:35:43 +0530 Subject: [PATCH] Fix pytest.approx mapping details for non-numeric values ApproxMapping._repr_compare guards its diff arithmetic with `except ZeroDivisionError`, so an unequal pair of non-numeric values under the same key raises TypeError into the assertion-repr hook and the mismatch table is replaced by "representation of details failed". The sequence path already handles this. Catching TypeError alone is not enough here. decimal.FloatOperation subclasses it, and #15006 made the sequence path re-raise that ahead of the non-number handler so a mapping mixing Decimals with floats cannot report the smaller of two differences as the maximum. Mirror both clauses rather than only the second. Adds the mapping counterpart of test_mixed_decimal_and_float_sequence_does_not_hide_float_operation, which fails without the re-raise, so the wrong maximum is now caught by the suite. Closes #15009 Co-Authored-By: Claude Opus 5 --- AUTHORS | 1 + changelog/15009.bugfix.rst | 12 +++++++++ src/_pytest/approx.py | 7 +++++- testing/python/approx.py | 51 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 changelog/15009.bugfix.rst diff --git a/AUTHORS b/AUTHORS index ad4c2093892..dd61acb332f 100644 --- a/AUTHORS +++ b/AUTHORS @@ -140,6 +140,7 @@ David Szotten David Vierra Daw-Ran Liou Debi Mishra +deepak7lal Denis Cherednichenko Denis Kirisov Denivy Braiam Rück diff --git a/changelog/15009.bugfix.rst b/changelog/15009.bugfix.rst new file mode 100644 index 00000000000..48a98901f42 --- /dev/null +++ b/changelog/15009.bugfix.rst @@ -0,0 +1,12 @@ +:func:`pytest.approx` now reports mismatch details for mappings whose values are +not numbers. + +Previously an unequal pair of non-numeric values under the same key -- for +example ``{"item": "a"} == approx({"item": "b"})`` -- made the diff calculation +raise ``TypeError``, so the assertion detail table was replaced by a +"representation of details failed" message. The equivalent sequence comparison +already handled this. + +``decimal.FloatOperation`` is re-raised ahead of that handler, matching the +sequence path, so a mapping that mixes Decimals with floats still surfaces the +trap instead of reporting the smaller of two differences as the maximum. diff --git a/src/_pytest/approx.py b/src/_pytest/approx.py index 70a881e3326..6a3875d03c8 100644 --- a/src/_pytest/approx.py +++ b/src/_pytest/approx.py @@ -352,7 +352,12 @@ def _repr_compare(self, other_side: Mapping[object, float]) -> list[str]: / approx_value.expected ), ) - except ZeroDivisionError: + # decimal.FloatOperation subclasses TypeError, so it would be + # caught below and reported as a missing difference. + except FloatOperation: + raise + # Ignore non-numbers for the diff calculations (#15009). + except (ZeroDivisionError, TypeError): pass different_ids.append(approx_key) diff --git a/testing/python/approx.py b/testing/python/approx.py index e0bfc37f85d..62f8115e5f1 100644 --- a/testing/python/approx.py +++ b/testing/python/approx.py @@ -1283,6 +1283,57 @@ def test_mixed_decimal_and_float_sequence_does_not_hide_float_operation( with pytest.raises(decimal.FloatOperation): approx(expected)._repr_compare(other) + def test_mixed_decimal_and_float_mapping_does_not_hide_float_operation( + self, monkeypatch + ) -> None: + """The mapping path must re-raise FloatOperation like the sequence one. + + Catching TypeError to skip non-numbers also catches + decimal.FloatOperation, and the reported maximum then silently becomes + the smaller of the two differences. Insertion order decides which, + so the wrong number is not even stable. + """ + expected = {"a": Decimal(9), "b": 9.0} + other = {"a": Decimal(1), "b": 2.0} + assert approx(expected)._repr_compare(other)[1] == "Max absolute difference: 8" + + monkeypatch.setitem(decimal.getcontext().traps, decimal.FloatOperation, True) + with pytest.raises(decimal.FloatOperation): + approx(expected)._repr_compare(other) + + def test_mixed_mapping(self, assert_approx_raises_regex) -> None: + """Approx should work on mappings that also contain non-numbers (#15009).""" + assert_approx_raises_regex( + {"a": 1.1, "b": 2, "c": "word"}, + {"a": 1.0, "b": 2, "c": "different"}, + [ + r"", + r" comparison failed. Mismatched elements: 2 / 3:", + rf" Max absolute difference: {SOME_FLOAT}", + rf" Max relative difference: {SOME_FLOAT}", + r" Index \| Obtained\s+\| Expected\s*", + r"\s*a\s*\|\s*1\.1\s*\|\s*1\.0\s*±\s*1\.0e\-06\s*", + r"\s*c\s*\|\s*word\s*\|\s*different\s*", + ], + verbosity_level=2, + ) + + def test_mapping_of_only_strings(self, assert_approx_raises_regex) -> None: + """A mapping whose values are all non-numeric still reports mismatches (#15009).""" + assert_approx_raises_regex( + {"item": "a"}, + {"item": "b"}, + [ + r"", + r" comparison failed. Mismatched elements: 1 / 1:", + r" Max absolute difference: -inf", + r" Max relative difference: -inf", + r" Index \| Obtained\s*\| Expected\s*", + r"\s*item\s*\|\s*a\s*\|\s*b\s*", + ], + verbosity_level=2, + ) + def test_decimal_nan_tolerance_raises_value_error(self) -> None: """A Decimal NaN tolerance must not escape as decimal.InvalidOperation.""" nan_abs = approx(Decimal(1), abs=Decimal("NaN"))