Skip to content

approx: Decimal and numpy cleanup - #15006

Merged
RonnyPfannschmidt merged 6 commits into
pytest-dev:mainfrom
RonnyPfannschmidt:fix-15005-decimal-outside-float-range
Sep 13, 2026
Merged

RonnyPfannschmidt merged 6 commits into
pytest-dev:mainfrom
RonnyPfannschmidt:fix-15005-decimal-outside-float-range

Conversation

@RonnyPfannschmidt

@RonnyPfannschmidt RonnyPfannschmidt commented Sep 13, 2026

Copy link
Copy Markdown
Member

This was explored and written by an AI agent (Claude Opus 5 via Claude Code) that I prompted. The diagnosis, the patches, the tests and this description are its work; I read it, reproduced it and am posting it.

Closes #15005.

A cleanup pass over pytest.approx, starting from #15005 and following the same mistake through the module. Five commits, each standalone with its own changelog fragment — reviewable and revertable one at a time.

Decimal and float meet in _pytest/approx.py in two ways, and only one is lossy:

  • float(Decimal) saturates. float(Decimal("1e400")) is inf and float(Decimal("1e-400")) is 0.0, silently. Every math.* call on a Decimal takes this route.
  • Comparing a Decimal to a float is exact. CPython compares true values (Decimal("0.1") < 0.1 is correctly True). Safe arithmetically; it only signals decimal.FloatOperation when that trap is set.

1. nan/inf checks — closes #15005

math.isnan/math.isinf take the lossy route, so approx() treated a finite Decimal("1e400") as infinite and short-circuited before the exact Decimal tolerance was used:

>>> Decimal("1.0000001e400") == pytest.approx(Decimal("1e400"), rel=Decimal("1e-6"))
False   # difference 1E+393, tolerance 1E+394

Five sites, not just the infinity guard the issue names: the same guard in __repr__, the NaN checks in __eq__ (which also raised ValueError on Decimal("sNaN")), and both NaN validations in tolerance. Fixed with _is_nan/_is_inf helpers using Decimal.is_nan()/Decimal.is_infinite(). No change for float/complex; Decimal("Infinity") still compares only to itself.

2. The displayed tolerance

#13543 fixed the FloatOperation crash of #13530 by overriding __repr__ in ApproxDecimal so self.tolerance was never touched. The override copied ApproxScalar's 1e-3 <= self.tolerance < 1e3 — where that bound only picks between :n and :.1e formatting — and reinterpreted it as a gate on whether to show rel at all. Its test asserted only that == stopped crashing, so no repr was pinned and the regression shipped.

8.3.5 9.1.1 here
approx(Decimal("2.60")) 2.60 ± 2.6e-6 2.60 ± ??? 2.60 ± 2.6e-6
rel=Decimal("1e-6") 2.60 ± 2.6e-6 2.60 ± ??? 2.60 ± 2.6e-6
rel=Decimal("0.01") 2.60 ± 2.6e-2 2.60 ± 1.0e-2 2.60 ± 2.6e-2
approx(Decimal("Infinity")) Infinity Infinity ± ??? Infinity
approx(Decimal("NaN")) raises InvalidOperation NaN ± ??? NaN ± ???

The ± N is the absolute band that decides pass/fail; rel is a dimensionless ratio. Printing rel there is a unit error — 2.60 ± 1.0e-2 reads as "within 0.01 of 2.60" when the band is 0.026.

The override is dropped and ApproxScalar.__repr__ made type-correct: for a Decimal tolerance the float-literal bound is skipped and :.1e used unconditionally. That is also the only readable choice, since ApproxDecimal.__init__ converts float tolerances with Decimal.from_float — exact, and so 55 digits for abs=0.01, which :n would print in full.

Removing the override re-exposed a bug it had masked: for a NaN Decimal the negativity check in tolerance runs before the NaN check and raises decimal.InvalidOperation instead of the ValueError that __repr__ catches. NaN is now checked first, matching the float path — which is why the last row improves on 8.3.5 rather than merely restoring it.

3. Float sentinels in failure messages

