diff --git a/doc/source/reference/offset_frequency.rst b/doc/source/reference/offset_frequency.rst index 17544cb7a1225..6240181708f97 100644 --- a/doc/source/reference/offset_frequency.rst +++ b/doc/source/reference/offset_frequency.rst @@ -1056,6 +1056,7 @@ Methods Tick.is_anchored Tick.is_on_offset Tick.__call__ + Tick.apply Day --- @@ -1088,6 +1089,7 @@ Methods Day.is_anchored Day.is_on_offset Day.__call__ + Day.apply Hour ---- @@ -1120,6 +1122,7 @@ Methods Hour.is_anchored Hour.is_on_offset Hour.__call__ + Hour.apply Minute ------ @@ -1152,6 +1155,7 @@ Methods Minute.is_anchored Minute.is_on_offset Minute.__call__ + Minute.apply Second ------ @@ -1184,6 +1188,7 @@ Methods Second.is_anchored Second.is_on_offset Second.__call__ + Second.apply Milli ----- @@ -1216,6 +1221,7 @@ Methods Milli.is_anchored Milli.is_on_offset Milli.__call__ + Milli.apply Micro ----- @@ -1248,6 +1254,7 @@ Methods Micro.is_anchored Micro.is_on_offset Micro.__call__ + Micro.apply Nano ---- @@ -1280,6 +1287,7 @@ Methods Nano.is_anchored Nano.is_on_offset Nano.__call__ + Nano.apply BDay ---- diff --git a/pandas/_libs/tslibs/offsets.pxd b/pandas/_libs/tslibs/offsets.pxd index e75cd8bdf1baf..c6afb557ba2ef 100644 --- a/pandas/_libs/tslibs/offsets.pxd +++ b/pandas/_libs/tslibs/offsets.pxd @@ -1,3 +1,2 @@ cdef to_offset(object obj) cdef bint is_offset_object(object obj) -cdef bint is_tick_object(object obj) diff --git a/pandas/_libs/tslibs/offsets.pyx b/pandas/_libs/tslibs/offsets.pyx index 0e8210c7565f5..f2860fad75428 100644 --- a/pandas/_libs/tslibs/offsets.pyx +++ b/pandas/_libs/tslibs/offsets.pyx @@ -21,7 +21,7 @@ cnp.import_array() from pandas._libs.tslibs cimport util from pandas._libs.tslibs.util cimport is_integer_object -from pandas._libs.tslibs.base cimport ABCTick, ABCTimestamp +from pandas._libs.tslibs.base cimport ABCTick, ABCTimestamp, is_tick_object from pandas._libs.tslibs.ccalendar import MONTHS, DAYS from pandas._libs.tslibs.ccalendar cimport get_days_in_month, dayofweek @@ -93,10 +93,6 @@ cdef bint is_offset_object(object obj): return isinstance(obj, _BaseOffset) -cdef bint is_tick_object(object obj): - return isinstance(obj, _Tick) - - cdef to_offset(object obj): """ Wrap pandas.tseries.frequencies.to_offset to keep centralize runtime @@ -335,7 +331,7 @@ def to_dt64D(dt): # Validation -def validate_business_time(t_input): +def _validate_business_time(t_input): if isinstance(t_input, str): try: t = time.strptime(t_input, '%H:%M') @@ -528,6 +524,21 @@ class _BaseOffset: out = f'<{n_str}{className}{plural}{self._repr_attrs()}>' return out + def _repr_attrs(self) -> str: + 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 + @property def name(self) -> str: return self.rule_code @@ -790,6 +801,97 @@ class BusinessMixin: return out +class BusinessHourMixin(BusinessMixin): + _adjust_dst = False + + def __init__(self, start="09:00", end="17:00", offset=timedelta(0)): + # must be validated here to equality check + if np.ndim(start) == 0: + # i.e. not is_list_like + start = [start] + if not len(start): + raise ValueError("Must include at least 1 start time") + + if np.ndim(end) == 0: + # i.e. not is_list_like + end = [end] + if not len(end): + raise ValueError("Must include at least 1 end time") + + start = np.array([_validate_business_time(x) for x in start]) + end = np.array([_validate_business_time(x) for x in end]) + + # Validation of input + if len(start) != len(end): + raise ValueError("number of starting time and ending time must be the same") + num_openings = len(start) + + # sort starting and ending time by starting time + index = np.argsort(start) + + # convert to tuple so that start and end are hashable + start = tuple(start[index]) + end = tuple(end[index]) + + total_secs = 0 + for i in range(num_openings): + total_secs += self._get_business_hours_by_sec(start[i], end[i]) + total_secs += self._get_business_hours_by_sec( + end[i], start[(i + 1) % num_openings] + ) + if total_secs != 24 * 60 * 60: + raise ValueError( + "invalid starting and ending time(s): " + "opening hours should not touch or overlap with " + "one another" + ) + + object.__setattr__(self, "start", start) + object.__setattr__(self, "end", end) + object.__setattr__(self, "_offset", offset) + + def _repr_attrs(self) -> str: + out = super()._repr_attrs() + hours = ",".join( + f'{st.strftime("%H:%M")}-{en.strftime("%H:%M")}' + for st, en in zip(self.start, self.end) + ) + attrs = [f"{self._prefix}={hours}"] + out += ": " + ", ".join(attrs) + return out + + def _get_business_hours_by_sec(self, start, end): + """ + Return business hours in a day by seconds. + """ + # create dummy datetime to calculate business hours in a day + dtstart = datetime(2014, 4, 1, start.hour, start.minute) + day = 1 if start < end else 2 + until = datetime(2014, 4, day, end.hour, end.minute) + return int((until - dtstart).total_seconds()) + + def _get_closing_time(self, dt): + """ + Get the closing time of a business hour interval by its opening time. + + Parameters + ---------- + dt : datetime + Opening time of a business hour interval. + + Returns + ------- + result : datetime + Corresponding closing time. + """ + for i, st in enumerate(self.start): + if st.hour == dt.hour and st.minute == dt.minute: + return dt + timedelta( + seconds=self._get_business_hours_by_sec(st, self.end[i]) + ) + assert False + + class CustomMixin: """ Mixin for classes that define and validate calendar, holidays, @@ -809,6 +911,31 @@ class CustomMixin: object.__setattr__(self, "calendar", calendar) +class WeekOfMonthMixin: + """ + Mixin for methods common to WeekOfMonth and LastWeekOfMonth. + """ + + @apply_wraps + def apply(self, other): + 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 + + shifted = shift_month(other, months, "start") + to_day = self._get_offset_day(shifted) + return shift_day(shifted, to_day - shifted.day) + + def is_on_offset(self, dt) -> bool: + if self.normalize and not is_normalized(dt): + return False + return dt.day == self._get_offset_day(dt) + + # ---------------------------------------------------------------------- # RelativeDelta Arithmetic diff --git a/pandas/_libs/tslibs/period.pyx b/pandas/_libs/tslibs/period.pyx index a6aea912eac39..c5be5b1d96469 100644 --- a/pandas/_libs/tslibs/period.pyx +++ b/pandas/_libs/tslibs/period.pyx @@ -37,7 +37,7 @@ cdef extern from "src/datetime/np_datetime.h": cimport pandas._libs.tslibs.util as util -from pandas._libs.tslibs.base cimport ABCPeriod, is_period_object +from pandas._libs.tslibs.base cimport ABCPeriod, is_period_object, is_tick_object from pandas._libs.tslibs.timestamps import Timestamp from pandas._libs.tslibs.timezones cimport is_utc, is_tzlocal, get_dst_info @@ -68,7 +68,7 @@ from pandas._libs.tslibs.nattype cimport ( c_NaT as NaT, c_nat_strings as nat_strings, ) -from pandas._libs.tslibs.offsets cimport to_offset, is_tick_object +from pandas._libs.tslibs.offsets cimport to_offset from pandas._libs.tslibs.tzconversion cimport tz_convert_utc_to_tzlocal diff --git a/pandas/tseries/offsets.py b/pandas/tseries/offsets.py index f95d89a39b563..4c06fea51ea8d 100644 --- a/pandas/tseries/offsets.py +++ b/pandas/tseries/offsets.py @@ -32,8 +32,6 @@ from pandas.errors import AbstractMethodError from pandas.util._decorators import Appender, Substitution, cache_readonly -from pandas.core.dtypes.inference import is_list_like - __all__ = [ "Day", "BusinessDay", @@ -282,24 +280,6 @@ def is_anchored(self) -> bool: # if there were a canonical docstring for what is_anchored means. return self.n == 1 - # TODO: Combine this with BusinessMixin version by defining a whitelisted - # set of attributes on each object rather than the existing behavior of - # iterating over internal ``__dict__`` - def _repr_attrs(self) -> str: - 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 - def is_on_offset(self, dt): if self.normalize and not is_normalized(dt): return False @@ -434,53 +414,7 @@ def is_on_offset(self, dt: datetime) -> bool: return dt.weekday() < 5 -class BusinessHourMixin(BusinessMixin): - _adjust_dst = False - - def __init__(self, start="09:00", end="17:00", offset=timedelta(0)): - # must be validated here to equality check - if not is_list_like(start): - start = [start] - if not len(start): - raise ValueError("Must include at least 1 start time") - - if not is_list_like(end): - end = [end] - if not len(end): - raise ValueError("Must include at least 1 end time") - - start = np.array([liboffsets.validate_business_time(x) for x in start]) - end = np.array([liboffsets.validate_business_time(x) for x in end]) - - # Validation of input - if len(start) != len(end): - raise ValueError("number of starting time and ending time must be the same") - num_openings = len(start) - - # sort starting and ending time by starting time - index = np.argsort(start) - - # convert to tuple so that start and end are hashable - start = tuple(start[index]) - end = tuple(end[index]) - - total_secs = 0 - for i in range(num_openings): - total_secs += self._get_business_hours_by_sec(start[i], end[i]) - total_secs += self._get_business_hours_by_sec( - end[i], start[(i + 1) % num_openings] - ) - if total_secs != 24 * 60 * 60: - raise ValueError( - "invalid starting and ending time(s): " - "opening hours should not touch or overlap with " - "one another" - ) - - object.__setattr__(self, "start", start) - object.__setattr__(self, "end", end) - object.__setattr__(self, "_offset", offset) - +class BusinessHourMixin(liboffsets.BusinessHourMixin): @cache_readonly def next_bday(self): """ @@ -579,16 +513,6 @@ def _prev_opening_time(self, other): """ return self._next_opening_time(other, sign=-1) - def _get_business_hours_by_sec(self, start, end): - """ - Return business hours in a day by seconds. - """ - # create dummy datetime to calculate business hours in a day - dtstart = datetime(2014, 4, 1, start.hour, start.minute) - day = 1 if start < end else 2 - until = datetime(2014, 4, day, end.hour, end.minute) - return int((until - dtstart).total_seconds()) - @apply_wraps def rollback(self, dt): """ @@ -614,27 +538,6 @@ def rollforward(self, dt): return self._prev_opening_time(dt) return dt - def _get_closing_time(self, dt): - """ - Get the closing time of a business hour interval by its opening time. - - Parameters - ---------- - dt : datetime - Opening time of a business hour interval. - - Returns - ------- - result : datetime - Corresponding closing time. - """ - for i, st in enumerate(self.start): - if st.hour == dt.hour and st.minute == dt.minute: - return dt + timedelta( - seconds=self._get_business_hours_by_sec(st, self.end[i]) - ) - assert False - @apply_wraps def apply(self, other): if isinstance(other, datetime): @@ -770,16 +673,6 @@ def _is_on_offset(self, dt): else: return False - def _repr_attrs(self): - out = super()._repr_attrs() - hours = ",".join( - f'{st.strftime("%H:%M")}-{en.strftime("%H:%M")}' - for st, en in zip(self.start, self.end) - ) - attrs = [f"{self._prefix}={hours}"] - out += ": " + ", ".join(attrs) - return out - class BusinessHour(BusinessHourMixin, SingleConstructorOffset): """ @@ -1410,32 +1303,7 @@ def _from_name(cls, suffix=None): return cls(weekday=weekday) -class _WeekOfMonthMixin: - """ - Mixin for methods common to WeekOfMonth and LastWeekOfMonth. - """ - - @apply_wraps - def apply(self, other): - 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 - - shifted = shift_month(other, months, "start") - to_day = self._get_offset_day(shifted) - return liboffsets.shift_day(shifted, to_day - shifted.day) - - def is_on_offset(self, dt): - if self.normalize and not is_normalized(dt): - return False - return dt.day == self._get_offset_day(dt) - - -class WeekOfMonth(_WeekOfMonthMixin, SingleConstructorOffset): +class WeekOfMonth(liboffsets.WeekOfMonthMixin, SingleConstructorOffset): """ Describes monthly dates like "the Tuesday of the 2nd week of each month". @@ -1504,7 +1372,7 @@ def _from_name(cls, suffix=None): return cls(week=week, weekday=weekday) -class LastWeekOfMonth(_WeekOfMonthMixin, SingleConstructorOffset): +class LastWeekOfMonth(liboffsets.WeekOfMonthMixin, SingleConstructorOffset): """ Describes monthly dates in last week of month like "the last Tuesday of each month". @@ -2348,7 +2216,6 @@ def delta(self) -> Timedelta: def nanos(self): return delta_to_nanoseconds(self.delta) - # TODO: Should Tick have its own apply_index? def apply(self, other): # Timestamp can handle tz and nano sec, thus no need to use apply_wraps if isinstance(other, Timestamp):