Skip to content

ENH: Timestamp.__sub__(datetimelike) support non-nano #47346

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Jun 15, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 22 additions & 12 deletions pandas/_libs/tslibs/timestamps.pyx
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down
70 changes: 70 additions & 0 deletions pandas/tests/scalar/timestamp/test_timestamp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")

Expand Down Expand Up @@ -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):
Expand Down