Skip to content

Commit 8fd141f

Browse files
committed
BUG: Fix freq setter for datetimelike indexes
1 parent 563a6ad commit 8fd141f

File tree

8 files changed

+146
-38
lines changed

8 files changed

+146
-38
lines changed

doc/source/whatsnew/v0.23.0.txt

+2
Original file line numberDiff line numberDiff line change
@@ -891,6 +891,7 @@ Deprecations
891891
removed in a future version (:issue:`20419`).
892892
- ``DatetimeIndex.offset`` is deprecated. Use ``DatetimeIndex.freq`` instead (:issue:`20716`)
893893
- ``Index.get_duplicates()`` is deprecated and will be removed in a future version (:issue:`20239`)
894+
- Setting ``PeriodIndex.freq`` is deprecated. Use :meth:`PeriodIndex.asfreq` instead (:issue:`20678`)
894895

895896
.. _whatsnew_0230.prior_deprecations:
896897

@@ -1046,6 +1047,7 @@ Datetimelike
10461047
- Bug in :func:`to_datetime` where passing an out-of-bounds datetime with ``errors='coerce'`` and ``utc=True`` would raise ``OutOfBoundsDatetime`` instead of parsing to ``NaT`` (:issue:`19612`)
10471048
- Bug in :class:`DatetimeIndex` and :class:`TimedeltaIndex` addition and subtraction where name of the returned object was not always set consistently. (:issue:`19744`)
10481049
- Bug in :class:`DatetimeIndex` and :class:`TimedeltaIndex` addition and subtraction where operations with numpy arrays raised ``TypeError`` (:issue:`19847`)
1050+
- Bug in :class:`DatetimeIndex` and :class:`TimedeltaIndex` where setting the ``freq`` attribute was not fully supported (:issue:`20678`)
10491051

10501052
Timedelta
10511053
^^^^^^^^^

pandas/core/indexes/datetimelike.py

+39-2
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,43 @@ def floor(self, freq):
205205
def ceil(self, freq):
206206
return self._round(freq, np.ceil)
207207

208+
@classmethod
209+
def _validate_frequency(cls, index, freq, **kwargs):
210+
"""
211+
Validate that a frequency is compatible with the values of a given
212+
DatetimeIndex or TimedeltaIndex
213+
214+
Parameters
215+
----------
216+
index : DatetimeIndex or TimedeltaIndex
217+
The index on which to determine if the given frequency is valid
218+
freq : DateOffset
219+
The frequency to validate
220+
"""
221+
inferred = index.inferred_freq
222+
if index.empty or inferred == freq.freqstr:
223+
return None
224+
225+
on_freq = cls._generate(
226+
index[0], None, len(index), None, freq, **kwargs)
227+
if not np.array_equal(index.asi8, on_freq.asi8):
228+
msg = ('Inferred frequency {infer} from passed values does not '
229+
'conform to passed frequency {passed}')
230+
raise ValueError(msg.format(infer=inferred, passed=freq.freqstr))
231+
232+
@property
233+
def freq(self):
234+
"""Return the frequency object if it is set, otherwise None"""
235+
return self._freq
236+
237+
@freq.setter
238+
def freq(self, value):
239+
if value is not None:
240+
value = frequencies.to_offset(value)
241+
self._validate_frequency(self, value)
242+
243+
self._freq = value
244+
208245

209246
class DatetimeIndexOpsMixin(object):
210247
""" common ops mixin to support a unified interface datetimelike Index """
@@ -401,7 +438,7 @@ def __getitem__(self, key):
401438
@property
402439
def freqstr(self):
403440
"""
404-
Return the frequency object as a string if its set, otherwise None
441+
Return the frequency object as a string if it is set, otherwise None
405442
"""
406443
if self.freq is None:
407444
return None
@@ -410,7 +447,7 @@ def freqstr(self):
410447
@cache_readonly
411448
def inferred_freq(self):
412449
"""
413-
Tryies to return a string representing a frequency guess,
450+
Tries to return a string representing a frequency guess,
414451
generated by infer_freq. Returns None if it can't autodetect the
415452
frequency.
416453
"""

pandas/core/indexes/datetimes.py

