Skip to content

Commit 79d539c

Browse files
committed
Make some attributes of DateOffset read-only; add tests
1 parent 929c66f commit 79d539c

File tree

2 files changed

+111
-44
lines changed

2 files changed

+111
-44
lines changed

pandas/tests/tseries/test_offsets.py

+27-12
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,27 @@ def test_apply_out_of_range(self):
162162
"cannot create out_of_range offset: {0} {1}".format(
163163
str(self).split('.')[-1], e))
164164

165+
def test_immutable(self):
166+
if self._offset is None:
167+
return
168+
elif not issubclass(self._offset, DateOffset):
169+
raise TypeError(self._offset)
170+
171+
offset = self._get_offset(self._offset, value=14)
172+
with pytest.raises(TypeError):
173+
offset.n = 3
174+
with pytest.raises(TypeError):
175+
offset._offset = timedelta(4)
176+
with pytest.raises(TypeError):
177+
offset.normalize = True
178+
179+
if hasattr(offset, '_inc'):
180+
with pytest.raises(TypeError):
181+
offset._inc = 'foo'
182+
if hasattr(offset, 'delta'):
183+
with pytest.raises(TypeError):
184+
offset.delta = 6*offset._inc
185+
165186

166187
class TestCommon(Base):
167188

@@ -550,8 +571,7 @@ def setup_method(self, method):
550571
def test_different_normalize_equals(self):
551572
# equivalent in this special case
552573
offset = BDay()
553-
offset2 = BDay()
554-
offset2.normalize = True
574+
offset2 = BDay(normalize=True)
555575
assert offset == offset2
556576

557577
def test_repr(self):
@@ -745,8 +765,7 @@ def test_constructor_errors(self):
745765
def test_different_normalize_equals(self):
746766
# equivalent in this special case
747767
offset = self._offset()
748-
offset2 = self._offset()
749-
offset2.normalize = True
768+
offset2 = self._offset(normalize=True)
750769
assert offset == offset2
751770

752771
def test_repr(self):
@@ -1437,8 +1456,7 @@ def test_constructor_errors(self):
14371456
def test_different_normalize_equals(self):
14381457
# equivalent in this special case
14391458
offset = self._offset()
1440-
offset2 = self._offset()
1441-
offset2.normalize = True
1459+
offset2 = self._offset(normalize=True)
14421460
assert offset == offset2
14431461

14441462
def test_repr(self):
@@ -1678,8 +1696,7 @@ def setup_method(self, method):
16781696
def test_different_normalize_equals(self):
16791697
# equivalent in this special case
16801698
offset = CDay()
1681-
offset2 = CDay()
1682-
offset2.normalize = True
1699+
offset2 = CDay(normalize=True)
16831700
assert offset == offset2
16841701

16851702
def test_repr(self):
@@ -1959,8 +1976,7 @@ class TestCustomBusinessMonthEnd(CustomBusinessMonthBase, Base):
19591976
def test_different_normalize_equals(self):
19601977
# equivalent in this special case
19611978
offset = CBMonthEnd()
1962-
offset2 = CBMonthEnd()
1963-
offset2.normalize = True
1979+
offset2 = CBMonthEnd(normalize=True)
19641980
assert offset == offset2
19651981

19661982
def test_repr(self):
@@ -2073,8 +2089,7 @@ class TestCustomBusinessMonthBegin(CustomBusinessMonthBase, Base):
20732089
def test_different_normalize_equals(self):
20742090
# equivalent in this special case
20752091
offset = CBMonthBegin()
2076-
offset2 = CBMonthBegin()
2077-
offset2.normalize = True
2092+
offset2 = CBMonthBegin(normalize=True)
20782093
assert offset == offset2
20792094

20802095
def test_repr(self):

pandas/tseries/offsets.py

+84-32
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@
1010
# import after tools, dateutil check
1111
from dateutil.relativedelta import relativedelta, weekday
1212
from dateutil.easter import easter
13+
1314
from pandas._libs import tslib, Timestamp, OutOfBoundsDatetime, Timedelta
15+
from pandas._libs.lib import cache_readonly
1416

1517
import functools
1618
import operator
@@ -132,6 +134,32 @@ class CacheableOffset(object):
132134
_cacheable = True
133135

134136

