From 8699d01d66bf56a4e3372ca306544b51da14cf9f Mon Sep 17 00:00:00 2001 From: Brock Mendel Date: Wed, 6 Jun 2018 09:05:44 -0700 Subject: [PATCH 1/3] make DateOffset immutable --- pandas/_libs/tslibs/offsets.pyx | 9 ++ pandas/tests/tseries/offsets/test_offsets.py | 18 +-- pandas/tseries/offsets.py | 129 +++++++++---------- 3 files changed, 73 insertions(+), 83 deletions(-) diff --git a/pandas/_libs/tslibs/offsets.pyx b/pandas/_libs/tslibs/offsets.pyx index 8caf9ea0e0389..7e76619b3f45a 100644 --- a/pandas/_libs/tslibs/offsets.pyx +++ b/pandas/_libs/tslibs/offsets.pyx @@ -304,6 +304,15 @@ class _BaseOffset(object): _day_opt = None _attributes = frozenset(['n', 'normalize']) + def __init__(self, n=1, normalize=False): + n = self._validate_n(n) + object.__setattr__(self, "n", n) + object.__setattr__(self, "normalize", normalize) + object.__setattr__(self, "_cache", {}) + + def __setattr__(self, name, value): + raise AttributeError("DateOffset objects are immutable.") + @property def kwds(self): # for backwards-compatibility diff --git a/pandas/tests/tseries/offsets/test_offsets.py b/pandas/tests/tseries/offsets/test_offsets.py index 5369b1a94a956..06c27cf72f253 100644 --- a/pandas/tests/tseries/offsets/test_offsets.py +++ b/pandas/tests/tseries/offsets/test_offsets.py @@ -520,8 +520,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): @@ -715,8 +714,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): @@ -1393,8 +1391,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): @@ -1634,8 +1631,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): @@ -1920,8 +1916,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): @@ -2037,8 +2032,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 c294110d89ec5..4ab857a5ee2bc 100644 --- a/pandas/tseries/offsets.py +++ b/pandas/tseries/offsets.py @@ -23,7 +23,6 @@ ApplyTypeError, as_datetime, _is_normalized, _get_calendar, _to_dt64, - _determine_offset, apply_index_wraps, roll_yearday, shift_month, @@ -192,11 +191,14 @@ def __add__(date): normalize = False def __init__(self, n=1, normalize=False, **kwds): - self.n = self._validate_n(n) - self.normalize = normalize + BaseOffset.__init__(self, n, normalize) - self._offset, self._use_relativedelta = _determine_offset(kwds) - self.__dict__.update(kwds) + off, use_rd = liboffsets._determine_offset(kwds) + object.__setattr__(self, "_offset", off) + object.__setattr__(self, "_use_relativedelta", use_rd) + for key in kwds: + val = kwds[key] + object.__setattr__(self, key, val) @apply_wraps def apply(self, other): @@ -439,13 +441,14 @@ def __setstate__(self, state): kwds = {key: odict[key] for key in odict if odict[key]} state.update(kwds) - self.__dict__ = state + self.__dict__.update(state) + if 'weekmask' in state and 'holidays' in state: calendar, holidays = _get_calendar(weekmask=self.weekmask, holidays=self.holidays, calendar=None) - self.calendar = calendar - self.holidays = holidays + object.__setattr__(self, "calendar", calendar) + object.__setattr__(self, "holidays", holidays) class SingleConstructorOffset(DateOffset): @@ -470,9 +473,9 @@ def __init__(self, weekmask, holidays, calendar): # following two attributes. See DateOffset._params() # holidays, weekmask - self.weekmask = weekmask - self.holidays = holidays - self.calendar = calendar + object.__setattr__(self, "weekmask", weekmask) + object.__setattr__(self, "holidays", holidays) + object.__setattr__(self, "calendar", calendar) class BusinessMixin(object): @@ -519,9 +522,8 @@ class BusinessDay(BusinessMixin, SingleConstructorOffset): _attributes = frozenset(['n', 'normalize', 'offset']) def __init__(self, n=1, normalize=False, offset=timedelta(0)): - self.n = self._validate_n(n) - self.normalize = normalize - self._offset = offset + BaseOffset.__init__(self, n, normalize) + object.__setattr__(self, "_offset", offset) def _offset_str(self): def get_str(td): @@ -617,9 +619,11 @@ class BusinessHourMixin(BusinessMixin): def __init__(self, start='09:00', end='17:00', offset=timedelta(0)): # must be validated here to equality check - self.start = liboffsets._validate_business_time(start) - self.end = liboffsets._validate_business_time(end) - self._offset = offset + start = liboffsets._validate_business_time(start) + object.__setattr__(self, "start", start) + end = liboffsets._validate_business_time(end) + object.__setattr__(self, "end", end) + object.__setattr__(self, "_offset", offset) @cache_readonly def next_bday(self): @@ -690,7 +694,6 @@ def _get_business_hours_by_sec(self): until = datetime(2014, 4, 1, self.end.hour, self.end.minute) return (until - dtstart).total_seconds() else: - self.daytime = False dtstart = datetime(2014, 4, 1, self.start.hour, self.start.minute) until = datetime(2014, 4, 2, self.end.hour, self.end.minute) return (until - dtstart).total_seconds() @@ -847,8 +850,7 @@ class BusinessHour(BusinessHourMixin, SingleConstructorOffset): def __init__(self, n=1, normalize=False, start='09:00', end='17:00', offset=timedelta(0)): - self.n = self._validate_n(n) - self.normalize = normalize + BaseOffset.__init__(self, n, normalize) super(BusinessHour, self).__init__(start=start, end=end, offset=offset) @@ -877,9 +879,8 @@ class CustomBusinessDay(_CustomMixin, BusinessDay): def __init__(self, n=1, normalize=False, weekmask='Mon Tue Wed Thu Fri', holidays=None, calendar=None, offset=timedelta(0)): - self.n = self._validate_n(n) - self.normalize = normalize - self._offset = offset + BaseOffset.__init__(self, n, normalize) + object.__setattr__(self, "_offset", offset) _CustomMixin.__init__(self, weekmask, holidays, calendar) @@ -938,9 +939,8 @@ class CustomBusinessHour(_CustomMixin, BusinessHourMixin, 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 = self._validate_n(n) - self.normalize = normalize - self._offset = offset + BaseOffset.__init__(self, n, normalize) + object.__setattr__(self, "_offset", offset) _CustomMixin.__init__(self, weekmask, holidays, calendar) BusinessHourMixin.__init__(self, start=start, end=end, offset=offset) @@ -954,9 +954,7 @@ class MonthOffset(SingleConstructorOffset): _adjust_dst = True _attributes = frozenset(['n', 'normalize']) - def __init__(self, n=1, normalize=False): - self.n = self._validate_n(n) - self.normalize = normalize + __init__ = BaseOffset.__init__ @property def name(self): @@ -1035,9 +1033,8 @@ class _CustomBusinessMonth(_CustomMixin, BusinessMixin, MonthOffset): def __init__(self, n=1, normalize=False, weekmask='Mon Tue Wed Thu Fri', holidays=None, calendar=None, offset=timedelta(0)): - self.n = self._validate_n(n) - self.normalize = normalize - self._offset = offset + BaseOffset.__init__(self, n, normalize) + object.__setattr__(self, "_offset", offset) _CustomMixin.__init__(self, weekmask, holidays, calendar) @@ -1114,18 +1111,18 @@ class SemiMonthOffset(DateOffset): _attributes = frozenset(['n', 'normalize', 'day_of_month']) 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 + object.__setattr__(self, "day_of_month", + self._default_day_of_month) else: - self.day_of_month = int(day_of_month) + object.__setattr__(self, "day_of_month", int(day_of_month)) if not self._min_day_of_month <= self.day_of_month <= 27: 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 = self._validate_n(n) - self.normalize = normalize - @classmethod def _from_name(cls, suffix=None): return cls(day_of_month=suffix) @@ -1331,9 +1328,8 @@ class Week(DateOffset): _attributes = frozenset(['n', 'normalize', 'weekday']) def __init__(self, n=1, normalize=False, weekday=None): - self.n = self._validate_n(n) - self.normalize = normalize - self.weekday = weekday + BaseOffset.__init__(self, n, normalize) + object.__setattr__(self, "weekday", weekday) if self.weekday is not None: if self.weekday < 0 or self.weekday > 6: @@ -1461,10 +1457,9 @@ class WeekOfMonth(_WeekOfMonthMixin, DateOffset): _attributes = frozenset(['n', 'normalize', 'week', 'weekday']) def __init__(self, n=1, normalize=False, week=0, weekday=0): - self.n = self._validate_n(n) - self.normalize = normalize - self.weekday = weekday - self.week = week + BaseOffset.__init__(self, n, normalize) + object.__setattr__(self, "weekday", weekday) + object.__setattr__(self, "week", week) if self.weekday < 0 or self.weekday > 6: raise ValueError('Day must be 0<=day<=6, got {day}' @@ -1533,9 +1528,8 @@ class LastWeekOfMonth(_WeekOfMonthMixin, DateOffset): _attributes = frozenset(['n', 'normalize', 'weekday']) def __init__(self, n=1, normalize=False, weekday=0): - self.n = self._validate_n(n) - self.normalize = normalize - self.weekday = weekday + BaseOffset.__init__(self, n, normalize) + object.__setattr__(self, "weekday", weekday) if self.n == 0: raise ValueError('N cannot be 0') @@ -1593,11 +1587,11 @@ class QuarterOffset(DateOffset): # startingMonth vs month attr names are resolved def __init__(self, n=1, normalize=False, startingMonth=None): - self.n = self._validate_n(n) - self.normalize = normalize + BaseOffset.__init__(self, n, normalize) + if startingMonth is None: startingMonth = self._default_startingMonth - self.startingMonth = startingMonth + object.__setattr__(self, "startingMonth", startingMonth) def isAnchored(self): return (self.n == 1 and self.startingMonth is not None) @@ -1719,11 +1713,10 @@ def onOffset(self, dt): return dt.month == self.month and dt.day == self._get_offset_day(dt) def __init__(self, n=1, normalize=False, month=None): - self.n = self._validate_n(n) - self.normalize = normalize + BaseOffset.__init__(self, n, normalize) month = month if month is not None else self._default_month - self.month = month + object.__setattr__(self, "month", month) if self.month < 1 or self.month > 12: raise ValueError('Month must go from 1 to 12') @@ -1816,12 +1809,11 @@ class FY5253(DateOffset): def __init__(self, n=1, normalize=False, weekday=0, startingMonth=1, variation="nearest"): - self.n = self._validate_n(n) - self.normalize = normalize - self.startingMonth = startingMonth - self.weekday = weekday + BaseOffset.__init__(self, n, normalize) + object.__setattr__(self, "startingMonth", startingMonth) + object.__setattr__(self, "weekday", weekday) - self.variation = variation + object.__setattr__(self, "variation", variation) if self.n == 0: raise ValueError('N cannot be 0') @@ -2016,13 +2008,12 @@ class FY5253Quarter(DateOffset): def __init__(self, n=1, normalize=False, weekday=0, startingMonth=1, qtr_with_extra_week=1, variation="nearest"): - self.n = self._validate_n(n) - self.normalize = normalize + BaseOffset.__init__(self, n, normalize) - self.weekday = weekday - self.startingMonth = startingMonth - self.qtr_with_extra_week = qtr_with_extra_week - self.variation = variation + object.__setattr__(self, "startingMonth", startingMonth) + object.__setattr__(self, "weekday", weekday) + object.__setattr__(self, "qtr_with_extra_week", qtr_with_extra_week) + object.__setattr__(self, "variation", variation) if self.n == 0: raise ValueError('N cannot be 0') @@ -2169,9 +2160,7 @@ class Easter(DateOffset): _adjust_dst = True _attributes = frozenset(['n', 'normalize']) - def __init__(self, n=1, normalize=False): - self.n = self._validate_n(n) - self.normalize = normalize + __init__ = BaseOffset.__init__ @apply_wraps def apply(self, other): @@ -2216,10 +2205,8 @@ class Tick(SingleConstructorOffset): _prefix = 'undefined' _attributes = frozenset(['n', 'normalize']) - def __init__(self, n=1, normalize=False): - # TODO: do Tick classes with normalize=True make sense? - self.n = self._validate_n(n) - self.normalize = normalize + # TODO: do Tick classes with `normalize=True` make sense? + __init__ = BaseOffset.__init__ __gt__ = _tick_comp(operator.gt) __ge__ = _tick_comp(operator.ge) From 71204a45568d1858992673010d5be6b24dabc65a Mon Sep 17 00:00:00 2001 From: Brock Mendel Date: Wed, 20 Jun 2018 11:01:24 -0700 Subject: [PATCH 2/3] whatsnew, tests --- doc/source/whatsnew/v0.24.0.txt | 2 +- pandas/tests/tseries/offsets/test_offsets.py | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/doc/source/whatsnew/v0.24.0.txt b/doc/source/whatsnew/v0.24.0.txt index c23ed006ff637..fc6ead63e9a49 100644 --- a/doc/source/whatsnew/v0.24.0.txt +++ b/doc/source/whatsnew/v0.24.0.txt @@ -150,7 +150,7 @@ Timezones Offsets ^^^^^^^ -- +- :class:`DateOffset` objects are now immutable. Attempting to alter one of these will now raise ``AttributeError`` (:issue:`21341`) - - diff --git a/pandas/tests/tseries/offsets/test_offsets.py b/pandas/tests/tseries/offsets/test_offsets.py index 5dd2a199405bf..66cb9baeb9357 100644 --- a/pandas/tests/tseries/offsets/test_offsets.py +++ b/pandas/tests/tseries/offsets/test_offsets.py @@ -234,6 +234,14 @@ class TestCommon(Base): 'Nano': Timestamp(np_datetime64_compat( '2011-01-01T09:00:00.000000001Z'))} + def test_immutable(self, offset_types): + # GH#21341 check that __setattr__ raises + offset = self._get_offset(offset_types) + with pytest.raises(AttributeError): + offset.normalize = True + with pytest.raises(AttributeError): + offset.n = 91 + def test_return_type(self, offset_types): offset = self._get_offset(offset_types) From cfc409d892c39fed88c000f07d56bce220aefbbb Mon Sep 17 00:00:00 2001 From: Jeff Reback Date: Thu, 21 Jun 2018 06:22:19 -0400 Subject: [PATCH 3/3] doc --- doc/source/whatsnew/v0.24.0.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/source/whatsnew/v0.24.0.txt b/doc/source/whatsnew/v0.24.0.txt index fc6ead63e9a49..5409b79fe06ab 100644 --- a/doc/source/whatsnew/v0.24.0.txt +++ b/doc/source/whatsnew/v0.24.0.txt @@ -67,6 +67,7 @@ Datetimelike API Changes ^^^^^^^^^^^^^^^^^^^^^^^^ - For :class:`DatetimeIndex` and :class:`TimedeltaIndex` with non-``None`` ``freq`` attribute, addition or subtraction of integer-dtyped array or ``Index`` will return an object of the same class (:issue:`19959`) +- :class:`DateOffset` objects are now immutable. Attempting to alter one of these will now raise ``AttributeError`` (:issue:`21341`) .. _whatsnew_0240.api.other: @@ -150,7 +151,6 @@ Timezones Offsets ^^^^^^^ -- :class:`DateOffset` objects are now immutable. Attempting to alter one of these will now raise ``AttributeError`` (:issue:`21341`) - -