diff --git a/pandas/_libs/tslibs/offsets.pyx b/pandas/_libs/tslibs/offsets.pyx index 87be9fa910101..c7f43d6c10a7a 100644 --- a/pandas/_libs/tslibs/offsets.pyx +++ b/pandas/_libs/tslibs/offsets.pyx @@ -10,6 +10,7 @@ from dateutil.relativedelta import relativedelta import numpy as np cimport numpy as np +from numpy cimport int64_t np.import_array() @@ -315,8 +316,8 @@ class EndMixin(object): # --------------------------------------------------------------------- # Base Classes - -class _BaseOffset(object): +@cython.auto_pickle(False) +cdef class _BaseOffset(object): """ Base class for DateOffset methods that are not overriden by subclasses and will (after pickle errors are resolved) go into a cdef class. @@ -325,6 +326,14 @@ class _BaseOffset(object): _normalize_cache = True _cacheable = False + cdef readonly: + int64_t n + bint normalize + + def __init__(self, n=1, normalize=False): + self.n = n + self.normalize = normalize + def __call__(self, other): return self.apply(other) @@ -361,6 +370,58 @@ class _BaseOffset(object): out = '<%s' % n_str + className + plural + self._repr_attrs() + '>' return out + def __setstate__(self, state): + """Reconstruct an instance from a pickled state""" + # Note: __setstate__ needs to be defined in the cython class otherwise + # trying to set self.n and self.normalize below will + # raise an AttributeError. + if 'normalize' not in state: + # default for prior pickles + # See GH #7748, #7789 + state['normalize'] = False + if '_use_relativedelta' not in state: + state['_use_relativedelta'] = False + + if 'offset' in state: + # Older versions Business offsets have offset attribute + # instead of _offset + if '_offset' in state: # pragma: no cover + raise ValueError('Unexpected key `_offset`') + state['_offset'] = state.pop('offset') + state['kwds']['offset'] = state['_offset'] + + self.n = state.pop('n', 1) + self.normalize = state.pop('normalize', False) + self.__dict__ = state + + if 'weekmask' in state and 'holidays' in state: + # Business subclasses + calendar, holidays = _get_calendar(weekmask=self.weekmask, + holidays=self.holidays, + calendar=None) + self.kwds['calendar'] = self.calendar = calendar + self.kwds['holidays'] = self.holidays = holidays + self.kwds['weekmask'] = state['weekmask'] + + def __getstate__(self): + """Return a pickleable state""" + state = self.__dict__.copy() + + # Add attributes from the C base class that aren't in self.__dict__ + state['n'] = self.n + state['normalize'] = self.normalize + + # we don't want to actually pickle the calendar object + # as its a np.busyday; we recreate on deserilization + if 'calendar' in state: + del state['calendar'] + try: + state['kwds'].pop('calendar') + except KeyError: + pass + + return state + class BaseOffset(_BaseOffset): # Here we add __rfoo__ methods that don't play well with cdef classes diff --git a/pandas/compat/pickle_compat.py b/pandas/compat/pickle_compat.py index 8015642919611..dbe137de1ce2b 100644 --- a/pandas/compat/pickle_compat.py +++ b/pandas/compat/pickle_compat.py @@ -2,6 +2,7 @@ Support pre-0.12 series pickle compatibility. """ +import inspect import sys import pandas # noqa import copy @@ -22,7 +23,6 @@ def load_reduce(self): stack[-1] = func(*args) return except Exception as e: - # If we have a deprecated function, # try to replace and try again. @@ -47,6 +47,22 @@ def load_reduce(self): except: pass + if (len(args) and inspect.isclass(args[0]) and + getattr(args[0], '_typ', None) == 'dateoffset' and + args[1] is object): + # See GH#17313 + from pandas.tseries import offsets + args = (args[0], offsets.BaseOffset,) + args[2:] + if len(args) == 3 and args[2] is None: + args = args[:2] + (1,) + # kludge + try: + stack[-1] = func(*args) + return + except: + pass + + # unknown exception, re-raise if getattr(self, 'is_verbose', None): print(sys.exc_info()) diff --git a/pandas/tests/tseries/test_offsets.py b/pandas/tests/tseries/test_offsets.py index 4fd3bba01602f..30ae113637435 100644 --- a/pandas/tests/tseries/test_offsets.py +++ b/pandas/tests/tseries/test_offsets.py @@ -539,8 +539,7 @@ def setup_method(self, method): def test_different_normalize_equals(self): # equivalent in this special case offset = BDay() - offset2 = BDay() - offset2.normalize = True + offset2 = BDay(normalize=True) assert offset == offset2 def test_repr(self): @@ -734,8 +733,7 @@ def test_constructor_errors(self): def test_different_normalize_equals(self): # equivalent in this special case offset = self._offset() - offset2 = self._offset() - offset2.normalize = True + offset2 = self._offset(normalize=True) assert offset == offset2 def test_repr(self): @@ -1426,8 +1424,7 @@ def test_constructor_errors(self): def test_different_normalize_equals(self): # equivalent in this special case offset = self._offset() - offset2 = self._offset() - offset2.normalize = True + offset2 = self._offset(normalize=True) assert offset == offset2 def test_repr(self): @@ -1667,8 +1664,7 @@ def setup_method(self, method): def test_different_normalize_equals(self): # equivalent in this special case offset = CDay() - offset2 = CDay() - offset2.normalize = True + offset2 = CDay(normalize=True) assert offset == offset2 def test_repr(self): @@ -1953,8 +1949,7 @@ class TestCustomBusinessMonthEnd(CustomBusinessMonthBase, Base): def test_different_normalize_equals(self): # equivalent in this special case offset = CBMonthEnd() - offset2 = CBMonthEnd() - offset2.normalize = True + offset2 = CBMonthEnd(normalize=True) assert offset == offset2 def test_repr(self): @@ -2067,8 +2062,7 @@ class TestCustomBusinessMonthBegin(CustomBusinessMonthBase, Base): def test_different_normalize_equals(self): # equivalent in this special case offset = CBMonthBegin() - offset2 = CBMonthBegin() - offset2.normalize = True + offset2 = CBMonthBegin(normalize=True) assert offset == offset2 def test_repr(self): @@ -4899,3 +4893,15 @@ def test_all_offset_classes(self): first = Timestamp(test_values[0], tz='US/Eastern') + offset() second = Timestamp(test_values[1], tz='US/Eastern') assert first == second + + +def test_date_offset_immutable(): + offset = offsets.MonthBegin(n=2, normalize=True) + with pytest.raises(AttributeError): + offset.n = 1 + + # Check that it didn't get changed + assert offset.n == 2 + + with pytest.raises(AttributeError): + offset.normalize = False diff --git a/pandas/tseries/offsets.py b/pandas/tseries/offsets.py index 5843aaa23be57..80dbd95f51621 100644 --- a/pandas/tseries/offsets.py +++ b/pandas/tseries/offsets.py @@ -156,15 +156,10 @@ def __add__(date): Since 0 is a bit weird, we suggest avoiding its use. """ - _use_relativedelta = False _adjust_dst = False - # default for prior pickles - normalize = False - def __init__(self, n=1, normalize=False, **kwds): - self.n = int(n) - self.normalize = normalize + BaseOffset.__init__(self, n, normalize) self.kwds = kwds self._offset, self._use_relativedelta = _determine_offset(kwds) @@ -256,6 +251,11 @@ def isAnchored(self): def _params(self): all_paras = dict(list(vars(self).items()) + list(self.kwds.items())) + + # Add in C-class attributes not present in self.__dict__ + all_paras['n'] = self.n + all_paras['normalize'] = self.normalize + if 'holidays' in all_paras and not all_paras['holidays']: all_paras.pop('holidays') exclude = ['kwds', 'name', 'normalize', 'calendar'] @@ -427,38 +427,6 @@ def _repr_attrs(self): out += ': ' + ', '.join(attrs) return out - def __getstate__(self): - """Return a pickleable state""" - state = self.__dict__.copy() - - # we don't want to actually pickle the calendar object - # as its a np.busyday; we recreate on deserilization - if 'calendar' in state: - del state['calendar'] - try: - state['kwds'].pop('calendar') - except KeyError: - pass - - return state - - def __setstate__(self, state): - """Reconstruct an instance from a pickled state""" - if 'offset' in state: - # Older versions have offset attribute instead of _offset - if '_offset' in state: # pragma: no cover - raise ValueError('Unexpected key `_offset`') - state['_offset'] = state.pop('offset') - state['kwds']['offset'] = state['_offset'] - self.__dict__ = state - if 'weekmask' in state and 'holidays' in state: - calendar, holidays = _get_calendar(weekmask=self.weekmask, - holidays=self.holidays, - calendar=None) - self.kwds['calendar'] = self.calendar = calendar - self.kwds['holidays'] = self.holidays = holidays - self.kwds['weekmask'] = state['weekmask'] - class BusinessDay(BusinessMixin, SingleConstructorOffset): """ @@ -468,8 +436,7 @@ class BusinessDay(BusinessMixin, SingleConstructorOffset): _adjust_dst = True def __init__(self, n=1, normalize=False, offset=timedelta(0)): - self.n = int(n) - self.normalize = normalize + BaseOffset.__init__(self, n, normalize) self.kwds = {'offset': offset} self._offset = offset @@ -776,8 +743,7 @@ class BusinessHour(BusinessHourMixin, SingleConstructorOffset): def __init__(self, n=1, normalize=False, start='09:00', end='17:00', offset=timedelta(0)): - self.n = int(n) - self.normalize = normalize + BaseOffset.__init__(self, n, normalize) super(BusinessHour, self).__init__(start=start, end=end, offset=offset) @cache_readonly @@ -813,8 +779,7 @@ class CustomBusinessDay(BusinessDay): def __init__(self, n=1, normalize=False, weekmask='Mon Tue Wed Thu Fri', holidays=None, calendar=None, offset=timedelta(0)): - self.n = int(n) - self.normalize = normalize + BaseOffset.__init__(self, n, normalize) self._offset = offset self.kwds = {} @@ -881,8 +846,7 @@ class CustomBusinessHour(BusinessHourMixin, SingleConstructorOffset): def __init__(self, n=1, normalize=False, weekmask='Mon Tue Wed Thu Fri', holidays=None, calendar=None, start='09:00', end='17:00', offset=timedelta(0)): - self.n = int(n) - self.normalize = normalize + BaseOffset.__init__(self, n, normalize) super(CustomBusinessHour, self).__init__(start=start, end=end, offset=offset) @@ -975,6 +939,7 @@ class SemiMonthOffset(DateOffset): _min_day_of_month = 2 def __init__(self, n=1, normalize=False, day_of_month=None): + BaseOffset.__init__(self, n, normalize) if day_of_month is None: self.day_of_month = self._default_day_of_month else: @@ -983,8 +948,8 @@ def __init__(self, n=1, normalize=False, day_of_month=None): msg = 'day_of_month must be {min}<=day_of_month<=27, got {day}' raise ValueError(msg.format(min=self._min_day_of_month, day=self.day_of_month)) - self.n = int(n) - self.normalize = normalize + # self.n = int(n) + # self.normalize = normalize self.kwds = {'day_of_month': self.day_of_month} @classmethod @@ -1259,8 +1224,7 @@ class CustomBusinessMonthEnd(BusinessMixin, MonthOffset): def __init__(self, n=1, normalize=False, weekmask='Mon Tue Wed Thu Fri', holidays=None, calendar=None, offset=timedelta(0)): - self.n = int(n) - self.normalize = normalize + BaseOffset.__init__(self, n, normalize) self._offset = offset self.kwds = {} @@ -1330,8 +1294,7 @@ class CustomBusinessMonthBegin(BusinessMixin, MonthOffset): def __init__(self, n=1, normalize=False, weekmask='Mon Tue Wed Thu Fri', holidays=None, calendar=None, offset=timedelta(0)): - self.n = int(n) - self.normalize = normalize + BaseOffset.__init__(self, n, normalize) self._offset = offset self.kwds = {} @@ -1394,8 +1357,7 @@ class Week(EndMixin, DateOffset): _prefix = 'W' def __init__(self, n=1, normalize=False, weekday=None): - self.n = n - self.normalize = normalize + BaseOffset.__init__(self, n, normalize) self.weekday = weekday if self.weekday is not None: @@ -1485,8 +1447,7 @@ class WeekOfMonth(DateOffset): _adjust_dst = True def __init__(self, n=1, normalize=False, week=None, weekday=None): - self.n = n - self.normalize = normalize + BaseOffset.__init__(self, n, normalize) self.weekday = weekday self.week = week @@ -1582,8 +1543,7 @@ class LastWeekOfMonth(DateOffset): _prefix = 'LWOM' def __init__(self, n=1, normalize=False, weekday=None): - self.n = n - self.normalize = normalize + BaseOffset.__init__(self, n, normalize) self.weekday = weekday if self.n == 0: @@ -1656,8 +1616,7 @@ class QuarterOffset(DateOffset): # point def __init__(self, n=1, normalize=False, startingMonth=None): - self.n = n - self.normalize = normalize + BaseOffset.__init__(self, n, normalize) if startingMonth is None: startingMonth = self._default_startingMonth self.startingMonth = startingMonth @@ -2092,8 +2051,7 @@ class FY5253(DateOffset): def __init__(self, n=1, normalize=False, weekday=0, startingMonth=1, variation="nearest"): - self.n = n - self.normalize = normalize + BaseOffset.__init__(self, n, normalize) self.startingMonth = startingMonth self.weekday = weekday @@ -2342,8 +2300,7 @@ class FY5253Quarter(DateOffset): def __init__(self, n=1, normalize=False, weekday=0, startingMonth=1, qtr_with_extra_week=1, variation="nearest"): - self.n = n - self.normalize = normalize + BaseOffset.__init__(self, n, normalize) self.weekday = weekday self.startingMonth = startingMonth