diff --git a/pandas/tests/tseries/test_offsets.py b/pandas/tests/tseries/test_offsets.py index e03b3e0a85e5e..38ede04a5dd55 100644 --- a/pandas/tests/tseries/test_offsets.py +++ b/pandas/tests/tseries/test_offsets.py @@ -162,6 +162,21 @@ def test_apply_out_of_range(self): "cannot create out_of_range offset: {0} {1}".format( str(self).split('.')[-1], e)) + def test_cache_invalidation(self): + if self._offset is None: + return + elif not issubclass(self._offset, DateOffset): + raise TypeError(self._offset) + + offset = self._get_offset(self._offset, value=14) + if len(offset.kwds) == 0: + _ = offset._params() + assert '_cached_params' in offset._cache + + offset.n = offset.n + 3 + # Setting offset.n should clear the cache + assert len(offset._cache) == 0 + class TestCommon(Base): @@ -550,8 +565,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): @@ -745,8 +759,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): @@ -1437,8 +1450,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): @@ -1678,8 +1690,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): @@ -1959,8 +1970,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): @@ -2073,8 +2083,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): diff --git a/pandas/tseries/offsets.py b/pandas/tseries/offsets.py index 2a120a0696836..56516c2cace8a 100644 --- a/pandas/tseries/offsets.py +++ b/pandas/tseries/offsets.py @@ -10,7 +10,9 @@ # import after tools, dateutil check from dateutil.relativedelta import relativedelta, weekday from dateutil.easter import easter + from pandas._libs import tslib, Timestamp, OutOfBoundsDatetime, Timedelta +from pandas._libs.lib import cache_readonly import functools import operator @@ -132,6 +134,31 @@ class CacheableOffset(object): _cacheable = True +def _determine_offset(kwds): + # timedelta is used for sub-daily plural offsets and all singular + # offsets relativedelta is used for plural offsets of daily length or + # more nanosecond(s) are handled by apply_wraps + kwds_no_nanos = dict( + (k, v) for k, v in kwds.items() + if k not in ('nanosecond', 'nanoseconds') + ) + + _kwds_use_relativedelta = ( + 'years', 'months', 'weeks', 'days', + 'year', 'month', 'week', 'day', 'weekday', + 'hour', 'minute', 'second', 'microsecond' + ) + if len(kwds_no_nanos) > 0: + if any(k in _kwds_use_relativedelta for k in kwds_no_nanos): + offset = relativedelta(**kwds_no_nanos) + else: + # sub-daily offset - use timedelta (tz-aware) + offset = timedelta(**kwds_no_nanos) + else: + offset = timedelta(1) + return offset + + class DateOffset(object): """ Standard kind of date increment used for a date range. @@ -177,43 +204,40 @@ def __add__(date): """ _cacheable = False _normalize_cache = True - _kwds_use_relativedelta = ( - 'years', 'months', 'weeks', 'days', - 'year', 'month', 'week', 'day', 'weekday', - 'hour', 'minute', 'second', 'microsecond' - ) - _use_relativedelta = False _adjust_dst = False + _typ = "dateoffset" # default for prior pickles normalize = False + def __setattr__(self, name, value): + # DateOffset needs to be effectively immutable in order for the + # caching in _cached_params to be correct. + if hasattr(self, name): + # Resetting any existing attribute clears the cache_readonly + # cache. + try: + cache = self._cache + except AttributeError: + # if the cache_readonly cache has not been accessed yet, + # this attribute may not exist + pass + else: + cache.clear() + object.__setattr__(self, name, value) + def __init__(self, n=1, normalize=False, **kwds): self.n = int(n) self.normalize = normalize self.kwds = kwds - self._offset, self._use_relativedelta = self._determine_offset() - - def _determine_offset(self): - # timedelta is used for sub-daily plural offsets and all singular - # offsets relativedelta is used for plural offsets of daily length or - # more nanosecond(s) are handled by apply_wraps - kwds_no_nanos = dict( - (k, v) for k, v in self.kwds.items() - if k not in ('nanosecond', 'nanoseconds') - ) - use_relativedelta = False - - if len(kwds_no_nanos) > 0: - if any(k in self._kwds_use_relativedelta for k in kwds_no_nanos): - use_relativedelta = True - offset = relativedelta(**kwds_no_nanos) - else: - # sub-daily offset - use timedelta (tz-aware) - offset = timedelta(**kwds_no_nanos) - else: - offset = timedelta(1) - return offset, use_relativedelta + self._offset = _determine_offset(kwds) + + @property + def _use_relativedelta(self): + # We need to check for _offset existence because it may not exist + # if we are in the process of unpickling. + return (hasattr(self, '_offset') and + isinstance(self._offset, relativedelta)) @apply_wraps def apply(self, other): @@ -308,7 +332,24 @@ def copy(self): def _should_cache(self): return self.isAnchored() and self._cacheable + @cache_readonly + def _cached_params(self): + assert len(self.kwds) == 0 + all_paras = dict(list(vars(self).items())) + # equiv: self.__dict__.copy() + if 'holidays' in all_paras and not all_paras['holidays']: + all_paras.pop('holidays') + exclude = ['kwds', 'name', 'normalize', '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(self.__class__)] + attrs) + return params + def _params(self): + if len(self.kwds) == 0: + return self._cached_params + all_paras = dict(list(vars(self).items()) + list(self.kwds.items())) if 'holidays' in all_paras and not all_paras['holidays']: all_paras.pop('holidays') @@ -2690,6 +2731,7 @@ def f(self, other): class Tick(SingleConstructorOffset): _inc = Timedelta(microseconds=1000) + _typ = "tick" __gt__ = _tick_comp(operator.gt) __ge__ = _tick_comp(operator.ge) @@ -2741,7 +2783,7 @@ def __ne__(self, other): else: return DateOffset.__ne__(self, other) - @property + @cache_readonly def delta(self): return self.n * self._inc