diff --git a/pandas/_libs/tslibs/offsets.pyx b/pandas/_libs/tslibs/offsets.pyx index d22f2b9117326..bf2998bfcd9d1 100644 --- a/pandas/_libs/tslibs/offsets.pyx +++ b/pandas/_libs/tslibs/offsets.pyx @@ -491,18 +491,20 @@ cdef class BaseOffset: # Name and Rendering Methods def __repr__(self) -> str: - className = getattr(self, '_outputName', type(self).__name__) + # _output_name used by B(Year|Quarter)(End|Begin) to + # expand "B" -> "Business" + class_name = getattr(self, "_output_name", type(self).__name__) if abs(self.n) != 1: - plural = 's' + plural = "s" else: - plural = '' + plural = "" n_str = "" if self.n != 1: n_str = f"{self.n} * " - out = f'<{n_str}{className}{plural}{self._repr_attrs()}>' + out = f"<{n_str}{class_name}{plural}{self._repr_attrs()}>" return out def _repr_attrs(self) -> str: @@ -608,7 +610,7 @@ cdef class BaseOffset: dt = dt + type(self)(1, normalize=self.normalize, **self.kwds) return dt - def _get_offset_day(self, datetime other): + def _get_offset_day(self, other: datetime) -> int: # subclass must implement `_day_opt`; calling from the base class # will raise NotImplementedError. cdef: @@ -632,7 +634,7 @@ 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 - def _validate_n(n): + def _validate_n(n) -> int: """ Require that `n` be an integer. @@ -1010,7 +1012,7 @@ cdef class RelativeDeltaOffset(BaseOffset): self.__dict__.update(state) @apply_wraps - def apply(self, other): + def apply(self, other: datetime) -> datetime: if self._use_relativedelta: other = _as_datetime(other) @@ -1379,7 +1381,7 @@ cdef class BusinessDay(BusinessMixin): @apply_index_wraps def apply_index(self, dtindex): - i8other = dtindex.asi8 + i8other = dtindex.view("i8") return shift_bdays(i8other, self.n) def is_on_offset(self, dt) -> bool: @@ -1482,7 +1484,7 @@ cdef class BusinessHour(BusinessMixin): until = datetime(2014, 4, day, end.hour, end.minute) return int((until - dtstart).total_seconds()) - def _get_closing_time(self, dt): + def _get_closing_time(self, dt: datetime) -> datetime: """ Get the closing time of a business hour interval by its opening time. @@ -1582,7 +1584,7 @@ cdef class BusinessHour(BusinessMixin): return datetime(other.year, other.month, other.day, hour, minute) - def _prev_opening_time(self, other): + def _prev_opening_time(self, other: datetime) -> datetime: """ If n is positive, return the latest opening time earlier than or equal to current time. @@ -1602,7 +1604,7 @@ cdef class BusinessHour(BusinessMixin): return self._next_opening_time(other, sign=-1) @apply_wraps - def rollback(self, dt): + def rollback(self, dt: datetime) -> datetime: """ Roll provided date backward to next offset only if not on offset. """ @@ -1615,7 +1617,7 @@ cdef class BusinessHour(BusinessMixin): return dt @apply_wraps - def rollforward(self, dt): + def rollforward(self, dt: datetime) -> datetime: """ Roll provided date forward to next offset only if not on offset. """ @@ -1627,108 +1629,105 @@ cdef class BusinessHour(BusinessMixin): return dt @apply_wraps - def apply(self, other): - if PyDateTime_Check(other): - # used for detecting edge condition - nanosecond = getattr(other, "nanosecond", 0) - # reset timezone and nanosecond - # other may be a Timestamp, thus not use replace - other = datetime( - other.year, - other.month, - other.day, - other.hour, - other.minute, - other.second, - other.microsecond, - ) - n = self.n + def apply(self, other: datetime) -> datetime: + # used for detecting edge condition + nanosecond = getattr(other, "nanosecond", 0) + # reset timezone and nanosecond + # other may be a Timestamp, thus not use replace + other = datetime( + other.year, + other.month, + other.day, + other.hour, + other.minute, + other.second, + other.microsecond, + ) + n = self.n - # adjust other to reduce number of cases to handle - if n >= 0: - if other.time() in self.end or not self._is_on_offset(other): - other = self._next_opening_time(other) + # adjust other to reduce number of cases to handle + if n >= 0: + if other.time() in self.end or not self._is_on_offset(other): + other = self._next_opening_time(other) + else: + if other.time() in self.start: + # adjustment to move to previous business day + other = other - timedelta(seconds=1) + if not self._is_on_offset(other): + other = self._next_opening_time(other) + other = self._get_closing_time(other) + + # get total business hours by sec in one business day + businesshours = sum( + self._get_business_hours_by_sec(st, en) + for st, en in zip(self.start, self.end) + ) + + bd, r = divmod(abs(n * 60), businesshours // 60) + if n < 0: + bd, r = -bd, -r + + # adjust by business days first + if bd != 0: + if self._prefix.startswith("C"): + # GH#30593 this is a Custom offset + skip_bd = CustomBusinessDay( + n=bd, + weekmask=self.weekmask, + holidays=self.holidays, + calendar=self.calendar, + ) else: - if other.time() in self.start: - # adjustment to move to previous business day - other = other - timedelta(seconds=1) - if not self._is_on_offset(other): - other = self._next_opening_time(other) - other = self._get_closing_time(other) - - # get total business hours by sec in one business day - businesshours = sum( - self._get_business_hours_by_sec(st, en) - for st, en in zip(self.start, self.end) - ) + skip_bd = BusinessDay(n=bd) + # midnight business hour may not on BusinessDay + if not self.next_bday.is_on_offset(other): + prev_open = self._prev_opening_time(other) + remain = other - prev_open + other = prev_open + skip_bd + remain + else: + other = other + skip_bd - bd, r = divmod(abs(n * 60), businesshours // 60) - if n < 0: - bd, r = -bd, -r - - # adjust by business days first - if bd != 0: - if self._prefix.startswith("C"): - # GH#30593 this is a Custom offset - skip_bd = CustomBusinessDay( - n=bd, - weekmask=self.weekmask, - holidays=self.holidays, - calendar=self.calendar, - ) + # remaining business hours to adjust + bhour_remain = timedelta(minutes=r) + + if n >= 0: + while bhour_remain != timedelta(0): + # business hour left in this business time interval + bhour = ( + self._get_closing_time(self._prev_opening_time(other)) - other + ) + if bhour_remain < bhour: + # finish adjusting if possible + other += bhour_remain + bhour_remain = timedelta(0) else: - skip_bd = BusinessDay(n=bd) - # midnight business hour may not on BusinessDay - if not self.next_bday.is_on_offset(other): - prev_open = self._prev_opening_time(other) - remain = other - prev_open - other = prev_open + skip_bd + remain + # go to next business time interval + bhour_remain -= bhour + other = self._next_opening_time(other + bhour) + else: + while bhour_remain != timedelta(0): + # business hour left in this business time interval + bhour = self._next_opening_time(other) - other + if ( + bhour_remain > bhour + or bhour_remain == bhour + and nanosecond != 0 + ): + # finish adjusting if possible + other += bhour_remain + bhour_remain = timedelta(0) else: - other = other + skip_bd - - # remaining business hours to adjust - bhour_remain = timedelta(minutes=r) - - if n >= 0: - while bhour_remain != timedelta(0): - # business hour left in this business time interval - bhour = ( - self._get_closing_time(self._prev_opening_time(other)) - other - ) - if bhour_remain < bhour: - # finish adjusting if possible - other += bhour_remain - bhour_remain = timedelta(0) - else: - # go to next business time interval - bhour_remain -= bhour - other = self._next_opening_time(other + bhour) - else: - while bhour_remain != timedelta(0): - # business hour left in this business time interval - bhour = self._next_opening_time(other) - other - if ( - bhour_remain > bhour - or bhour_remain == bhour - and nanosecond != 0 - ): - # finish adjusting if possible - other += bhour_remain - bhour_remain = timedelta(0) - else: - # go to next business time interval - bhour_remain -= bhour - other = self._get_closing_time( - self._next_opening_time( - other + bhour - timedelta(seconds=1) - ) + # go to next business time interval + bhour_remain -= bhour + other = self._get_closing_time( + self._next_opening_time( + other + bhour - timedelta(seconds=1) ) + ) - return other - else: - raise ApplyTypeError("Only know how to combine business hour with datetime") + return other - def is_on_offset(self, dt): + def is_on_offset(self, dt: datetime) -> bool: if self.normalize and not _is_normalized(dt): return False @@ -1740,7 +1739,7 @@ cdef class BusinessHour(BusinessMixin): # Distinguish by the time spent from previous opening time return self._is_on_offset(dt) - def _is_on_offset(self, dt): + def _is_on_offset(self, dt: datetime) -> bool: """ Slight speedups using calculated values. """ @@ -1779,14 +1778,11 @@ cdef class WeekOfMonthMixin(SingleConstructorOffset): raise ValueError(f"Day must be 0<=day<=6, got {weekday}") @apply_wraps - def apply(self, other): + def apply(self, other: datetime) -> datetime: compare_day = self._get_offset_day(other) months = self.n - if months > 0 and compare_day > other.day: - months -= 1 - elif months <= 0 and compare_day < other.day: - months += 1 + months = roll_convention(other.day, months, compare_day) shifted = shift_month(other, months, "start") to_day = self._get_offset_day(shifted) @@ -1861,7 +1857,7 @@ cdef class YearOffset(SingleConstructorOffset): return get_day_of_month(&dts, self._day_opt) @apply_wraps - def apply(self, other): + def apply(self, other: datetime) -> datetime: years = roll_qtrday(other, self.n, self.month, self._day_opt, modby=12) months = years * 12 + (self.month - other.month) return shift_month(other, months, self._day_opt) @@ -1869,7 +1865,7 @@ cdef class YearOffset(SingleConstructorOffset): @apply_index_wraps def apply_index(self, dtindex): shifted = shift_quarters( - dtindex.asi8, self.n, self.month, self._day_opt, modby=12 + dtindex.view("i8"), self.n, self.month, self._day_opt, modby=12 ) return shifted @@ -1963,8 +1959,8 @@ cdef class QuarterOffset(SingleConstructorOffset): # startingMonth vs month attr names are resolved # FIXME: python annotations here breaks things - # _default_startingMonth: int - # _from_name_startingMonth: int + # _default_starting_month: int + # _from_name_starting_month: int cdef readonly: int startingMonth @@ -1973,7 +1969,7 @@ cdef class QuarterOffset(SingleConstructorOffset): BaseOffset.__init__(self, n, normalize) if startingMonth is None: - startingMonth = self._default_startingMonth + startingMonth = self._default_starting_month self.startingMonth = startingMonth cpdef __setstate__(self, state): @@ -1987,8 +1983,8 @@ cdef class QuarterOffset(SingleConstructorOffset): if suffix: kwargs["startingMonth"] = MONTH_TO_CAL_NUM[suffix] else: - if cls._from_name_startingMonth is not None: - kwargs["startingMonth"] = cls._from_name_startingMonth + if cls._from_name_starting_month is not None: + kwargs["startingMonth"] = cls._from_name_starting_month return cls(**kwargs) @property @@ -2006,7 +2002,7 @@ cdef class QuarterOffset(SingleConstructorOffset): return mod_month == 0 and dt.day == self._get_offset_day(dt) @apply_wraps - def apply(self, other): + def apply(self, other: datetime) -> datetime: # months_since: find the calendar quarter containing other.month, # e.g. if other.month == 8, the calendar quarter is [Jul, Aug, Sep]. # Then find the month in that quarter containing an is_on_offset date for @@ -2022,7 +2018,7 @@ cdef class QuarterOffset(SingleConstructorOffset): @apply_index_wraps def apply_index(self, dtindex): shifted = shift_quarters( - dtindex.asi8, self.n, self.startingMonth, self._day_opt + dtindex.view("i8"), self.n, self.startingMonth, self._day_opt ) return shifted @@ -2048,9 +2044,9 @@ cdef class BQuarterEnd(QuarterOffset): >>> ts + BQuarterEnd(startingMonth=2) Timestamp('2020-05-29 05:01:15') """ - _outputName = "BusinessQuarterEnd" - _default_startingMonth = 3 - _from_name_startingMonth = 12 + _output_name = "BusinessQuarterEnd" + _default_starting_month = 3 + _from_name_starting_month = 12 _prefix = "BQ" _day_opt = "business_end" @@ -2076,9 +2072,9 @@ cdef class BQuarterBegin(QuarterOffset): >>> ts + BQuarterBegin(-1) Timestamp('2020-03-02 05:01:15') """ - _outputName = "BusinessQuarterBegin" - _default_startingMonth = 3 - _from_name_startingMonth = 1 + _output_name = "BusinessQuarterBegin" + _default_starting_month = 3 + _from_name_starting_month = 1 _prefix = "BQS" _day_opt = "business_start" @@ -2091,8 +2087,7 @@ cdef class QuarterEnd(QuarterOffset): startingMonth = 2 corresponds to dates like 2/28/2007, 5/31/2007, ... startingMonth = 3 corresponds to dates like 3/31/2007, 6/30/2007, ... """ - _outputName = "QuarterEnd" - _default_startingMonth = 3 + _default_starting_month = 3 _prefix = "Q" _day_opt = "end" @@ -2105,6 +2100,7 @@ cdef class QuarterEnd(QuarterOffset): QuarterOffset.__init__(self, n, normalize, startingMonth) self._period_dtype_code = PeriodDtypeCode.Q_DEC + self.startingMonth % 12 + cdef class QuarterBegin(QuarterOffset): """ DateOffset increments between Quarter start dates. @@ -2113,9 +2109,8 @@ cdef class QuarterBegin(QuarterOffset): startingMonth = 2 corresponds to dates like 2/01/2007, 5/01/2007, ... startingMonth = 3 corresponds to dates like 3/01/2007, 6/01/2007, ... """ - _outputName = "QuarterBegin" - _default_startingMonth = 3 - _from_name_startingMonth = 1 + _default_starting_month = 3 + _from_name_starting_month = 1 _prefix = "QS" _day_opt = "start" @@ -2130,14 +2125,14 @@ cdef class MonthOffset(SingleConstructorOffset): return dt.day == self._get_offset_day(dt) @apply_wraps - def apply(self, other): + def apply(self, other: datetime) -> datetime: compare_day = self._get_offset_day(other) n = roll_convention(other.day, self.n, compare_day) return shift_month(other, n, self._day_opt) @apply_index_wraps def apply_index(self, dtindex): - shifted = shift_months(dtindex.asi8, self.n, self._day_opt) + shifted = shift_months(dtindex.view("i8"), self.n, self._day_opt) return shifted cpdef __setstate__(self, state): @@ -2244,29 +2239,31 @@ cdef class SemiMonthOffset(SingleConstructorOffset): return self._prefix + suffix @apply_wraps - def apply(self, other): + def apply(self, other: datetime) -> datetime: + is_start = isinstance(self, SemiMonthBegin) + # shift `other` to self.day_of_month, incrementing `n` if necessary n = roll_convention(other.day, self.n, self.day_of_month) days_in_month = get_days_in_month(other.year, other.month) - # For SemiMonthBegin on other.day == 1 and # SemiMonthEnd on other.day == days_in_month, # shifting `other` to `self.day_of_month` _always_ requires # incrementing/decrementing `n`, regardless of whether it is # initially positive. - if type(self) is SemiMonthBegin and (self.n <= 0 and other.day == 1): + if is_start and (self.n <= 0 and other.day == 1): n -= 1 - elif type(self) is SemiMonthEnd and (self.n > 0 and other.day == days_in_month): + elif (not is_start) and (self.n > 0 and other.day == days_in_month): n += 1 - return self._apply(n, other) + if is_start: + months = n // 2 + n % 2 + to_day = 1 if n % 2 else self.day_of_month + else: + months = n // 2 + to_day = 31 if n % 2 else self.day_of_month - def _apply(self, n, other): - """ - Handle specific apply logic for child classes. - """ - raise NotImplementedError(self) + return shift_month(other, months, to_day) @apply_index_wraps @cython.wraparound(False) @@ -2348,11 +2345,6 @@ cdef class SemiMonthEnd(SemiMonthOffset): days_in_month = get_days_in_month(dt.year, dt.month) return dt.day in (self.day_of_month, days_in_month) - def _apply(self, n, other): - months = n // 2 - day = 31 if n % 2 else self.day_of_month - return shift_month(other, months, day) - cdef class SemiMonthBegin(SemiMonthOffset): """ @@ -2373,11 +2365,6 @@ cdef class SemiMonthBegin(SemiMonthOffset): return False return dt.day in (1, self.day_of_month) - def _apply(self, n, other): - months = n // 2 + n % 2 - day = 1 if n % 2 else self.day_of_month - return shift_month(other, months, day) - # --------------------------------------------------------------------- # Week-Based Offset Classes @@ -2446,25 +2433,25 @@ cdef class Week(SingleConstructorOffset): td64 = np.timedelta64(td, "ns") return dtindex + td64 else: - return self._end_apply_index(dtindex) + i8other = dtindex.view("i8") + return self._end_apply_index(i8other) @cython.wraparound(False) @cython.boundscheck(False) - def _end_apply_index(self, dtindex): + cdef _end_apply_index(self, const int64_t[:] i8other): """ Add self to the given DatetimeIndex, specialized for case where self.weekday is non-null. Parameters ---------- - dtindex : DatetimeIndex + i8other : const int64_t[:] Returns ------- ndarray[int64_t] """ cdef: - int64_t[:] i8other = dtindex.view("i8") Py_ssize_t i, count = len(i8other) int64_t val int64_t[:] out = np.empty(count, dtype="i8") @@ -2493,7 +2480,7 @@ cdef class Week(SingleConstructorOffset): return out.base - def is_on_offset(self, dt) -> bool: + def is_on_offset(self, dt: datetime) -> bool: if self.normalize and not _is_normalized(dt): return False elif self.weekday is None: @@ -2647,6 +2634,7 @@ cdef class LastWeekOfMonth(WeekOfMonthMixin): weekday = weekday_to_int[suffix] return cls(weekday=weekday) + # --------------------------------------------------------------------- # Special Offset Classes @@ -2767,7 +2755,7 @@ cdef class FY5253(FY5253Mixin): return year_end == dt @apply_wraps - def apply(self, other): + def apply(self, other: datetime) -> datetime: norm = Timestamp(other).normalize() n = self.n @@ -2822,7 +2810,7 @@ cdef class FY5253(FY5253Mixin): ) return result - def get_year_end(self, dt): + def get_year_end(self, dt: datetime) -> datetime: assert dt.tzinfo is None dim = get_days_in_month(dt.year, self.startingMonth) @@ -2968,7 +2956,7 @@ cdef class FY5253Quarter(FY5253Mixin): variation=self.variation, ) - def _rollback_to_year(self, other): + def _rollback_to_year(self, other: datetime): """ Roll `other` back to the most recent date that was on a fiscal year end. @@ -3016,7 +3004,7 @@ cdef class FY5253Quarter(FY5253Mixin): return start, num_qtrs, tdelta @apply_wraps - def apply(self, other): + def apply(self, other: datetime) -> datetime: # Note: self.n == 0 is not allowed. n = self.n @@ -3044,7 +3032,7 @@ cdef class FY5253Quarter(FY5253Mixin): return res - def get_weeks(self, dt): + def get_weeks(self, dt: datetime): ret = [13] * 4 year_has_extra_week = self.year_has_extra_week(dt) @@ -3107,7 +3095,7 @@ cdef class Easter(SingleConstructorOffset): self.normalize = state.pop("normalize") @apply_wraps - def apply(self, other): + def apply(self, other: datetime) -> datetime: current_easter = easter(other.year) current_easter = datetime( current_easter.year, current_easter.month, current_easter.day @@ -3329,7 +3317,7 @@ cdef class _CustomBusinessMonth(BusinessMixin): return roll_func @apply_wraps - def apply(self, other): + def apply(self, other: datetime) -> datetime: # First move to month offset cur_month_offset_date = self.month_roll(other) @@ -3947,7 +3935,7 @@ cpdef int roll_convention(int other, int n, int compare) nogil: def roll_qtrday(other: datetime, n: int, month: int, - day_opt: object, modby: int) -> int: + day_opt: str, modby: int) -> int: """ Possibly increment or decrement the number of periods to shift based on rollforward/rollbackward conventions. @@ -3957,7 +3945,7 @@ def roll_qtrday(other: datetime, n: int, month: int, other : datetime or Timestamp n : number of periods to increment, before adjusting for rolling month : int reference month giving the first month of the year - day_opt : 'start', 'end', 'business_start', 'business_end', or int + day_opt : {'start', 'end', 'business_start', 'business_end'} The convention to use in finding the day in a given month against which to compare for rollforward/rollbackward decisions. modby : int 3 for quarters, 12 for years