diff --git a/doc/source/whatsnew/v3.0.0.rst b/doc/source/whatsnew/v3.0.0.rst index d11ab82294be1..940dc0613ffea 100644 --- a/doc/source/whatsnew/v3.0.0.rst +++ b/doc/source/whatsnew/v3.0.0.rst @@ -742,6 +742,7 @@ Other Deprecations - Deprecated option "future.no_silent_downcasting", as it is no longer used. In a future version accessing this option will raise (:issue:`59502`) - Deprecated passing non-Index types to :meth:`Index.join`; explicitly convert to Index first (:issue:`62897`) - 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..6f7f2a339d944 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,45 @@ 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__", + r"unsupported operand type\(s\) for /: 'int' and 'str'", + ] + ) + with pytest.raises(TypeError, match=msg): + td / item + with pytest.raises(TypeError, match=msg): + item // td + with pytest.raises(TypeError, match=msg): + td // item