From 2df58fc8176fdb57df62f8a3050795b69ccf3b39 Mon Sep 17 00:00:00 2001 From: Brock Date: Tue, 21 Feb 2023 07:52:04 -0800 Subject: [PATCH 1/2] BUG: Timedelta comparisons with very large pytimedeltas overflowing --- doc/source/whatsnew/v2.0.0.rst | 1 + pandas/_libs/tslibs/timedeltas.pyx | 19 ++++++++- .../tests/scalar/timedelta/test_arithmetic.py | 40 +++++++++++++++++++ 3 files changed, 58 insertions(+), 2 deletions(-) diff --git a/doc/source/whatsnew/v2.0.0.rst b/doc/source/whatsnew/v2.0.0.rst index c0082b451c95d..a1b0426486e5c 100644 --- a/doc/source/whatsnew/v2.0.0.rst +++ b/doc/source/whatsnew/v2.0.0.rst @@ -1216,6 +1216,7 @@ Timedelta - Bug in :func:`to_timedelta` raising error when input has nullable dtype ``Float64`` (:issue:`48796`) - Bug in :class:`Timedelta` constructor incorrectly raising instead of returning ``NaT`` when given a ``np.timedelta64("nat")`` (:issue:`48898`) - Bug in :class:`Timedelta` constructor failing to raise when passed both a :class:`Timedelta` object and keywords (e.g. days, seconds) (:issue:`48898`) +- Bug in :class:`Timedelta` comparisons with very large ``datetime.timedelta`` objects incorrect raising ``OutOfBoundsTimedelta`` (:issue:`49021`) Timezones ^^^^^^^^^ diff --git a/pandas/_libs/tslibs/timedeltas.pyx b/pandas/_libs/tslibs/timedeltas.pyx index a8c5474f0f532..7ca68646fab75 100644 --- a/pandas/_libs/tslibs/timedeltas.pyx +++ b/pandas/_libs/tslibs/timedeltas.pyx @@ -4,6 +4,8 @@ import warnings cimport cython from cpython.object cimport ( Py_EQ, + Py_LE, + Py_LT, Py_NE, PyObject, PyObject_RichCompare, @@ -1149,8 +1151,21 @@ cdef class _Timedelta(timedelta): if isinstance(other, _Timedelta): ots = other elif is_any_td_scalar(other): - ots = Timedelta(other) - # TODO: watch out for overflows + try: + ots = Timedelta(other) + except OutOfBoundsTimedelta as err: + # GH#49021 pytimedelta.max overflows + if not PyDelta_Check(other): + # TODO: handle this case + raise + if op == Py_EQ: + return False + elif op == Py_NE: + return True + elif op == Py_LE or op == Py_LT: + return self.days <= other.days + else: + return self.days >= other.days elif other is NaT: return op == Py_NE diff --git a/pandas/tests/scalar/timedelta/test_arithmetic.py b/pandas/tests/scalar/timedelta/test_arithmetic.py index d67d451e4fc6d..9b8ae8c833196 100644 --- a/pandas/tests/scalar/timedelta/test_arithmetic.py +++ b/pandas/tests/scalar/timedelta/test_arithmetic.py @@ -966,6 +966,46 @@ def test_td_op_timedelta_timedeltalike_array(self, op, arr): class TestTimedeltaComparison: + def test_compare_pytimedelta_bounds(self): + # GH#49021 don't overflow on comparison with very large pytimedeltas + + for unit in ["ns", "us"]: + tdmax = Timedelta.max.as_unit(unit).max + tdmin = Timedelta.min.as_unit(unit).min + + assert tdmax < timedelta.max + assert tdmax <= timedelta.max + assert not tdmax > timedelta.max + assert not tdmax >= timedelta.max + assert tdmax != timedelta.max + assert not tdmax == timedelta.max + + assert tdmin > timedelta.min + assert tdmin >= timedelta.min + assert not tdmin < timedelta.min + assert not tdmin <= timedelta.min + assert tdmin != timedelta.min + assert not tdmin == timedelta.min + + # But the "ms" and "s"-reso bounds extend pass pytimedelta + for unit in ["ms", "s"]: + tdmax = Timedelta.max.as_unit(unit).max + tdmin = Timedelta.min.as_unit(unit).min + + assert tdmax > timedelta.max + assert tdmax >= timedelta.max + assert not tdmax < timedelta.max + assert not tdmax <= timedelta.max + assert tdmax != timedelta.max + assert not tdmax == timedelta.max + + assert tdmin < timedelta.min + assert tdmin <= timedelta.min + assert not tdmin > timedelta.min + assert not tdmin >= timedelta.min + assert tdmin != timedelta.min + assert not tdmin == timedelta.min + def test_compare_tick(self, tick_classes): cls = tick_classes From 39c5f4cb27f8b9b3bfc5281885066a41fd09c1b7 Mon Sep 17 00:00:00 2001 From: Brock Date: Fri, 24 Mar 2023 16:30:49 -0700 Subject: [PATCH 2/2] Handle missed cases --- pandas/_libs/tslibs/timedeltas.pyx | 20 +++++++++++----- .../tests/scalar/timedelta/test_arithmetic.py | 24 +++++++++++++++++++ 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/pandas/_libs/tslibs/timedeltas.pyx b/pandas/_libs/tslibs/timedeltas.pyx index 9e9316ce040a8..f553a8c13bef8 100644 --- a/pandas/_libs/tslibs/timedeltas.pyx +++ b/pandas/_libs/tslibs/timedeltas.pyx @@ -4,6 +4,8 @@ import warnings cimport cython from cpython.object cimport ( Py_EQ, + Py_GE, + Py_GT, Py_LE, Py_LT, Py_NE, @@ -1163,14 +1165,20 @@ cdef class _Timedelta(timedelta): if not PyDelta_Check(other): # TODO: handle this case raise + ltup = (self.days, self.seconds, self.microseconds, self.nanoseconds) + rtup = (other.days, other.seconds, other.microseconds, 0) if op == Py_EQ: - return False + return ltup == rtup elif op == Py_NE: - return True - elif op == Py_LE or op == Py_LT: - return self.days <= other.days - else: - return self.days >= other.days + return ltup != rtup + elif op == Py_LT: + return ltup < rtup + elif op == Py_LE: + return ltup <= rtup + elif op == Py_GT: + return ltup > rtup + elif op == Py_GE: + return ltup >= rtup elif other is NaT: return op == Py_NE diff --git a/pandas/tests/scalar/timedelta/test_arithmetic.py b/pandas/tests/scalar/timedelta/test_arithmetic.py index 9b8ae8c833196..e583de1f489db 100644 --- a/pandas/tests/scalar/timedelta/test_arithmetic.py +++ b/pandas/tests/scalar/timedelta/test_arithmetic.py @@ -1006,6 +1006,30 @@ def test_compare_pytimedelta_bounds(self): assert tdmin != timedelta.min assert not tdmin == timedelta.min + def test_compare_pytimedelta_bounds2(self): + # a pytimedelta outside the microsecond bounds + pytd = timedelta(days=999999999, seconds=86399) + # NB: np.timedelta64(td, "s"") incorrectly overflows + td64 = np.timedelta64(pytd.days, "D") + np.timedelta64(pytd.seconds, "s") + td = Timedelta(td64) + assert td.days == pytd.days + assert td.seconds == pytd.seconds + + assert td == pytd + assert not td != pytd + assert not td < pytd + assert not td > pytd + assert td <= pytd + assert td >= pytd + + td2 = td - Timedelta(seconds=1).as_unit("s") + assert td2 != pytd + assert not td2 == pytd + assert td2 < pytd + assert td2 <= pytd + assert not td2 > pytd + assert not td2 >= pytd + def test_compare_tick(self, tick_classes): cls = tick_classes