Skip to content

Commit 7c5c81e

Browse files
authored
REF: delta_to_nanoseconds handle non-nano (#47191)
1 parent ec32a00 commit 7c5c81e

13 files changed

+123
-59
lines changed

pandas/_libs/tslibs/offsets.pyi

+1
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ def to_offset(freq: None) -> None: ...
108108
def to_offset(freq: timedelta | BaseOffset | str) -> BaseOffset: ...
109109

110110
class Tick(SingleConstructorOffset):
111+
_reso: int
111112
def __init__(self, n: int = ..., normalize: bool = ...) -> None: ...
112113
@property
113114
def delta(self) -> Timedelta: ...

pandas/_libs/tslibs/offsets.pyx

+7
Original file line numberDiff line numberDiff line change
@@ -968,42 +968,49 @@ cdef class Day(Tick):
968968
_nanos_inc = 24 * 3600 * 1_000_000_000
969969
_prefix = "D"
970970
_period_dtype_code = PeriodDtypeCode.D
971+
_reso = NPY_DATETIMEUNIT.NPY_FR_D
971972

972973

973974
cdef class Hour(Tick):
974975
_nanos_inc = 3600 * 1_000_000_000
975976
_prefix = "H"
976977
_period_dtype_code = PeriodDtypeCode.H
978+
_reso = NPY_DATETIMEUNIT.NPY_FR_h
977979

978980

979981
cdef class Minute(Tick):
980982
_nanos_inc = 60 * 1_000_000_000
981983
_prefix = "T"
982984
_period_dtype_code = PeriodDtypeCode.T
985+
_reso = NPY_DATETIMEUNIT.NPY_FR_m
983986

984987

985988
cdef class Second(Tick):
986989
_nanos_inc = 1_000_000_000
987990
_prefix = "S"
988991
_period_dtype_code = PeriodDtypeCode.S
992+
_reso = NPY_DATETIMEUNIT.NPY_FR_s
989993

990994

991995
cdef class Milli(Tick):
992996
_nanos_inc = 1_000_000
993997
_prefix = "L"
994998
_period_dtype_code = PeriodDtypeCode.L
999+
_reso = NPY_DATETIMEUNIT.NPY_FR_ms
9951000

9961001

9971002
cdef class Micro(Tick):
9981003
_nanos_inc = 1000
9991004
_prefix = "U"
10001005
_period_dtype_code = PeriodDtypeCode.U
1006+
_reso = NPY_DATETIMEUNIT.NPY_FR_us
10011007

10021008

10031009
cdef class Nano(Tick):
10041010
_nanos_inc = 1
10051011
_prefix = "N"
10061012
_period_dtype_code = PeriodDtypeCode.N
1013+
_reso = NPY_DATETIMEUNIT.NPY_FR_ns
10071014

10081015

10091016
def delta_to_tick(delta: timedelta) -> Tick:

pandas/_libs/tslibs/period.pyx

+9-6
Original file line numberDiff line numberDiff line change
@@ -1680,14 +1680,17 @@ cdef class _Period(PeriodMixin):
16801680

16811681
def _add_timedeltalike_scalar(self, other) -> "Period":
16821682
cdef:
1683-
int64_t nanos, base_nanos
1683+
int64_t inc
16841684

16851685
if is_tick_object(self.freq):
1686-
nanos = delta_to_nanoseconds(other)
1687-
base_nanos = self.freq.base.nanos
1688-
if nanos % base_nanos == 0:
1689-
ordinal = self.ordinal + (nanos // base_nanos)
1690-
return Period(ordinal=ordinal, freq=self.freq)
1686+
try:
1687+
inc = delta_to_nanoseconds(other, reso=self.freq._reso, round_ok=False)
1688+
except ValueError as err:
1689+
raise IncompatibleFrequency("Input cannot be converted to "
1690+
f"Period(freq={self.freqstr})") from err
1691+
# TODO: overflow-check here
1692+
ordinal = self.ordinal + inc
1693+
return Period(ordinal=ordinal, freq=self.freq)
16911694
raise IncompatibleFrequency("Input cannot be converted to "
16921695
f"Period(freq={self.freqstr})")
16931696

pandas/_libs/tslibs/timedeltas.pxd

+3-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ from .np_datetime cimport NPY_DATETIMEUNIT
55

66

77
# Exposed for tslib, not intended for outside use.
8-
cpdef int64_t delta_to_nanoseconds(delta) except? -1
8+
cpdef int64_t delta_to_nanoseconds(
9+
delta, NPY_DATETIMEUNIT reso=*, bint round_ok=*, bint allow_year_month=*
10+
) except? -1
911
cdef convert_to_timedelta64(object ts, str unit)
1012
cdef bint is_any_td_scalar(object obj)
1113

pandas/_libs/tslibs/timedeltas.pyi

+6-1
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,12 @@ def array_to_timedelta64(
7272
errors: str = ...,
7373
) -> np.ndarray: ... # np.ndarray[m8ns]
7474
def parse_timedelta_unit(unit: str | None) -> UnitChoices: ...
75-
def delta_to_nanoseconds(delta: np.timedelta64 | timedelta | Tick) -> int: ...
75+
def delta_to_nanoseconds(
76+
delta: np.timedelta64 | timedelta | Tick,
77+
reso: int = ..., # NPY_DATETIMEUNIT
78+
round_ok: bool = ...,
79+
allow_year_month: bool = ...,
80+
) -> int: ...
7681

7782
class Timedelta(timedelta):
7883
min: ClassVar[Timedelta]

pandas/_libs/tslibs/timedeltas.pyx

+62-13
Original file line numberDiff line numberDiff line change
@@ -201,28 +201,76 @@ def ints_to_pytimedelta(ndarray m8values, box=False):
201201

202202
# ----------------------------------------------------------------------
203203

204-
cpdef int64_t delta_to_nanoseconds(delta) except? -1:
205-
if is_tick_object(delta):
206-
return delta.nanos
207-
if isinstance(delta, _Timedelta):
208-
if delta._reso == NPY_FR_ns:
209-
return delta.value
210-
raise NotImplementedError(delta._reso)
211204

212-
if is_timedelta64_object(delta):
213-
return get_timedelta64_value(ensure_td64ns(delta))
205+
cpdef int64_t delta_to_nanoseconds(
206+
delta,
207+
NPY_DATETIMEUNIT reso=NPY_FR_ns,
208+
bint round_ok=True,
209+
bint allow_year_month=False,
210+
) except? -1:
211+
cdef:
212+
_Timedelta td
213+
NPY_DATETIMEUNIT in_reso
214+
int64_t n
215+
216+
if is_tick_object(delta):
217+
n = delta.n
218+
in_reso = delta._reso
219+
if in_reso == reso:
220+
return n
221+
else:
222+
td = Timedelta._from_value_and_reso(delta.n, reso=in_reso)
223+
224+
elif isinstance(delta, _Timedelta):
225+
td = delta
226+
n = delta.value
227+
in_reso = delta._reso
228+
if in_reso == reso:
229+
return n
230+
231+
elif is_timedelta64_object(delta):
232+
in_reso = get_datetime64_unit(delta)
233+
n = get_timedelta64_value(delta)
234+
if in_reso == reso:
235+
return n
236+
else:
237+
# _from_value_and_reso does not support Year, Month, or unit-less,
238+
# so we have special handling if speciifed
239+
try:
240+
td = Timedelta._from_value_and_reso(n, reso=in_reso)
241+
except NotImplementedError:
242+
if allow_year_month:
243+
td64 = ensure_td64ns(delta)
244+
return delta_to_nanoseconds(td64, reso=reso)
245+
else:
246+
raise
214247

215-
if PyDelta_Check(delta):
248+
elif PyDelta_Check(delta):
249+
in_reso = NPY_DATETIMEUNIT.NPY_FR_us
216250
try:
217-
return (
251+
n = (
218252
delta.days * 24 * 3600 * 1_000_000
219253
+ delta.seconds * 1_000_000
220254
+ delta.microseconds
221-
) * 1000
255+
)
222256
except OverflowError as err:
223257
raise OutOfBoundsTimedelta(*err.args) from err
224258

225-
raise TypeError(type(delta))
259+
if in_reso == reso:
260+
return n
261+
else:
262+
td = Timedelta._from_value_and_reso(n, reso=in_reso)
263+
264+
else:
265+
raise TypeError(type(delta))
266+
267+
try:
268+
return td._as_reso(reso, round_ok=round_ok).value
269+
except OverflowError as err:
270+
unit_str = npy_unit_to_abbrev(reso)
271+
raise OutOfBoundsTimedelta(
272+
f"Cannot cast {str(delta)} to unit={unit_str} without overflow."
273+
) from err
226274

227275

228276
@cython.overflowcheck(True)
@@ -1411,6 +1459,7 @@ cdef class _Timedelta(timedelta):
14111459
else:
14121460
mult = get_conversion_factor(self._reso, reso)
14131461
with cython.overflowcheck(True):
1462+
# Note: caller is responsible for re-raising as OutOfBoundsTimedelta
14141463
value = self.value * mult
14151464
return type(self)._from_value_and_reso(value, reso=reso)
14161465

pandas/_libs/tslibs/timestamps.pyx

+11-1
Original file line numberDiff line numberDiff line change
@@ -353,7 +353,17 @@ cdef class _Timestamp(ABCTimestamp):
353353
raise NotImplementedError(self._reso)
354354

355355
if is_any_td_scalar(other):
356-
nanos = delta_to_nanoseconds(other)
356+
if (
357+
is_timedelta64_object(other)
358+
and get_datetime64_unit(other) == NPY_DATETIMEUNIT.NPY_FR_GENERIC
359+
):
360+
# TODO: deprecate allowing this? We only get here
361+
# with test_timedelta_add_timestamp_interval
362+
other = np.timedelta64(other.view("i8"), "ns")
363+
# TODO: disallow round_ok, allow_year_month?
364+
nanos = delta_to_nanoseconds(
365+
other, reso=self._reso, round_ok=True, allow_year_month=True
366+
)
357367
try:
358368
result = type(self)(self.value + nanos, tz=self.tzinfo)
359369
except OverflowError:

pandas/core/arrays/datetimelike.py

+3-22
Original file line numberDiff line numberDiff line change
@@ -1120,28 +1120,9 @@ def _add_timedeltalike_scalar(self, other):
11201120
new_values.fill(iNaT)
11211121
return type(self)(new_values, dtype=self.dtype)
11221122

1123-
# FIXME: this may overflow with non-nano
1124-
inc = delta_to_nanoseconds(other)
1125-
1126-
if not is_period_dtype(self.dtype):
1127-
# FIXME: don't hardcode 7, 8, 9, 10 here
1128-
# TODO: maybe patch delta_to_nanoseconds to take reso?
1129-
1130-
# error: "DatetimeLikeArrayMixin" has no attribute "_reso"
1131-
reso = self._reso # type: ignore[attr-defined]
1132-
if reso == 10:
1133-
pass
1134-
elif reso == 9:
1135-
# microsecond
1136-
inc = inc // 1000
1137-
elif reso == 8:
1138-
# millisecond
1139-
inc = inc // 1_000_000
1140-
elif reso == 7:
1141-
# second
1142-
inc = inc // 1_000_000_000
1143-
else:
1144-
raise NotImplementedError(reso)
1123+
# PeriodArray overrides, so we only get here with DTA/TDA
1124+
# error: "DatetimeLikeArrayMixin" has no attribute "_reso"
1125+
inc = delta_to_nanoseconds(other, reso=self._reso) # type: ignore[attr-defined]
11451126

11461127
new_values = checked_add_with_arr(self.asi8, inc, arr_mask=self._isnan)
11471128
new_values = new_values.view("i8")

pandas/core/arrays/period.py

+10-4
Original file line numberDiff line numberDiff line change
@@ -782,6 +782,7 @@ def _add_offset(self, other: BaseOffset):
782782
self._require_matching_freq(other, base=True)
783783
return self._addsub_int_array_or_scalar(other.n, operator.add)
784784

785+
# TODO: can we de-duplicate with Period._add_timedeltalike_scalar?
785786
def _add_timedeltalike_scalar(self, other):
786787
"""
787788
Parameters
@@ -797,10 +798,15 @@ def _add_timedeltalike_scalar(self, other):
797798
raise raise_on_incompatible(self, other)
798799

799800
if notna(other):
800-
# special handling for np.timedelta64("NaT"), avoid calling
801-
# _check_timedeltalike_freq_compat as that would raise TypeError
802-
other = self._check_timedeltalike_freq_compat(other)
803-
other = np.timedelta64(other, "ns")
801+
# Convert to an integer increment of our own freq, disallowing
802+
# e.g. 30seconds if our freq is minutes.
803+
try:
804+
inc = delta_to_nanoseconds(other, reso=self.freq._reso, round_ok=False)
805+
except ValueError as err:
806+
# "Cannot losslessly convert units"
807+
raise raise_on_incompatible(self, other) from err
808+
809+
return self._addsub_int_array_or_scalar(inc, operator.add)
804810

805811
return super()._add_timedeltalike_scalar(other)
806812

pandas/tests/scalar/timedelta/test_arithmetic.py

+1
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ def test_td_add_timestamp_overflow(self):
103103
with pytest.raises(OverflowError, match=msg):
104104
Timestamp("1700-01-01") + Timedelta(13 * 19999, unit="D")
105105

106+
msg = "Cannot cast 259987 days, 0:00:00 to unit=ns without overflow"
106107
with pytest.raises(OutOfBoundsTimedelta, match=msg):
107108
Timestamp("1700-01-01") + timedelta(days=13 * 19999)
108109

pandas/tests/scalar/timedelta/test_constructors.py

+1
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,7 @@ def test_overflow_on_construction():
215215
with pytest.raises(OverflowError, match=msg):
216216
Timedelta(7 * 19999, unit="D")
217217

218+
msg = "Cannot cast 259987 days, 0:00:00 to unit=ns without overflow"
218219
with pytest.raises(OutOfBoundsTimedelta, match=msg):
219220
Timedelta(timedelta(days=13 * 19999))
220221

pandas/tests/scalar/timestamp/test_arithmetic.py

+8-3
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
from pandas._libs.tslibs import (
1111
OutOfBoundsDatetime,
12+
OutOfBoundsTimedelta,
1213
Timedelta,
1314
Timestamp,
1415
offsets,
@@ -45,16 +46,20 @@ def test_overflow_offset_raises(self):
4546
"will overflow"
4647
)
4748
lmsg = "|".join(
48-
["Python int too large to convert to C long", "int too big to convert"]
49+
[
50+
"Python int too large to convert to C (long|int)",
51+
"int too big to convert",
52+
]
4953
)
54+
lmsg2 = r"Cannot cast <-?20169940 \* Days> to unit=ns without overflow"
5055

51-
with pytest.raises(OverflowError, match=lmsg):
56+
with pytest.raises(OutOfBoundsTimedelta, match=lmsg2):
5257
stamp + offset_overflow
5358

5459
with pytest.raises(OverflowError, match=msg):
5560
offset_overflow + stamp
5661

57-
with pytest.raises(OverflowError, match=lmsg):
62+
with pytest.raises(OutOfBoundsTimedelta, match=lmsg2):
5863
stamp - offset_overflow
5964

6065
# xref https://github.com/pandas-dev/pandas/issues/14080

pandas/tests/tools/test_to_datetime.py

+1-8
Original file line numberDiff line numberDiff line change
@@ -1847,14 +1847,7 @@ def test_to_datetime_list_of_integers(self):
18471847
def test_to_datetime_overflow(self):
18481848
# gh-17637
18491849
# we are overflowing Timedelta range here
1850-
1851-
msg = "|".join(
1852-
[
1853-
"Python int too large to convert to C long",
1854-
"long too big to convert",
1855-
"int too big to convert",
1856-
]
1857-
)
1850+
msg = "Cannot cast 139999 days, 0:00:00 to unit=ns without overflow"
18581851
with pytest.raises(OutOfBoundsTimedelta, match=msg):
18591852
date_range(start="1/1/1700", freq="B", periods=100000)
18601853

0 commit comments

Comments
 (0)