+2-20
Original file line numberDiff line numberDiff line change
@@ -454,15 +454,7 @@ def __new__(cls, data=None,
454454

455455
if verify_integrity and len(subarr) > 0:
456456
if freq is not None and not freq_infer:
457-
inferred = subarr.inferred_freq
458-
if inferred != freq.freqstr:
459-
on_freq = cls._generate(subarr[0], None, len(subarr), None,
460-
freq, tz=tz, ambiguous=ambiguous)
461-
if not np.array_equal(subarr.asi8, on_freq.asi8):
462-
raise ValueError('Inferred frequency {0} from passed '
463-
'dates does not conform to passed '
464-
'frequency {1}'
465-
.format(inferred, freq.freqstr))
457+
cls._validate_frequency(subarr, freq, ambiguous=ambiguous)
466458

467459
if freq_infer:
468460
inferred = subarr.inferred_freq
@@ -836,7 +828,7 @@ def __setstate__(self, state):
836828
np.ndarray.__setstate__(data, nd_state)
837829

838830
self.name = own_state[0]
839-
self.freq = own_state[1]
831+
self._freq = own_state[1]
840832
self._tz = timezones.tz_standardize(own_state[2])
841833

842834
# provide numpy < 1.7 compat
@@ -1726,16 +1718,6 @@ def slice_indexer(self, start=None, end=None, step=None, kind=None):
17261718
else:
17271719
raise
17281720

1729-
@property
1730-
def freq(self):
1731-
"""get/set the frequency of the Index"""
1732-
return self._freq
1733-
1734-
@freq.setter
1735-
def freq(self, value):
1736-
"""get/set the frequency of the Index"""
1737-
self._freq = value
1738-
17391721
@property
17401722
def offset(self):
17411723
"""get/set the frequency of the Index"""

pandas/core/indexes/period.py

+16-3
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,7 @@ class PeriodIndex(DatelikeOps, DatetimeIndexOpsMixin, Int64Index):
219219
_is_numeric_dtype = False
220220
_infer_as_myclass = True
221221

222-
freq = None
222+
_freq = None
223223

224224
_engine_type = libindex.PeriodEngine
225225

@@ -367,7 +367,7 @@ def _from_ordinals(cls, values, name=None, freq=None, **kwargs):
367367
result.name = name
368368
if freq is None:
369369
raise ValueError('freq is not specified and cannot be inferred')
370-
result.freq = Period._maybe_convert_freq(freq)
370+
result._freq = Period._maybe_convert_freq(freq)
371371
result._reset_identity()
372372
return result
373373

@@ -560,6 +560,19 @@ def is_full(self):
560560
values = self.values
561561
return ((values[1:] - values[:-1]) < 2).all()
562562

563+
@property
564+
def freq(self):
565+
"""Return the frequency object if it is set, otherwise None"""
566+
return self._freq
567+
568+
@freq.setter
569+
def freq(self, value):
570+
msg = ('Setting PeriodIndex.freq has been deprecated and will be '
571+
'removed in a future version; use PeriodIndex.asfreq instead. '
572+
'The PeriodIndex.freq setter is not guaranteed to work.')
573+
warnings.warn(msg, FutureWarning, stacklevel=2)
574+
self._freq = value
575+
563576
def asfreq(self, freq=None, how='E'):
564577
"""
565578
Convert the PeriodIndex to the specified frequency `freq`.
@@ -1060,7 +1073,7 @@ def __setstate__(self, state):
10601073
np.ndarray.__setstate__(data, nd_state)
10611074

10621075
# backcompat
1063-
self.freq = Period._maybe_convert_freq(own_state[1])
1076+
self._freq = Period._maybe_convert_freq(own_state[1])
10641077

10651078
else: # pragma: no cover
10661079
data = np.empty(state)

pandas/core/indexes/timedeltas.py

+5-12
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@
2828
import pandas.core.common as com
2929
import pandas.core.dtypes.concat as _concat
3030
from pandas.util._decorators import Appender, Substitution, deprecate_kwarg
31-
from pandas.core.indexes.datetimelike import TimelikeOps, DatetimeIndexOpsMixin
31+
from pandas.core.indexes.datetimelike import (
32+
TimelikeOps, DatetimeIndexOpsMixin)
3233
from pandas.core.tools.timedeltas import (
3334
to_timedelta, _coerce_scalar_to_timedelta_type)
3435
from pandas.tseries.offsets import Tick, DateOffset
@@ -195,7 +196,7 @@ def _add_comparison_methods(cls):
195196
_is_numeric_dtype = True
196197
_infer_as_myclass = True
197198

198-
freq = None
199+
_freq = None
199200

200201
def __new__(cls, data=None, unit=None, freq=None, start=None, end=None,
201202
periods=None, closed=None, dtype=None, copy=False,
@@ -251,15 +252,7 @@ def __new__(cls, data=None, unit=None, freq=None, start=None, end=None,
251252
if verify_integrity and len(data) > 0:
252253
if freq is not None and not freq_infer:
253254
index = cls._simple_new(data, name=name)
254-
inferred = index.inferred_freq
255-
if inferred != freq.freqstr:
256-
on_freq = cls._generate(
257-
index[0], None, len(index), name, freq)
258-
if not np.array_equal(index.asi8, on_freq.asi8):
259-
raise ValueError('Inferred frequency {0} from passed '
260-
'timedeltas does not conform to '
261-
'passed frequency {1}'
262-
.format(inferred, freq.freqstr))
255+
cls._validate_frequency(index, freq)
263256
index.freq = freq
264257
return index
265258

@@ -327,7 +320,7 @@ def _simple_new(cls, values, name=None, freq=None, **kwargs):
327320
result = object.__new__(cls)
328321
result._data = values
329322
result.name = name
330-
result.freq = freq
323+
result._freq = freq
331324
result._reset_identity()
332325
return result
333326

pandas/tests/indexes/datetimes/test_ops.py

+34-1
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,9 @@
99
from pandas import (DatetimeIndex, PeriodIndex, Series, Timestamp,
1010
date_range, _np_version_under1p10, Index,
1111
bdate_range)
12-
from pandas.tseries.offsets import BMonthEnd, CDay, BDay
12+
from pandas.tseries.offsets import BMonthEnd, CDay, BDay, Day, Hour
1313
from pandas.tests.test_base import Ops
14+
from pandas.core.dtypes.generic import ABCDateOffset
1415

1516

1617
@pytest.fixture(params=[None, 'UTC', 'Asia/Tokyo', 'US/Eastern',
@@ -405,6 +406,38 @@ def test_equals(self):
405406
assert not idx.equals(list(idx3))
406407
assert not idx.equals(pd.Series(idx3))
407408

409+
@pytest.mark.parametrize('values', [
410+
['20180101', '20180103', '20180105'], []])
411+
@pytest.mark.parametrize('freq', [
412+
'2D', Day(2), '2B', BDay(2), '48H', Hour(48)])
413+
@pytest.mark.parametrize('tz', [None, 'US/Eastern'])
414+
def test_freq_setter(self, values, freq, tz):
415+
# GH 20678
416+
idx = DatetimeIndex(values, tz=tz)
417+
418+
# can set to an offset, converting from string if necessary
419+
idx.freq = freq
420+
assert idx.freq == freq
421+
assert isinstance(idx.freq, ABCDateOffset)
422+
423+
# can reset to None
424+
idx.freq = None
425+
assert idx.freq is None
426+
427+
def test_freq_setter_errors(self):
428+
# GH 20678
429+
idx = DatetimeIndex(['20180101', '20180103', '20180105'])
430+
431+
# setting with an incompatible freq
432+
msg = ('Inferred frequency 2D from passed values does not conform to '
433+
'passed frequency 5D')
434+
with tm.assert_raises_regex(ValueError, msg):
435+
idx.freq = '5D'
436+
437+
# setting with non-freq string
438+
with tm.assert_raises_regex(ValueError, 'Invalid frequency'):
439+
idx.freq = 'foo'
440+
408441
def test_offset_deprecated(self):
409442
# GH 20716
410443
idx = pd.DatetimeIndex(['20180101', '20180102'])

pandas/tests/indexes/period/test_ops.py

+12
Original file line numberDiff line numberDiff line change
@@ -401,6 +401,18 @@ def test_equals(self, freq):
401401
assert not idx.equals(list(idx3))
402402
assert not idx.equals(pd.Series(idx3))
403403

404+
def test_freq_setter_deprecated(self):
405+
# GH 20678
406+
idx = pd.period_range('2018Q1', periods=4, freq='Q')
407+
408+
# no warning for getter
409+
with tm.assert_produces_warning(None):
410+
idx.freq
411+
412+
# warning for setter
413+
with tm.assert_produces_warning(FutureWarning):
414+
idx.freq = pd.offsets.Day()
415+
404416

405417
class TestPeriodIndexSeriesMethods(object):
406418
""" Test PeriodIndex and Period Series Ops consistency """

pandas/tests/indexes/timedeltas/test_ops.py

+36
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
_np_version_under1p10)
1212
from pandas._libs.tslib import iNaT
1313
from pandas.tests.test_base import Ops
14+
from pandas.tseries.offsets import Day, Hour
15+
from pandas.core.dtypes.generic import ABCDateOffset
1416

1517

1618
class TestTimedeltaIndexOps(Ops):
@@ -306,6 +308,40 @@ def test_equals(self):
306308
assert not idx.equals(list(idx2))
307309
assert not idx.equals(pd.Series(idx2))
308310

311+
@pytest.mark.parametrize('values', [['0 days', '2 days', '4 days'], []])
312+
@pytest.mark.parametrize('freq', ['2D', Day(2), '48H', Hour(48)])
313+
def test_freq_setter(self, values, freq):
314+
# GH 20678
315+
idx = TimedeltaIndex(values)
316+
317+
# can set to an offset, converting from string if necessary
318+
idx.freq = freq
319+
assert idx.freq == freq
320+
assert isinstance(idx.freq, ABCDateOffset)
321+
322+
# can reset to None
323+
idx.freq = None
324+
assert idx.freq is None
325+
326+
def test_freq_setter_errors(self):
327+
# GH 20678
328+
idx = TimedeltaIndex(['0 days', '2 days', '4 days'])
329+
330+
# setting with an incompatible freq
331+
msg = ('Inferred frequency 2D from passed values does not conform to '
332+
'passed frequency 5D')
333+
with tm.assert_raises_regex(ValueError, msg):
334+
idx.freq = '5D'
335+
336+
# setting with a non-fixed frequency
337+
msg = '<2 \* BusinessDays> is a non-fixed frequency'
338+
with tm.assert_raises_regex(ValueError, msg):
339+
idx.freq = '2B'
340+
341+
# setting with non-freq string
342+
with tm.assert_raises_regex(ValueError, 'Invalid frequency'):
343+
idx.freq = 'foo'
344+
309345

310346
class TestTimedeltas(object):
311347

0 commit comments

Comments
 (0)