diff --git a/pandas/_libs/tslibs/timedeltas.pyx b/pandas/_libs/tslibs/timedeltas.pyx index d75fa5c91a3df..5fd3e33808800 100644 --- a/pandas/_libs/tslibs/timedeltas.pyx +++ b/pandas/_libs/tslibs/timedeltas.pyx @@ -791,8 +791,19 @@ def _binary_op_method_timedeltalike(op, name): # e.g. if original other was timedelta64('NaT') return NaT - if self._reso != other._reso: - raise NotImplementedError + # We allow silent casting to the lower resolution if and only + # if it is lossless. + try: + if self._reso < other._reso: + other = (<_Timedelta>other)._as_reso(self._reso, round_ok=False) + elif self._reso > other._reso: + self = (<_Timedelta>self)._as_reso(other._reso, round_ok=False) + except ValueError as err: + raise ValueError( + "Timedelta addition/subtraction with mismatched resolutions is not " + "allowed when casting to the lower resolution would require " + "lossy rounding." + ) from err res = op(self.value, other.value) if res == NPY_NAT: diff --git a/pandas/_libs/tslibs/timestamps.pyx b/pandas/_libs/tslibs/timestamps.pyx index 7c503132fb9fc..aedecc33ceee9 100644 --- a/pandas/_libs/tslibs/timestamps.pyx +++ b/pandas/_libs/tslibs/timestamps.pyx @@ -103,6 +103,7 @@ from pandas._libs.tslibs.offsets cimport ( to_offset, ) from pandas._libs.tslibs.timedeltas cimport ( + _Timedelta, delta_to_nanoseconds, ensure_td64ns, is_any_td_scalar, @@ -384,11 +385,36 @@ cdef class _Timestamp(ABCTimestamp): # TODO: no tests get here other = ensure_td64ns(other) - # TODO: what to do with mismatched resos? - # TODO: disallow round_ok - nanos = delta_to_nanoseconds( - other, reso=self._reso, round_ok=True - ) + if isinstance(other, _Timedelta): + # TODO: share this with __sub__, Timedelta.__add__ + # We allow silent casting to the lower resolution if and only + # if it is lossless. See also Timestamp.__sub__ + # and Timedelta.__add__ + try: + if self._reso < other._reso: + other = (<_Timedelta>other)._as_reso(self._reso, round_ok=False) + elif self._reso > other._reso: + self = (<_Timestamp>self)._as_reso(other._reso, round_ok=False) + except ValueError as err: + raise ValueError( + "Timestamp addition with mismatched resolutions is not " + "allowed when casting to the lower resolution would require " + "lossy rounding." + ) from err + + try: + nanos = delta_to_nanoseconds( + other, reso=self._reso, round_ok=False + ) + except OutOfBoundsTimedelta: + raise + except ValueError as err: + raise ValueError( + "Addition between Timestamp and Timedelta with mismatched " + "resolutions is not allowed when casting to the lower " + "resolution would require lossy rounding." + ) from err + try: new_value = self.value + nanos except OverflowError: diff --git a/pandas/tests/scalar/timedelta/test_timedelta.py b/pandas/tests/scalar/timedelta/test_timedelta.py index 96a60af58dec2..f9cc1c6878068 100644 --- a/pandas/tests/scalar/timedelta/test_timedelta.py +++ b/pandas/tests/scalar/timedelta/test_timedelta.py @@ -231,6 +231,41 @@ def test_floordiv_numeric(self, td): assert res.value == td.value // 2 assert res._reso == td._reso + def test_addsub_mismatched_reso(self, td): + other = Timedelta(days=1) # can losslessly convert to other resos + + result = td + other + assert result._reso == td._reso + assert result.days == td.days + 1 + + result = other + td + assert result._reso == td._reso + assert result.days == td.days + 1 + + result = td - other + assert result._reso == td._reso + assert result.days == td.days - 1 + + result = other - td + assert result._reso == td._reso + assert result.days == 1 - td.days + + other2 = Timedelta(500) # can't cast losslessly + + msg = ( + "Timedelta addition/subtraction with mismatched resolutions is " + "not allowed when casting to the lower resolution would require " + "lossy rounding" + ) + with pytest.raises(ValueError, match=msg): + td + other2 + with pytest.raises(ValueError, match=msg): + other2 + td + with pytest.raises(ValueError, match=msg): + td - other2 + with pytest.raises(ValueError, match=msg): + other2 - td + class TestTimedeltaUnaryOps: def test_invert(self): diff --git a/pandas/tests/scalar/timestamp/test_timestamp.py b/pandas/tests/scalar/timestamp/test_timestamp.py index a02268956651c..353c99688c139 100644 --- a/pandas/tests/scalar/timestamp/test_timestamp.py +++ b/pandas/tests/scalar/timestamp/test_timestamp.py @@ -966,6 +966,51 @@ def test_sub_datetimelike_mismatched_reso(self, ts_tz): with pytest.raises(ValueError, match=msg): other - ts2 + def test_sub_timedeltalike_mismatched_reso(self, ts_tz): + # case with non-lossy rounding + ts = ts_tz + + # choose a unit for `other` that doesn't match ts_tz's; + # this construction ensures we get cases with other._reso < ts._reso + # and cases with other._reso > ts._reso + unit = { + NpyDatetimeUnit.NPY_FR_us.value: "ms", + NpyDatetimeUnit.NPY_FR_ms.value: "s", + NpyDatetimeUnit.NPY_FR_s.value: "us", + }[ts._reso] + other = Timedelta(0)._as_unit(unit) + assert other._reso != ts._reso + + result = ts + other + assert isinstance(result, Timestamp) + assert result == ts + assert result._reso == min(ts._reso, other._reso) + + result = other + ts + assert isinstance(result, Timestamp) + assert result == ts + assert result._reso == min(ts._reso, other._reso) + + msg = "Timestamp addition with mismatched resolutions" + if ts._reso < other._reso: + # Case where rounding is lossy + other2 = other + Timedelta._from_value_and_reso(1, other._reso) + with pytest.raises(ValueError, match=msg): + ts + other2 + with pytest.raises(ValueError, match=msg): + other2 + ts + else: + ts2 = ts + Timedelta._from_value_and_reso(1, ts._reso) + with pytest.raises(ValueError, match=msg): + ts2 + other + with pytest.raises(ValueError, match=msg): + other + ts2 + + msg = "Addition between Timestamp and Timedelta with mismatched resolutions" + with pytest.raises(ValueError, match=msg): + # With a mismatched td64 as opposed to Timedelta + ts + np.timedelta64(1, "ns") + class TestAsUnit: def test_as_unit(self):