diff --git a/pandas/_libs/tslibs/timestamps.pyx b/pandas/_libs/tslibs/timestamps.pyx index 1d21f602fac05..da2377a9b085c 100644 --- a/pandas/_libs/tslibs/timestamps.pyx +++ b/pandas/_libs/tslibs/timestamps.pyx @@ -400,18 +400,19 @@ cdef class _Timestamp(ABCTimestamp): new_value = int(self.value) + int(nanos) try: - result = type(self)._from_value_and_reso(new_value, reso=self._reso, tz=self.tzinfo) + result = type(self)._from_value_and_reso( + new_value, reso=self._reso, tz=self.tzinfo + ) except OverflowError as err: # TODO: don't hard-code nanosecond here - raise OutOfBoundsDatetime(f"Out of bounds nanosecond timestamp: {new_value}") from err + raise OutOfBoundsDatetime( + f"Out of bounds nanosecond timestamp: {new_value}" + ) from err if result is not NaT: result._set_freq(self._freq) # avoid warning in constructor return result - elif isinstance(self, _Timestamp) and self._reso != NPY_FR_ns: - raise NotImplementedError(self._reso) - elif is_integer_object(other): raise integer_op_not_supported(self) @@ -446,9 +447,6 @@ cdef class _Timestamp(ABCTimestamp): neg_other = -other return self + neg_other - elif isinstance(self, _Timestamp) and self._reso != NPY_FR_ns: - raise NotImplementedError(self._reso) - elif is_array(other): if other.dtype.kind in ['i', 'u']: raise integer_op_not_supported(self) @@ -479,10 +477,25 @@ cdef class _Timestamp(ABCTimestamp): "Cannot subtract tz-naive and tz-aware datetime-like objects." ) + # We allow silent casting to the lower resolution if and only + # if it is lossless. + try: + if self._reso < other._reso: + other = (<_Timestamp>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 subtraction with mismatched resolutions is not " + "allowed when casting to the lower resolution would require " + "lossy rounding." + ) from err + # scalar Timestamp/datetime - Timestamp/datetime -> yields a # Timedelta try: - return Timedelta(self.value - other.value) + res_value = self.value - other.value + return Timedelta._from_value_and_reso(res_value, self._reso) except (OverflowError, OutOfBoundsDatetime, OutOfBoundsTimedelta) as err: if isinstance(other, _Timestamp): if both_timestamps: @@ -503,9 +516,6 @@ cdef class _Timestamp(ABCTimestamp): return NotImplemented def __rsub__(self, other): - if self._reso != NPY_FR_ns: - raise NotImplementedError(self._reso) - if PyDateTime_Check(other): try: return type(self)(other) - self diff --git a/pandas/tests/scalar/timestamp/test_timestamp.py b/pandas/tests/scalar/timestamp/test_timestamp.py index 79c8a300b34e3..f7f19e49d0bac 100644 --- a/pandas/tests/scalar/timestamp/test_timestamp.py +++ b/pandas/tests/scalar/timestamp/test_timestamp.py @@ -22,6 +22,7 @@ from pandas._libs.tslibs.timezones import ( dateutil_gettz as gettz, get_timezone, + maybe_get_tz, tz_compare, ) from pandas.errors import OutOfBoundsDatetime @@ -712,6 +713,11 @@ def dt64(self, reso): def ts(self, dt64): return Timestamp._from_dt64(dt64) + @pytest.fixture + def ts_tz(self, ts, tz_aware_fixture): + tz = maybe_get_tz(tz_aware_fixture) + return Timestamp._from_value_and_reso(ts.value, ts._reso, tz) + def test_non_nano_construction(self, dt64, ts, reso): assert ts.value == dt64.view("i8") @@ -893,6 +899,70 @@ def test_addsub_timedeltalike_non_nano(self, dt64, ts, td): assert result._reso == ts._reso assert result == expected + @pytest.mark.xfail(reason="tz_localize not yet implemented for non-nano") + def test_addsub_offset(self, ts_tz): + # specifically non-Tick offset + off = offsets.YearBegin(1) + result = ts_tz + off + + assert isinstance(result, Timestamp) + assert result._reso == ts_tz._reso + # If ts_tz is ever on the last day of the year, the year would be + # incremented by one + assert result.year == ts_tz.year + assert result.day == 31 + assert result.month == 12 + assert tz_compare(result.tz, ts_tz.tz) + + result = ts_tz - off + + assert isinstance(result, Timestamp) + assert result._reso == ts_tz._reso + assert result.year == ts_tz.year - 1 + assert result.day == 31 + assert result.month == 12 + assert tz_compare(result.tz, ts_tz.tz) + + def test_sub_datetimelike_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 = ts._as_unit(unit) + assert other._reso != ts._reso + + result = ts - other + assert isinstance(result, Timedelta) + assert result.value == 0 + assert result._reso == min(ts._reso, other._reso) + + result = other - ts + assert isinstance(result, Timedelta) + assert result.value == 0 + assert result._reso == min(ts._reso, other._reso) + + msg = "Timestamp subtraction 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 + class TestAsUnit: def test_as_unit(self):