From e7203972aba7373ef3985b44c8ba6be1e310c437 Mon Sep 17 00:00:00 2001 From: Hamza Mobeen Date: Thu, 23 Apr 2026 12:15:17 +0100 Subject: [PATCH 1/7] Add datetime/timedelta support to pytest.approx (#8395) Closes #8395 Co-authored-by: Antigravity --- AUTHORS | 1 + changelog/8395.feature.rst | 1 + src/_pytest/python_api.py | 101 ++++++++++++++++++++- testing/python/approx.py | 176 +++++++++++++++++++++++++++++++++++++ 4 files changed, 278 insertions(+), 1 deletion(-) create mode 100644 changelog/8395.feature.rst diff --git a/AUTHORS b/AUTHORS index c33cf5fafbd..030078438f3 100644 --- a/AUTHORS +++ b/AUTHORS @@ -194,6 +194,7 @@ Grig Gheorghiu Grigorii Eremeev (budulianin) Guido Wesdorp Guoqiang Zhang +Hamza Mobeen Harald Armin Massa Harshna Henk-Jaap Wagenaar diff --git a/changelog/8395.feature.rst b/changelog/8395.feature.rst new file mode 100644 index 00000000000..b7b8b959b03 --- /dev/null +++ b/changelog/8395.feature.rst @@ -0,0 +1 @@ +Added support for :class:`~datetime.datetime` and :class:`~datetime.timedelta` comparisons with :func:`pytest.approx`. An explicit ``abs`` tolerance as a :class:`~datetime.timedelta` is required; relative tolerance is not supported for time-based comparisons -- by :user:`hamza-mobeen`. diff --git a/src/_pytest/python_api.py b/src/_pytest/python_api.py index 9e2e1826a4f..90404140971 100644 --- a/src/_pytest/python_api.py +++ b/src/_pytest/python_api.py @@ -1,10 +1,13 @@ # mypy: allow-untyped-defs from __future__ import annotations +import builtins from collections.abc import Collection from collections.abc import Mapping from collections.abc import Sequence from collections.abc import Sized +from datetime import datetime +from datetime import timedelta from decimal import Decimal import math from numbers import Complex @@ -558,10 +561,87 @@ def __repr__(self) -> str: return f"{self.expected} ± {tol_str}" +class ApproxTimedelta(ApproxBase): + """Perform approximate comparisons where the expected value is a + datetime or timedelta. + + Requires an explicit absolute tolerance as a timedelta. + Relative tolerance is not supported for time-based comparisons. + """ + + def __init__(self, expected, rel=None, abs=None, nan_ok: bool = False) -> None: + __tracebackhide__ = True + if rel is not None: + raise TypeError( + "pytest.approx() does not support relative tolerance for " + "datetime/timedelta comparisons. Use abs=timedelta(...) instead." + ) + if nan_ok: + raise TypeError( + "pytest.approx() does not support nan_ok for " + "datetime/timedelta comparisons." + ) + if abs is None: + raise TypeError( + "pytest.approx() requires an absolute tolerance for " + "datetime/timedelta comparisons: " + "e.g. approx(expected, abs=timedelta(seconds=1))" + ) + if not isinstance(abs, timedelta): + raise TypeError( + f"absolute tolerance for datetime/timedelta must be a " + f"timedelta, got {type(abs).__name__}" + ) + # Store the timedelta tolerance directly. + self.expected = expected + self._tolerance = abs + # Call grandparent init to set up basic state without _check_type. + self.abs = abs + self.rel = None + self.nan_ok = False + + def __repr__(self) -> str: + return f"{self.expected} ± {self._tolerance}" + + def __eq__(self, actual) -> bool: + try: + return bool(builtins.abs(self.expected - actual) <= self._tolerance) + except (TypeError, OverflowError): + return False + + __hash__ = None + + def __ne__(self, actual) -> bool: + return not (actual == self) + + def __bool__(self): + __tracebackhide__ = True + raise AssertionError( + "approx() is not supported in a boolean context.\n" + "Did you mean: `assert a == approx(b)`?" + ) + + def _yield_comparisons(self, actual): + yield actual, self.expected + + def _repr_compare(self, other_side: Any) -> list[str]: + try: + abs_diff = builtins.abs(self.expected - other_side) + except (TypeError, OverflowError): + abs_diff = "N/A" + return [ + "comparison failed", + f"Obtained: {other_side}", + f"Expected: {self.expected} ± {self._tolerance}", + f"Absolute difference: {abs_diff}", + f"Tolerance: {self._tolerance}", + ] + + def approx( expected: Any, rel: float | Decimal | None = None, - abs: float | Decimal | None = None, + abs: float | Decimal | timedelta | None = None, nan_ok: bool = False, ) -> ApproxBase: """Assert that two numbers (or two ordered sequences of numbers) are equal to each other @@ -677,6 +757,23 @@ def approx( >>> ["foo", 1.0000005] == approx([None,1]) False + **datetime and timedelta** + + You can also use ``approx`` to compare :class:`~datetime.datetime` and + :class:`~datetime.timedelta` objects by specifying an absolute tolerance + as a :class:`~datetime.timedelta`:: + + >>> from datetime import datetime, timedelta + >>> dt1 = datetime(2024, 1, 1, 12, 0, 0) + >>> dt2 = datetime(2024, 1, 1, 12, 0, 0, 500000) + >>> dt1 == approx(dt2, abs=timedelta(seconds=1)) + True + + Note that ``rel`` is not supported for datetime/timedelta comparisons, + and ``abs`` must be explicitly provided as a ``timedelta`` object. + + .. versionadded:: 8.4 + If you're thinking about using ``approx``, then you might want to know how it compares to other good ways of comparing floating-point numbers. All of these algorithms are based on relative and absolute tolerances and should @@ -785,6 +882,8 @@ def approx( elif isinstance(expected, Collection) and not isinstance(expected, str | bytes): msg = f"pytest.approx() only supports ordered sequences, but got: {expected!r}" raise TypeError(msg) + elif isinstance(expected, (datetime, timedelta)): + cls = ApproxTimedelta else: cls = ApproxScalar diff --git a/testing/python/approx.py b/testing/python/approx.py index bfbb59fb61d..42f3bbd25c4 100644 --- a/testing/python/approx.py +++ b/testing/python/approx.py @@ -1105,6 +1105,182 @@ def test_approx_on_unordered_mapping_matching(): result.assert_outcomes(passed=1) +class TestApproxDatetime: + """Tests for datetime/timedelta support in approx (issue #8395).""" + + def test_datetime_exactly_equal(self): + from datetime import datetime, timedelta + + dt = datetime(2024, 1, 1, 12, 0, 0) + assert dt == approx(dt, abs=timedelta(seconds=1)) + + def test_datetime_within_tolerance(self): + from datetime import datetime, timedelta + + dt1 = datetime(2024, 1, 1, 12, 0, 0) + dt2 = datetime(2024, 1, 1, 12, 0, 0, 500000) # +0.5s + assert dt1 == approx(dt2, abs=timedelta(seconds=1)) + + def test_datetime_outside_tolerance(self): + from datetime import datetime, timedelta + + dt1 = datetime(2024, 1, 1, 12, 0, 0) + dt2 = datetime(2024, 1, 1, 12, 0, 2) # +2s + assert dt1 != approx(dt2, abs=timedelta(seconds=1)) + + def test_datetime_negative_difference(self): + from datetime import datetime, timedelta + + dt1 = datetime(2024, 1, 1, 12, 0, 1) + dt2 = datetime(2024, 1, 1, 12, 0, 0) # dt2 < dt1 + assert dt1 == approx(dt2, abs=timedelta(seconds=2)) + assert dt1 != approx(dt2, abs=timedelta(milliseconds=500)) + + def test_timedelta_within_tolerance(self): + from datetime import timedelta + + td1 = timedelta(seconds=100) + td2 = timedelta(seconds=100.5) + assert td1 == approx(td2, abs=timedelta(seconds=1)) + + def test_timedelta_outside_tolerance(self): + from datetime import timedelta + + td1 = timedelta(seconds=100) + td2 = timedelta(seconds=102) + assert td1 != approx(td2, abs=timedelta(seconds=1)) + + def test_requires_abs(self): + from datetime import datetime + + with pytest.raises(TypeError, match="requires an absolute tolerance"): + approx(datetime(2024, 1, 1)) + + def test_rejects_rel(self): + from datetime import datetime, timedelta + + with pytest.raises(TypeError, match="does not support relative tolerance"): + approx(datetime(2024, 1, 1), rel=0.1, abs=timedelta(seconds=1)) + + def test_abs_must_be_timedelta(self): + from datetime import datetime + + with pytest.raises(TypeError, match="must be a timedelta"): + approx(datetime(2024, 1, 1), abs=1.0) + + def test_rejects_nan_ok(self): + from datetime import datetime, timedelta + + with pytest.raises(TypeError, match="does not support nan_ok"): + approx(datetime(2024, 1, 1), abs=timedelta(seconds=1), nan_ok=True) + + def test_datetime_repr(self): + from datetime import datetime, timedelta + + dt = datetime(2024, 1, 1, 12, 0, 0) + result = repr(approx(dt, abs=timedelta(seconds=1))) + assert "2024-01-01 12:00:00" in result + assert "0:00:01" in result + + def test_timedelta_repr(self): + from datetime import timedelta + + td = timedelta(seconds=100) + result = repr(approx(td, abs=timedelta(seconds=1))) + assert "0:01:40" in result # 100 seconds + assert "0:00:01" in result # 1 second tolerance + + def test_datetime_symmetry(self): + """approx comparison should work on both sides of ==.""" + from datetime import datetime, timedelta + + dt1 = datetime(2024, 1, 1, 12, 0, 0) + dt2 = datetime(2024, 1, 1, 12, 0, 0, 500000) + tol = timedelta(seconds=1) + assert dt1 == approx(dt2, abs=tol) + assert approx(dt2, abs=tol) == dt1 + + def test_datetime_ne_operator(self): + from datetime import datetime, timedelta + + dt1 = datetime(2024, 1, 1, 12, 0, 0) + dt2 = datetime(2024, 1, 1, 12, 0, 5) + tol = timedelta(seconds=1) + assert dt1 != approx(dt2, abs=tol) + assert not (dt1 == approx(dt2, abs=tol)) + + def test_datetime_with_timezone(self): + from datetime import datetime, timedelta, timezone + + tz = timezone.utc + dt1 = datetime(2024, 1, 1, 12, 0, 0, tzinfo=tz) + dt2 = datetime(2024, 1, 1, 12, 0, 0, 500000, tzinfo=tz) + assert dt1 == approx(dt2, abs=timedelta(seconds=1)) + + def test_datetime_error_message(self): + from datetime import datetime, timedelta + + dt1 = datetime(2024, 1, 1, 12, 0, 0) + dt2 = datetime(2024, 1, 1, 12, 0, 5) # 5 seconds off + with pytest.raises(AssertionError, match="comparison failed"): + assert dt1 == approx(dt2, abs=timedelta(seconds=1)) + + def test_timedelta_zero(self): + from datetime import timedelta + + td1 = timedelta(seconds=0) + td2 = timedelta(seconds=0) + assert td1 == approx(td2, abs=timedelta(seconds=1)) + + def test_datetime_boundary_exact(self): + """Test that values exactly at the tolerance boundary are equal.""" + from datetime import datetime, timedelta + + dt1 = datetime(2024, 1, 1, 12, 0, 0) + dt2 = datetime(2024, 1, 1, 12, 0, 1) # exactly 1 second + assert dt1 == approx(dt2, abs=timedelta(seconds=1)) + + def test_datetime_microsecond_tolerance(self): + from datetime import datetime, timedelta + + dt1 = datetime(2024, 1, 1, 12, 0, 0, 0) + dt2 = datetime(2024, 1, 1, 12, 0, 0, 100) # +100 microseconds + assert dt1 == approx(dt2, abs=timedelta(microseconds=200)) + assert dt1 != approx(dt2, abs=timedelta(microseconds=50)) + + def test_bool_context_raises(self): + from datetime import datetime, timedelta + + with pytest.raises(AssertionError, match="boolean context"): + bool(approx(datetime(2024, 1, 1), abs=timedelta(seconds=1))) + + def test_wrong_type_comparison(self): + """Comparing a datetime approx with a non-datetime should return False.""" + from datetime import datetime, timedelta + + assert 42 != approx(datetime(2024, 1, 1), abs=timedelta(seconds=1)) + assert "string" != approx(datetime(2024, 1, 1), abs=timedelta(seconds=1)) + + def test_yield_comparisons(self): + """Test that _yield_comparisons yields (actual, expected) pairs.""" + from datetime import datetime, timedelta + + dt = datetime(2024, 1, 1, 12, 0, 0) + a = approx(dt, abs=timedelta(seconds=1)) + actual = datetime(2024, 1, 1, 12, 0, 0, 500000) + pairs = list(a._yield_comparisons(actual)) + assert pairs == [(actual, dt)] + + def test_repr_compare_with_incompatible_type(self): + """_repr_compare handles TypeError when actual is not a datetime.""" + from datetime import datetime, timedelta + + a = approx(datetime(2024, 1, 1), abs=timedelta(seconds=1)) + result = a._repr_compare("not a datetime") + assert "comparison failed" in result[0] + assert "N/A" in result[3] + + class MyVec3: # incomplete """sequence like""" From db193c126da0ac0e1d38c524130a484de2dfcea7 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 23 Apr 2026 11:41:58 +0000 Subject: [PATCH 2/7] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- testing/python/approx.py | 54 ++++++++++++++++++++++++++-------------- 1 file changed, 36 insertions(+), 18 deletions(-) diff --git a/testing/python/approx.py b/testing/python/approx.py index 42f3bbd25c4..0aa0b6182c2 100644 --- a/testing/python/approx.py +++ b/testing/python/approx.py @@ -1109,27 +1109,31 @@ class TestApproxDatetime: """Tests for datetime/timedelta support in approx (issue #8395).""" def test_datetime_exactly_equal(self): - from datetime import datetime, timedelta + from datetime import datetime + from datetime import timedelta dt = datetime(2024, 1, 1, 12, 0, 0) assert dt == approx(dt, abs=timedelta(seconds=1)) def test_datetime_within_tolerance(self): - from datetime import datetime, timedelta + from datetime import datetime + from datetime import timedelta dt1 = datetime(2024, 1, 1, 12, 0, 0) dt2 = datetime(2024, 1, 1, 12, 0, 0, 500000) # +0.5s assert dt1 == approx(dt2, abs=timedelta(seconds=1)) def test_datetime_outside_tolerance(self): - from datetime import datetime, timedelta + from datetime import datetime + from datetime import timedelta dt1 = datetime(2024, 1, 1, 12, 0, 0) dt2 = datetime(2024, 1, 1, 12, 0, 2) # +2s assert dt1 != approx(dt2, abs=timedelta(seconds=1)) def test_datetime_negative_difference(self): - from datetime import datetime, timedelta + from datetime import datetime + from datetime import timedelta dt1 = datetime(2024, 1, 1, 12, 0, 1) dt2 = datetime(2024, 1, 1, 12, 0, 0) # dt2 < dt1 @@ -1157,7 +1161,8 @@ def test_requires_abs(self): approx(datetime(2024, 1, 1)) def test_rejects_rel(self): - from datetime import datetime, timedelta + from datetime import datetime + from datetime import timedelta with pytest.raises(TypeError, match="does not support relative tolerance"): approx(datetime(2024, 1, 1), rel=0.1, abs=timedelta(seconds=1)) @@ -1169,13 +1174,15 @@ def test_abs_must_be_timedelta(self): approx(datetime(2024, 1, 1), abs=1.0) def test_rejects_nan_ok(self): - from datetime import datetime, timedelta + from datetime import datetime + from datetime import timedelta with pytest.raises(TypeError, match="does not support nan_ok"): approx(datetime(2024, 1, 1), abs=timedelta(seconds=1), nan_ok=True) def test_datetime_repr(self): - from datetime import datetime, timedelta + from datetime import datetime + from datetime import timedelta dt = datetime(2024, 1, 1, 12, 0, 0) result = repr(approx(dt, abs=timedelta(seconds=1))) @@ -1191,8 +1198,9 @@ def test_timedelta_repr(self): assert "0:00:01" in result # 1 second tolerance def test_datetime_symmetry(self): - """approx comparison should work on both sides of ==.""" - from datetime import datetime, timedelta + """Approx comparison should work on both sides of ==.""" + from datetime import datetime + from datetime import timedelta dt1 = datetime(2024, 1, 1, 12, 0, 0) dt2 = datetime(2024, 1, 1, 12, 0, 0, 500000) @@ -1201,7 +1209,8 @@ def test_datetime_symmetry(self): assert approx(dt2, abs=tol) == dt1 def test_datetime_ne_operator(self): - from datetime import datetime, timedelta + from datetime import datetime + from datetime import timedelta dt1 = datetime(2024, 1, 1, 12, 0, 0) dt2 = datetime(2024, 1, 1, 12, 0, 5) @@ -1210,7 +1219,9 @@ def test_datetime_ne_operator(self): assert not (dt1 == approx(dt2, abs=tol)) def test_datetime_with_timezone(self): - from datetime import datetime, timedelta, timezone + from datetime import datetime + from datetime import timedelta + from datetime import timezone tz = timezone.utc dt1 = datetime(2024, 1, 1, 12, 0, 0, tzinfo=tz) @@ -1218,7 +1229,8 @@ def test_datetime_with_timezone(self): assert dt1 == approx(dt2, abs=timedelta(seconds=1)) def test_datetime_error_message(self): - from datetime import datetime, timedelta + from datetime import datetime + from datetime import timedelta dt1 = datetime(2024, 1, 1, 12, 0, 0) dt2 = datetime(2024, 1, 1, 12, 0, 5) # 5 seconds off @@ -1234,14 +1246,16 @@ def test_timedelta_zero(self): def test_datetime_boundary_exact(self): """Test that values exactly at the tolerance boundary are equal.""" - from datetime import datetime, timedelta + from datetime import datetime + from datetime import timedelta dt1 = datetime(2024, 1, 1, 12, 0, 0) dt2 = datetime(2024, 1, 1, 12, 0, 1) # exactly 1 second assert dt1 == approx(dt2, abs=timedelta(seconds=1)) def test_datetime_microsecond_tolerance(self): - from datetime import datetime, timedelta + from datetime import datetime + from datetime import timedelta dt1 = datetime(2024, 1, 1, 12, 0, 0, 0) dt2 = datetime(2024, 1, 1, 12, 0, 0, 100) # +100 microseconds @@ -1249,21 +1263,24 @@ def test_datetime_microsecond_tolerance(self): assert dt1 != approx(dt2, abs=timedelta(microseconds=50)) def test_bool_context_raises(self): - from datetime import datetime, timedelta + from datetime import datetime + from datetime import timedelta with pytest.raises(AssertionError, match="boolean context"): bool(approx(datetime(2024, 1, 1), abs=timedelta(seconds=1))) def test_wrong_type_comparison(self): """Comparing a datetime approx with a non-datetime should return False.""" - from datetime import datetime, timedelta + from datetime import datetime + from datetime import timedelta assert 42 != approx(datetime(2024, 1, 1), abs=timedelta(seconds=1)) assert "string" != approx(datetime(2024, 1, 1), abs=timedelta(seconds=1)) def test_yield_comparisons(self): """Test that _yield_comparisons yields (actual, expected) pairs.""" - from datetime import datetime, timedelta + from datetime import datetime + from datetime import timedelta dt = datetime(2024, 1, 1, 12, 0, 0) a = approx(dt, abs=timedelta(seconds=1)) @@ -1273,7 +1290,8 @@ def test_yield_comparisons(self): def test_repr_compare_with_incompatible_type(self): """_repr_compare handles TypeError when actual is not a datetime.""" - from datetime import datetime, timedelta + from datetime import datetime + from datetime import timedelta a = approx(datetime(2024, 1, 1), abs=timedelta(seconds=1)) result = a._repr_compare("not a datetime") From 8858cfd9efc367fe8f1e9a641cd1f7360fac8fa1 Mon Sep 17 00:00:00 2001 From: Hamza Mobeen Date: Sat, 2 May 2026 16:26:09 +0100 Subject: [PATCH 3/7] Reuse ApproxBase behavior for datetime approx Co-authored-by: Codex --- src/_pytest/python_api.py | 28 +++++----------------------- 1 file changed, 5 insertions(+), 23 deletions(-) diff --git a/src/_pytest/python_api.py b/src/_pytest/python_api.py index 90404140971..f21c96c962b 100644 --- a/src/_pytest/python_api.py +++ b/src/_pytest/python_api.py @@ -592,35 +592,17 @@ 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__}" ) - # Store the timedelta tolerance directly. - self.expected = expected - self._tolerance = abs - # Call grandparent init to set up basic state without _check_type. - self.abs = abs - self.rel = None - self.nan_ok = False + super().__init__(expected, rel=None, abs=abs, nan_ok=False) def __repr__(self) -> str: - return f"{self.expected} ± {self._tolerance}" + return f"{self.expected} ± {self.abs}" def __eq__(self, actual) -> bool: try: - return bool(builtins.abs(self.expected - actual) <= self._tolerance) + return bool(builtins.abs(self.expected - actual) <= self.abs) except (TypeError, OverflowError): return False - __hash__ = None - - def __ne__(self, actual) -> bool: - return not (actual == self) - - def __bool__(self): - __tracebackhide__ = True - raise AssertionError( - "approx() is not supported in a boolean context.\n" - "Did you mean: `assert a == approx(b)`?" - ) - def _yield_comparisons(self, actual): yield actual, self.expected @@ -632,9 +614,9 @@ def _repr_compare(self, other_side: Any) -> list[str]: return [ "comparison failed", f"Obtained: {other_side}", - f"Expected: {self.expected} ± {self._tolerance}", + f"Expected: {self.expected} ± {self.abs}", f"Absolute difference: {abs_diff}", - f"Tolerance: {self._tolerance}", + f"Tolerance: {self.abs}", ] From 712692fd1e123b603df2d97668f813331819bde6 Mon Sep 17 00:00:00 2001 From: Hamza Mobeen Date: Thu, 7 May 2026 14:38:35 +0100 Subject: [PATCH 4/7] Update src/_pytest/python_api.py Co-authored-by: Pierre Sassoulas --- src/_pytest/python_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/_pytest/python_api.py b/src/_pytest/python_api.py index f21c96c962b..6f635a2089a 100644 --- a/src/_pytest/python_api.py +++ b/src/_pytest/python_api.py @@ -622,7 +622,7 @@ def _repr_compare(self, other_side: Any) -> list[str]: def approx( expected: Any, - rel: float | Decimal | None = None, + rel: float | Decimal | timedelta | None = None, abs: float | Decimal | timedelta | None = None, nan_ok: bool = False, ) -> ApproxBase: From 9658d75c02046fb22a243b8afc02217b0bb8b4aa Mon Sep 17 00:00:00 2001 From: Hamza Mobeen Date: Thu, 7 May 2026 14:39:03 +0100 Subject: [PATCH 5/7] Update src/_pytest/python_api.py Co-authored-by: Pierre Sassoulas --- src/_pytest/python_api.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/_pytest/python_api.py b/src/_pytest/python_api.py index 6f635a2089a..8d857c108c7 100644 --- a/src/_pytest/python_api.py +++ b/src/_pytest/python_api.py @@ -751,8 +751,8 @@ def approx( >>> dt1 == approx(dt2, abs=timedelta(seconds=1)) True - Note that ``rel`` is not supported for datetime/timedelta comparisons, - and ``abs`` must be explicitly provided as a ``timedelta`` object. + Note that ``rel`` is not supported for datetime comparisons, + and ``abs`` or ``rel`` must be explicitly provided as a ``timedelta`` object. .. versionadded:: 8.4 From 94448a8f30ad3a0535302c080297fc663f7cba80 Mon Sep 17 00:00:00 2001 From: Hamza Mobeen Date: Thu, 7 May 2026 14:47:10 +0100 Subject: [PATCH 6/7] Allow timedelta rel tolerance in approx Co-authored-by: Codex --- src/_pytest/python_api.py | 22 ++++++++++++++-------- testing/python/approx.py | 29 ++++++++++++++++++++++++++--- 2 files changed, 40 insertions(+), 11 deletions(-) diff --git a/src/_pytest/python_api.py b/src/_pytest/python_api.py index 8d857c108c7..f6d5e31a588 100644 --- a/src/_pytest/python_api.py +++ b/src/_pytest/python_api.py @@ -565,34 +565,40 @@ class ApproxTimedelta(ApproxBase): """Perform approximate comparisons where the expected value is a datetime or timedelta. - Requires an explicit absolute tolerance as a timedelta. - Relative tolerance is not supported for time-based comparisons. + Requires an explicit tolerance as a timedelta. + Relative tolerance is not supported for datetime comparisons. """ def __init__(self, expected, rel=None, abs=None, nan_ok: bool = False) -> None: __tracebackhide__ = True - if rel is not None: + if isinstance(expected, datetime) and rel is not None: raise TypeError( "pytest.approx() does not support relative tolerance for " - "datetime/timedelta comparisons. Use abs=timedelta(...) instead." + "datetime comparisons. Use abs=timedelta(...) instead." ) if nan_ok: raise TypeError( "pytest.approx() does not support nan_ok for " "datetime/timedelta comparisons." ) - if abs is None: + if abs is None and rel is None: raise TypeError( - "pytest.approx() requires an absolute tolerance for " + "pytest.approx() requires an explicit tolerance for " "datetime/timedelta comparisons: " "e.g. approx(expected, abs=timedelta(seconds=1))" ) - if not isinstance(abs, timedelta): + if abs is not None and not isinstance(abs, timedelta): raise TypeError( f"absolute tolerance for datetime/timedelta must be a " f"timedelta, got {type(abs).__name__}" ) - super().__init__(expected, rel=None, abs=abs, nan_ok=False) + 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) def __repr__(self) -> str: return f"{self.expected} ± {self.abs}" diff --git a/testing/python/approx.py b/testing/python/approx.py index 56952bd678a..4369dc24ad4 100644 --- a/testing/python/approx.py +++ b/testing/python/approx.py @@ -1167,25 +1167,48 @@ def test_timedelta_outside_tolerance(self): td2 = timedelta(seconds=102) assert td1 != approx(td2, abs=timedelta(seconds=1)) - def test_requires_abs(self): + def test_timedelta_rel_within_tolerance(self): + from datetime import timedelta + + td1 = timedelta(seconds=100) + td2 = timedelta(seconds=100.5) + assert td1 == approx(td2, rel=timedelta(seconds=1)) + + 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)) + + def test_requires_tolerance(self): from datetime import datetime - with pytest.raises(TypeError, match="requires an absolute tolerance"): + with pytest.raises(TypeError, match="requires an explicit tolerance"): approx(datetime(2024, 1, 1)) - def test_rejects_rel(self): + def test_datetime_rejects_rel(self): from datetime import datetime from datetime import timedelta with pytest.raises(TypeError, match="does not support relative tolerance"): approx(datetime(2024, 1, 1), rel=0.1, abs=timedelta(seconds=1)) + with pytest.raises(TypeError, match="does not support relative tolerance"): + approx(datetime(2024, 1, 1), rel=timedelta(seconds=1)) + def test_abs_must_be_timedelta(self): from datetime import datetime 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): + from datetime import timedelta + + with pytest.raises(TypeError, match="must be a timedelta"): + approx(timedelta(seconds=1), rel=0.1) + def test_rejects_nan_ok(self): from datetime import datetime from datetime import timedelta From 2a6c5a15f0a05822579a5e5d8f624a897f4b6685 Mon Sep 17 00:00:00 2001 From: Hamza Mobeen Date: Fri, 8 May 2026 08:34:34 +0100 Subject: [PATCH 7/7] Update changelog/8395.feature.rst Co-authored-by: Pierre Sassoulas --- changelog/8395.feature.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog/8395.feature.rst b/changelog/8395.feature.rst index b7b8b959b03..61c16216182 100644 --- a/changelog/8395.feature.rst +++ b/changelog/8395.feature.rst @@ -1 +1 @@ -Added support for :class:`~datetime.datetime` and :class:`~datetime.timedelta` comparisons with :func:`pytest.approx`. An explicit ``abs`` tolerance as a :class:`~datetime.timedelta` is required; relative tolerance is not supported for time-based comparisons -- by :user:`hamza-mobeen`. +Added support for :class:`~datetime.datetime` and :class:`~datetime.timedelta` comparisons with :func:`pytest.approx`. An explicit ``abs`` or ``rel`` tolerance as a :class:`~datetime.timedelta` is required and relative tolerance is not supported for datetime comparisons -- by :user:`hamza-mobeen`.