All three _repr_compare implementations seeded their running maxima with -math.inf and tested == 0.0, so building a message for Decimal collections compared a float against a Decimal. Under the trap the two container variants diverged:

Now accumulated from None, with -math.inf substituted only at the end where it still means "no difference could be computed" as #13012 needs. Divide-by-zero is a flag rather than a mid-loop math.inf assignment, which would reintroduce the mixing on the next element. The sequence variant re-raises decimal.FloatOperation explicitly, so a future leak fails loudly instead of reporting -inf.

4. numpy object dtype

>>> np.array([1.0, 2.0], dtype=object) == pytest.approx(np.array([1.0, 2.0], dtype=object))
AttributeError: 'float' object has no attribute 'item'

_yield_comparisons calls .item() to unbox numpy scalars; indexing an object array yields the stored Python object, which has none. Not Decimal-specific — object arrays of float and Fraction fail identically — but Decimal is how you get there without asking, since np.asarray([Decimal(1)]) picks object dtype on its own. It fires whenever the expected side is an object array.

Not a regression: .item() replaced np.asscalar() in 42bb0b390 when numpy 1.16 deprecated it, and np.asscalar() required an ndarray, so object dtype never worked. Unbox only when the element offers item().

I could not find an existing issue for this (searched asscalar, object dtype, dtype=object, and the error text); the nearest is #12114, a different attribute in a different method.

5. Warning on inexact float tolerances

A float tolerance with a Decimal expected value is converted exactly, so it is wider than what was written, and that can change the result:

>>> Decimal("1.10000000000000000001") == approx(Decimal("1"), rel=0.1)
True     # float band is 0.1000000000000000055511151231
>>> Decimal("1.10000000000000000001") == approx(Decimal("1"), rel=Decimal("0.1"))
False    # Decimal band is exactly 0.1

Of the tolerances anyone writes, only dyadic rationals (0.5, 0.25) survive unchanged; 1e-6, 1e-3, 0.01, 0.1, 5e-6, 1e-12 are all inexact. New pytest.PytestApproxDecimalToleranceWarning fires on those and stays quiet for exact floats, ints, Decimals, and non-Decimal comparisons.

The check lives in approx(), not ApproxDecimal.__init__. Containers build one ApproxDecimal per element from inside generator expressions — measured at 100 for a single repr() of a 100-element list — so warning there reports pytest's own source as the location, several times over. In approx() it warns once, at the line the user wrote, for scalars and containers alike.

This is also the tolerance half of what @RonnyPfannschmidt asked for on #8495 in 2021 ("we should definitively warn when we approx compare floats vs Decimals") — the half that currently succeeds silently.

Validation

Beyond the suite, the displayed tolerance was checked to be the actual pass/fail boundary across 65 combinations — magnitudes from 1e-400 to 1e100000, spanning both float underflow and overflow, crossed with default/rel/abs/both/rel=0 — with decimal.FloatOperation trapped throughout, so any remaining leak raises rather than passing quietly. The full approx suite also passes with that trap set globally.