137+
_kwds_use_relativedelta = (
138+
'years', 'months', 'weeks', 'days',
139+
'year', 'month', 'week', 'day', 'weekday',
140+
'hour', 'minute', 'second', 'microsecond'
141+
)
142+
143+
def _determine_offset(kwds):
144+
# timedelta is used for sub-daily plural offsets and all singular
145+
# offsets relativedelta is used for plural offsets of daily length or
146+
# more nanosecond(s) are handled by apply_wraps
147+
kwds_no_nanos = dict(
148+
(k, v) for k, v in kwds.items()
149+
if k not in ('nanosecond', 'nanoseconds')
150+
)
151+
152+
if len(kwds_no_nanos) > 0:
153+
if any(k in _kwds_use_relativedelta for k in kwds_no_nanos):
154+
offset = relativedelta(**kwds_no_nanos)
155+
else:
156+
# sub-daily offset - use timedelta (tz-aware)
157+
offset = timedelta(**kwds_no_nanos)
158+
else:
159+
offset = timedelta(1)
160+
return offset
161+
162+
135163
class DateOffset(object):
136164
"""
137165
Standard kind of date increment used for a date range.
@@ -177,43 +205,37 @@ def __add__(date):
177205
"""
178206
_cacheable = False
179207
_normalize_cache = True
180-
_kwds_use_relativedelta = (
181-
'years', 'months', 'weeks', 'days',
182-
'year', 'month', 'week', 'day', 'weekday',
183-
'hour', 'minute', 'second', 'microsecond'
184-
)
185-
_use_relativedelta = False
186208
_adjust_dst = False
209+
_typ = "dateoffset"
187210

188-
# default for prior pickles
189-
normalize = False
211+
def __setattr__(self, name, value):
212+
# DateOffset needs to be effectively immutable in order for the
213+
# caching in _cached_params to be correct.
214+
frozen = ['n', '_offset', 'normalize', '_inc']
215+
if name in frozen and hasattr(self, name):
216+
msg = '%s cannot change attribute %s' % (self.__class__, name)
217+
raise TypeError(msg)
218+
object.__setattr__(self, name, value)
219+
220+
def __setstate__(self, state):
221+
"""Reconstruct an instance from a pickled state"""
222+
self.__dict__ = state
223+
if 'normalize' not in state and not hasattr(self, 'normalize'):
224+
# default for prior pickles
225+
self.normalize = False
190226

191227
def __init__(self, n=1, normalize=False, **kwds):
192228
self.n = int(n)
193229
self.normalize = normalize
194230
self.kwds = kwds
195-
self._offset, self._use_relativedelta = self._determine_offset()
196-
197-
def _determine_offset(self):
198-
# timedelta is used for sub-daily plural offsets and all singular
199-
# offsets relativedelta is used for plural offsets of daily length or
200-
# more nanosecond(s) are handled by apply_wraps
201-
kwds_no_nanos = dict(
202-
(k, v) for k, v in self.kwds.items()
203-
if k not in ('nanosecond', 'nanoseconds')
204-
)
205-
use_relativedelta = False
206-
207-
if len(kwds_no_nanos) > 0:
208-
if any(k in self._kwds_use_relativedelta for k in kwds_no_nanos):
209-
use_relativedelta = True
210-
offset = relativedelta(**kwds_no_nanos)
211-
else:
212-
# sub-daily offset - use timedelta (tz-aware)
213-
offset = timedelta(**kwds_no_nanos)
214-
else:
215-
offset = timedelta(1)
216-
return offset, use_relativedelta
231+
self._offset = _determine_offset(kwds)
232+
233+
@property
234+
def _use_relativedelta(self):
235+
# We need to check for _offset existence because it may not exist
236+
# if we are in the process of unpickling.
237+
return (hasattr(self, '_offset') and
238+
isinstance(self._offset, relativedelta))
217239

218240
@apply_wraps
219241
def apply(self, other):
@@ -308,7 +330,24 @@ def copy(self):
308330
def _should_cache(self):
309331
return self.isAnchored() and self._cacheable
310332

333+
@cache_readonly
334+
def _cached_params(self):
335+
assert len(self.kwds) == 0
336+
all_paras = dict(list(vars(self).items()))
337+
# equiv: self.__dict__.copy()
338+
if 'holidays' in all_paras and not all_paras['holidays']:
339+
all_paras.pop('holidays')
340+
exclude = ['kwds', 'name', 'normalize', 'calendar']
341+
attrs = [(k, v) for k, v in all_paras.items()
342+
if (k not in exclude) and (k[0] != '_')]
343+
attrs = sorted(set(attrs))
344+
params = tuple([str(self.__class__)] + attrs)
345+
return params
346+
311347
def _params(self):
348+
if len(self.kwds) == 0:
349+
return self._cached_params
350+
312351
all_paras = dict(list(vars(self).items()) + list(self.kwds.items()))
313352
if 'holidays' in all_paras and not all_paras['holidays']:
314353
all_paras.pop('holidays')
@@ -578,6 +617,9 @@ def __setstate__(self, state):
578617
self.kwds['holidays'] = self.holidays = holidays
579618
self.kwds['weekmask'] = state['weekmask']
580619

