Skip to content

Commit f7be58a

Browse files
authored
ENH: Timestamp.__sub__(datetimelike) support non-nano (#47346)
* ENH: Timestamp +- timedeltalike scalar support non-nano * ENH: Timestamp.__sub__(datetime) with non-nano * better exception message
1 parent f600fd4 commit f7be58a

File tree

2 files changed

+91
-12
lines changed

2 files changed

+91
-12
lines changed

pandas/_libs/tslibs/timestamps.pyx

+22-12
Original file line numberDiff line numberDiff line change
@@ -393,18 +393,19 @@ cdef class _Timestamp(ABCTimestamp):
393393
new_value = int(self.value) + int(nanos)
394394

395395
try:
396-
result = type(self)._from_value_and_reso(new_value, reso=self._reso, tz=self.tzinfo)
396+
result = type(self)._from_value_and_reso(
397+
new_value, reso=self._reso, tz=self.tzinfo
398+
)
397399
except OverflowError as err:
398400
# TODO: don't hard-code nanosecond here
399-
raise OutOfBoundsDatetime(f"Out of bounds nanosecond timestamp: {new_value}") from err
401+
raise OutOfBoundsDatetime(
402+
f"Out of bounds nanosecond timestamp: {new_value}"
403+
) from err
400404

401405
if result is not NaT:
402406
result._set_freq(self._freq) # avoid warning in constructor
403407
return result
404408

405-
elif isinstance(self, _Timestamp) and self._reso != NPY_FR_ns:
406-
raise NotImplementedError(self._reso)
407-
408409
elif is_integer_object(other):
409410
raise integer_op_not_supported(self)
410411

@@ -439,9 +440,6 @@ cdef class _Timestamp(ABCTimestamp):
439440
neg_other = -other
440441
return self + neg_other
441442

442-
elif isinstance(self, _Timestamp) and self._reso != NPY_FR_ns:
443-
raise NotImplementedError(self._reso)
444-
445443
elif is_array(other):
446444
if other.dtype.kind in ['i', 'u']:
447445
raise integer_op_not_supported(self)
@@ -472,10 +470,25 @@ cdef class _Timestamp(ABCTimestamp):
472470
"Cannot subtract tz-naive and tz-aware datetime-like objects."
473471
)
474472

473+
# We allow silent casting to the lower resolution if and only
474+
# if it is lossless.
475+
try:
476+
if self._reso < other._reso:
477+
other = (<_Timestamp>other)._as_reso(self._reso, round_ok=False)
478+
elif self._reso > other._reso:
479+
self = (<_Timestamp>self)._as_reso(other._reso, round_ok=False)
480+
except ValueError as err:
481+
raise ValueError(
482+
"Timestamp subtraction with mismatched resolutions is not "
483+
"allowed when casting to the lower resolution would require "
484+
"lossy rounding."
485+
) from err
486+
475487
# scalar Timestamp/datetime - Timestamp/datetime -> yields a
476488
# Timedelta
477489
try:
478-
return Timedelta(self.value - other.value)
490+
res_value = self.value - other.value
491+
return Timedelta._from_value_and_reso(res_value, self._reso)
479492
except (OverflowError, OutOfBoundsDatetime, OutOfBoundsTimedelta) as err:
480493
if isinstance(other, _Timestamp):
481494
if both_timestamps:
@@ -496,9 +509,6 @@ cdef class _Timestamp(ABCTimestamp):
496509
return NotImplemented
497510

498511
def __rsub__(self, other):
499-
if self._reso != NPY_FR_ns:
500-
raise NotImplementedError(self._reso)
501-
502512
if PyDateTime_Check(other):
503513
try:
504514
return type(self)(other) - self

pandas/tests/scalar/timestamp/test_timestamp.py

+69
Original file line numberDiff line numberDiff line change
@@ -713,6 +713,11 @@ def dt64(self, reso):
713713
def ts(self, dt64):
714714
return Timestamp._from_dt64(dt64)
715715

716+
@pytest.fixture
717+
def ts_tz(self, ts, tz_aware_fixture):
718+
tz = maybe_get_tz(tz_aware_fixture)
719+
return Timestamp._from_value_and_reso(ts.value, ts._reso, tz)
720+
716721
def test_non_nano_construction(self, dt64, ts, reso):
717722
assert ts.value == dt64.view("i8")
718723

@@ -897,6 +902,70 @@ def test_addsub_timedeltalike_non_nano(self, dt64, ts, td):
897902
assert result._reso == ts._reso
898903
assert result == expected
899904

905+
@pytest.mark.xfail(reason="tz_localize not yet implemented for non-nano")
906+
def test_addsub_offset(self, ts_tz):
907+
# specifically non-Tick offset
908+
off = offsets.YearBegin(1)
909+
result = ts_tz + off
910+
911+
assert isinstance(result, Timestamp)
912+
assert result._reso == ts_tz._reso
913+
# If ts_tz is ever on the last day of the year, the year would be
914+
# incremented by one
915+
assert result.year == ts_tz.year
916+
assert result.day == 31
917+
assert result.month == 12
918+
assert tz_compare(result.tz, ts_tz.tz)
919+
920+
result = ts_tz - off
921+
922+
assert isinstance(result, Timestamp)
923+
assert result._reso == ts_tz._reso
924+
assert result.year == ts_tz.year - 1
925+
assert result.day == 31
926+
assert result.month == 12
927+
assert tz_compare(result.tz, ts_tz.tz)
928+
929+
def test_sub_datetimelike_mismatched_reso(self, ts_tz):
930+
# case with non-lossy rounding
931+
ts = ts_tz
932+
933+
# choose a unit for `other` that doesn't match ts_tz's;
934+
# this construction ensures we get cases with other._reso < ts._reso
935+
# and cases with other._reso > ts._reso
936+
unit = {
937+
NpyDatetimeUnit.NPY_FR_us.value: "ms",
938+
NpyDatetimeUnit.NPY_FR_ms.value: "s",
939+
NpyDatetimeUnit.NPY_FR_s.value: "us",
940+
}[ts._reso]
941+
other = ts._as_unit(unit)
942+
assert other._reso != ts._reso
943+
944+
result = ts - other
945+
assert isinstance(result, Timedelta)
946+
assert result.value == 0
947+
assert result._reso == min(ts._reso, other._reso)
948+
949+
result = other - ts
950+
assert isinstance(result, Timedelta)
951+
assert result.value == 0
952+
assert result._reso == min(ts._reso, other._reso)
953+
954+
msg = "Timestamp subtraction with mismatched resolutions"
955+
if ts._reso < other._reso:
956+
# Case where rounding is lossy
957+
other2 = other + Timedelta._from_value_and_reso(1, other._reso)
958+
with pytest.raises(ValueError, match=msg):
959+
ts - other2
960+
with pytest.raises(ValueError, match=msg):
961+
other2 - ts
962+
else:
963+
ts2 = ts + Timedelta._from_value_and_reso(1, ts._reso)
964+
with pytest.raises(ValueError, match=msg):
965+
ts2 - other
966+
with pytest.raises(ValueError, match=msg):
967+
other - ts2
968+
900969

901970
class TestAsUnit:
902971
def test_as_unit(self):

0 commit comments

Comments
 (0)