From 0d4838928cbeb95c3a22bc24646f205e361ca2de Mon Sep 17 00:00:00 2001 From: Brock Date: Tue, 4 Nov 2025 15:03:27 -0800 Subject: [PATCH 1/2] DEPR: silent string casting in binary Timedelta operations --- doc/source/whatsnew/v3.0.0.rst | 1 + pandas/_libs/tslibs/timedeltas.pyx | 29 ++++++++++-- .../tests/scalar/timedelta/test_arithmetic.py | 46 ++++++++++++++++++- 3 files changed, 70 insertions(+), 6 deletions(-) diff --git a/doc/source/whatsnew/v3.0.0.rst b/doc/source/whatsnew/v3.0.0.rst index 01650940c4692..3a23c2674905f 100644 --- a/doc/source/whatsnew/v3.0.0.rst +++ b/doc/source/whatsnew/v3.0.0.rst @@ -741,6 +741,7 @@ Other Deprecations - Deprecated backward-compatibility behavior for :meth:`DataFrame.select_dtypes` matching "str" dtype when ``np.object_`` is specified (:issue:`61916`) - Deprecated option "future.no_silent_downcasting", as it is no longer used. In a future version accessing this option will raise (:issue:`59502`) - Deprecated silent casting of non-datetime 'other' to datetime in :meth:`Series.combine_first` (:issue:`62931`) +- Deprecated silently casting strings to :class:`Timedelta` in binary operations with :class:`Timedelta` (:issue:`59653`) - Deprecated slicing on a :class:`Series` or :class:`DataFrame` with a :class:`DatetimeIndex` using a ``datetime.date`` object, explicitly cast to :class:`Timestamp` instead (:issue:`35830`) - Deprecated support for the Dataframe Interchange Protocol (:issue:`56732`) - Deprecated the 'inplace' keyword from :meth:`Resampler.interpolate`, as passing ``True`` raises ``AttributeError`` (:issue:`58690`) diff --git a/pandas/_libs/tslibs/timedeltas.pyx b/pandas/_libs/tslibs/timedeltas.pyx index 1cd875d4ce41d..7f90bc5d7da74 100644 --- a/pandas/_libs/tslibs/timedeltas.pyx +++ b/pandas/_libs/tslibs/timedeltas.pyx @@ -798,7 +798,7 @@ def _binary_op_method_timedeltalike(op, name): return NotImplemented try: - other = Timedelta(other) + other = _wrapped_to_timedelta(other) except ValueError: # failed to parse as timedelta return NotImplemented @@ -2341,7 +2341,7 @@ class Timedelta(_Timedelta): def __truediv__(self, other): if _should_cast_to_timedelta(other): # We interpret NaT as timedelta64("NaT") - other = Timedelta(other) + other = _wrapped_to_timedelta(other) if other is NaT: return np.nan if other._creso != self._creso: @@ -2374,7 +2374,7 @@ class Timedelta(_Timedelta): def __rtruediv__(self, other): if _should_cast_to_timedelta(other): # We interpret NaT as timedelta64("NaT") - other = Timedelta(other) + other = _wrapped_to_timedelta(other) if other is NaT: return np.nan if self._creso != other._creso: @@ -2402,7 +2402,7 @@ class Timedelta(_Timedelta): # just defer if _should_cast_to_timedelta(other): # We interpret NaT as timedelta64("NaT") - other = Timedelta(other) + other = _wrapped_to_timedelta(other) if other is NaT: return np.nan if self._creso != other._creso: @@ -2457,7 +2457,7 @@ class Timedelta(_Timedelta): # just defer if _should_cast_to_timedelta(other): # We interpret NaT as timedelta64("NaT") - other = Timedelta(other) + other = _wrapped_to_timedelta(other) if other is NaT: return np.nan if self._creso != other._creso: @@ -2525,6 +2525,7 @@ def truediv_object_array(ndarray left, ndarray right): if cnp.get_timedelta64_value(td64) == NPY_NAT: # td here should be interpreted as a td64 NaT if _should_cast_to_timedelta(obj): + _wrapped_to_timedelta(obj) # deprecate if allowing string res_value = np.nan else: # if its a number then let numpy handle division, otherwise @@ -2554,6 +2555,7 @@ def floordiv_object_array(ndarray left, ndarray right): if cnp.get_timedelta64_value(td64) == NPY_NAT: # td here should be interpreted as a td64 NaT if _should_cast_to_timedelta(obj): + _wrapped_to_timedelta(obj) # deprecate allowing string res_value = np.nan else: # if its a number then let numpy handle division, otherwise @@ -2585,6 +2587,23 @@ cdef bint is_any_td_scalar(object obj): ) +cdef inline _wrapped_to_timedelta(object other): + # Helper for deprecating cases where we cast str to Timedelta + td = Timedelta(other) + if isinstance(other, str): + from pandas.errors import Pandas4Warning + warnings.warn( + # GH#59653 + "Scalar operations between Timedelta and string are " + "deprecated and will raise in a future version. " + "Explicitly cast to Timedelta first.", + Pandas4Warning, + stacklevel=find_stack_level(), + ) + # When this is enforced, remove str from _should_cast_to_timedelta + return td + + cdef bint _should_cast_to_timedelta(object obj): """ Should we treat this object as a Timedelta for the purpose of a binary op diff --git a/pandas/tests/scalar/timedelta/test_arithmetic.py b/pandas/tests/scalar/timedelta/test_arithmetic.py index 9347784fa1ec3..42c072afc3411 100644 --- a/pandas/tests/scalar/timedelta/test_arithmetic.py +++ b/pandas/tests/scalar/timedelta/test_arithmetic.py @@ -11,7 +11,10 @@ import numpy as np import pytest -from pandas.errors import OutOfBoundsTimedelta +from pandas.errors import ( + OutOfBoundsTimedelta, + Pandas4Warning, +) import pandas as pd from pandas import ( @@ -1182,3 +1185,44 @@ def test_ops_error_str(): assert not left == right assert left != right + + +@pytest.mark.parametrize("box", [True, False]) +def test_ops_str_deprecated(box): + # GH#59653 + td = Timedelta("1 day") + item = "1" + if box: + item = np.array([item], dtype=object) + + msg = "Scalar operations between Timedelta and string are deprecated" + with tm.assert_produces_warning(Pandas4Warning, match=msg): + td + item + with tm.assert_produces_warning(Pandas4Warning, match=msg): + item + td + with tm.assert_produces_warning(Pandas4Warning, match=msg): + td - item + with tm.assert_produces_warning(Pandas4Warning, match=msg): + item - td + with tm.assert_produces_warning(Pandas4Warning, match=msg): + item / td + if not box: + with tm.assert_produces_warning(Pandas4Warning, match=msg): + td / item + with tm.assert_produces_warning(Pandas4Warning, match=msg): + item // td + with tm.assert_produces_warning(Pandas4Warning, match=msg): + td // item + else: + msg = "|".join( + [ + "ufunc 'divide' cannot use operands", + "Invalid dtype object for __floordiv__", + ] + ) + with pytest.raises(TypeError, match=msg): + td / item + with pytest.raises(TypeError, match=msg): + item // td + with pytest.raises(TypeError, match=msg): + td // item From ba26308c6fd3f4a46ed356a2aa09bcc25bd08c55 Mon Sep 17 00:00:00 2001 From: Brock Date: Wed, 5 Nov 2025 10:26:38 -0800 Subject: [PATCH 2/2] update exception messages --- pandas/tests/scalar/timedelta/test_arithmetic.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pandas/tests/scalar/timedelta/test_arithmetic.py b/pandas/tests/scalar/timedelta/test_arithmetic.py index 42c072afc3411..6f7f2a339d944 100644 --- a/pandas/tests/scalar/timedelta/test_arithmetic.py +++ b/pandas/tests/scalar/timedelta/test_arithmetic.py @@ -1218,6 +1218,7 @@ def test_ops_str_deprecated(box): [ "ufunc 'divide' cannot use operands", "Invalid dtype object for __floordiv__", + r"unsupported operand type\(s\) for /: 'int' and 'str'", ] ) with pytest.raises(TypeError, match=msg):