Skip to content

Commit 038273f

Browse files
authored
REF: Implement RelativeDeltaOffset (#34263)
1 parent 55e8891 commit 038273f

File tree

3 files changed

+128
-163
lines changed

3 files changed

+128
-163
lines changed

pandas/_libs/tslibs/offsets.pyx

+122-5
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ cdef to_offset(object obj):
112112
return to_offset(obj)
113113

114114

115-
def as_datetime(obj: datetime) -> datetime:
115+
cdef datetime _as_datetime(datetime obj):
116116
if isinstance(obj, ABCTimestamp):
117117
return obj.to_pydatetime()
118118
return obj
@@ -360,10 +360,10 @@ def _validate_business_time(t_input):
360360
# ---------------------------------------------------------------------
361361
# Constructor Helpers
362362

363-
relativedelta_kwds = {'years', 'months', 'weeks', 'days', 'year', 'month',
364-
'day', 'weekday', 'hour', 'minute', 'second',
365-
'microsecond', 'nanosecond', 'nanoseconds', 'hours',
366-
'minutes', 'seconds', 'microseconds'}
363+
_relativedelta_kwds = {"years", "months", "weeks", "days", "year", "month",
364+
"day", "weekday", "hour", "minute", "second",
365+
"microsecond", "nanosecond", "nanoseconds", "hours",
366+
"minutes", "seconds", "microseconds"}
367367

368368

369369
def _determine_offset(kwds):
@@ -1004,6 +1004,123 @@ def delta_to_tick(delta: timedelta) -> Tick:
10041004
return Nano(nanos)
10051005

10061006

1007+
# --------------------------------------------------------------------
1008+
1009+
class RelativeDeltaOffset(BaseOffset):
1010+
"""
1011+
DateOffset subclass backed by a dateutil relativedelta object.
1012+
"""
1013+
_attributes = frozenset(["n", "normalize"] + list(_relativedelta_kwds))
1014+
_adjust_dst = False
1015+
1016+
def __init__(self, n=1, normalize=False, **kwds):
1017+
BaseOffset.__init__(self, n, normalize)
1018+
1019+
off, use_rd = _determine_offset(kwds)
1020+
object.__setattr__(self, "_offset", off)
1021+
object.__setattr__(self, "_use_relativedelta", use_rd)
1022+
for key in kwds:
1023+
val = kwds[key]
1024+
object.__setattr__(self, key, val)
1025+
1026+
@apply_wraps
1027+
def apply(self, other):
1028+
if self._use_relativedelta:
1029+
other = _as_datetime(other)
1030+
1031+
if len(self.kwds) > 0:
1032+
tzinfo = getattr(other, "tzinfo", None)
1033+
if tzinfo is not None and self._use_relativedelta:
1034+
# perform calculation in UTC
1035+
other = other.replace(tzinfo=None)
1036+
1037+
if self.n > 0:
1038+
for i in range(self.n):
1039+
other = other + self._offset
1040+
else:
1041+
for i in range(-self.n):
1042+
other = other - self._offset
1043+
1044+
if tzinfo is not None and self._use_relativedelta:
1045+
# bring tz back from UTC calculation
1046+
other = localize_pydatetime(other, tzinfo)
1047+
1048+
from .timestamps import Timestamp
1049+
return Timestamp(other)
1050+
else:
1051+
return other + timedelta(self.n)
1052+
1053+
@apply_index_wraps
1054+
def apply_index(self, index):
1055+
"""
1056+
Vectorized apply of DateOffset to DatetimeIndex,
1057+
raises NotImplementedError for offsets without a
1058+
vectorized implementation.
1059+
1060+
Parameters
1061+
----------
1062+
index : DatetimeIndex
1063+
1064+
Returns
1065+
-------
1066+
DatetimeIndex
1067+
"""
1068+
kwds = self.kwds
1069+
relativedelta_fast = {
1070+
"years",
1071+
"months",
1072+
"weeks",
1073+
"days",
1074+
"hours",
1075+
"minutes",
1076+
"seconds",
1077+
"microseconds",
1078+
}
1079+
# relativedelta/_offset path only valid for base DateOffset
1080+
if self._use_relativedelta and set(kwds).issubset(relativedelta_fast):
1081+
1082+
months = (kwds.get("years", 0) * 12 + kwds.get("months", 0)) * self.n
1083+
if months:
1084+
shifted = shift_months(index.asi8, months)
1085+
index = type(index)(shifted, dtype=index.dtype)
1086+
1087+
weeks = kwds.get("weeks", 0) * self.n
1088+
if weeks:
1089+
# integer addition on PeriodIndex is deprecated,
1090+
# so we directly use _time_shift instead
1091+
asper = index.to_period("W")
1092+
shifted = asper._time_shift(weeks)
1093+
index = shifted.to_timestamp() + index.to_perioddelta("W")
1094+
1095+
timedelta_kwds = {
1096+
k: v
1097+
for k, v in kwds.items()
1098+
if k in ["days", "hours", "minutes", "seconds", "microseconds"]
1099+
}
1100+
if timedelta_kwds:
1101+
from .timedeltas import Timedelta
1102+
delta = Timedelta(**timedelta_kwds)
1103+
index = index + (self.n * delta)
1104+
return index
1105+
elif not self._use_relativedelta and hasattr(self, "_offset"):
1106+
# timedelta
1107+
return index + (self._offset * self.n)
1108+
else:
1109+
# relativedelta with other keywords
1110+
kwd = set(kwds) - relativedelta_fast
1111+
raise NotImplementedError(
1112+
"DateOffset with relativedelta "
1113+
f"keyword(s) {kwd} not able to be "
1114+
"applied vectorized"
1115+
)
1116+
1117+
def is_on_offset(self, dt) -> bool:
1118+
if self.normalize and not is_normalized(dt):
1119+
return False
1120+
# TODO: see GH#1395
1121+
return True
1122+
1123+
10071124
# --------------------------------------------------------------------
10081125

10091126

pandas/tests/tseries/offsets/test_offsets.py

+4-4
Original file line numberDiff line numberDiff line change
@@ -4336,7 +4336,7 @@ def test_valid_default_arguments(offset_types):
43364336
cls()
43374337

43384338

4339-
@pytest.mark.parametrize("kwd", sorted(liboffsets.relativedelta_kwds))
4339+
@pytest.mark.parametrize("kwd", sorted(liboffsets._relativedelta_kwds))
43404340
def test_valid_month_attributes(kwd, month_classes):
43414341
# GH#18226
43424342
cls = month_classes
@@ -4352,14 +4352,14 @@ def test_month_offset_name(month_classes):
43524352
assert obj2.name == obj.name
43534353

43544354

4355-
@pytest.mark.parametrize("kwd", sorted(liboffsets.relativedelta_kwds))
4355+
@pytest.mark.parametrize("kwd", sorted(liboffsets._relativedelta_kwds))
43564356
def test_valid_relativedelta_kwargs(kwd):
4357-
# Check that all the arguments specified in liboffsets.relativedelta_kwds
4357+
# Check that all the arguments specified in liboffsets._relativedelta_kwds
43584358
# are in fact valid relativedelta keyword args
43594359
DateOffset(**{kwd: 1})
43604360

43614361

4362-
@pytest.mark.parametrize("kwd", sorted(liboffsets.relativedelta_kwds))
4362+
@pytest.mark.parametrize("kwd", sorted(liboffsets._relativedelta_kwds))
43634363
def test_valid_tick_attributes(kwd, tick_classes):
43644364
# GH#18226
43654365
cls = tick_classes

pandas/tseries/offsets.py

+2-154
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,6 @@
4040
YearEnd,
4141
apply_index_wraps,
4242
apply_wraps,
43-
as_datetime,
4443
is_normalized,
4544
shift_month,
4645
to_dt64D,
@@ -107,7 +106,7 @@ def __subclasscheck__(cls, obj) -> bool:
107106
return issubclass(obj, BaseOffset)
108107

109108

110-
class DateOffset(BaseOffset, metaclass=OffsetMeta):
109+
class DateOffset(liboffsets.RelativeDeltaOffset, metaclass=OffsetMeta):
111110
"""
112111
Standard kind of date increment used for a date range.
113112
@@ -202,158 +201,7 @@ def __add__(date):
202201
Timestamp('2017-03-01 09:10:11')
203202
"""
204203

205-
_attributes = frozenset(["n", "normalize"] + list(liboffsets.relativedelta_kwds))
206-
_adjust_dst = False
207-
208-
def __init__(self, n=1, normalize=False, **kwds):
209-
BaseOffset.__init__(self, n, normalize)
210-
211-
off, use_rd = liboffsets._determine_offset(kwds)
212-
object.__setattr__(self, "_offset", off)
213-
object.__setattr__(self, "_use_relativedelta", use_rd)
214-
for key in kwds:
215-
val = kwds[key]
216-
object.__setattr__(self, key, val)
217-
218-
@apply_wraps
219-
def apply(self, other):
220-
if self._use_relativedelta:
221-
other = as_datetime(other)
222-
223-
if len(self.kwds) > 0:
224-
tzinfo = getattr(other, "tzinfo", None)
225-
if tzinfo is not None and self._use_relativedelta:
226-
# perform calculation in UTC
227-
other = other.replace(tzinfo=None)
228-
229-
if self.n > 0:
230-
for i in range(self.n):
231-
other = other + self._offset
232-
else:
233-
for i in range(-self.n):
234-
other = other - self._offset
235-
236-
if tzinfo is not None and self._use_relativedelta:
237-
# bring tz back from UTC calculation
238-
other = conversion.localize_pydatetime(other, tzinfo)
239-
240-
return Timestamp(other)
241-
else:
242-
return other + timedelta(self.n)
243-
244-
@apply_index_wraps
245-
def apply_index(self, i):
246-
"""
247-
Vectorized apply of DateOffset to DatetimeIndex,
248-
raises NotImplementedError for offsets without a
249-
vectorized implementation.
250-
251-
Parameters
252-
----------
253-
i : DatetimeIndex
254-
255-
Returns
256-
-------
257-
y : DatetimeIndex
258-
"""
259-
kwds = self.kwds
260-
relativedelta_fast = {
261-
"years",
262-
"months",
263-
"weeks",
264-
"days",
265-
"hours",
266-
"minutes",
267-
"seconds",
268-
"microseconds",
269-
}
270-
# relativedelta/_offset path only valid for base DateOffset
271-
if self._use_relativedelta and set(kwds).issubset(relativedelta_fast):
272-
273-
months = (kwds.get("years", 0) * 12 + kwds.get("months", 0)) * self.n
274-
if months:
275-
shifted = liboffsets.shift_months(i.asi8, months)
276-
i = type(i)(shifted, dtype=i.dtype)
277-
278-
weeks = (kwds.get("weeks", 0)) * self.n
279-
if weeks:
280-
# integer addition on PeriodIndex is deprecated,
281-
# so we directly use _time_shift instead
282-
asper = i.to_period("W")
283-
shifted = asper._time_shift(weeks)
284-
i = shifted.to_timestamp() + i.to_perioddelta("W")
285-
286-
timedelta_kwds = {
287-
k: v
288-
for k, v in kwds.items()
289-
if k in ["days", "hours", "minutes", "seconds", "microseconds"]
290-
}
291-
if timedelta_kwds:
292-
delta = Timedelta(**timedelta_kwds)
293-
i = i + (self.n * delta)
294-
return i
295-
elif not self._use_relativedelta and hasattr(self, "_offset"):
296-
# timedelta
297-
return i + (self._offset * self.n)
298-
else:
299-
# relativedelta with other keywords
300-
kwd = set(kwds) - relativedelta_fast
301-
raise NotImplementedError(
302-
"DateOffset with relativedelta "
303-
f"keyword(s) {kwd} not able to be "
304-
"applied vectorized"
305-
)
306-
307-
def is_on_offset(self, dt):
308-
if self.normalize and not is_normalized(dt):
309-
return False
310-
# TODO, see #1395
311-
return True
312-
313-
def _repr_attrs(self) -> str:
314-
# The DateOffset class differs from other classes in that members
315-
# of self._attributes may not be defined, so we have to use __dict__
316-
# instead.
317-
exclude = {"n", "inc", "normalize"}
318-
attrs = []
319-
for attr in sorted(self.__dict__):
320-
if attr.startswith("_") or attr == "kwds":
321-
continue
322-
elif attr not in exclude:
323-
value = getattr(self, attr)
324-
attrs.append(f"{attr}={value}")
325-
326-
out = ""
327-
if attrs:
328-
out += ": " + ", ".join(attrs)
329-
return out
330-
331-
@cache_readonly
332-
def _params(self):
333-
"""
334-
Returns a tuple containing all of the attributes needed to evaluate
335-
equality between two DateOffset objects.
336-
"""
337-
# The DateOffset class differs from other classes in that members
338-
# of self._attributes may not be defined, so we have to use __dict__
339-
# instead.
340-
all_paras = self.__dict__.copy()
341-
all_paras["n"] = self.n
342-
all_paras["normalize"] = self.normalize
343-
for key in self.__dict__:
344-
if key not in all_paras:
345-
# cython attributes are not in __dict__
346-
all_paras[key] = getattr(self, key)
347-
348-
if "holidays" in all_paras and not all_paras["holidays"]:
349-
all_paras.pop("holidays")
350-
exclude = ["kwds", "name", "calendar"]
351-
attrs = [
352-
(k, v) for k, v in all_paras.items() if (k not in exclude) and (k[0] != "_")
353-
]
354-
attrs = sorted(set(attrs))
355-
params = tuple([str(type(self))] + attrs)
356-
return params
204+
pass
357205

358206

359207
class BusinessDay(BusinessMixin):

0 commit comments

Comments
 (0)