diff --git a/pandas/_libs/tslibs/offsets.pyx b/pandas/_libs/tslibs/offsets.pyx index 73cbf3bf49686..0e4bcaaa7995b 100644 --- a/pandas/_libs/tslibs/offsets.pyx +++ b/pandas/_libs/tslibs/offsets.pyx @@ -112,7 +112,7 @@ cdef to_offset(object obj): return to_offset(obj) -def as_datetime(obj: datetime) -> datetime: +cdef datetime _as_datetime(datetime obj): if isinstance(obj, ABCTimestamp): return obj.to_pydatetime() return obj @@ -360,10 +360,10 @@ def _validate_business_time(t_input): # --------------------------------------------------------------------- # Constructor Helpers -relativedelta_kwds = {'years', 'months', 'weeks', 'days', 'year', 'month', - 'day', 'weekday', 'hour', 'minute', 'second', - 'microsecond', 'nanosecond', 'nanoseconds', 'hours', - 'minutes', 'seconds', 'microseconds'} +_relativedelta_kwds = {"years", "months", "weeks", "days", "year", "month", + "day", "weekday", "hour", "minute", "second", + "microsecond", "nanosecond", "nanoseconds", "hours", + "minutes", "seconds", "microseconds"} def _determine_offset(kwds): @@ -1004,6 +1004,123 @@ def delta_to_tick(delta: timedelta) -> Tick: return Nano(nanos) +# -------------------------------------------------------------------- + +class RelativeDeltaOffset(BaseOffset): + """ + DateOffset subclass backed by a dateutil relativedelta object. + """ + _attributes = frozenset(["n", "normalize"] + list(_relativedelta_kwds)) + _adjust_dst = False + + def __init__(self, n=1, normalize=False, **kwds): + BaseOffset.__init__(self, n, normalize) + + off, use_rd = _determine_offset(kwds) + object.__setattr__(self, "_offset", off) + object.__setattr__(self, "_use_relativedelta", use_rd) + for key in kwds: + val = kwds[key] + object.__setattr__(self, key, val) + + @apply_wraps + def apply(self, other): + if self._use_relativedelta: + other = _as_datetime(other) + + if len(self.kwds) > 0: + tzinfo = getattr(other, "tzinfo", None) + if tzinfo is not None and self._use_relativedelta: + # perform calculation in UTC + other = other.replace(tzinfo=None) + + if self.n > 0: + for i in range(self.n): + other = other + self._offset + else: + for i in range(-self.n): + other = other - self._offset + + if tzinfo is not None and self._use_relativedelta: + # bring tz back from UTC calculation + other = localize_pydatetime(other, tzinfo) + + from .timestamps import Timestamp + return Timestamp(other) + else: + return other + timedelta(self.n) + + @apply_index_wraps + def apply_index(self, index): + """ + Vectorized apply of DateOffset to DatetimeIndex, + raises NotImplementedError for offsets without a + vectorized implementation. + + Parameters + ---------- + index : DatetimeIndex + + Returns + ------- + DatetimeIndex + """ + kwds = self.kwds + relativedelta_fast = { + "years", + "months", + "weeks", + "days", + "hours", + "minutes", + "seconds", + "microseconds", + } + # relativedelta/_offset path only valid for base DateOffset + if self._use_relativedelta and set(kwds).issubset(relativedelta_fast): + + months = (kwds.get("years", 0) * 12 + kwds.get("months", 0)) * self.n + if months: + shifted = shift_months(index.asi8, months) + index = type(index)(shifted, dtype=index.dtype) + + weeks = kwds.get("weeks", 0) * self.n + if weeks: + # integer addition on PeriodIndex is deprecated, + # so we directly use _time_shift instead + asper = index.to_period("W") + shifted = asper._time_shift(weeks) + index = shifted.to_timestamp() + index.to_perioddelta("W") + + timedelta_kwds = { + k: v + for k, v in kwds.items() + if k in ["days", "hours", "minutes", "seconds", "microseconds"] + } + if timedelta_kwds: + from .timedeltas import Timedelta + delta = Timedelta(**timedelta_kwds) + index = index + (self.n * delta) + return index + elif not self._use_relativedelta and hasattr(self, "_offset"): + # timedelta + return index + (self._offset * self.n) + else: + # relativedelta with other keywords + kwd = set(kwds) - relativedelta_fast + raise NotImplementedError( + "DateOffset with relativedelta " + f"keyword(s) {kwd} not able to be " + "applied vectorized" + ) + + def is_on_offset(self, dt) -> bool: + if self.normalize and not is_normalized(dt): + return False + # TODO: see GH#1395 + return True + + # -------------------------------------------------------------------- diff --git a/pandas/tests/tseries/offsets/test_offsets.py b/pandas/tests/tseries/offsets/test_offsets.py index 48d94974f4828..b3d722be56dbc 100644 --- a/pandas/tests/tseries/offsets/test_offsets.py +++ b/pandas/tests/tseries/offsets/test_offsets.py @@ -4336,7 +4336,7 @@ def test_valid_default_arguments(offset_types): cls() -@pytest.mark.parametrize("kwd", sorted(liboffsets.relativedelta_kwds)) +@pytest.mark.parametrize("kwd", sorted(liboffsets._relativedelta_kwds)) def test_valid_month_attributes(kwd, month_classes): # GH#18226 cls = month_classes @@ -4352,14 +4352,14 @@ def test_month_offset_name(month_classes): assert obj2.name == obj.name -@pytest.mark.parametrize("kwd", sorted(liboffsets.relativedelta_kwds)) +@pytest.mark.parametrize("kwd", sorted(liboffsets._relativedelta_kwds)) def test_valid_relativedelta_kwargs(kwd): - # Check that all the arguments specified in liboffsets.relativedelta_kwds + # Check that all the arguments specified in liboffsets._relativedelta_kwds # are in fact valid relativedelta keyword args DateOffset(**{kwd: 1}) -@pytest.mark.parametrize("kwd", sorted(liboffsets.relativedelta_kwds)) +@pytest.mark.parametrize("kwd", sorted(liboffsets._relativedelta_kwds)) def test_valid_tick_attributes(kwd, tick_classes): # GH#18226 cls = tick_classes diff --git a/pandas/tseries/offsets.py b/pandas/tseries/offsets.py index 2357dc31a763e..c3ad48d5ce34d 100644 --- a/pandas/tseries/offsets.py +++ b/pandas/tseries/offsets.py @@ -40,7 +40,6 @@ YearEnd, apply_index_wraps, apply_wraps, - as_datetime, is_normalized, shift_month, to_dt64D, @@ -107,7 +106,7 @@ def __subclasscheck__(cls, obj) -> bool: return issubclass(obj, BaseOffset) -class DateOffset(BaseOffset, metaclass=OffsetMeta): +class DateOffset(liboffsets.RelativeDeltaOffset, metaclass=OffsetMeta): """ Standard kind of date increment used for a date range. @@ -202,158 +201,7 @@ def __add__(date): Timestamp('2017-03-01 09:10:11') """ - _attributes = frozenset(["n", "normalize"] + list(liboffsets.relativedelta_kwds)) - _adjust_dst = False - - def __init__(self, n=1, normalize=False, **kwds): - BaseOffset.__init__(self, n, normalize) - - off, use_rd = liboffsets._determine_offset(kwds) - object.__setattr__(self, "_offset", off) - object.__setattr__(self, "_use_relativedelta", use_rd) - for key in kwds: - val = kwds[key] - object.__setattr__(self, key, val) - - @apply_wraps - def apply(self, other): - if self._use_relativedelta: - other = as_datetime(other) - - if len(self.kwds) > 0: - tzinfo = getattr(other, "tzinfo", None) - if tzinfo is not None and self._use_relativedelta: - # perform calculation in UTC - other = other.replace(tzinfo=None) - - if self.n > 0: - for i in range(self.n): - other = other + self._offset - else: - for i in range(-self.n): - other = other - self._offset - - if tzinfo is not None and self._use_relativedelta: - # bring tz back from UTC calculation - other = conversion.localize_pydatetime(other, tzinfo) - - return Timestamp(other) - else: - return other + timedelta(self.n) - - @apply_index_wraps - def apply_index(self, i): - """ - Vectorized apply of DateOffset to DatetimeIndex, - raises NotImplementedError for offsets without a - vectorized implementation. - - Parameters - ---------- - i : DatetimeIndex - - Returns - ------- - y : DatetimeIndex - """ - kwds = self.kwds - relativedelta_fast = { - "years", - "months", - "weeks", - "days", - "hours", - "minutes", - "seconds", - "microseconds", - } - # relativedelta/_offset path only valid for base DateOffset - if self._use_relativedelta and set(kwds).issubset(relativedelta_fast): - - months = (kwds.get("years", 0) * 12 + kwds.get("months", 0)) * self.n - if months: - shifted = liboffsets.shift_months(i.asi8, months) - i = type(i)(shifted, dtype=i.dtype) - - weeks = (kwds.get("weeks", 0)) * self.n - if weeks: - # integer addition on PeriodIndex is deprecated, - # so we directly use _time_shift instead - asper = i.to_period("W") - shifted = asper._time_shift(weeks) - i = shifted.to_timestamp() + i.to_perioddelta("W") - - timedelta_kwds = { - k: v - for k, v in kwds.items() - if k in ["days", "hours", "minutes", "seconds", "microseconds"] - } - if timedelta_kwds: - delta = Timedelta(**timedelta_kwds) - i = i + (self.n * delta) - return i - elif not self._use_relativedelta and hasattr(self, "_offset"): - # timedelta - return i + (self._offset * self.n) - else: - # relativedelta with other keywords - kwd = set(kwds) - relativedelta_fast - raise NotImplementedError( - "DateOffset with relativedelta " - f"keyword(s) {kwd} not able to be " - "applied vectorized" - ) - - def is_on_offset(self, dt): - if self.normalize and not is_normalized(dt): - return False - # TODO, see #1395 - return True - - def _repr_attrs(self) -> str: - # The DateOffset class differs from other classes in that members - # of self._attributes may not be defined, so we have to use __dict__ - # instead. - exclude = {"n", "inc", "normalize"} - attrs = [] - for attr in sorted(self.__dict__): - if attr.startswith("_") or attr == "kwds": - continue - elif attr not in exclude: - value = getattr(self, attr) - attrs.append(f"{attr}={value}") - - out = "" - if attrs: - out += ": " + ", ".join(attrs) - return out - - @cache_readonly - def _params(self): - """ - Returns a tuple containing all of the attributes needed to evaluate - equality between two DateOffset objects. - """ - # The DateOffset class differs from other classes in that members - # of self._attributes may not be defined, so we have to use __dict__ - # instead. - all_paras = self.__dict__.copy() - all_paras["n"] = self.n - all_paras["normalize"] = self.normalize - for key in self.__dict__: - if key not in all_paras: - # cython attributes are not in __dict__ - all_paras[key] = getattr(self, key) - - if "holidays" in all_paras and not all_paras["holidays"]: - all_paras.pop("holidays") - exclude = ["kwds", "name", "calendar"] - attrs = [ - (k, v) for k, v in all_paras.items() if (k not in exclude) and (k[0] != "_") - ] - attrs = sorted(set(attrs)) - params = tuple([str(type(self))] + attrs) - return params + pass class BusinessDay(BusinessMixin):