Skip to content
43 changes: 31 additions & 12 deletions src/_pytest/python_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -565,7 +567,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.
"""

Expand All @@ -585,20 +587,35 @@ 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(
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 abs is not None and abs < timedelta(0):
raise ValueError(f"absolute tolerance can't be negative: {abs}")
if rel is not None:
if not isinstance(rel, (int, float)):
raise TypeError(
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
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}"
Expand Down Expand Up @@ -757,8 +774,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

Expand Down
100 changes: 95 additions & 5 deletions testing/python/approx.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -1203,11 +1203,57 @@ 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_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

# 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
Expand Down Expand Up @@ -1334,6 +1380,50 @@ 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
)
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
"""sequence like"""
Expand Down
Loading