From 3dff92680a44b3f1d6dae3a6e167bee3131a527a Mon Sep 17 00:00:00 2001 From: EternalRights <3147268827@qq.com> Date: Tue, 12 May 2026 08:42:49 +0800 Subject: [PATCH 1/7] fix approx rel for timedelta: accept float, compute rel * expected --- changelog/14462.bugfix.rst | 1 + src/_pytest/python_api.py | 35 +++++++++++++++++++++++++---------- testing/python/approx.py | 38 +++++++++++++++++++++++++++++++++----- 3 files changed, 59 insertions(+), 15 deletions(-) create mode 100644 changelog/14462.bugfix.rst diff --git a/changelog/14462.bugfix.rst b/changelog/14462.bugfix.rst new file mode 100644 index 00000000000..8127267e654 --- /dev/null +++ b/changelog/14462.bugfix.rst @@ -0,0 +1 @@ +Fixed ``rel`` parameter in :func:`pytest.approx` for :class:`~datetime.timedelta` comparisons -- it was being treated as an absolute tolerance instead of a relative one. diff --git a/src/_pytest/python_api.py b/src/_pytest/python_api.py index f6d5e31a588..22832e11d6c 100644 --- a/src/_pytest/python_api.py +++ b/src/_pytest/python_api.py @@ -565,7 +565,7 @@ class ApproxTimedelta(ApproxBase): """Perform approximate comparisons where the expected value is a datetime or timedelta. - Requires an explicit tolerance as a timedelta. + Requires an explicit tolerance as a timedelta for abs, or a float for rel. Relative tolerance is not supported for datetime comparisons. """ @@ -592,13 +592,26 @@ def __init__(self, expected, rel=None, abs=None, nan_ok: bool = False) -> None: f"absolute tolerance for datetime/timedelta must be a " f"timedelta, got {type(abs).__name__}" ) - if rel is not None and not isinstance(rel, timedelta): - raise TypeError( - f"relative tolerance for timedelta must be a " - f"timedelta, got {type(rel).__name__}" - ) - tolerance = max(t for t in (abs, rel) if t is not None) - super().__init__(expected, rel=None, abs=tolerance, nan_ok=False) + if rel is not None: + if isinstance(expected, datetime): + raise TypeError( + "pytest.approx() does not support relative tolerance for " + "datetime comparisons. Use abs=timedelta(...) instead." + ) + if not isinstance(rel, (int, float)): + raise TypeError( + f"relative tolerance for timedelta must be a " + f"number, got {type(rel).__name__}" + ) + # Compute the effective tolerance. abs_tolerance is a timedelta, rel * expected + # gives a timedelta (timedelta * float works in Python). + abs_tolerance = abs + rel_tolerance = rel * builtins.abs(expected) if rel is not None else None + if abs_tolerance is not None and rel_tolerance is not None: + tolerance = max(abs_tolerance, rel_tolerance) + else: + tolerance = abs_tolerance if abs_tolerance is not None else rel_tolerance + super().__init__(expected, rel=rel, abs=tolerance, nan_ok=False) def __repr__(self) -> str: return f"{self.expected} ± {self.abs}" @@ -757,8 +770,10 @@ def approx( >>> dt1 == approx(dt2, abs=timedelta(seconds=1)) True - Note that ``rel`` is not supported for datetime comparisons, - and ``abs`` or ``rel`` must be explicitly provided as a ``timedelta`` object. + Note that ``rel`` is not supported for datetime comparisons. + For timedelta comparisons, ``rel`` is a number (not a timedelta) that + represents a relative tolerance -- a fraction of the expected value. + ``abs`` must be a ``timedelta`` object in both cases. .. versionadded:: 8.4 diff --git a/testing/python/approx.py b/testing/python/approx.py index 4369dc24ad4..8facbb6d280 100644 --- a/testing/python/approx.py +++ b/testing/python/approx.py @@ -1172,14 +1172,14 @@ def test_timedelta_rel_within_tolerance(self): td1 = timedelta(seconds=100) td2 = timedelta(seconds=100.5) - assert td1 == approx(td2, rel=timedelta(seconds=1)) + assert td1 == approx(td2, rel=0.01) def test_timedelta_rel_outside_tolerance(self): from datetime import timedelta td1 = timedelta(seconds=100) td2 = timedelta(seconds=102) - assert td1 != approx(td2, rel=timedelta(seconds=1)) + assert td1 != approx(td2, rel=0.01) def test_requires_tolerance(self): from datetime import datetime @@ -1203,11 +1203,39 @@ def test_abs_must_be_timedelta(self): with pytest.raises(TypeError, match="must be a timedelta"): approx(datetime(2024, 1, 1), abs=1.0) - def test_timedelta_rel_must_be_timedelta(self): + def test_timedelta_rel_must_be_number(self): from datetime import timedelta - with pytest.raises(TypeError, match="must be a timedelta"): - approx(timedelta(seconds=1), rel=0.1) + with pytest.raises(TypeError, match="must be a number"): + approx(timedelta(seconds=1), rel=timedelta(seconds=1)) + + def test_timedelta_rel_with_abs(self): + from datetime import timedelta + + # rel=0.05 gives 5s tolerance, abs=timedelta(seconds=1) gives 1s. + # max(1s, 5s) = 5s tolerance. + td1 = timedelta(seconds=100) + td2 = timedelta(seconds=104) + assert td1 == approx(td2, rel=0.05, abs=timedelta(seconds=1)) + + def test_timedelta_rel_zero(self): + from datetime import timedelta + + # rel=0 means exact match required (0 * expected = 0) + td1 = timedelta(seconds=100) + assert td1 == approx(td1, rel=0.0, abs=timedelta(seconds=0)) + assert td1 != approx(timedelta(seconds=101), rel=0.0, abs=timedelta(seconds=0)) + + def test_timedelta_rel_scales_with_expected(self): + from datetime import timedelta + + # Same rel=0.1, but different expected values. + # 10% of 100s = 10s, 10% of 200s = 20s. + assert timedelta(seconds=109) == approx(timedelta(seconds=100), rel=0.1) + assert timedelta(seconds=218) == approx(timedelta(seconds=200), rel=0.1) + # 11s is > 10% of 100s, but < 10% of 200s + assert timedelta(seconds=111) != approx(timedelta(seconds=100), rel=0.1) + assert timedelta(seconds=211) == approx(timedelta(seconds=200), rel=0.1) def test_rejects_nan_ok(self): from datetime import datetime From 66656a9579e03360c4496b6cf1dbf38cecf24cfd Mon Sep 17 00:00:00 2001 From: eternalrights <3147268827@qq.com> Date: Tue, 12 May 2026 12:45:46 +0800 Subject: [PATCH 2/7] Add validation for negative and NaN tolerances in ApproxTimedelta The PR forgot to validate rel and abs for timedelta the same way ApproxScalar does. Without these checks, a negative rel produces a negative timedelta tolerance that makes every comparison silently return False, and NaN rel throws a confusing error message. - Raise ValueError for negative rel - Raise ValueError for NaN rel - Raise ValueError for negative abs (timedelta) - Add corresponding tests --- src/_pytest/python_api.py | 10 ++++++++++ testing/python/approx.py | 18 ++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/src/_pytest/python_api.py b/src/_pytest/python_api.py index 22832e11d6c..61e677bf4a9 100644 --- a/src/_pytest/python_api.py +++ b/src/_pytest/python_api.py @@ -592,6 +592,10 @@ def __init__(self, expected, rel=None, abs=None, nan_ok: bool = False) -> None: f"absolute tolerance for datetime/timedelta must be a " f"timedelta, got {type(abs).__name__}" ) + if abs is not None and abs < timedelta(0): + raise ValueError( + f"absolute tolerance can't be negative: {abs}" + ) if rel is not None: if isinstance(expected, datetime): raise TypeError( @@ -603,6 +607,12 @@ def __init__(self, expected, rel=None, abs=None, nan_ok: bool = False) -> None: f"relative tolerance for timedelta must be a " f"number, got {type(rel).__name__}" ) + if rel < 0: + raise ValueError( + f"relative tolerance can't be negative: {rel}" + ) + if math.isnan(rel): + raise ValueError("relative tolerance can't be NaN.") # Compute the effective tolerance. abs_tolerance is a timedelta, rel * expected # gives a timedelta (timedelta * float works in Python). abs_tolerance = abs diff --git a/testing/python/approx.py b/testing/python/approx.py index 8facbb6d280..1b1c949c1fd 100644 --- a/testing/python/approx.py +++ b/testing/python/approx.py @@ -1209,6 +1209,24 @@ def test_timedelta_rel_must_be_number(self): with pytest.raises(TypeError, match="must be a number"): approx(timedelta(seconds=1), rel=timedelta(seconds=1)) + def test_timedelta_rel_must_be_non_negative(self): + from datetime import timedelta + + with pytest.raises(ValueError, match="relative tolerance can't be negative"): + approx(timedelta(seconds=1), rel=-0.1) + + def test_timedelta_rel_must_not_be_nan(self): + from datetime import timedelta + + with pytest.raises(ValueError, match="relative tolerance can't be NaN"): + approx(timedelta(seconds=1), rel=float("nan")) + + def test_timedelta_abs_must_be_non_negative(self): + from datetime import timedelta + + with pytest.raises(ValueError, match="absolute tolerance can't be negative"): + approx(timedelta(seconds=1), abs=timedelta(seconds=-1)) + def test_timedelta_rel_with_abs(self): from datetime import timedelta From a9d0fe012e6c84604ff3a38c1954588d617c681d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 04:46:14 +0000 Subject: [PATCH 3/7] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/_pytest/python_api.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/_pytest/python_api.py b/src/_pytest/python_api.py index 61e677bf4a9..aaa9b61ac75 100644 --- a/src/_pytest/python_api.py +++ b/src/_pytest/python_api.py @@ -593,9 +593,7 @@ def __init__(self, expected, rel=None, abs=None, nan_ok: bool = False) -> None: f"timedelta, got {type(abs).__name__}" ) if abs is not None and abs < timedelta(0): - raise ValueError( - f"absolute tolerance can't be negative: {abs}" - ) + raise ValueError(f"absolute tolerance can't be negative: {abs}") if rel is not None: if isinstance(expected, datetime): raise TypeError( @@ -608,9 +606,7 @@ def __init__(self, expected, rel=None, abs=None, nan_ok: bool = False) -> None: f"number, got {type(rel).__name__}" ) if rel < 0: - raise ValueError( - f"relative tolerance can't be negative: {rel}" - ) + raise ValueError(f"relative tolerance can't be negative: {rel}") if math.isnan(rel): raise ValueError("relative tolerance can't be NaN.") # Compute the effective tolerance. abs_tolerance is a timedelta, rel * expected From 26359b6b44558b97a7480d056787a94914d2b7d3 Mon Sep 17 00:00:00 2001 From: eternalrights <3147268827@qq.com> Date: Tue, 12 May 2026 13:21:57 +0800 Subject: [PATCH 4/7] handle timedelta/datetime in _approx_scalar for sequence and mapping comparisons _approx_scalar only knew about Decimal vs scalar, so approx([timedelta(...)], rel=0.05) silently fell back to strict equality. Route datetime/timedelta elements through ApproxTimedelta instead. Also mention rel in the "requires tolerance" error message and fix the return type annotation. --- src/_pytest/python_api.py | 7 +++++-- testing/python/approx.py | 23 +++++++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/_pytest/python_api.py b/src/_pytest/python_api.py index aaa9b61ac75..8c6b131f95e 100644 --- a/src/_pytest/python_api.py +++ b/src/_pytest/python_api.py @@ -93,9 +93,11 @@ def __bool__(self): def __ne__(self, actual) -> bool: return not (actual == self) - def _approx_scalar(self, x) -> ApproxScalar: + def _approx_scalar(self, x) -> ApproxBase: if isinstance(x, Decimal): return ApproxDecimal(x, rel=self.rel, abs=self.abs, nan_ok=self.nan_ok) + if isinstance(x, (datetime, timedelta)): + return ApproxTimedelta(x, rel=self.rel, abs=self.abs, nan_ok=self.nan_ok) return ApproxScalar(x, rel=self.rel, abs=self.abs, nan_ok=self.nan_ok) def _yield_comparisons(self, actual): @@ -585,7 +587,8 @@ def __init__(self, expected, rel=None, abs=None, nan_ok: bool = False) -> None: raise TypeError( "pytest.approx() requires an explicit tolerance for " "datetime/timedelta comparisons: " - "e.g. approx(expected, abs=timedelta(seconds=1))" + "e.g. approx(expected, abs=timedelta(seconds=1)) " + "or approx(expected, rel=0.01)" ) if abs is not None and not isinstance(abs, timedelta): raise TypeError( diff --git a/testing/python/approx.py b/testing/python/approx.py index 1b1c949c1fd..ee799996ea3 100644 --- a/testing/python/approx.py +++ b/testing/python/approx.py @@ -1380,6 +1380,29 @@ def test_repr_compare_with_incompatible_type(self): assert "comparison failed" in result[0] assert "N/A" in result[3] + def test_timedelta_in_sequence(self): + from datetime import timedelta + + assert [timedelta(seconds=105)] == approx( + [timedelta(seconds=100)], rel=0.05 + ) + assert [timedelta(seconds=110)] != approx( + [timedelta(seconds=100)], rel=0.05 + ) + assert [timedelta(seconds=105)] == approx( + [timedelta(seconds=100)], abs=timedelta(seconds=10) + ) + + def test_timedelta_in_mapping(self): + from datetime import timedelta + + assert {"x": timedelta(seconds=105)} == approx( + {"x": timedelta(seconds=100)}, rel=0.05 + ) + assert {"x": timedelta(seconds=110)} != approx( + {"x": timedelta(seconds=100)}, rel=0.05 + ) + class MyVec3: # incomplete """sequence like""" From 5eee524a4d8eeb091cbaf3fcd705e601d8c96fb2 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 05:22:59 +0000 Subject: [PATCH 5/7] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- testing/python/approx.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/testing/python/approx.py b/testing/python/approx.py index ee799996ea3..8b3b7d3e566 100644 --- a/testing/python/approx.py +++ b/testing/python/approx.py @@ -1383,12 +1383,8 @@ def test_repr_compare_with_incompatible_type(self): def test_timedelta_in_sequence(self): from datetime import timedelta - assert [timedelta(seconds=105)] == approx( - [timedelta(seconds=100)], rel=0.05 - ) - assert [timedelta(seconds=110)] != approx( - [timedelta(seconds=100)], rel=0.05 - ) + assert [timedelta(seconds=105)] == approx([timedelta(seconds=100)], rel=0.05) + assert [timedelta(seconds=110)] != approx([timedelta(seconds=100)], rel=0.05) assert [timedelta(seconds=105)] == approx( [timedelta(seconds=100)], abs=timedelta(seconds=10) ) From 211e6301353d35b0fd6654d2d07c258588b0b87f Mon Sep 17 00:00:00 2001 From: eternalrights <3147268827@qq.com> Date: Tue, 12 May 2026 15:40:27 +0800 Subject: [PATCH 6/7] remove dead code and add missing test coverage for approx timedelta/datetime The isinstance(expected, datetime) check inside the rel block was unreachable because the same condition is already caught earlier at the top of __init__. Removing it fixes the codecov/patch failure. Also add tests for two uncovered branches in _approx_scalar: - timedelta in mapping with abs tolerance (only rel was tested) - datetime in sequence and mapping (not tested at all) --- src/_pytest/python_api.py | 5 ----- testing/python/approx.py | 25 +++++++++++++++++++++++++ 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/src/_pytest/python_api.py b/src/_pytest/python_api.py index 8c6b131f95e..cc2a2b7126a 100644 --- a/src/_pytest/python_api.py +++ b/src/_pytest/python_api.py @@ -598,11 +598,6 @@ def __init__(self, expected, rel=None, abs=None, nan_ok: bool = False) -> None: if abs is not None and abs < timedelta(0): raise ValueError(f"absolute tolerance can't be negative: {abs}") if rel is not None: - if isinstance(expected, datetime): - raise TypeError( - "pytest.approx() does not support relative tolerance for " - "datetime comparisons. Use abs=timedelta(...) instead." - ) if not isinstance(rel, (int, float)): raise TypeError( f"relative tolerance for timedelta must be a " diff --git a/testing/python/approx.py b/testing/python/approx.py index 8b3b7d3e566..88d46cbb755 100644 --- a/testing/python/approx.py +++ b/testing/python/approx.py @@ -1398,6 +1398,31 @@ def test_timedelta_in_mapping(self): assert {"x": timedelta(seconds=110)} != approx( {"x": timedelta(seconds=100)}, rel=0.05 ) + assert {"x": timedelta(seconds=105)} == approx( + {"x": timedelta(seconds=100)}, abs=timedelta(seconds=10) + ) + + def test_datetime_in_sequence(self): + from datetime import datetime + from datetime import timedelta + + assert [datetime(2024, 1, 1, 12, 0, 0, 500_000)] == approx( + [datetime(2024, 1, 1, 12, 0, 0)], abs=timedelta(seconds=1) + ) + assert [datetime(2024, 1, 1, 12, 0, 5)] != approx( + [datetime(2024, 1, 1, 12, 0, 0)], abs=timedelta(seconds=1) + ) + + def test_datetime_in_mapping(self): + from datetime import datetime + from datetime import timedelta + + assert {"t": datetime(2024, 1, 1, 12, 0, 0, 500_000)} == approx( + {"t": datetime(2024, 1, 1, 12, 0, 0)}, abs=timedelta(seconds=1) + ) + assert {"t": datetime(2024, 1, 1, 12, 0, 5)} != approx( + {"t": datetime(2024, 1, 1, 12, 0, 0)}, abs=timedelta(seconds=1) + ) class MyVec3: # incomplete From c836b1c87ec13eec77277fb349a49038220cb49d Mon Sep 17 00:00:00 2001 From: eternalrights <3147268827@qq.com> Date: Tue, 12 May 2026 17:06:56 +0800 Subject: [PATCH 7/7] remove changelog entry for unreleased behavior The rel parameter for timedelta never worked correctly in a released version, so there is no bug to document. --- changelog/14462.bugfix.rst | 1 - 1 file changed, 1 deletion(-) delete mode 100644 changelog/14462.bugfix.rst diff --git a/changelog/14462.bugfix.rst b/changelog/14462.bugfix.rst deleted file mode 100644 index 8127267e654..00000000000 --- a/changelog/14462.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -Fixed ``rel`` parameter in :func:`pytest.approx` for :class:`~datetime.timedelta` comparisons -- it was being treated as an absolute tolerance instead of a relative one.