diff --git a/pandas/_libs/tslibs/offsets.pyx b/pandas/_libs/tslibs/offsets.pyx index b25558e8572fe..17ea389611b84 100644 --- a/pandas/_libs/tslibs/offsets.pyx +++ b/pandas/_libs/tslibs/offsets.pyx @@ -5,6 +5,7 @@ from typing import Any import warnings from cpython.datetime cimport (PyDateTime_IMPORT, PyDateTime_Check, + PyDate_Check, PyDelta_Check, datetime, timedelta, date, time as dt_time) @@ -35,6 +36,8 @@ from pandas._libs.tslibs.np_datetime cimport ( from pandas._libs.tslibs.timezones cimport utc_pytz as UTC from pandas._libs.tslibs.tzconversion cimport tz_convert_single +from .timedeltas cimport delta_to_nanoseconds + # --------------------------------------------------------------------- # Constants @@ -87,11 +90,11 @@ for _d in DAYS: # Misc Helpers cdef bint is_offset_object(object obj): - return isinstance(obj, _BaseOffset) + return isinstance(obj, BaseOffset) cdef bint is_tick_object(object obj): - return isinstance(obj, _Tick) + return isinstance(obj, Tick) cdef to_offset(object obj): @@ -99,7 +102,7 @@ cdef to_offset(object obj): Wrap pandas.tseries.frequencies.to_offset to keep centralize runtime imports """ - if isinstance(obj, _BaseOffset): + if isinstance(obj, BaseOffset): return obj from pandas.tseries.frequencies import to_offset return to_offset(obj) @@ -161,10 +164,11 @@ def apply_wraps(func): if other is NaT: return NaT - elif isinstance(other, (timedelta, BaseOffset)): + elif isinstance(other, BaseOffset) or PyDelta_Check(other): # timedelta path return func(self, other) - elif isinstance(other, (datetime, date)) or is_datetime64_object(other): + elif is_datetime64_object(other) or PyDate_Check(other): + # PyDate_Check includes date, datetime other = Timestamp(other) else: # This will end up returning NotImplemented back in __add__ @@ -227,7 +231,6 @@ cdef _wrap_timedelta_result(result): """ if PyDelta_Check(result): # convert Timedelta back to a Tick - from pandas.tseries.offsets import delta_to_tick return delta_to_tick(result) return result @@ -398,7 +401,7 @@ class ApplyTypeError(TypeError): # --------------------------------------------------------------------- # Base Classes -cdef class _BaseOffset: +cdef class BaseOffset: """ Base class for DateOffset methods that are not overridden by subclasses and will (after pickle errors are resolved) go into a cdef class. @@ -477,6 +480,9 @@ cdef class _BaseOffset: return type(self)(n=1, normalize=self.normalize, **self.kwds) def __add__(self, other): + if not isinstance(self, BaseOffset): + # cython semantics; this is __radd__ + return other.__add__(self) try: return self.apply(other) except ApplyTypeError: @@ -488,6 +494,9 @@ cdef class _BaseOffset: elif type(other) == type(self): return type(self)(self.n - other.n, normalize=self.normalize, **self.kwds) + elif not isinstance(self, BaseOffset): + # cython semantics, this is __rsub__ + return (-other).__add__(self) else: # pragma: no cover return NotImplemented @@ -506,6 +515,9 @@ cdef class _BaseOffset: elif is_integer_object(other): return type(self)(n=other * self.n, normalize=self.normalize, **self.kwds) + elif not isinstance(self, BaseOffset): + # cython semantics, this is __rmul__ + return other.__mul__(self) return NotImplemented def __neg__(self): @@ -657,8 +669,8 @@ cdef class _BaseOffset: # ------------------------------------------------------------------ - # Staticmethod so we can call from _Tick.__init__, will be unnecessary - # once BaseOffset is a cdef class and is inherited by _Tick + # Staticmethod so we can call from Tick.__init__, will be unnecessary + # once BaseOffset is a cdef class and is inherited by Tick @staticmethod def _validate_n(n): """ @@ -758,24 +770,7 @@ cdef class _BaseOffset: return self.n == 1 -class BaseOffset(_BaseOffset): - # Here we add __rfoo__ methods that don't play well with cdef classes - def __rmul__(self, other): - return self.__mul__(other) - - def __radd__(self, other): - return self.__add__(other) - - def __rsub__(self, other): - return (-self).__add__(other) - - -cdef class _Tick(_BaseOffset): - """ - dummy class to mix into tseries.offsets.Tick so that in tslibs.period we - can do isinstance checks on _Tick and avoid importing tseries.offsets - """ - +cdef class Tick(BaseOffset): # ensure that reversed-ops with numpy scalars return NotImplemented __array_priority__ = 1000 _adjust_dst = False @@ -793,13 +788,25 @@ cdef class _Tick(_BaseOffset): "Tick offset with `normalize=True` are not allowed." ) + @classmethod + def _from_name(cls, suffix=None): + # default _from_name calls cls with no args + if suffix: + raise ValueError(f"Bad freq suffix {suffix}") + return cls() + + def _repr_attrs(self) -> str: + # Since cdef classes have no __dict__, we need to override + return "" + @property def delta(self): - return self.n * self._inc + from .timedeltas import Timedelta + return self.n * Timedelta(self._nanos_inc) @property def nanos(self) -> int64_t: - return self.delta.value + return self.n * self._nanos_inc def is_on_offset(self, dt) -> bool: return True @@ -837,13 +844,63 @@ cdef class _Tick(_BaseOffset): return self.delta.__gt__(other) def __truediv__(self, other): - if not isinstance(self, _Tick): + if not isinstance(self, Tick): # cython semantics mean the args are sometimes swapped result = other.delta.__rtruediv__(self) else: result = self.delta.__truediv__(other) return _wrap_timedelta_result(result) + def __add__(self, other): + if not isinstance(self, Tick): + # cython semantics; this is __radd__ + return other.__add__(self) + + if isinstance(other, Tick): + if type(self) == type(other): + return type(self)(self.n + other.n) + else: + return delta_to_tick(self.delta + other.delta) + try: + return self.apply(other) + except ApplyTypeError: + # Includes pd.Period + return NotImplemented + except OverflowError as err: + raise OverflowError( + f"the add operation between {self} and {other} will overflow" + ) from err + + def apply(self, other): + # Timestamp can handle tz and nano sec, thus no need to use apply_wraps + if isinstance(other, ABCTimestamp): + + # GH#15126 + # in order to avoid a recursive + # call of __add__ and __radd__ if there is + # an exception, when we call using the + operator, + # we directly call the known method + result = other.__add__(self) + if result is NotImplemented: + raise OverflowError + return result + elif other is NaT: + return NaT + elif is_datetime64_object(other) or PyDate_Check(other): + # PyDate_Check includes date, datetime + from pandas import Timestamp + return Timestamp(other) + self + + if PyDelta_Check(other): + return other + self.delta + elif isinstance(other, type(self)): + # TODO: this is reached in tests that specifically call apply, + # but should not be reached "naturally" because __add__ should + # catch this case first. + return type(self)(self.n + other.n) + + raise ApplyTypeError(f"Unhandled type: {type(other).__name__}") + # -------------------------------------------------------------------- # Pickle Methods @@ -855,6 +912,67 @@ cdef class _Tick(_BaseOffset): self.normalize = False +cdef class Day(Tick): + _nanos_inc = 24 * 3600 * 1_000_000_000 + _prefix = "D" + + +cdef class Hour(Tick): + _nanos_inc = 3600 * 1_000_000_000 + _prefix = "H" + + +cdef class Minute(Tick): + _nanos_inc = 60 * 1_000_000_000 + _prefix = "T" + + +cdef class Second(Tick): + _nanos_inc = 1_000_000_000 + _prefix = "S" + + +cdef class Milli(Tick): + _nanos_inc = 1_000_000 + _prefix = "L" + + +cdef class Micro(Tick): + _nanos_inc = 1000 + _prefix = "U" + + +cdef class Nano(Tick): + _nanos_inc = 1 + _prefix = "N" + + +def delta_to_tick(delta: timedelta) -> Tick: + if delta.microseconds == 0 and getattr(delta, "nanoseconds", 0) == 0: + # nanoseconds only for pd.Timedelta + if delta.seconds == 0: + return Day(delta.days) + else: + seconds = delta.days * 86400 + delta.seconds + if seconds % 3600 == 0: + return Hour(seconds / 3600) + elif seconds % 60 == 0: + return Minute(seconds / 60) + else: + return Second(seconds) + else: + nanos = delta_to_nanoseconds(delta) + if nanos % 1_000_000 == 0: + return Milli(nanos // 1_000_000) + elif nanos % 1000 == 0: + return Micro(nanos // 1000) + else: # pragma: no cover + return Nano(nanos) + + +# -------------------------------------------------------------------- + + class BusinessMixin(BaseOffset): """ Mixin to business types to provide related functions. diff --git a/pandas/core/arrays/period.py b/pandas/core/arrays/period.py index 3978161829481..7a7fdba88cda1 100644 --- a/pandas/core/arrays/period.py +++ b/pandas/core/arrays/period.py @@ -12,6 +12,7 @@ period as libperiod, ) from pandas._libs.tslibs.fields import isleapyear_arr +from pandas._libs.tslibs.offsets import Tick, delta_to_tick from pandas._libs.tslibs.period import ( DIFFERENT_FREQ, IncompatibleFrequency, @@ -45,7 +46,7 @@ import pandas.core.common as com from pandas.tseries import frequencies -from pandas.tseries.offsets import DateOffset, Tick, delta_to_tick +from pandas.tseries.offsets import DateOffset def _field_accessor(name: str, alias: int, docstring=None): diff --git a/pandas/tests/tseries/offsets/test_ticks.py b/pandas/tests/tseries/offsets/test_ticks.py index a37dbbc89f5af..e5b0142dae48b 100644 --- a/pandas/tests/tseries/offsets/test_ticks.py +++ b/pandas/tests/tseries/offsets/test_ticks.py @@ -7,6 +7,8 @@ import numpy as np import pytest +from pandas._libs.tslibs.offsets import delta_to_tick + from pandas import Timedelta, Timestamp import pandas._testing as tm @@ -33,11 +35,11 @@ def test_apply_ticks(): def test_delta_to_tick(): delta = timedelta(3) - tick = offsets.delta_to_tick(delta) + tick = delta_to_tick(delta) assert tick == offsets.Day(3) td = Timedelta(nanoseconds=5) - tick = offsets.delta_to_tick(td) + tick = delta_to_tick(td) assert tick == Nano(5) @@ -234,7 +236,7 @@ def test_tick_division(cls): assert not isinstance(result, cls) assert result.delta == off.delta / 1000 - if cls._inc < Timedelta(seconds=1): + if cls._nanos_inc < Timedelta(seconds=1).value: # Case where we end up with a bigger class result = off / 0.001 assert isinstance(result, offsets.Tick) diff --git a/pandas/tseries/frequencies.py b/pandas/tseries/frequencies.py index f907c5570bd18..f20734598bc74 100644 --- a/pandas/tseries/frequencies.py +++ b/pandas/tseries/frequencies.py @@ -164,7 +164,7 @@ def to_offset(freq) -> Optional[DateOffset]: ) stride = int(stride) offset = _get_offset(name) - offset = offset * int(np.fabs(stride) * stride_sign) # type: ignore + offset = offset * int(np.fabs(stride) * stride_sign) if delta is None: delta = offset else: @@ -218,7 +218,7 @@ def _get_offset(name: str) -> DateOffset: klass = prefix_mapping[split[0]] # handles case where there's no suffix (and will TypeError if too # many '-') - offset = klass._from_name(*split[1:]) # type: ignore + offset = klass._from_name(*split[1:]) except (ValueError, TypeError, KeyError) as err: # bad prefix or suffix raise ValueError(libfreqs.INVALID_FREQ_ERR_MSG.format(name)) from err diff --git a/pandas/tseries/offsets.py b/pandas/tseries/offsets.py index 364a50be5c291..3dd5f2a2fc4c8 100644 --- a/pandas/tseries/offsets.py +++ b/pandas/tseries/offsets.py @@ -6,20 +6,26 @@ import numpy as np from pandas._libs.tslibs import ( - Period, Timedelta, Timestamp, ccalendar, conversion, - delta_to_nanoseconds, frequencies as libfrequencies, offsets as liboffsets, ) -from pandas._libs.tslibs.offsets import ( +from pandas._libs.tslibs.offsets import ( # noqa:F401 ApplyTypeError, BaseOffset, BusinessMixin, CustomMixin, + Day, + Hour, + Micro, + Milli, + Minute, + Nano, + Second, + Tick, apply_index_wraps, apply_wraps, as_datetime, @@ -2125,118 +2131,6 @@ def is_on_offset(self, dt: datetime) -> bool: # --------------------------------------------------------------------- -# Ticks - - -class Tick(liboffsets._Tick, SingleConstructorOffset): - _inc = Timedelta(microseconds=1000) - - def __add__(self, other): - if isinstance(other, Tick): - if type(self) == type(other): - return type(self)(self.n + other.n) - else: - return delta_to_tick(self.delta + other.delta) - elif isinstance(other, Period): - return other + self - try: - return self.apply(other) - except ApplyTypeError: - return NotImplemented - except OverflowError as err: - raise OverflowError( - f"the add operation between {self} and {other} will overflow" - ) from err - - # This is identical to DateOffset.__hash__, but has to be redefined here - # for Python 3, because we've redefined __eq__. - def __hash__(self) -> int: - return hash(self._params) - - def apply(self, other): - # Timestamp can handle tz and nano sec, thus no need to use apply_wraps - if isinstance(other, Timestamp): - - # GH 15126 - # in order to avoid a recursive - # call of __add__ and __radd__ if there is - # an exception, when we call using the + operator, - # we directly call the known method - result = other.__add__(self) - if result is NotImplemented: - raise OverflowError - return result - elif isinstance(other, (datetime, np.datetime64, date)): - return Timestamp(other) + self - - if isinstance(other, timedelta): - return other + self.delta - elif isinstance(other, type(self)): - # TODO: this is reached in tests that specifically call apply, - # but should not be reached "naturally" because __add__ should - # catch this case first. - return type(self)(self.n + other.n) - - raise ApplyTypeError(f"Unhandled type: {type(other).__name__}") - - -def delta_to_tick(delta: timedelta) -> Tick: - if delta.microseconds == 0 and getattr(delta, "nanoseconds", 0) == 0: - # nanoseconds only for pd.Timedelta - if delta.seconds == 0: - return Day(delta.days) - else: - seconds = delta.days * 86400 + delta.seconds - if seconds % 3600 == 0: - return Hour(seconds / 3600) - elif seconds % 60 == 0: - return Minute(seconds / 60) - else: - return Second(seconds) - else: - nanos = delta_to_nanoseconds(delta) - if nanos % 1_000_000 == 0: - return Milli(nanos // 1_000_000) - elif nanos % 1000 == 0: - return Micro(nanos // 1000) - else: # pragma: no cover - return Nano(nanos) - - -class Day(Tick): - _inc = Timedelta(days=1) - _prefix = "D" - - -class Hour(Tick): - _inc = Timedelta(hours=1) - _prefix = "H" - - -class Minute(Tick): - _inc = Timedelta(minutes=1) - _prefix = "T" - - -class Second(Tick): - _inc = Timedelta(seconds=1) - _prefix = "S" - - -class Milli(Tick): - _inc = Timedelta(milliseconds=1) - _prefix = "L" - - -class Micro(Tick): - _inc = Timedelta(microseconds=1) - _prefix = "U" - - -class Nano(Tick): - _inc = Timedelta(nanoseconds=1) - _prefix = "N" - BDay = BusinessDay BMonthEnd = BusinessMonthEnd @@ -2246,7 +2140,7 @@ class Nano(Tick): CDay = CustomBusinessDay prefix_mapping = { - offset._prefix: offset # type: ignore + offset._prefix: offset for offset in [ YearBegin, # 'AS' YearEnd, # 'A'