diff --git a/AUTHORS b/AUTHORS index d6d2737a4bf..f4a2769ca3c 100644 --- a/AUTHORS +++ b/AUTHORS @@ -195,6 +195,7 @@ Grig Gheorghiu Grigorii Eremeev (budulianin) Guido Wesdorp Guoqiang Zhang +Hamza Mobeen Harald Armin Massa Harshna Henk-Jaap Wagenaar diff --git a/changelog/8395.feature.rst b/changelog/8395.feature.rst new file mode 100644 index 00000000000..61c16216182 --- /dev/null +++ b/changelog/8395.feature.rst @@ -0,0 +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`. diff --git a/src/_pytest/python_api.py b/src/_pytest/python_api.py index 9e2e1826a4f..f6d5e31a588 100644 --- a/src/_pytest/python_api.py +++ b/src/_pytest/python_api.py @@ -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 @@ -558,10 +561,75 @@ 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 tolerance as a timedelta. + Relative tolerance is not supported for datetime comparisons. + """ + + def __init__(self, expected, rel=None, abs=None, nan_ok: bool = False) -> None: + __tracebackhide__ = True + if isinstance(expected, datetime) and rel is not None: + raise TypeError( + "pytest.approx() does not support relative tolerance for " + "datetime 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 and rel is None: + raise TypeError( + "pytest.approx() requires an explicit tolerance for " + "datetime/timedelta comparisons: " + "e.g. approx(expected, abs=timedelta(seconds=1))" + ) + 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) + + def __repr__(self) -> str: + return f"{self.expected} ± {self.abs}" + + def __eq__(self, actual) -> bool: + try: + return bool(builtins.abs(self.expected - actual) <= self.abs) + except (TypeError, OverflowError): + return False + + 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.abs}", + f"Absolute difference: {abs_diff}", + f"Tolerance: {self.abs}", + ] + + def approx( expected: Any, - rel: float | Decimal | None = None, - abs: float | Decimal | None = None, + rel: float | Decimal | timedelta | 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 @@ -677,6 +745,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 comparisons, + and ``abs`` or ``rel`` 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 @@ -785,6 +870,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 diff --git a/testing/python/approx.py b/testing/python/approx.py index bf9fad6cb56..4369dc24ad4 100644 --- a/testing/python/approx.py +++ b/testing/python/approx.py @@ -1118,6 +1118,223 @@ def test_assertion_rewriting_works_with_approx_on_lhs( ] +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_timedelta_rel_within_tolerance(self): + from datetime import timedelta + + td1 = timedelta(seconds=100) + td2 = timedelta(seconds=100.5) + assert td1 == approx(td2, rel=timedelta(seconds=1)) + + 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)) + + def test_requires_tolerance(self): + from datetime import datetime + + with pytest.raises(TypeError, match="requires an explicit tolerance"): + approx(datetime(2024, 1, 1)) + + def test_datetime_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)) + + with pytest.raises(TypeError, match="does not support relative tolerance"): + approx(datetime(2024, 1, 1), rel=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_timedelta_rel_must_be_timedelta(self): + from datetime import timedelta + + with pytest.raises(TypeError, match="must be a timedelta"): + approx(timedelta(seconds=1), rel=0.1) + + 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"""