From e7203972aba7373ef3985b44c8ba6be1e310c437 Mon Sep 17 00:00:00 2001 From: Hamza Mobeen Date: Thu, 23 Apr 2026 12:15:17 +0100 Subject: [PATCH 1/2] 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/2] [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")