Skip to content

Make some attributes of DateOffset read-only; add tests #17181

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

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 21 additions & 12 deletions pandas/tests/tseries/test_offsets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):

Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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):
Expand Down
100 changes: 71 additions & 29 deletions pandas/tseries/offsets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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

Expand Down