Skip to content

Commit 83dc7de

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

File tree

2 files changed

+113
-44
lines changed

2 files changed

+113
-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

+86-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,33 @@ 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+
144+
def _determine_offset(kwds):
145+
# timedelta is used for sub-daily plural offsets and all singular
146+
# offsets relativedelta is used for plural offsets of daily length or
147+
# more nanosecond(s) are handled by apply_wraps
148+
kwds_no_nanos = dict(
149+
(k, v) for k, v in kwds.items()
150+
if k not in ('nanosecond', 'nanoseconds')
151+
)
152+
153+
if len(kwds_no_nanos) > 0:
154+
if any(k in _kwds_use_relativedelta for k in kwds_no_nanos):
155+
offset = relativedelta(**kwds_no_nanos)
156+
else:
157+
# sub-daily offset - use timedelta (tz-aware)
158+
offset = timedelta(**kwds_no_nanos)
159+
else:
160+
offset = timedelta(1)
161+
return offset
162+
163+
135164
class DateOffset(object):
136165
"""
137166
Standard kind of date increment used for a date range.
@@ -177,43 +206,37 @@ def __add__(date):
177206
"""
178207
_cacheable = False
179208
_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
186209
_adjust_dst = False
210+
_typ = "dateoffset"
187211

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

191228
def __init__(self, n=1, normalize=False, **kwds):
192229
self.n = int(n)
193230
self.normalize = normalize
194231
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
232+
self._offset = _determine_offset(kwds)
233+
234+
@property
235+
def _use_relativedelta(self):
236+
# We need to check for _offset existence because it may not exist
237+
# if we are in the process of unpickling.
238+
return (hasattr(self, '_offset') and
239+
isinstance(self._offset, relativedelta))
217240

218241
@apply_wraps
219242
def apply(self, other):
@@ -308,7 +331,24 @@ def copy(self):
308331
def _should_cache(self):
309332
return self.isAnchored() and self._cacheable
310333

334+
@cache_readonly
335+
def _cached_params(self):
336+
assert len(self.kwds) == 0
337+
all_paras = dict(list(vars(self).items()))
338+
# equiv: self.__dict__.copy()
339+
if 'holidays' in all_paras and not all_paras['holidays']:
340+
all_paras.pop('holidays')
341+
exclude = ['kwds', 'name', 'normalize', 'calendar']
342+
attrs = [(k, v) for k, v in all_paras.items()
343+
if (k not in exclude) and (k[0] != '_')]
344+
attrs = sorted(set(attrs))
345+
params = tuple([str(self.__class__)] + attrs)
346+
return params
347+
311348
def _params(self):
349+
if len(self.kwds) == 0:
350+
return self._cached_params
351+
312352
all_paras = dict(list(vars(self).items()) + list(self.kwds.items()))
313353
if 'holidays' in all_paras and not all_paras['holidays']:
314354
all_paras.pop('holidays')
@@ -578,6 +618,10 @@ def __setstate__(self, state):
578618
self.kwds['holidays'] = self.holidays = holidays
579619
self.kwds['weekmask'] = state['weekmask']
580620

621+
if 'normalize' not in state:
622+
# default for prior pickles
623+
self.normalize = False
624+
581625

582626
class BusinessDay(BusinessMixin, SingleConstructorOffset):
583627
"""
@@ -591,6 +635,7 @@ def __init__(self, n=1, normalize=False, **kwds):
591635
self.normalize = normalize
592636
self.kwds = kwds
593637
self.offset = kwds.get('offset', timedelta(0))
638+
self._offset = None
594639

595640
@property
596641
def freqstr(self):
@@ -709,6 +754,7 @@ def __init__(self, **kwds):
709754
self.offset = kwds.get('offset', timedelta(0))
710755
self.start = kwds.get('start', '09:00')
711756
self.end = kwds.get('end', '17:00')
757+
self._offset = None
712758

713759
def _validate_time(self, t_input):
714760
from datetime import time as dt_time
@@ -986,6 +1032,7 @@ def __init__(self, n=1, normalize=False, weekmask='Mon Tue Wed Thu Fri',
9861032
self.kwds['weekmask'] = self.weekmask = weekmask
9871033
self.kwds['holidays'] = self.holidays = holidays
9881034
self.kwds['calendar'] = self.calendar = calendar
1035+
self._offset = None
9891036

9901037
def get_calendar(self, weekmask, holidays, calendar):
9911038
"""Generate busdaycalendar"""
@@ -1182,6 +1229,7 @@ def __init__(self, n=1, day_of_month=None, normalize=False, **kwds):
11821229
self.normalize = normalize
11831230
self.kwds = kwds
11841231
self.kwds['day_of_month'] = self.day_of_month
1232+
self._offset = None
11851233

11861234
@classmethod
11871235
def _from_name(cls, suffix=None):
@@ -1580,6 +1628,7 @@ def __init__(self, n=1, normalize=False, **kwds):
15801628

15811629
self._inc = timedelta(weeks=1)
15821630
self.kwds = kwds
1631+
self._offset = None
15831632

15841633
def isAnchored(self):
15851634
return (self.n == 1 and self.weekday is not None)
@@ -1702,6 +1751,7 @@ def __init__(self, n=1, normalize=False, **kwds):
17021751
self.week)
17031752

17041753
self.kwds = kwds
1754+
self._offset = None
17051755

17061756
@apply_wraps
17071757
def apply(self, other):
@@ -1792,6 +1842,7 @@ def __init__(self, n=1, normalize=False, **kwds):
17921842
self.weekday)
17931843

17941844
self.kwds = kwds
1845+
self._offset = None
17951846

17961847
@apply_wraps
17971848
def apply(self, other):
@@ -1857,8 +1908,8 @@ def __init__(self, n=1, normalize=False, **kwds):
18571908
self.normalize = normalize
18581909
self.startingMonth = kwds.get('startingMonth',
18591910
self._default_startingMonth)
1860-
18611911
self.kwds = kwds
1912+
self._offset = None
18621913

18631914
def isAnchored(self):
18641915
return (self.n == 1 and self.startingMonth is not None)
@@ -1981,6 +2032,7 @@ def __init__(self, n=1, normalize=False, **kwds):
19812032
self.startingMonth = kwds.get('startingMonth', 3)
19822033

19832034
self.kwds = kwds
2035+
self._offset = None
19842036

19852037
def isAnchored(self):
19862038
return (self.n == 1 and self.startingMonth is not None)
@@ -2306,6 +2358,7 @@ def __init__(self, n=1, normalize=False, **kwds):
23062358
self.variation = kwds["variation"]
23072359

23082360
self.kwds = kwds
2361+
self._offset = None
23092362

23102363
if self.n == 0:
23112364
raise ValueError('N cannot be 0')
@@ -2690,6 +2743,7 @@ def f(self, other):
26902743

26912744
class Tick(SingleConstructorOffset):
26922745
_inc = Timedelta(microseconds=1000)
2746+
_typ = "tick"
26932747

26942748
__gt__ = _tick_comp(operator.gt)
26952749
__ge__ = _tick_comp(operator.ge)
@@ -2741,7 +2795,7 @@ def __ne__(self, other):
27412795
else:
27422796
return DateOffset.__ne__(self, other)
27432797

2744-
@property
2798+
@cache_readonly
27452799
def delta(self):
27462800
return self.n * self._inc
27472801

0 commit comments

Comments
 (0)