Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AUTHORS
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@ Grig Gheorghiu
Grigorii Eremeev (budulianin)
Guido Wesdorp
Guoqiang Zhang
Hamza Mobeen
Harald Armin Massa
Harshna
Henk-Jaap Wagenaar
Expand Down
1 change: 1 addition & 0 deletions changelog/8395.feature.rst
Original file line number Diff line number Diff line change
@@ -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`.
101 changes: 100 additions & 1 deletion src/_pytest/python_api.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
194 changes: 194 additions & 0 deletions testing/python/approx.py
Original file line number Diff line number Diff line change
Expand Up @@ -1105,6 +1105,200 @@ 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
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
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
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
from datetime import 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
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))

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
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
from datetime import 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
from datetime import 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
from datetime import 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
from datetime import timedelta
from datetime import 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
from datetime import 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
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
from datetime import 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
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
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
from datetime import 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
from datetime import 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"""

Expand Down