Skip to content

Commit 07dbfab

Browse files
hamza-mobeenshimonenatorcodexPierre-Sassoulas
authored
Add datetime/timedelta support to pytest.approx (#8395) (#14414)
Closes #8395 --------- Co-authored-by: Antigravity <antigravity@google.com> Co-authored-by: Codex <codex@openai.com> Co-authored-by: Pierre Sassoulas <pierre.sassoulas@gmail.com>
1 parent 84ae27e commit 07dbfab

4 files changed

Lines changed: 308 additions & 2 deletions

File tree

AUTHORS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,7 @@ Grig Gheorghiu
195195
Grigorii Eremeev (budulianin)
196196
Guido Wesdorp
197197
Guoqiang Zhang
198+
Hamza Mobeen
198199
Harald Armin Massa
199200
Harshna
200201
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`` or ``rel`` tolerance as a :class:`~datetime.timedelta` is required and relative tolerance is not supported for datetime comparisons -- by :user:`hamza-mobeen`.

src/_pytest/python_api.py

Lines changed: 89 additions & 2 deletions
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,75 @@ 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 tolerance as a timedelta.
569+
Relative tolerance is not supported for datetime comparisons.
570+
"""
571+
572+
def __init__(self, expected, rel=None, abs=None, nan_ok: bool = False) -> None:
573+
__tracebackhide__ = True
574+
if isinstance(expected, datetime) and rel is not None:
575+
raise TypeError(
576+
"pytest.approx() does not support relative tolerance for "
577+
"datetime 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 and rel is None:
585+
raise TypeError(
586+
"pytest.approx() requires an explicit tolerance for "
587+
"datetime/timedelta comparisons: "
588+
"e.g. approx(expected, abs=timedelta(seconds=1))"
589+
)
590+
if abs is not None and 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+
if rel is not None and not isinstance(rel, timedelta):
596+
raise TypeError(
597+
f"relative tolerance for timedelta must be a "
598+
f"timedelta, got {type(rel).__name__}"
599+
)
600+
tolerance = max(t for t in (abs, rel) if t is not None)
601+
super().__init__(expected, rel=None, abs=tolerance, nan_ok=False)
602+
603+
def __repr__(self) -> str:
604+
return f"{self.expected} ± {self.abs}"
605+
606+
def __eq__(self, actual) -> bool:
607+
try:
608+
return bool(builtins.abs(self.expected - actual) <= self.abs)
609+
except (TypeError, OverflowError):
610+
return False
611+
612+
def _yield_comparisons(self, actual):
613+
yield actual, self.expected
614+
615+
def _repr_compare(self, other_side: Any) -> list[str]:
616+
try:
617+
abs_diff = builtins.abs(self.expected - other_side)
618+
except (TypeError, OverflowError):
619+
abs_diff = "N/A"
620+
return [
621+
"comparison failed",
622+
f"Obtained: {other_side}",
623+
f"Expected: {self.expected} ± {self.abs}",
624+
f"Absolute difference: {abs_diff}",
625+
f"Tolerance: {self.abs}",
626+
]
627+
628+
561629
def approx(
562630
expected: Any,
563-
rel: float | Decimal | None = None,
564-
abs: float | Decimal | None = None,
631+
rel: float | Decimal | timedelta | None = None,
632+
abs: float | Decimal | timedelta | None = None,
565633
nan_ok: bool = False,
566634
) -> ApproxBase:
567635
"""Assert that two numbers (or two ordered sequences of numbers) are equal to each other
@@ -677,6 +745,23 @@ def approx(
677745
>>> ["foo", 1.0000005] == approx([None,1])
678746
False
679747
748+
**datetime and timedelta**
749+
750+
You can also use ``approx`` to compare :class:`~datetime.datetime` and
751+
:class:`~datetime.timedelta` objects by specifying an absolute tolerance
752+
as a :class:`~datetime.timedelta`::
753+
754+
>>> from datetime import datetime, timedelta
755+
>>> dt1 = datetime(2024, 1, 1, 12, 0, 0)
756+
>>> dt2 = datetime(2024, 1, 1, 12, 0, 0, 500000)
757+
>>> dt1 == approx(dt2, abs=timedelta(seconds=1))
758+
True
759+
760+
Note that ``rel`` is not supported for datetime comparisons,
761+
and ``abs`` or ``rel`` must be explicitly provided as a ``timedelta`` object.
762+
763+
.. versionadded:: 8.4
764+
680765
If you're thinking about using ``approx``, then you might want to know how
681766
it compares to other good ways of comparing floating-point numbers. All of
682767
these algorithms are based on relative and absolute tolerances and should
@@ -785,6 +870,8 @@ def approx(
785870
elif isinstance(expected, Collection) and not isinstance(expected, str | bytes):
786871
msg = f"pytest.approx() only supports ordered sequences, but got: {expected!r}"
787872
raise TypeError(msg)
873+
elif isinstance(expected, (datetime, timedelta)):
874+
cls = ApproxTimedelta
788875
else:
789876
cls = ApproxScalar
790877

testing/python/approx.py

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1118,6 +1118,223 @@ def test_assertion_rewriting_works_with_approx_on_lhs(
11181118
]
11191119

11201120

1121+
class TestApproxDatetime:
1122+
"""Tests for datetime/timedelta support in approx (issue #8395)."""
1123+
1124+
def test_datetime_exactly_equal(self):
1125+
from datetime import datetime
1126+
from datetime import timedelta
1127+
1128+
dt = datetime(2024, 1, 1, 12, 0, 0)
1129+
assert dt == approx(dt, abs=timedelta(seconds=1))
1130+
1131+
def test_datetime_within_tolerance(self):
1132+
from datetime import datetime
1133+
from datetime import timedelta
1134+
1135+
dt1 = datetime(2024, 1, 1, 12, 0, 0)
1136+
dt2 = datetime(2024, 1, 1, 12, 0, 0, 500000) # +0.5s
1137+
assert dt1 == approx(dt2, abs=timedelta(seconds=1))
1138+
1139+
def test_datetime_outside_tolerance(self):
1140+
from datetime import datetime
1141+
from datetime import timedelta
1142+
1143+
dt1 = datetime(2024, 1, 1, 12, 0, 0)
1144+
dt2 = datetime(2024, 1, 1, 12, 0, 2) # +2s
1145+
assert dt1 != approx(dt2, abs=timedelta(seconds=1))
1146+
1147+
def test_datetime_negative_difference(self):
1148+
from datetime import datetime
1149+
from datetime import timedelta
1150+
1151+
dt1 = datetime(2024, 1, 1, 12, 0, 1)
1152+
dt2 = datetime(2024, 1, 1, 12, 0, 0) # dt2 < dt1
1153+
assert dt1 == approx(dt2, abs=timedelta(seconds=2))
1154+
assert dt1 != approx(dt2, abs=timedelta(milliseconds=500))
1155+
1156+
def test_timedelta_within_tolerance(self):
1157+
from datetime import timedelta
1158+
1159+
td1 = timedelta(seconds=100)
1160+
td2 = timedelta(seconds=100.5)
1161+
assert td1 == approx(td2, abs=timedelta(seconds=1))
1162+
1163+
def test_timedelta_outside_tolerance(self):
1164+
from datetime import timedelta
1165+
1166+
td1 = timedelta(seconds=100)
1167+
td2 = timedelta(seconds=102)
1168+
assert td1 != approx(td2, abs=timedelta(seconds=1))
1169+
1170+
def test_timedelta_rel_within_tolerance(self):
1171+
from datetime import timedelta
1172+
1173+
td1 = timedelta(seconds=100)
1174+
td2 = timedelta(seconds=100.5)
1175+
assert td1 == approx(td2, rel=timedelta(seconds=1))
1176+
1177+
def test_timedelta_rel_outside_tolerance(self):
1178+
from datetime import timedelta
1179+
1180+
td1 = timedelta(seconds=100)
1181+
td2 = timedelta(seconds=102)
1182+
assert td1 != approx(td2, rel=timedelta(seconds=1))
1183+
1184+
def test_requires_tolerance(self):
1185+
from datetime import datetime
1186+
1187+
with pytest.raises(TypeError, match="requires an explicit tolerance"):
1188+
approx(datetime(2024, 1, 1))
1189+
1190+
def test_datetime_rejects_rel(self):
1191+
from datetime import datetime
1192+
from datetime import timedelta
1193+
1194+
with pytest.raises(TypeError, match="does not support relative tolerance"):
1195+
approx(datetime(2024, 1, 1), rel=0.1, abs=timedelta(seconds=1))
1196+
1197+
with pytest.raises(TypeError, match="does not support relative tolerance"):
1198+
approx(datetime(2024, 1, 1), rel=timedelta(seconds=1))
1199+
1200+
def test_abs_must_be_timedelta(self):
1201+
from datetime import datetime
1202+
1203+
with pytest.raises(TypeError, match="must be a timedelta"):
1204+
approx(datetime(2024, 1, 1), abs=1.0)
1205+
1206+
def test_timedelta_rel_must_be_timedelta(self):
1207+
from datetime import timedelta
1208+
1209+
with pytest.raises(TypeError, match="must be a timedelta"):
1210+
approx(timedelta(seconds=1), rel=0.1)
1211+
1212+
def test_rejects_nan_ok(self):
1213+
from datetime import datetime
1214+
from datetime import timedelta
1215+
1216+
with pytest.raises(TypeError, match="does not support nan_ok"):
1217+
approx(datetime(2024, 1, 1), abs=timedelta(seconds=1), nan_ok=True)
1218+
1219+
def test_datetime_repr(self):
1220+
from datetime import datetime
1221+
from datetime import timedelta
1222+
1223+
dt = datetime(2024, 1, 1, 12, 0, 0)
1224+
result = repr(approx(dt, abs=timedelta(seconds=1)))
1225+
assert "2024-01-01 12:00:00" in result
1226+
assert "0:00:01" in result
1227+
1228+
def test_timedelta_repr(self):
1229+
from datetime import timedelta
1230+
1231+
td = timedelta(seconds=100)
1232+
result = repr(approx(td, abs=timedelta(seconds=1)))
1233+
assert "0:01:40" in result # 100 seconds
1234+
assert "0:00:01" in result # 1 second tolerance
1235+
1236+
def test_datetime_symmetry(self):
1237+
"""Approx comparison should work on both sides of ==."""
1238+
from datetime import datetime
1239+
from datetime import timedelta
1240+
1241+
dt1 = datetime(2024, 1, 1, 12, 0, 0)
1242+
dt2 = datetime(2024, 1, 1, 12, 0, 0, 500000)
1243+
tol = timedelta(seconds=1)
1244+
assert dt1 == approx(dt2, abs=tol)
1245+
assert approx(dt2, abs=tol) == dt1
1246+
1247+
def test_datetime_ne_operator(self):
1248+
from datetime import datetime
1249+
from datetime import timedelta
1250+
1251+
dt1 = datetime(2024, 1, 1, 12, 0, 0)
1252+
dt2 = datetime(2024, 1, 1, 12, 0, 5)
1253+
tol = timedelta(seconds=1)
1254+
assert dt1 != approx(dt2, abs=tol)
1255+
assert not (dt1 == approx(dt2, abs=tol))
1256+
1257+
def test_datetime_with_timezone(self):
1258+
from datetime import datetime
1259+
from datetime import timedelta
1260+
from datetime import timezone
1261+
1262+
tz = timezone.utc
1263+
dt1 = datetime(2024, 1, 1, 12, 0, 0, tzinfo=tz)
1264+
dt2 = datetime(2024, 1, 1, 12, 0, 0, 500000, tzinfo=tz)
1265+
assert dt1 == approx(dt2, abs=timedelta(seconds=1))
1266+
1267+
def test_datetime_error_message(self):
1268+
from datetime import datetime
1269+
from datetime import timedelta
1270+
1271+
dt1 = datetime(2024, 1, 1, 12, 0, 0)
1272+
dt2 = datetime(2024, 1, 1, 12, 0, 5) # 5 seconds off
1273+
with pytest.raises(AssertionError, match="comparison failed"):
1274+
assert dt1 == approx(dt2, abs=timedelta(seconds=1))
1275+
1276+
def test_timedelta_zero(self):
1277+
from datetime import timedelta
1278+
1279+
td1 = timedelta(seconds=0)
1280+
td2 = timedelta(seconds=0)
1281+
assert td1 == approx(td2, abs=timedelta(seconds=1))
1282+
1283+
def test_datetime_boundary_exact(self):
1284+
"""Test that values exactly at the tolerance boundary are equal."""
1285+
from datetime import datetime
1286+
from datetime import timedelta
1287+
1288+
dt1 = datetime(2024, 1, 1, 12, 0, 0)
1289+
dt2 = datetime(2024, 1, 1, 12, 0, 1) # exactly 1 second
1290+
assert dt1 == approx(dt2, abs=timedelta(seconds=1))
1291+
1292+
def test_datetime_microsecond_tolerance(self):
1293+
from datetime import datetime
1294+
from datetime import timedelta
1295+
1296+
dt1 = datetime(2024, 1, 1, 12, 0, 0, 0)
1297+
dt2 = datetime(2024, 1, 1, 12, 0, 0, 100) # +100 microseconds
1298+
assert dt1 == approx(dt2, abs=timedelta(microseconds=200))
1299+
assert dt1 != approx(dt2, abs=timedelta(microseconds=50))
1300+
1301+
def test_bool_context_raises(self):
1302+
from datetime import datetime
1303+
from datetime import timedelta
1304+
1305+
with pytest.raises(AssertionError, match="boolean context"):
1306+
bool(approx(datetime(2024, 1, 1), abs=timedelta(seconds=1)))
1307+
1308+
def test_wrong_type_comparison(self):
1309+
"""Comparing a datetime approx with a non-datetime should return False."""
1310+
from datetime import datetime
1311+
from datetime import timedelta
1312+
1313+
assert 42 != approx(datetime(2024, 1, 1), abs=timedelta(seconds=1))
1314+
assert "string" != approx(datetime(2024, 1, 1), abs=timedelta(seconds=1))
1315+
1316+
def test_yield_comparisons(self):
1317+
"""Test that _yield_comparisons yields (actual, expected) pairs."""
1318+
from datetime import datetime
1319+
from datetime import timedelta
1320+
1321+
dt = datetime(2024, 1, 1, 12, 0, 0)
1322+
a = approx(dt, abs=timedelta(seconds=1))
1323+
actual = datetime(2024, 1, 1, 12, 0, 0, 500000)
1324+
pairs = list(a._yield_comparisons(actual))
1325+
assert pairs == [(actual, dt)]
1326+
1327+
def test_repr_compare_with_incompatible_type(self):
1328+
"""_repr_compare handles TypeError when actual is not a datetime."""
1329+
from datetime import datetime
1330+
from datetime import timedelta
1331+
1332+
a = approx(datetime(2024, 1, 1), abs=timedelta(seconds=1))
1333+
result = a._repr_compare("not a datetime")
1334+
assert "comparison failed" in result[0]
1335+
assert "N/A" in result[3]
1336+
1337+
11211338
class MyVec3: # incomplete
11221339
"""sequence like"""
11231340

0 commit comments

Comments
 (0)