Skip to content

ENH: Timestamp.min/max/resolution support non-nano #47720

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 1 commit into from
Jul 21, 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
9 changes: 8 additions & 1 deletion pandas/_libs/tslibs/np_datetime.pyx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from cpython.datetime cimport (
PyDateTime_CheckExact,
PyDateTime_DATE_GET_HOUR,
PyDateTime_DATE_GET_MICROSECOND,
PyDateTime_DATE_GET_MINUTE,
Expand Down Expand Up @@ -228,7 +229,13 @@ def py_td64_to_tdstruct(int64_t td64, NPY_DATETIMEUNIT unit):


cdef inline void pydatetime_to_dtstruct(datetime dt, npy_datetimestruct *dts):
dts.year = PyDateTime_GET_YEAR(dt)
if PyDateTime_CheckExact(dt):
dts.year = PyDateTime_GET_YEAR(dt)
else:
# We use dt.year instead of PyDateTime_GET_YEAR because with Timestamp
# we override year such that PyDateTime_GET_YEAR is incorrect.
dts.year = dt.year

dts.month = PyDateTime_GET_MONTH(dt)
dts.day = PyDateTime_GET_DAY(dt)
dts.hour = PyDateTime_DATE_GET_HOUR(dt)
Expand Down
2 changes: 1 addition & 1 deletion pandas/_libs/tslibs/timestamps.pxd
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ cdef _Timestamp create_timestamp_from_ts(int64_t value,

cdef class _Timestamp(ABCTimestamp):
cdef readonly:
int64_t value, nanosecond
int64_t value, nanosecond, year
BaseOffset _freq
NPY_DATETIMEUNIT _reso

Expand Down
81 changes: 67 additions & 14 deletions pandas/_libs/tslibs/timestamps.pyx
Original file line number Diff line number Diff line change
Expand Up @@ -143,12 +143,27 @@ cdef inline _Timestamp create_timestamp_from_ts(
""" convenience routine to construct a Timestamp from its parts """
cdef:
_Timestamp ts_base

ts_base = _Timestamp.__new__(Timestamp, dts.year, dts.month,
int64_t pass_year = dts.year

# We pass year=1970/1972 here and set year below because with non-nanosecond
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you reference why 1970/1972 is the base year? (guessing some Python implementation)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you could follow up with documenting the base year, that would be great

# resolution we may have datetimes outside of the stdlib pydatetime
# implementation bounds, which would raise.
# NB: this means the C-API macro PyDateTime_GET_YEAR is unreliable.
if 1 <= pass_year <= 9999:
# we are in-bounds for pydatetime
pass
elif ccalendar.is_leapyear(dts.year):
pass_year = 1972
else:
pass_year = 1970

ts_base = _Timestamp.__new__(Timestamp, pass_year, dts.month,
dts.day, dts.hour, dts.min,
dts.sec, dts.us, tz, fold=fold)

ts_base.value = value
ts_base._freq = freq
ts_base.year = dts.year
ts_base.nanosecond = dts.ps // 1000
ts_base._reso = reso

Expand Down Expand Up @@ -179,6 +194,40 @@ def integer_op_not_supported(obj):
return TypeError(int_addsub_msg)


class MinMaxReso:
"""
We need to define min/max/resolution on both the Timestamp _instance_
and Timestamp class. On an instance, these depend on the object's _reso.
On the class, we default to the values we would get with nanosecond _reso.

See also: timedeltas.MinMaxReso
"""
def __init__(self, name):
self._name = name

def __get__(self, obj, type=None):
cls = Timestamp
if self._name == "min":
val = np.iinfo(np.int64).min + 1
elif self._name == "max":
val = np.iinfo(np.int64).max
else:
assert self._name == "resolution"
val = 1
cls = Timedelta

if obj is None:
# i.e. this is on the class, default to nanos
return cls(val)
elif self._name == "resolution":
return Timedelta._from_value_and_reso(val, obj._reso)
else:
return Timestamp._from_value_and_reso(val, obj._reso, tz=None)

def __set__(self, obj, value):
raise AttributeError(f"{self._name} is not settable.")


# ----------------------------------------------------------------------

cdef class _Timestamp(ABCTimestamp):
Expand All @@ -188,6 +237,10 @@ cdef class _Timestamp(ABCTimestamp):
dayofweek = _Timestamp.day_of_week
dayofyear = _Timestamp.day_of_year

min = MinMaxReso("min")
max = MinMaxReso("max")
resolution = MinMaxReso("resolution") # GH#21336, GH#21365

cpdef void _set_freq(self, freq):
# set the ._freq attribute without going through the constructor,
# which would issue a warning
Expand Down Expand Up @@ -248,10 +301,12 @@ cdef class _Timestamp(ABCTimestamp):
def __hash__(_Timestamp self):
if self.nanosecond:
return hash(self.value)
if not (1 <= self.year <= 9999):
# out of bounds for pydatetime
return hash(self.value)
if self.fold:
return datetime.__hash__(self.replace(fold=0))
return datetime.__hash__(self)
# TODO(non-nano): what if we are out of bounds for pydatetime?

def __richcmp__(_Timestamp self, object other, int op):
cdef:
Expand Down Expand Up @@ -968,6 +1023,9 @@ cdef class _Timestamp(ABCTimestamp):
"""
base_ts = "microseconds" if timespec == "nanoseconds" else timespec
base = super(_Timestamp, self).isoformat(sep=sep, timespec=base_ts)
# We need to replace the fake year 1970 with our real year
base = f"{self.year}-" + base.split("-", 1)[1]

if self.nanosecond == 0 and timespec != "nanoseconds":
return base

Expand Down Expand Up @@ -2332,29 +2390,24 @@ default 'raise'
Return the day of the week represented by the date.
Monday == 1 ... Sunday == 7.
"""
return super().isoweekday()
# same as super().isoweekday(), but that breaks because of how
# we have overriden year, see note in create_timestamp_from_ts
return self.weekday() + 1

def weekday(self):
"""
Return the day of the week represented by the date.
Monday == 0 ... Sunday == 6.
"""
return super().weekday()
# same as super().weekday(), but that breaks because of how
# we have overriden year, see note in create_timestamp_from_ts
return ccalendar.dayofweek(self.year, self.month, self.day)


# Aliases
Timestamp.weekofyear = Timestamp.week
Timestamp.daysinmonth = Timestamp.days_in_month

# Add the min and max fields at the class level
cdef int64_t _NS_UPPER_BOUND = np.iinfo(np.int64).max
cdef int64_t _NS_LOWER_BOUND = NPY_NAT + 1

# Resolution is in nanoseconds
Timestamp.min = Timestamp(_NS_LOWER_BOUND)
Timestamp.max = Timestamp(_NS_UPPER_BOUND)
Timestamp.resolution = Timedelta(nanoseconds=1) # GH#21336, GH#21365


# ----------------------------------------------------------------------
# Scalar analogues to functions in vectorized.pyx
Expand Down
29 changes: 29 additions & 0 deletions pandas/tests/scalar/timestamp/test_timestamp.py
Original file line number Diff line number Diff line change
Expand Up @@ -1011,6 +1011,35 @@ def test_sub_timedeltalike_mismatched_reso(self, ts_tz):
# With a mismatched td64 as opposed to Timedelta
ts + np.timedelta64(1, "ns")

def test_min(self, ts):
assert ts.min <= ts
assert ts.min._reso == ts._reso
assert ts.min.value == NaT.value + 1

def test_max(self, ts):
assert ts.max >= ts
assert ts.max._reso == ts._reso
assert ts.max.value == np.iinfo(np.int64).max

def test_resolution(self, ts):
expected = Timedelta._from_value_and_reso(1, ts._reso)
result = ts.resolution
assert result == expected
assert result._reso == expected._reso


def test_timestamp_class_min_max_resolution():
# when accessed on the class (as opposed to an instance), we default
# to nanoseconds
assert Timestamp.min == Timestamp(NaT.value + 1)
assert Timestamp.min._reso == NpyDatetimeUnit.NPY_FR_ns.value

assert Timestamp.max == Timestamp(np.iinfo(np.int64).max)
assert Timestamp.max._reso == NpyDatetimeUnit.NPY_FR_ns.value

assert Timestamp.resolution == Timedelta(1)
assert Timestamp.resolution._reso == NpyDatetimeUnit.NPY_FR_ns.value


class TestAsUnit:
def test_as_unit(self):
Expand Down