Skip to content

Commit 46d0d64

Browse files
authored
REF: make Tick entirely a cdef class (#34227)
1 parent 1ce9f0c commit 46d0d64

File tree

5 files changed

+167
-152
lines changed

5 files changed

+167
-152
lines changed

pandas/_libs/tslibs/offsets.pyx

+148-30
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ from typing import Any
55
import warnings
66
from cpython.datetime cimport (PyDateTime_IMPORT,
77
PyDateTime_Check,
8+
PyDate_Check,
89
PyDelta_Check,
910
datetime, timedelta, date,
1011
time as dt_time)
@@ -35,6 +36,8 @@ from pandas._libs.tslibs.np_datetime cimport (
3536
from pandas._libs.tslibs.timezones cimport utc_pytz as UTC
3637
from pandas._libs.tslibs.tzconversion cimport tz_convert_single
3738

39+
from .timedeltas cimport delta_to_nanoseconds
40+
3841
# ---------------------------------------------------------------------
3942
# Constants
4043

@@ -87,19 +90,19 @@ for _d in DAYS:
8790
# Misc Helpers
8891

8992
cdef bint is_offset_object(object obj):
90-
return isinstance(obj, _BaseOffset)
93+
return isinstance(obj, BaseOffset)
9194

9295

9396
cdef bint is_tick_object(object obj):
94-
return isinstance(obj, _Tick)
97+
return isinstance(obj, Tick)
9598

9699

97100
cdef to_offset(object obj):
98101
"""
99102
Wrap pandas.tseries.frequencies.to_offset to keep centralize runtime
100103
imports
101104
"""
102-
if isinstance(obj, _BaseOffset):
105+
if isinstance(obj, BaseOffset):
103106
return obj
104107
from pandas.tseries.frequencies import to_offset
105108
return to_offset(obj)
@@ -161,10 +164,11 @@ def apply_wraps(func):
161164

162165
if other is NaT:
163166
return NaT
164-
elif isinstance(other, (timedelta, BaseOffset)):
167+
elif isinstance(other, BaseOffset) or PyDelta_Check(other):
165168
# timedelta path
166169
return func(self, other)
167-
elif isinstance(other, (datetime, date)) or is_datetime64_object(other):
170+
elif is_datetime64_object(other) or PyDate_Check(other):
171+
# PyDate_Check includes date, datetime
168172
other = Timestamp(other)
169173
else:
170174
# This will end up returning NotImplemented back in __add__
@@ -227,7 +231,6 @@ cdef _wrap_timedelta_result(result):
227231
"""
228232
if PyDelta_Check(result):
229233
# convert Timedelta back to a Tick
230-
from pandas.tseries.offsets import delta_to_tick
231234
return delta_to_tick(result)
232235

233236
return result
@@ -398,7 +401,7 @@ class ApplyTypeError(TypeError):
398401
# ---------------------------------------------------------------------
399402
# Base Classes
400403

401-
cdef class _BaseOffset:
404+
cdef class BaseOffset:
402405
"""
403406
Base class for DateOffset methods that are not overridden by subclasses
404407
and will (after pickle errors are resolved) go into a cdef class.
@@ -477,6 +480,9 @@ cdef class _BaseOffset:
477480
return type(self)(n=1, normalize=self.normalize, **self.kwds)
478481

479482
def __add__(self, other):
483+
if not isinstance(self, BaseOffset):
484+
# cython semantics; this is __radd__
485+
return other.__add__(self)
480486
try:
481487
return self.apply(other)
482488
except ApplyTypeError:
@@ -488,6 +494,9 @@ cdef class _BaseOffset:
488494
elif type(other) == type(self):
489495
return type(self)(self.n - other.n, normalize=self.normalize,
490496
**self.kwds)
497+
elif not isinstance(self, BaseOffset):
498+
# cython semantics, this is __rsub__
499+
return (-other).__add__(self)
491500
else: # pragma: no cover
492501
return NotImplemented
493502

@@ -506,6 +515,9 @@ cdef class _BaseOffset:
506515
elif is_integer_object(other):
507516
return type(self)(n=other * self.n, normalize=self.normalize,
508517
**self.kwds)
518+
elif not isinstance(self, BaseOffset):
519+
# cython semantics, this is __rmul__
520+
return other.__mul__(self)
509521
return NotImplemented
510522

511523
def __neg__(self):
@@ -657,8 +669,8 @@ cdef class _BaseOffset:
657669

658670
# ------------------------------------------------------------------
659671

660-
# Staticmethod so we can call from _Tick.__init__, will be unnecessary
661-
# once BaseOffset is a cdef class and is inherited by _Tick
672+
# Staticmethod so we can call from Tick.__init__, will be unnecessary
673+
# once BaseOffset is a cdef class and is inherited by Tick
662674
@staticmethod
663675
def _validate_n(n):
664676
"""
@@ -758,24 +770,7 @@ cdef class _BaseOffset:
758770
return self.n == 1
759771

760772

761-
class BaseOffset(_BaseOffset):
762-
# Here we add __rfoo__ methods that don't play well with cdef classes
763-
def __rmul__(self, other):
764-
return self.__mul__(other)
765-
766-
def __radd__(self, other):
767-
return self.__add__(other)
768-
769-
def __rsub__(self, other):
770-
return (-self).__add__(other)
771-
772-
773-
cdef class _Tick(_BaseOffset):
774-
"""
775-
dummy class to mix into tseries.offsets.Tick so that in tslibs.period we
776-
can do isinstance checks on _Tick and avoid importing tseries.offsets
777-
"""
778-
773+
cdef class Tick(BaseOffset):
779774
# ensure that reversed-ops with numpy scalars return NotImplemented
780775
__array_priority__ = 1000
781776
_adjust_dst = False
@@ -793,13 +788,25 @@ cdef class _Tick(_BaseOffset):
793788
"Tick offset with `normalize=True` are not allowed."
794789
)
795790

791+
@classmethod
792+
def _from_name(cls, suffix=None):
793+
# default _from_name calls cls with no args
794+
if suffix:
795+
raise ValueError(f"Bad freq suffix {suffix}")
796+
return cls()
797+
798+
def _repr_attrs(self) -> str:
799+
# Since cdef classes have no __dict__, we need to override
800+
return ""
801+
796802
@property
797803
def delta(self):
798-
return self.n * self._inc
804+
from .timedeltas import Timedelta
805+
return self.n * Timedelta(self._nanos_inc)
799806

800807
@property
801808
def nanos(self) -> int64_t:
802-
return self.delta.value
809+
return self.n * self._nanos_inc
803810

804811
def is_on_offset(self, dt) -> bool:
805812
return True
@@ -837,13 +844,63 @@ cdef class _Tick(_BaseOffset):
837844
return self.delta.__gt__(other)
838845

839846
def __truediv__(self, other):
840-
if not isinstance(self, _Tick):
847+
if not isinstance(self, Tick):
841848
# cython semantics mean the args are sometimes swapped
842849
result = other.delta.__rtruediv__(self)
843850
else:
844851
result = self.delta.__truediv__(other)
845852
return _wrap_timedelta_result(result)
846853

854+
def __add__(self, other):
855+
if not isinstance(self, Tick):
856+
# cython semantics; this is __radd__
857+
return other.__add__(self)
858+
859+
if isinstance(other, Tick):
860+
if type(self) == type(other):
861+
return type(self)(self.n + other.n)
862+
else:
863+
return delta_to_tick(self.delta + other.delta)
864+
try:
865+
return self.apply(other)
866+
except ApplyTypeError:
867+
# Includes pd.Period
868+
return NotImplemented
869+
except OverflowError as err:
870+
raise OverflowError(
871+
f"the add operation between {self} and {other} will overflow"
872+
) from err
873+
874+
def apply(self, other):
875+
# Timestamp can handle tz and nano sec, thus no need to use apply_wraps
876+
if isinstance(other, ABCTimestamp):
877+
878+
# GH#15126
879+
# in order to avoid a recursive
880+
# call of __add__ and __radd__ if there is
881+
# an exception, when we call using the + operator,
882+
# we directly call the known method
883+
result = other.__add__(self)
884+
if result is NotImplemented:
885+
raise OverflowError
886+
return result
887+
elif other is NaT:
888+
return NaT
889+
elif is_datetime64_object(other) or PyDate_Check(other):
890+
# PyDate_Check includes date, datetime
891+
from pandas import Timestamp
892+
return Timestamp(other) + self
893+
894+
if PyDelta_Check(other):
895+
return other + self.delta
896+
elif isinstance(other, type(self)):
897+
# TODO: this is reached in tests that specifically call apply,
898+
# but should not be reached "naturally" because __add__ should
899+
# catch this case first.
900+
return type(self)(self.n + other.n)
901+
902+
raise ApplyTypeError(f"Unhandled type: {type(other).__name__}")
903+
847904
# --------------------------------------------------------------------
848905
# Pickle Methods
849906

@@ -855,6 +912,67 @@ cdef class _Tick(_BaseOffset):
855912
self.normalize = False
856913

857914

915+
cdef class Day(Tick):
916+
_nanos_inc = 24 * 3600 * 1_000_000_000
917+
_prefix = "D"
918+
919+
920+
cdef class Hour(Tick):
921+
_nanos_inc = 3600 * 1_000_000_000
922+
_prefix = "H"
923+
924+
925+
cdef class Minute(Tick):
926+
_nanos_inc = 60 * 1_000_000_000
927+
_prefix = "T"
928+
929+
930+
cdef class Second(Tick):
931+
_nanos_inc = 1_000_000_000
932+
_prefix = "S"
933+
934+
935+
cdef class Milli(Tick):
936+
_nanos_inc = 1_000_000
937+
_prefix = "L"
938+
939+
940+
cdef class Micro(Tick):
941+
_nanos_inc = 1000
942+
_prefix = "U"
943+
944+
945+
cdef class Nano(Tick):
946+
_nanos_inc = 1
947+
_prefix = "N"
948+
949+
950+
def delta_to_tick(delta: timedelta) -> Tick:
951+
if delta.microseconds == 0 and getattr(delta, "nanoseconds", 0) == 0:
952+
# nanoseconds only for pd.Timedelta
953+
if delta.seconds == 0:
954+
return Day(delta.days)
955+
else:
956+
seconds = delta.days * 86400 + delta.seconds
957+
if seconds % 3600 == 0:
958+
return Hour(seconds / 3600)
959+
elif seconds % 60 == 0:
960+
return Minute(seconds / 60)
961+
else:
962+
return Second(seconds)
963+
else:
964+
nanos = delta_to_nanoseconds(delta)
965+
if nanos % 1_000_000 == 0:
966+
return Milli(nanos // 1_000_000)
967+
elif nanos % 1000 == 0:
968+
return Micro(nanos // 1000)
969+
else: # pragma: no cover
970+
return Nano(nanos)
971+
972+
973+
# --------------------------------------------------------------------
974+
975+
858976
class BusinessMixin(BaseOffset):
859977
"""
860978
Mixin to business types to provide related functions.

pandas/core/arrays/period.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
period as libperiod,
1313
)
1414
from pandas._libs.tslibs.fields import isleapyear_arr
15+
from pandas._libs.tslibs.offsets import Tick, delta_to_tick
1516
from pandas._libs.tslibs.period import (
1617
DIFFERENT_FREQ,
1718
IncompatibleFrequency,
@@ -45,7 +46,7 @@
4546
import pandas.core.common as com
4647

4748
from pandas.tseries import frequencies
48-
from pandas.tseries.offsets import DateOffset, Tick, delta_to_tick
49+
from pandas.tseries.offsets import DateOffset
4950

5051

5152
def _field_accessor(name: str, alias: int, docstring=None):

pandas/tests/tseries/offsets/test_ticks.py

+5-3
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
import numpy as np
88
import pytest
99

10+
from pandas._libs.tslibs.offsets import delta_to_tick
11+
1012
from pandas import Timedelta, Timestamp
1113
import pandas._testing as tm
1214

@@ -33,11 +35,11 @@ def test_apply_ticks():
3335
def test_delta_to_tick():
3436
delta = timedelta(3)
3537

36-
tick = offsets.delta_to_tick(delta)
38+
tick = delta_to_tick(delta)
3739
assert tick == offsets.Day(3)
3840

3941
td = Timedelta(nanoseconds=5)
40-
tick = offsets.delta_to_tick(td)
42+
tick = delta_to_tick(td)
4143
assert tick == Nano(5)
4244

4345

@@ -234,7 +236,7 @@ def test_tick_division(cls):
234236
assert not isinstance(result, cls)
235237
assert result.delta == off.delta / 1000
236238

237-
if cls._inc < Timedelta(seconds=1):
239+
if cls._nanos_inc < Timedelta(seconds=1).value:
238240
# Case where we end up with a bigger class
239241
result = off / 0.001
240242
assert isinstance(result, offsets.Tick)

pandas/tseries/frequencies.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@ def to_offset(freq) -> Optional[DateOffset]:
164164
)
165165
stride = int(stride)
166166
offset = _get_offset(name)
167-
offset = offset * int(np.fabs(stride) * stride_sign) # type: ignore
167+
offset = offset * int(np.fabs(stride) * stride_sign)
168168
if delta is None:
169169
delta = offset
170170
else:
@@ -218,7 +218,7 @@ def _get_offset(name: str) -> DateOffset:
218218
klass = prefix_mapping[split[0]]
219219
# handles case where there's no suffix (and will TypeError if too
220220
# many '-')
221-
offset = klass._from_name(*split[1:]) # type: ignore
221+
offset = klass._from_name(*split[1:])
222222
except (ValueError, TypeError, KeyError) as err:
223223
# bad prefix or suffix
224224
raise ValueError(libfreqs.INVALID_FREQ_ERR_MSG.format(name)) from err

0 commit comments

Comments
 (0)