Skip to content

Commit 23e7a5e

Browse files
Add datetime/timedelta support to pytest.approx (#8395)
Closes #8395 Co-authored-by: Antigravity <antigravity@google.com>
1 parent d4fde45 commit 23e7a5e

4 files changed

Lines changed: 259 additions & 1 deletion

File tree

AUTHORS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,7 @@ Grig Gheorghiu
194194
Grigorii Eremeev (budulianin)
195195
Guido Wesdorp
196196
Guoqiang Zhang
197+
Hamza Mobeen
197198
Harald Armin Massa
198199
Harshna
199200
Henk-Jaap Wagenaar

changelog/8395.feature.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +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`.

src/_pytest/python_api.py

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
# mypy: allow-untyped-defs
22
from __future__ import annotations
33

4+
import builtins
45
from collections.abc import Collection
56
from collections.abc import Mapping
67
from collections.abc import Sequence
78
from collections.abc import Sized
9+
from datetime import datetime
10+
from datetime import timedelta
811
from decimal import Decimal
912
import math
1013
from numbers import Complex
@@ -558,10 +561,87 @@ def __repr__(self) -> str:
558561
return f"{self.expected} ± {tol_str}"
559562

560563

564+
class ApproxTimedelta(ApproxBase):
565+
"""Perform approximate comparisons where the expected value is a
566+
datetime or timedelta.
567+
568+
Requires an explicit absolute tolerance as a timedelta.
569+
Relative tolerance is not supported for time-based comparisons.
570+
"""
571+
572+
def __init__(self, expected, rel=None, abs=None, nan_ok: bool = False) -> None:
573+
__tracebackhide__ = True
574+
if rel is not None:
575+
raise TypeError(
576+
"pytest.approx() does not support relative tolerance for "
577+
"datetime/timedelta comparisons. Use abs=timedelta(...) instead."
578+
)
579+
if nan_ok:
580+
raise TypeError(
581+
"pytest.approx() does not support nan_ok for "
582+
"datetime/timedelta comparisons."
583+
)
584+
if abs is None:
585+
raise TypeError(
586+
"pytest.approx() requires an absolute tolerance for "
587+
"datetime/timedelta comparisons: "
588+
"e.g. approx(expected, abs=timedelta(seconds=1))"
589+
)
590+
if not isinstance(abs, timedelta):
591+
raise TypeError(
592+
f"absolute tolerance for datetime/timedelta must be a "
593+
f"timedelta, got {type(abs).__name__}"
594+
)
595+
# Store the timedelta tolerance directly.
596+
self.expected = expected
597+
self._tolerance = abs
598+
# Call grandparent init to set up basic state without _check_type.
599+
self.abs = abs
600+
self.rel = None
601+
self.nan_ok = False
602+
603+
def __repr__(self) -> str:
604+
return f"{self.expected} ± {self._tolerance}"
605+
606+
def __eq__(self, actual) -> bool:
607+
try:
608+
return bool(builtins.abs(self.expected - actual) <= self._tolerance)
609+
except (TypeError, OverflowError):
610+
return False
611+
612+
__hash__ = None
613+
614+
def __ne__(self, actual) -> bool:
615+
return not (actual == self)
616+
617+
def __bool__(self):
618+
__tracebackhide__ = True
619+
raise AssertionError(
620+
"approx() is not supported in a boolean context.\n"
621+
"Did you mean: `assert a == approx(b)`?"
622+
)
623+
624+
def _yield_comparisons(self, actual):
625+
yield actual, self.expected
626+
627+
def _repr_compare(self, other_side: Any) -> list[str]:
628+
try:
629+
abs_diff = builtins.abs(self.expected - other_side)
630+
except (TypeError, OverflowError):
631+
abs_diff = "N/A"
632+
return [
633+
"comparison failed",
634+
f"Obtained: {other_side}",
635+
f"Expected: {self.expected} ± {self._tolerance}",
636+
f"Absolute difference: {abs_diff}",
637+
f"Tolerance: {self._tolerance}",
638+
]
639+
640+
561641
def approx(
562642
expected: Any,
563643
rel: float | Decimal | None = None,
564-
abs: float | Decimal | None = None,
644+
abs: float | Decimal | timedelta | None = None,
565645
nan_ok: bool = False,
566646
) -> ApproxBase:
567647
"""Assert that two numbers (or two ordered sequences of numbers) are equal to each other
@@ -677,6 +757,23 @@ def approx(
677757
>>> ["foo", 1.0000005] == approx([None,1])
678758
False
679759
760+
**datetime and timedelta**
761+
762+
You can also use ``approx`` to compare :class:`~datetime.datetime` and
763+
:class:`~datetime.timedelta` objects by specifying an absolute tolerance
764+
as a :class:`~datetime.timedelta`::
765+
766+
>>> from datetime import datetime, timedelta
767+
>>> dt1 = datetime(2024, 1, 1, 12, 0, 0)
768+
>>> dt2 = datetime(2024, 1, 1, 12, 0, 0, 500000)
769+
>>> dt1 == approx(dt2, abs=timedelta(seconds=1))
770+
True
771+
772+
Note that ``rel`` is not supported for datetime/timedelta comparisons,
773+
and ``abs`` must be explicitly provided as a ``timedelta`` object.
774+
775+
.. versionadded:: 8.4
776+
680777
If you're thinking about using ``approx``, then you might want to know how
681778
it compares to other good ways of comparing floating-point numbers. All of
682779
these algorithms are based on relative and absolute tolerances and should
@@ -785,6 +882,8 @@ def approx(
785882
elif isinstance(expected, Collection) and not isinstance(expected, str | bytes):
786883
msg = f"pytest.approx() only supports ordered sequences, but got: {expected!r}"
787884
raise TypeError(msg)
885+
elif isinstance(expected, (datetime, timedelta)):
886+
cls = ApproxTimedelta
788887
else:
789888
cls = ApproxScalar
790889

testing/python/approx.py

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1105,6 +1105,163 @@ def test_approx_on_unordered_mapping_matching():
11051105
result.assert_outcomes(passed=1)
11061106

11071107

1108+
class TestApproxDatetime:
1109+
"""Tests for datetime/timedelta support in approx (issue #8395)."""
1110+
1111+
def test_datetime_exactly_equal(self):
1112+
from datetime import datetime, timedelta
1113+
1114+
dt = datetime(2024, 1, 1, 12, 0, 0)
1115+
assert dt == approx(dt, abs=timedelta(seconds=1))
1116+
1117+
def test_datetime_within_tolerance(self):
1118+
from datetime import datetime, timedelta
1119+
1120+
dt1 = datetime(2024, 1, 1, 12, 0, 0)
1121+
dt2 = datetime(2024, 1, 1, 12, 0, 0, 500000) # +0.5s
1122+
assert dt1 == approx(dt2, abs=timedelta(seconds=1))
1123+
1124+
def test_datetime_outside_tolerance(self):
1125+
from datetime import datetime, timedelta
1126+
1127+
dt1 = datetime(2024, 1, 1, 12, 0, 0)
1128+
dt2 = datetime(2024, 1, 1, 12, 0, 2) # +2s
1129+
assert dt1 != approx(dt2, abs=timedelta(seconds=1))
1130+
1131+
def test_datetime_negative_difference(self):
1132+
from datetime import datetime, timedelta
1133+
1134+
dt1 = datetime(2024, 1, 1, 12, 0, 1)
1135+
dt2 = datetime(2024, 1, 1, 12, 0, 0) # dt2 < dt1
1136+
assert dt1 == approx(dt2, abs=timedelta(seconds=2))
1137+
assert dt1 != approx(dt2, abs=timedelta(milliseconds=500))
1138+
1139+
def test_timedelta_within_tolerance(self):
1140+
from datetime import timedelta
1141+
1142+
td1 = timedelta(seconds=100)
1143+
td2 = timedelta(seconds=100.5)
1144+
assert td1 == approx(td2, abs=timedelta(seconds=1))
1145+
1146+
def test_timedelta_outside_tolerance(self):
1147+
from datetime import timedelta
1148+
1149+
td1 = timedelta(seconds=100)
1150+
td2 = timedelta(seconds=102)
1151+
assert td1 != approx(td2, abs=timedelta(seconds=1))
1152+
1153+
def test_requires_abs(self):
1154+
from datetime import datetime
1155+
1156+
with pytest.raises(TypeError, match="requires an absolute tolerance"):
1157+
approx(datetime(2024, 1, 1))
1158+
1159+
def test_rejects_rel(self):
1160+
from datetime import datetime, timedelta
1161+
1162+
with pytest.raises(TypeError, match="does not support relative tolerance"):
1163+
approx(datetime(2024, 1, 1), rel=0.1, abs=timedelta(seconds=1))
1164+
1165+
def test_abs_must_be_timedelta(self):
1166+
from datetime import datetime
1167+
1168+
with pytest.raises(TypeError, match="must be a timedelta"):
1169+
approx(datetime(2024, 1, 1), abs=1.0)
1170+
1171+
def test_rejects_nan_ok(self):
1172+
from datetime import datetime, timedelta
1173+
1174+
with pytest.raises(TypeError, match="does not support nan_ok"):
1175+
approx(datetime(2024, 1, 1), abs=timedelta(seconds=1), nan_ok=True)
1176+
1177+
def test_datetime_repr(self):
1178+
from datetime import datetime, timedelta
1179+
1180+
dt = datetime(2024, 1, 1, 12, 0, 0)
1181+
result = repr(approx(dt, abs=timedelta(seconds=1)))
1182+
assert "2024-01-01 12:00:00" in result
1183+
assert "0:00:01" in result
1184+
1185+
def test_timedelta_repr(self):
1186+
from datetime import timedelta
1187+
1188+
td = timedelta(seconds=100)
1189+
result = repr(approx(td, abs=timedelta(seconds=1)))
1190+
assert "0:01:40" in result # 100 seconds
1191+
assert "0:00:01" in result # 1 second tolerance
1192+
1193+
def test_datetime_symmetry(self):
1194+
"""approx comparison should work on both sides of ==."""
1195+
from datetime import datetime, timedelta
1196+
1197+
dt1 = datetime(2024, 1, 1, 12, 0, 0)
1198+
dt2 = datetime(2024, 1, 1, 12, 0, 0, 500000)
1199+
tol = timedelta(seconds=1)
1200+
assert dt1 == approx(dt2, abs=tol)
1201+
assert approx(dt2, abs=tol) == dt1
1202+
1203+
def test_datetime_ne_operator(self):
1204+
from datetime import datetime, timedelta
1205+
1206+
dt1 = datetime(2024, 1, 1, 12, 0, 0)
1207+
dt2 = datetime(2024, 1, 1, 12, 0, 5)
1208+
tol = timedelta(seconds=1)
1209+
assert dt1 != approx(dt2, abs=tol)
1210+
assert not (dt1 == approx(dt2, abs=tol))
1211+
1212+
def test_datetime_with_timezone(self):
1213+
from datetime import datetime, timedelta, timezone
1214+
1215+
tz = timezone.utc
1216+
dt1 = datetime(2024, 1, 1, 12, 0, 0, tzinfo=tz)
1217+
dt2 = datetime(2024, 1, 1, 12, 0, 0, 500000, tzinfo=tz)
1218+
assert dt1 == approx(dt2, abs=timedelta(seconds=1))
1219+
1220+
def test_datetime_error_message(self):
1221+
from datetime import datetime, timedelta
1222+
1223+
dt1 = datetime(2024, 1, 1, 12, 0, 0)
1224+
dt2 = datetime(2024, 1, 1, 12, 0, 5) # 5 seconds off
1225+
with pytest.raises(AssertionError, match="comparison failed"):
1226+
assert dt1 == approx(dt2, abs=timedelta(seconds=1))
1227+
1228+
def test_timedelta_zero(self):
1229+
from datetime import timedelta
1230+
1231+
td1 = timedelta(seconds=0)
1232+
td2 = timedelta(seconds=0)
1233+
assert td1 == approx(td2, abs=timedelta(seconds=1))
1234+
1235+
def test_datetime_boundary_exact(self):
1236+
"""Test that values exactly at the tolerance boundary are equal."""
1237+
from datetime import datetime, timedelta
1238+
1239+
dt1 = datetime(2024, 1, 1, 12, 0, 0)
1240+
dt2 = datetime(2024, 1, 1, 12, 0, 1) # exactly 1 second
1241+
assert dt1 == approx(dt2, abs=timedelta(seconds=1))
1242+
1243+
def test_datetime_microsecond_tolerance(self):
1244+
from datetime import datetime, timedelta
1245+
1246+
dt1 = datetime(2024, 1, 1, 12, 0, 0, 0)
1247+
dt2 = datetime(2024, 1, 1, 12, 0, 0, 100) # +100 microseconds
1248+
assert dt1 == approx(dt2, abs=timedelta(microseconds=200))
1249+
assert dt1 != approx(dt2, abs=timedelta(microseconds=50))
1250+
1251+
def test_bool_context_raises(self):
1252+
from datetime import datetime, timedelta
1253+
1254+
with pytest.raises(AssertionError, match="boolean context"):
1255+
bool(approx(datetime(2024, 1, 1), abs=timedelta(seconds=1)))
1256+
1257+
def test_wrong_type_comparison(self):
1258+
"""Comparing a datetime approx with a non-datetime should return False."""
1259+
from datetime import datetime, timedelta
1260+
1261+
assert 42 != approx(datetime(2024, 1, 1), abs=timedelta(seconds=1))
1262+
assert "string" != approx(datetime(2024, 1, 1), abs=timedelta(seconds=1))
1263+
1264+
11081265
class MyVec3: # incomplete
11091266
"""sequence like"""
11101267

0 commit comments

Comments
 (0)