Skip to content

REF: Implement RelativeDeltaOffset #34263

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
May 25, 2020
127 changes: 122 additions & 5 deletions pandas/_libs/tslibs/offsets.pyx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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


# --------------------------------------------------------------------


Expand Down
8 changes: 4 additions & 4 deletions pandas/tests/tseries/offsets/test_offsets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
156 changes: 2 additions & 154 deletions pandas/tseries/offsets.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@
YearEnd,
apply_index_wraps,
apply_wraps,
as_datetime,
is_normalized,
shift_month,
to_dt64D,
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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):
Expand Down