From 275f98d9ddda60619788c6402bbdff9349f55319 Mon Sep 17 00:00:00 2001 From: Brock Date: Thu, 18 Nov 2021 16:40:15 -0800 Subject: [PATCH 1/3] DEPR: DateOffset.apply --- pandas/_libs/tslibs/offsets.pyx | 45 +++++++++++-------- pandas/core/arrays/datetimes.py | 4 +- pandas/tests/tseries/offsets/common.py | 4 +- .../tseries/offsets/test_business_day.py | 2 +- .../tseries/offsets/test_business_hour.py | 2 +- .../offsets/test_custom_business_hour.py | 2 +- pandas/tests/tseries/offsets/test_fiscal.py | 6 +-- pandas/tests/tseries/offsets/test_offsets.py | 25 ++++++++--- pandas/tests/tseries/offsets/test_ticks.py | 4 +- 9 files changed, 57 insertions(+), 37 deletions(-) diff --git a/pandas/_libs/tslibs/offsets.pyx b/pandas/_libs/tslibs/offsets.pyx index f689b8ce242e5..e6dee34f3db0a 100644 --- a/pandas/_libs/tslibs/offsets.pyx +++ b/pandas/_libs/tslibs/offsets.pyx @@ -435,7 +435,7 @@ cdef class BaseOffset: # cython semantics; this is __radd__ return other.__add__(self) try: - return self.apply(other) + return self._apply(other) except ApplyTypeError: return NotImplemented @@ -458,7 +458,16 @@ cdef class BaseOffset: FutureWarning, stacklevel=1, ) - return self.apply(other) + return self._apply(other) + + def apply(self, other): + warnings.warn( + f"{type(self).__name__}.apply is deprecated and will be removed " + "in a future version. Use `offset + other` instead", + FutureWarning, + stacklevel=2, + ) + return self._apply(other) def __mul__(self, other): if util.is_array(other): @@ -889,7 +898,7 @@ cdef class Tick(SingleConstructorOffset): else: return delta_to_tick(self.delta + other.delta) try: - return self.apply(other) + return self._apply(other) except ApplyTypeError: # Includes pd.Period return NotImplemented @@ -898,7 +907,7 @@ cdef class Tick(SingleConstructorOffset): f"the add operation between {self} and {other} will overflow" ) from err - def apply(self, other): + def _apply(self, other): # Timestamp can handle tz and nano sec, thus no need to use apply_wraps if isinstance(other, _Timestamp): # GH#15126 @@ -1041,7 +1050,7 @@ cdef class RelativeDeltaOffset(BaseOffset): self.__dict__.update(state) @apply_wraps - def apply(self, other: datetime) -> datetime: + def _apply(self, other: datetime) -> datetime: if self._use_relativedelta: other = _as_datetime(other) @@ -1371,7 +1380,7 @@ cdef class BusinessDay(BusinessMixin): return "+" + repr(self.offset) @apply_wraps - def apply(self, other): + def _apply(self, other): if PyDateTime_Check(other): n = self.n wday = other.weekday() @@ -1684,7 +1693,7 @@ cdef class BusinessHour(BusinessMixin): return dt @apply_wraps - def apply(self, other: datetime) -> datetime: + def _apply(self, other: datetime) -> datetime: # used for detecting edge condition nanosecond = getattr(other, "nanosecond", 0) # reset timezone and nanosecond @@ -1833,7 +1842,7 @@ cdef class WeekOfMonthMixin(SingleConstructorOffset): raise ValueError(f"Day must be 0<=day<=6, got {weekday}") @apply_wraps - def apply(self, other: datetime) -> datetime: + def _apply(self, other: datetime) -> datetime: compare_day = self._get_offset_day(other) months = self.n @@ -1913,7 +1922,7 @@ cdef class YearOffset(SingleConstructorOffset): return get_day_of_month(&dts, self._day_opt) @apply_wraps - def apply(self, other: datetime) -> datetime: + 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) @@ -2062,7 +2071,7 @@ cdef class QuarterOffset(SingleConstructorOffset): return mod_month == 0 and dt.day == self._get_offset_day(dt) @apply_wraps - def apply(self, other: datetime) -> datetime: + 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 @@ -2189,7 +2198,7 @@ cdef class MonthOffset(SingleConstructorOffset): return dt.day == self._get_offset_day(dt) @apply_wraps - def apply(self, other: datetime) -> datetime: + 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) @@ -2307,7 +2316,7 @@ cdef class SemiMonthOffset(SingleConstructorOffset): return self._prefix + suffix @apply_wraps - def apply(self, other: datetime) -> datetime: + def _apply(self, other: datetime) -> datetime: is_start = isinstance(self, SemiMonthBegin) # shift `other` to self.day_of_month, incrementing `n` if necessary @@ -2482,7 +2491,7 @@ cdef class Week(SingleConstructorOffset): return self.n == 1 and self.weekday is not None @apply_wraps - def apply(self, other): + def _apply(self, other): if self.weekday is None: return other + self.n * self._inc @@ -2833,7 +2842,7 @@ cdef class FY5253(FY5253Mixin): return year_end == dt @apply_wraps - def apply(self, other: datetime) -> datetime: + def _apply(self, other: datetime) -> datetime: norm = Timestamp(other).normalize() n = self.n @@ -3082,7 +3091,7 @@ cdef class FY5253Quarter(FY5253Mixin): return start, num_qtrs, tdelta @apply_wraps - def apply(self, other: datetime) -> datetime: + def _apply(self, other: datetime) -> datetime: # Note: self.n == 0 is not allowed. n = self.n @@ -3173,7 +3182,7 @@ cdef class Easter(SingleConstructorOffset): self.normalize = state.pop("normalize") @apply_wraps - def apply(self, other: datetime) -> datetime: + def _apply(self, other: datetime) -> datetime: current_easter = easter(other.year) current_easter = datetime( current_easter.year, current_easter.month, current_easter.day @@ -3252,7 +3261,7 @@ cdef class CustomBusinessDay(BusinessDay): BusinessDay.__setstate__(self, state) @apply_wraps - def apply(self, other): + def _apply(self, other): if self.n <= 0: roll = "forward" else: @@ -3415,7 +3424,7 @@ cdef class _CustomBusinessMonth(BusinessMixin): return roll_func @apply_wraps - def apply(self, other: datetime) -> datetime: + def _apply(self, other: datetime) -> datetime: # First move to month offset cur_month_offset_date = self.month_roll(other) diff --git a/pandas/core/arrays/datetimes.py b/pandas/core/arrays/datetimes.py index a0a7ef3501d7f..e0cbc1d15f32a 100644 --- a/pandas/core/arrays/datetimes.py +++ b/pandas/core/arrays/datetimes.py @@ -2593,7 +2593,7 @@ def generate_range(start=None, end=None, periods=None, offset=BDay()): break # faster than cur + offset - next_date = offset.apply(cur) + next_date = offset._apply(cur) if next_date <= cur: raise ValueError(f"Offset {offset} did not increment date") cur = next_date @@ -2607,7 +2607,7 @@ def generate_range(start=None, end=None, periods=None, offset=BDay()): break # faster than cur + offset - next_date = offset.apply(cur) + next_date = offset._apply(cur) if next_date >= cur: raise ValueError(f"Offset {offset} did not decrement date") cur = next_date diff --git a/pandas/tests/tseries/offsets/common.py b/pandas/tests/tseries/offsets/common.py index 0227a07877db0..d8e98bb0c6876 100644 --- a/pandas/tests/tseries/offsets/common.py +++ b/pandas/tests/tseries/offsets/common.py @@ -28,7 +28,7 @@ def assert_offset_equal(offset, base, expected): actual = offset + base actual_swapped = base + offset - actual_apply = offset.apply(base) + actual_apply = offset._apply(base) try: assert actual == expected assert actual_swapped == expected @@ -155,7 +155,7 @@ def test_rsub(self): # i.e. skip for TestCommon and YQM subclasses that do not have # offset2 attr return - assert self.d - self.offset2 == (-self.offset2).apply(self.d) + assert self.d - self.offset2 == (-self.offset2)._apply(self.d) def test_radd(self): if self._offset is None or not hasattr(self, "offset2"): diff --git a/pandas/tests/tseries/offsets/test_business_day.py b/pandas/tests/tseries/offsets/test_business_day.py index 92176515b6b6f..7fd5cca173bd6 100644 --- a/pandas/tests/tseries/offsets/test_business_day.py +++ b/pandas/tests/tseries/offsets/test_business_day.py @@ -239,4 +239,4 @@ def test_apply_corner(self): "with datetime, datetime64 or timedelta" ) with pytest.raises(ApplyTypeError, match=msg): - self._offset().apply(BMonthEnd()) + self._offset()._apply(BMonthEnd()) diff --git a/pandas/tests/tseries/offsets/test_business_hour.py b/pandas/tests/tseries/offsets/test_business_hour.py index ee05eab5ec5ca..401bfe664a3a2 100644 --- a/pandas/tests/tseries/offsets/test_business_hour.py +++ b/pandas/tests/tseries/offsets/test_business_hour.py @@ -318,7 +318,7 @@ def test_roll_date_object(self): def test_normalize(self, case): offset, cases = case for dt, expected in cases.items(): - assert offset.apply(dt) == expected + assert offset._apply(dt) == expected on_offset_cases = [] on_offset_cases.append( diff --git a/pandas/tests/tseries/offsets/test_custom_business_hour.py b/pandas/tests/tseries/offsets/test_custom_business_hour.py index 8bc06cdd45a50..dbc0ff4371fd9 100644 --- a/pandas/tests/tseries/offsets/test_custom_business_hour.py +++ b/pandas/tests/tseries/offsets/test_custom_business_hour.py @@ -192,7 +192,7 @@ def test_roll_date_object(self): def test_normalize(self, norm_cases): offset, cases = norm_cases for dt, expected in cases.items(): - assert offset.apply(dt) == expected + assert offset._apply(dt) == expected def test_is_on_offset(self): tests = [ diff --git a/pandas/tests/tseries/offsets/test_fiscal.py b/pandas/tests/tseries/offsets/test_fiscal.py index 1eee9e611e0f1..8df93102d4bd2 100644 --- a/pandas/tests/tseries/offsets/test_fiscal.py +++ b/pandas/tests/tseries/offsets/test_fiscal.py @@ -643,18 +643,18 @@ def test_bunched_yearends(): fy = FY5253(n=1, weekday=5, startingMonth=12, variation="nearest") dt = Timestamp("2004-01-01") assert fy.rollback(dt) == Timestamp("2002-12-28") - assert (-fy).apply(dt) == Timestamp("2002-12-28") + assert (-fy)._apply(dt) == Timestamp("2002-12-28") assert dt - fy == Timestamp("2002-12-28") assert fy.rollforward(dt) == Timestamp("2004-01-03") - assert fy.apply(dt) == Timestamp("2004-01-03") + assert fy._apply(dt) == Timestamp("2004-01-03") assert fy + dt == Timestamp("2004-01-03") assert dt + fy == Timestamp("2004-01-03") # Same thing, but starting from a Timestamp in the previous year. dt = Timestamp("2003-12-31") assert fy.rollback(dt) == Timestamp("2002-12-28") - assert (-fy).apply(dt) == Timestamp("2002-12-28") + assert (-fy)._apply(dt) == Timestamp("2002-12-28") assert dt - fy == Timestamp("2002-12-28") diff --git a/pandas/tests/tseries/offsets/test_offsets.py b/pandas/tests/tseries/offsets/test_offsets.py index 0c79c0b64f4cd..ae3c500656ab7 100644 --- a/pandas/tests/tseries/offsets/test_offsets.py +++ b/pandas/tests/tseries/offsets/test_offsets.py @@ -125,7 +125,7 @@ def test_return_type(self, offset_types): assert offset + NaT is NaT assert NaT - offset is NaT - assert (-offset).apply(NaT) is NaT + assert (-offset)._apply(NaT) is NaT def test_offset_n(self, offset_types): offset = self._get_offset(offset_types) @@ -188,7 +188,7 @@ def _check_offsetfunc_works(self, offset, funcname, dt, expected, normalize=Fals if ( type(offset_s).__name__ == "DateOffset" - and (funcname == "apply" or normalize) + and (funcname in ["apply", "_apply"] or normalize) and ts.nanosecond > 0 ): exp_warning = UserWarning @@ -196,6 +196,16 @@ def _check_offsetfunc_works(self, offset, funcname, dt, expected, normalize=Fals # test nanosecond is preserved with tm.assert_produces_warning(exp_warning): result = func(ts) + + if exp_warning is None and funcname == "_apply": + # Check in this particular case to avoid headaches with + # testing for multiple warnings produced by the same call. + with tm.assert_produces_warning(FutureWarning, match="apply is deprecated"): + res2 = offset_s.apply(ts) + + assert type(res2) is type(result) + assert res2 == result + assert isinstance(result, Timestamp) if normalize is False: assert result == expected + Nano(5) @@ -225,7 +235,7 @@ def _check_offsetfunc_works(self, offset, funcname, dt, expected, normalize=Fals if ( type(offset_s).__name__ == "DateOffset" - and (funcname == "apply" or normalize) + and (funcname in ["apply", "_apply"] or normalize) and ts.nanosecond > 0 ): exp_warning = UserWarning @@ -243,13 +253,14 @@ def test_apply(self, offset_types): sdt = datetime(2011, 1, 1, 9, 0) ndt = np_datetime64_compat("2011-01-01 09:00Z") + expected = self.expecteds[offset_types.__name__] + expected_norm = Timestamp(expected.date()) + for dt in [sdt, ndt]: - expected = self.expecteds[offset_types.__name__] - self._check_offsetfunc_works(offset_types, "apply", dt, expected) + self._check_offsetfunc_works(offset_types, "_apply", dt, expected) - expected = Timestamp(expected.date()) self._check_offsetfunc_works( - offset_types, "apply", dt, expected, normalize=True + offset_types, "_apply", dt, expected_norm, normalize=True ) def test_rollforward(self, offset_types): diff --git a/pandas/tests/tseries/offsets/test_ticks.py b/pandas/tests/tseries/offsets/test_ticks.py index ae6bd2d85579a..464eeaed1e725 100644 --- a/pandas/tests/tseries/offsets/test_ticks.py +++ b/pandas/tests/tseries/offsets/test_ticks.py @@ -45,7 +45,7 @@ def test_apply_ticks(): - result = offsets.Hour(3).apply(offsets.Hour(4)) + result = offsets.Hour(3)._apply(offsets.Hour(4)) exp = offsets.Hour(7) assert result == exp @@ -76,7 +76,7 @@ def test_tick_add_sub(cls, n, m): expected = cls(n + m) assert left + right == expected - assert left.apply(right) == expected + assert left._apply(right) == expected expected = cls(n - m) assert left - right == expected From cf34ad615c81bdbce5f734c46ce2fe4ea763f1f0 Mon Sep 17 00:00:00 2001 From: Brock Date: Thu, 18 Nov 2021 16:42:08 -0800 Subject: [PATCH 2/3] Whatsnew, GH ref --- doc/source/whatsnew/v1.4.0.rst | 1 + pandas/_libs/tslibs/offsets.pyx | 1 + pandas/tests/tseries/offsets/test_offsets.py | 1 + 3 files changed, 3 insertions(+) diff --git a/doc/source/whatsnew/v1.4.0.rst b/doc/source/whatsnew/v1.4.0.rst index 2456406f0eca3..4168d4e12f0a7 100644 --- a/doc/source/whatsnew/v1.4.0.rst +++ b/doc/source/whatsnew/v1.4.0.rst @@ -463,6 +463,7 @@ Other Deprecations - Deprecated casting behavior when passing an item with mismatched-timezone to :meth:`DatetimeIndex.insert`, :meth:`DatetimeIndex.putmask`, :meth:`DatetimeIndex.where` :meth:`DatetimeIndex.fillna`, :meth:`Series.mask`, :meth:`Series.where`, :meth:`Series.fillna`, :meth:`Series.shift`, :meth:`Series.replace`, :meth:`Series.reindex` (and :class:`DataFrame` column analogues). In the past this has cast to object dtype. In a future version, these will cast the passed item to the index or series's timezone (:issue:`37605`) - Deprecated the 'errors' keyword argument in :meth:`Series.where`, :meth:`DataFrame.where`, :meth:`Series.mask`, and meth:`DataFrame.mask`; in a future version the argument will be removed (:issue:`44294`) - Deprecated :meth:`PeriodIndex.astype` to ``datetime64[ns]`` or ``DatetimeTZDtype``, use ``obj.to_timestamp(how).tz_localize(dtype.tz)`` instead (:issue:`44398`) +- Deprecated :meth:`DateOffset.apply`, use ``offset + other`` instead (:issue:`44522`) - .. --------------------------------------------------------------------------- diff --git a/pandas/_libs/tslibs/offsets.pyx b/pandas/_libs/tslibs/offsets.pyx index e6dee34f3db0a..055c568f6c9ae 100644 --- a/pandas/_libs/tslibs/offsets.pyx +++ b/pandas/_libs/tslibs/offsets.pyx @@ -461,6 +461,7 @@ cdef class BaseOffset: return self._apply(other) def apply(self, other): + # GH#44522 warnings.warn( f"{type(self).__name__}.apply is deprecated and will be removed " "in a future version. Use `offset + other` instead", diff --git a/pandas/tests/tseries/offsets/test_offsets.py b/pandas/tests/tseries/offsets/test_offsets.py index ae3c500656ab7..511f364051f8f 100644 --- a/pandas/tests/tseries/offsets/test_offsets.py +++ b/pandas/tests/tseries/offsets/test_offsets.py @@ -198,6 +198,7 @@ def _check_offsetfunc_works(self, offset, funcname, dt, expected, normalize=Fals result = func(ts) if exp_warning is None and funcname == "_apply": + # GH#44522 # Check in this particular case to avoid headaches with # testing for multiple warnings produced by the same call. with tm.assert_produces_warning(FutureWarning, match="apply is deprecated"): From f147f055df71b5fb27998ec390ac51aac5ea3a2b Mon Sep 17 00:00:00 2001 From: Brock Date: Sat, 20 Nov 2021 08:24:11 -0800 Subject: [PATCH 3/3] update docs --- doc/source/user_guide/timeseries.rst | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/doc/source/user_guide/timeseries.rst b/doc/source/user_guide/timeseries.rst index 3ddb6434ed932..2e81032bd4c22 100644 --- a/doc/source/user_guide/timeseries.rst +++ b/doc/source/user_guide/timeseries.rst @@ -852,7 +852,7 @@ savings time. However, all :class:`DateOffset` subclasses that are an hour or sm The basic :class:`DateOffset` acts similar to ``dateutil.relativedelta`` (`relativedelta documentation`_) that shifts a date time by the corresponding calendar duration specified. The -arithmetic operator (``+``) or the ``apply`` method can be used to perform the shift. +arithmetic operator (``+``) can be used to perform the shift. .. ipython:: python @@ -866,7 +866,6 @@ arithmetic operator (``+``) or the ``apply`` method can be used to perform the s friday.day_name() # Add 2 business days (Friday --> Tuesday) two_business_days = 2 * pd.offsets.BDay() - two_business_days.apply(friday) friday + two_business_days (friday + two_business_days).day_name() @@ -938,14 +937,14 @@ in the operation). ts = pd.Timestamp("2014-01-01 09:00") day = pd.offsets.Day() - day.apply(ts) - day.apply(ts).normalize() + day + ts + (day + ts).normalize() ts = pd.Timestamp("2014-01-01 22:00") hour = pd.offsets.Hour() - hour.apply(ts) - hour.apply(ts).normalize() - hour.apply(pd.Timestamp("2014-01-01 23:30")).normalize() + hour + ts + (hour + ts).normalize() + (hour + pd.Timestamp("2014-01-01 23:30")).normalize() .. _relativedelta documentation: https://dateutil.readthedocs.io/en/stable/relativedelta.html @@ -1185,16 +1184,16 @@ under the default business hours (9:00 - 17:00), there is no gap (0 minutes) bet pd.offsets.BusinessHour().rollback(pd.Timestamp("2014-08-02 15:00")) pd.offsets.BusinessHour().rollforward(pd.Timestamp("2014-08-02 15:00")) - # It is the same as BusinessHour().apply(pd.Timestamp('2014-08-01 17:00')). - # And it is the same as BusinessHour().apply(pd.Timestamp('2014-08-04 09:00')) - pd.offsets.BusinessHour().apply(pd.Timestamp("2014-08-02 15:00")) + # It is the same as BusinessHour() + pd.Timestamp('2014-08-01 17:00'). + # And it is the same as BusinessHour() + pd.Timestamp('2014-08-04 09:00') + pd.offsets.BusinessHour() + pd.Timestamp("2014-08-02 15:00") # BusinessDay results (for reference) pd.offsets.BusinessHour().rollforward(pd.Timestamp("2014-08-02")) - # It is the same as BusinessDay().apply(pd.Timestamp('2014-08-01')) + # It is the same as BusinessDay() + pd.Timestamp('2014-08-01') # The result is the same as rollworward because BusinessDay never overlap. - pd.offsets.BusinessHour().apply(pd.Timestamp("2014-08-02")) + pd.offsets.BusinessHour() + pd.Timestamp("2014-08-02") ``BusinessHour`` regards Saturday and Sunday as holidays. To use arbitrary holidays, you can use ``CustomBusinessHour`` offset, as explained in the