620+
if 'normalize' not in state:
621+
# default for prior pickles
622+
self.normalize = False
581623

582624
class BusinessDay(BusinessMixin, SingleConstructorOffset):
583625
"""
@@ -591,6 +633,7 @@ def __init__(self, n=1, normalize=False, **kwds):
591633
self.normalize = normalize
592634
self.kwds = kwds
593635
self.offset = kwds.get('offset', timedelta(0))
636+
self._offset = None
594637

595638
@property
596639
def freqstr(self):
@@ -709,6 +752,7 @@ def __init__(self, **kwds):
709752
self.offset = kwds.get('offset', timedelta(0))
710753
self.start = kwds.get('start', '09:00')
711754
self.end = kwds.get('end', '17:00')
755+
self._offset = None
712756

713757
def _validate_time(self, t_input):
714758
from datetime import time as dt_time
@@ -986,6 +1030,7 @@ def __init__(self, n=1, normalize=False, weekmask='Mon Tue Wed Thu Fri',
9861030
self.kwds['weekmask'] = self.weekmask = weekmask
9871031
self.kwds['holidays'] = self.holidays = holidays
9881032
self.kwds['calendar'] = self.calendar = calendar
1033+
self._offset = None
9891034

9901035
def get_calendar(self, weekmask, holidays, calendar):
9911036
"""Generate busdaycalendar"""
@@ -1182,6 +1227,7 @@ def __init__(self, n=1, day_of_month=None, normalize=False, **kwds):
11821227
self.normalize = normalize
11831228
self.kwds = kwds
11841229
self.kwds['day_of_month'] = self.day_of_month
1230+
self._offset = None
11851231

11861232
@classmethod
11871233
def _from_name(cls, suffix=None):
@@ -1580,6 +1626,7 @@ def __init__(self, n=1, normalize=False, **kwds):
15801626

15811627
self._inc = timedelta(weeks=1)
15821628
self.kwds = kwds
1629+
self._offset = None
15831630

15841631
def isAnchored(self):
15851632
return (self.n == 1 and self.weekday is not None)
@@ -1702,6 +1749,7 @@ def __init__(self, n=1, normalize=False, **kwds):
17021749
self.week)
17031750

17041751
self.kwds = kwds
1752+
self._offset = None
17051753

17061754
@apply_wraps
17071755
def apply(self, other):
@@ -1792,6 +1840,7 @@ def __init__(self, n=1, normalize=False, **kwds):
17921840
self.weekday)
17931841

17941842
self.kwds = kwds
1843+
self._offset = None
17951844

17961845
@apply_wraps
17971846
def apply(self, other):
@@ -1857,8 +1906,8 @@ def __init__(self, n=1, normalize=False, **kwds):
18571906
self.normalize = normalize
18581907
self.startingMonth = kwds.get('startingMonth',
18591908
self._default_startingMonth)
1860-
18611909
self.kwds = kwds
1910+
self._offset = None
18621911

18631912
def isAnchored(self):
18641913
return (self.n == 1 and self.startingMonth is not None)
@@ -1981,6 +2030,7 @@ def __init__(self, n=1, normalize=False, **kwds):
19812030
self.startingMonth = kwds.get('startingMonth', 3)
19822031

19832032
self.kwds = kwds
2033+
self._offset = None
19842034

19852035
def isAnchored(self):
19862036
return (self.n == 1 and self.startingMonth is not None)
@@ -2306,6 +2356,7 @@ def __init__(self, n=1, normalize=False, **kwds):
23062356
self.variation = kwds["variation"]
23072357

23082358
self.kwds = kwds
2359+
self._offset = None
23092360

23102361
if self.n == 0:
23112362
raise ValueError('N cannot be 0')
@@ -2690,6 +2741,7 @@ def f(self, other):
26902741

26912742
class Tick(SingleConstructorOffset):
26922743
_inc = Timedelta(microseconds=1000)
2744+
_typ = "tick"
26932745

26942746
__gt__ = _tick_comp(operator.gt)
26952747
__ge__ = _tick_comp(operator.ge)
@@ -2741,7 +2793,7 @@ def __ne__(self, other):
27412793
else:
27422794
return DateOffset.__ne__(self, other)
27432795

2744-
@property
2796+
@cache_readonly
27452797
def delta(self):
27462798
return self.n * self._inc
27472799

0 commit comments

Comments
 (0)