approx: Decimal and numpy cleanup - #15006
Merged
RonnyPfannschmidt merged 6 commits intoSep 13, 2026
Merged
RonnyPfannschmidt merged 6 commits into
RonnyPfannschmidt merged 6 commits into
Conversation
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>
RonnyPfannschmidt
force-pushed
the
fix-15005-decimal-outside-float-range
branch
from
September 13, 2026 07:03
4a11d3a to
bccb2f2
Compare
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.Decimalandfloatmeet in_pytest/approx.pyin two ways, and only one is lossy:float(Decimal)saturates.float(Decimal("1e400"))isinfandfloat(Decimal("1e-400"))is0.0, silently. Everymath.*call on a Decimal takes this route.Decimalto afloatis exact. CPython compares true values (Decimal("0.1") < 0.1is correctlyTrue). Safe arithmetically; it only signalsdecimal.FloatOperationwhen that trap is set.1.
nan/infchecks — closes #15005math.isnan/math.isinftake the lossy route, soapprox()treated a finiteDecimal("1e400")as infinite and short-circuited before the exact Decimal tolerance was used:Five sites, not just the infinity guard the issue names: the same guard in
__repr__, the NaN checks in__eq__(which also raisedValueErroronDecimal("sNaN")), and both NaN validations intolerance. Fixed with_is_nan/_is_infhelpers usingDecimal.is_nan()/Decimal.is_infinite(). No change forfloat/complex;Decimal("Infinity")still compares only to itself.2. The displayed tolerance
#13543 fixed the
FloatOperationcrash of #13530 by overriding__repr__inApproxDecimalsoself.tolerancewas never touched. The override copiedApproxScalar's1e-3 <= self.tolerance < 1e3— where that bound only picks between:nand:.1eformatting — and reinterpreted it as a gate on whether to showrelat all. Its test asserted only that==stopped crashing, so no repr was pinned and the regression shipped.approx(Decimal("2.60"))2.60 ± 2.6e-62.60 ± ???2.60 ± 2.6e-6rel=Decimal("1e-6")2.60 ± 2.6e-62.60 ± ???2.60 ± 2.6e-6rel=Decimal("0.01")2.60 ± 2.6e-22.60 ± 1.0e-22.60 ± 2.6e-2approx(Decimal("Infinity"))InfinityInfinity ± ???Infinityapprox(Decimal("NaN"))InvalidOperationNaN ± ???NaN ± ???The
± Nis the absolute band that decides pass/fail;relis a dimensionless ratio. Printingrelthere is a unit error —2.60 ± 1.0e-2reads 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:.1eused unconditionally. That is also the only readable choice, sinceApproxDecimal.__init__converts float tolerances withDecimal.from_float— exact, and so 55 digits forabs=0.01, which:nwould print in full.Removing the override re-exposed a bug it had masked: for a NaN Decimal the negativity check in
toleranceruns before the NaN check and raisesdecimal.InvalidOperationinstead of theValueErrorthat__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_compareimplementations seeded their running maxima with-math.infand tested== 0.0, so building a message for Decimal collections compared a float against a Decimal. Under the trap the two container variants diverged:ApproxSequenceLikeswallowed it.decimal.FloatOperationis aTypeErrorsubclass, so theexcept TypeErroradded for Fix 13010 - Ensure pytest.approx in sequence/collections only process instances of Number #13012 to skip non-numbers caught it, and the message reportedMax absolute difference: -inf.ApproxMappingdid not, so it escaped out of the assertion formatting as a baredecimal.FloatOperation.Now accumulated from
None, with-math.infsubstituted 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-loopmath.infassignment, which would reintroduce the mixing on the next element. The sequence variant re-raisesdecimal.FloatOperationexplicitly, so a future leak fails loudly instead of reporting-inf.4. numpy object dtype
_yield_comparisonscalls.item()to unbox numpy scalars; indexing an object array yields the stored Python object, which has none. Not Decimal-specific — object arrays offloatandFractionfail identically — but Decimal is how you get there without asking, sincenp.asarray([Decimal(1)])picks object dtype on its own. It fires whenever the expected side is an object array.Not a regression:
.item()replacednp.asscalar()in42bb0b390when numpy 1.16 deprecated it, andnp.asscalar()required an ndarray, so object dtype never worked. Unbox only when the element offersitem().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:
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-12are all inexact. Newpytest.PytestApproxDecimalToleranceWarningfires on those and stays quiet for exact floats, ints, Decimals, and non-Decimal comparisons.The check lives in
approx(), notApproxDecimal.__init__. Containers build oneApproxDecimalper element from inside generator expressions — measured at 100 for a singlerepr()of a 100-element list — so warning there reports pytest's own source as the location, several times over. Inapprox()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-400to1e100000, spanning both float underflow and overflow, crossed with default/rel/abs/both/rel=0— withdecimal.FloatOperationtrapped 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_relpinned the regressed string1.0e-2; it was added after the regression, so it encoded it.Infinity/NaNreprs; NaN tolerance raisingValueErrornotInvalidOperation; 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.pygoes 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:np.asarray()fail, reachingcannot compare ... to numpy.ndarray;ApproxMappingguards its relative difference withexpected == 0, buttimedelta(0)does not compare equal to0, so the division still raisesZeroDivisionError;Decimalandfloatelements compares the two there — which is exactly thedecimal.FloatOperationthat commit 3 stops swallowing. Before that commit it reported7where the real maximum was8.Not addressed
#8495 (
floatvsDecimalvalues 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