diff --git a/src/_pytest/python_api.py b/src/_pytest/python_api.py index f6d5e31a588..cc2a2b7126a 100644 --- a/src/_pytest/python_api.py +++ b/src/_pytest/python_api.py @@ -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): @@ -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. """ @@ -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}" @@ -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 diff --git a/testing/python/approx.py b/testing/python/approx.py index 4369dc24ad4..88d46cbb755 100644 --- a/testing/python/approx.py +++ b/testing/python/approx.py @@ -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 @@ -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 @@ -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"""