Test gaps closed

  • test_decimal_approx_repr (the pytest.approx(Decimal(...)) Causes decimal.FloatOperation in __repr__ (pytest 8.4.0+) #13530 regression test) asserted only that == worked, never a repr — which is exactly how the repr regression shipped. It now pins the repr under the trap.
  • test_decimal_approx_float_rel pinned the regressed string 1.0e-2; it was added after the regression, so it encoded it.
  • New coverage for: effective-tolerance reprs cross-checked against the real comparison boundary; reprs beyond the float range in both directions; Infinity/NaN reprs; NaN tolerance raising ValueError not InvalidOperation; Decimal failure messages under the trap for sequences, mappings and both divide-by-zero paths; numpy object arrays of Decimal, float and Fraction; and the warning firing once at the caller's line while staying silent for exact tolerances.

6. Coverage

src/_pytest/approx.py goes from 99% (4 missed statements and a partial branch) to 100% statement and branch coverage. Two of the three gaps predate this branch, and all three turned out to be reachable rather than dead:

  • a ragged nested sequence is what makes np.asarray() fail, reaching cannot compare ... to numpy.ndarray;
  • ApproxMapping guards its relative difference with expected == 0, but timedelta(0) does not compare equal to 0, so the division still raises ZeroDivisionError;
  • the running maximum compares the current best against the next candidate, so a sequence mixing Decimal and float elements compares the two there — which is exactly the decimal.FloatOperation that commit 3 stops swallowing. Before that commit it reported 7 where the real maximum was 8.

Not addressed

#8495 (float vs Decimal values in __eq__) still fails identically on this branch — it needs the coercion decision from that thread, which is a larger question than this cleanup. #3247 was checked and no longer reproduces.

🤖 Generated with Claude Code

@psf-chronographer psf-chronographer Bot added the bot:chronographer:provided (automation) changelog entry is part of PR label Sep 13, 2026
@RonnyPfannschmidt RonnyPfannschmidt changed the title approx: don't route Decimal through float for nan/inf checks approx: correct float/Decimal type routing Sep 13, 2026
@RonnyPfannschmidt RonnyPfannschmidt changed the title approx: correct float/Decimal type routing approx: Decimal and numpy cleanup Sep 13, 2026
RonnyPfannschmidt and others added 6 commits September 13, 2026 09:00
Decimal.__float__ returns inf for values outside the float range instead
of raising, so math.isinf(abs(Decimal("1e400"))) was True and approx()
short-circuited to False before the exact Decimal tolerance was ever
used. The same lossy conversion affected the NaN checks, which also
raised ValueError on Decimal("sNaN"), and the tolerance validation.

Add _is_nan/_is_inf helpers that use Decimal.is_nan()/is_infinite() for
Decimal and math.isnan/math.isinf otherwise, and use them at the five
affected sites. Genuine Decimal infinities keep comparing only to
themselves.

Closes pytest-dev#15005.

Co-Authored-By: Claude Opus 5 (1M context) via Claude Code <noreply@anthropic.com>
pytest-dev#13543 fixed the decimal.FloatOperation crash in __repr__ by overriding
it in ApproxDecimal so that self.tolerance was never touched. The
override copied ApproxScalar's `1e-3 <= self.tolerance < 1e3` bound,
where it only selects between plain and scientific formatting, and
reinterpreted it as a gate on whether to show `rel` at all. Two things
broke:

- the default tolerance, and any rel below 1e-3, rendered as "???";
- what was rendered was the raw `rel` ratio, not the absolute band that
  actually decides pass/fail, so approx(Decimal("2.60"), rel=Decimal("0.01"))
  claimed "± 1.0e-2" for a band of 0.026.

Drop the override and make ApproxScalar.__repr__ type-correct instead.
Decimal and float meet in exactly two places, and only one of them is
lossy: Decimal-to-float conversion saturates (float(Decimal("1e400")) is
inf, float(Decimal("1e-400")) is 0.0), while comparing a Decimal to a
float is exact and only signals FloatOperation. So the bound comparison
is skipped for Decimal and scientific notation used unconditionally --
which also keeps the output readable, since a tolerance that came from a
float carries its full binary expansion (abs=0.01 is 55 digits).

Removing the override re-exposed a bug it had been masking: for a NaN
Decimal, the negativity check in `tolerance` runs before the NaN check
and raises decimal.InvalidOperation rather than the ValueError that
__repr__ catches. Check for NaN first, matching the float path.

Every repr now matches pytest 8.3.5 again, except that Decimal("NaN")
degrades to "??? " instead of raising.

Co-Authored-By: Claude Opus 5 (1M context) via Claude Code <noreply@anthropic.com>
All three _repr_compare implementations seeded their running maxima with
-math.inf and tested `== 0.0`, so building a failure message for Decimal
collections compared a float against a Decimal. That comparison is exact,
but it signals decimal.FloatOperation when that trap is set, and the two
container variants handled the signal differently:

- ApproxSequenceLike caught it. decimal.FloatOperation is a TypeError
  subclass, so the `except TypeError` added for pytest-dev#13012 to skip
  non-numbers swallowed it and the message reported
  `Max absolute difference: -inf`.
- ApproxMapping did not, so it escaped out of the assertion formatting
  as a bare decimal.FloatOperation.

Accumulate from None instead and only substitute -math.inf at the end,
where it still means "no difference could be computed" as pytest-dev#13012 needs.
Divide-by-zero is tracked as a flag rather than by assigning math.inf
mid-loop, which would reintroduce the mixing on the next element. The
sequence variant now re-raises decimal.FloatOperation explicitly, so a
future float leak fails loudly instead of reporting -inf.

Co-Authored-By: Claude Opus 5 (1M context) via Claude Code <noreply@anthropic.com>
ApproxNumpy._yield_comparisons calls .item() on every element to unbox
numpy scalars. Indexing an object-dtype array yields the stored Python
object instead, which has no .item(), so any such comparison raised
AttributeError:

    >>> np.array([1.0, 2.0], dtype=object) == pytest.approx(...)
    AttributeError: 'float' object has no attribute 'item'

This is not Decimal-specific -- object arrays of float and Fraction fail
the same way -- but Decimal is how one gets there without asking, since
np.asarray([Decimal(1)]) picks object dtype on its own.

Not a regression: .item() replaced np.asscalar() in 42bb0b3 when numpy
1.16 deprecated it, and np.asscalar() required an ndarray, so object
dtype never worked. Unbox only when the element actually offers item().

Co-Authored-By: Claude Opus 5 (1M context) via Claude Code <noreply@anthropic.com>
A float tolerance passed alongside a Decimal expected value is converted
with Decimal.from_float(), which is exact and so keeps the float's full
binary expansion: rel=0.01 is really 0.010000000000000000208.... That is
wider than the tolerance that was written, and it can change the result:

    >>> Decimal("1.10000000000000000001") == approx(Decimal("1"), rel=0.1)
    True
    >>> Decimal("1.10000000000000000001") == approx(Decimal("1"), rel=Decimal("0.1"))
    False

Of the tolerances anyone writes, only dyadic rationals such as 0.5 come
through unchanged, so the warning fires on the inexact ones and stays
quiet for exact floats, ints and Decimals.

The check lives in approx() rather than in ApproxDecimal.__init__,
because containers build one ApproxDecimal per element from inside
generator expressions -- 100 of them for a single repr() of a 100-element
list. Warning there reports pytest's own source as the location, several
times over; warning once in approx() reports the line the user wrote.

Co-Authored-By: Claude Opus 5 (1M context) via Claude Code <noreply@anthropic.com>
Takes src/_pytest/approx.py to 100% statement and branch coverage. Two
of the three gaps predate this branch; all three turned out reachable.

- A ragged nested sequence cannot be converted to an ndarray, which is
  what ApproxNumpy.__eq__ reports as "cannot compare ... to
  numpy.ndarray".

- ApproxMapping guards its relative difference with `expected == 0`, but
  timedelta(0) does not compare equal to 0, so the division still raises
  ZeroDivisionError and the relative difference stays unknown.

- The running maximum compares the current best against the next
  candidate, so a sequence mixing Decimal and float elements compares a
  Decimal against a float there. That signals decimal.FloatOperation when
  the trap is set, which -- being a TypeError subclass -- would otherwise
  be swallowed as a non-number and silently report the smaller of the two
  differences.

Co-Authored-By: Claude Opus 5 (1M context) via Claude Code <noreply@anthropic.com>

@Pierre-Sassoulas Pierre-Sassoulas left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM !

@RonnyPfannschmidt
RonnyPfannschmidt merged commit 86c6c35 into pytest-dev:main Sep 13, 2026
36 checks passed
deepak7lal added a commit to deepak7lal/pytest that referenced this pull request Sep 14, 2026
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 pytest-dev#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 pytest-dev#15009

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bot:chronographer:provided (automation) changelog entry is part of PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

pytest.approx returns False for in-tolerance finite Decimal values above the float range

2 participants