Repository: pandas-dev/pandas

Issue:
DEPR: DatetimeIndex.offset
`DatetimeIndex.offset` and `DatimeIndex.freq` refer to the same thing, and are used interchangeably throughout the code for `DatetimeIndex`:

https://github.com/pandas-dev/pandas/blob/4a344972722cc3c27250cbc8e382472b13e66bde/pandas/core/indexes/datetimes.py#L1729-L1732

Is there a reason for having both?  

`TimedeltaIndex` and `PeriodIndex` only have `freq`, so it seems like we could keep `freq` for consistency, deprecate `DatetimeIndex.offset`, and modify the `DatetimeIndex` code to only use `freq`.


Discussion:
+1 to deprecate `.offset` (will probably need some rework, as now it is `freq` that is calling `offset` which is used internally it seems)
note we deprecated ``Timestamp.offset`` in #13593
If no one has started working on this, I would like to take this up.
@subhrm : I've started some work on this as a precursor to fixes for #20678
@jschendel Okay.   

Resolving patch:
diff --git a/pandas/core/indexes/datetimes.py b/pandas/core/indexes/datetimes.py
--- a/pandas/core/indexes/datetimes.py
+++ b/pandas/core/indexes/datetimes.py
@@ -302,7 +302,7 @@ def _add_comparison_methods(cls):
     _engine_type = libindex.DatetimeEngine
 
     tz = None
-    offset = None
+    _freq = None
     _comparables = ['name', 'freqstr', 'tz']
     _attributes = ['name', 'freq', 'tz']
 
@@ -415,7 +415,7 @@ def __new__(cls, data=None,
                 subarr = data.values
 
                 if freq is None:
-                    freq = data.offset
+                    freq = data.freq
                     verify_integrity = False
             else:
                 if data.dtype != _NS_DTYPE:
@@ -467,12 +467,12 @@ def __new__(cls, data=None,
         if freq_infer:
             inferred = subarr.inferred_freq
             if inferred:
-                subarr.offset = to_offset(inferred)
+                subarr.freq = to_offset(inferred)
 
         return subarr._deepcopy_if_needed(ref_to_data, copy)
 
     @classmethod
-    def _generate(cls, start, end, periods, name, offset,
+    def _generate(cls, start, end, periods, name, freq,
                   tz=None, normalize=False, ambiguous='raise', closed=None):
         if com._count_not_none(start, end, periods) != 2:
             raise ValueError('Of the three parameters: start, end, and '
@@ -535,7 +535,7 @@ def _generate(cls, start, end, periods, name, offset,
             else:
                 _normalized = _normalized and end.time() == _midnight
 
-        if hasattr(offset, 'delta') and offset != offsets.Day():
+        if hasattr(freq, 'delta') and freq != offsets.Day():
             if inferred_tz is None and tz is not None:
                 # naive dates
                 if start is not None and start.tz is None:
@@ -551,11 +551,11 @@ def _generate(cls, start, end, periods, name, offset,
                 if end.tz is None and start.tz is not None:
                     end = end.tz_localize(start.tz, ambiguous=False)
 
-            if _use_cached_range(offset, _normalized, start, end):
+            if _use_cached_range(freq, _normalized, start, end):
                 index = cls._cached_range(start, end, periods=periods,
-                                          offset=offset, name=name)
+                                          freq=freq, name=name)
             else:
-                index = _generate_regular_range(start, end, periods, offset)
+                index = _generate_regular_range(start, end, periods, freq)
 
         else:
 
@@ -574,11 +574,11 @@ def _generate(cls, start, end, periods, name, offset,
                 if end.tz is None and start.tz is not None:
                     start = start.replace(tzinfo=None)
 
-            if _use_cached_range(offset, _normalized, start, end):
+            if _use_cached_range(freq, _normalized, start, end):
                 index = cls._cached_range(start, end, periods=periods,
-                                          offset=offset, name=name)
+                                          freq=freq, name=name)
             else:
-                index = _generate_regular_range(start, end, periods, offset)
+                index = _generate_regular_range(start, end, periods, freq)
 
             if tz is not None and getattr(index, 'tz', None) is None:
                 index = conversion.tz_localize_to_utc(_ensure_int64(index), tz,
@@ -596,12 +596,12 @@ def _generate(cls, start, end, periods, name, offset,
             index = index[1:]
         if not right_closed and len(index) and index[-1] == end:
             index = index[:-1]
-        index = cls._simple_new(index, name=name, freq=offset, tz=tz)
+        index = cls._simple_new(index, name=name, freq=freq, tz=tz)
         return index
 
     @property
     def _box_func(self):
-        return lambda x: Timestamp(x, freq=self.offset, tz=self.tz)
+        return lambda x: Timestamp(x, freq=self.freq, tz=self.tz)
 
     def _convert_for_op(self, value):
         """ Convert value to be insertable to ndarray """
@@ -647,7 +647,7 @@ def _simple_new(cls, values, name=None, freq=None, tz=None,
         result = object.__new__(cls)
         result._data = values
         result.name = name
-        result.offset = freq
+        result._freq = freq
         result._tz = timezones.maybe_get_tz(tz)
         result._tz = timezones.tz_standardize(result._tz)
         result._reset_identity()
@@ -734,7 +734,7 @@ def _has_same_tz(self, other):
         return zzone == vzone
 
     @classmethod
-    def _cached_range(cls, start=None, end=None, periods=None, offset=None,
+    def _cached_range(cls, start=None, end=None, periods=None, freq=None,
                       name=None):
         if start is None and end is None:
             # I somewhat believe this should never be raised externally
@@ -747,30 +747,30 @@ def _cached_range(cls, start=None, end=None, periods=None, offset=None,
             raise TypeError(
                 'Must either specify period or provide both start and end.')
 
-        if offset is None:
+        if freq is None:
             # This can't happen with external-facing code
-            raise TypeError('Must provide offset.')
+            raise TypeError('Must provide freq.')
 
         drc = _daterange_cache
-        if offset not in _daterange_cache:
-            xdr = generate_range(offset=offset, start=_CACHE_START,
+        if freq not in _daterange_cache:
+            xdr = generate_range(offset=freq, start=_CACHE_START,
                                  end=_CACHE_END)
 
             arr = tools.to_datetime(list(xdr), box=False)
 
             cachedRange = DatetimeIndex._simple_new(arr)
-            cachedRange.offset = offset
+            cachedRange.freq = freq
             cachedRange = cachedRange.tz_localize(None)
             cachedRange.name = None
-            drc[offset] = cachedRange
+            drc[freq] = cachedRange
         else:
-            cachedRange = drc[offset]
+            cachedRange = drc[freq]
 
         if start is None:
             if not isinstance(end, Timestamp):
                 raise AssertionError('end must be an instance of Timestamp')
 
-            end = offset.rollback(end)
+            end = freq.rollback(end)
 
             endLoc = cachedRange.get_loc(end) + 1
             startLoc = endLoc - periods
@@ -778,23 +778,23 @@ def _cached_range(cls, start=None, end=None, periods=None, offset=None,
             if not isinstance(start, Timestamp):
                 raise AssertionError('start must be an instance of Timestamp')
 
-            start = offset.rollforward(start)
+            start = freq.rollforward(start)
 
             startLoc = cachedRange.get_loc(start)
             endLoc = startLoc + periods
         else:
-            if not offset.onOffset(start):
-                start = offset.rollforward(start)
+            if not freq.onOffset(start):
+                start = freq.rollforward(start)
 
-            if not offset.onOffset(end):
-                end = offset.rollback(end)
+            if not freq.onOffset(end):
+                end = freq.rollback(end)
 
             startLoc = cachedRange.get_loc(start)
             endLoc = cachedRange.get_loc(end) + 1
 
         indexSlice = cachedRange[startLoc:endLoc]
         indexSlice.name = name
-        indexSlice.offset = offset
+        indexSlice.freq = freq
 
         return indexSlice
 
@@ -836,7 +836,7 @@ def __setstate__(self, state):
                 np.ndarray.__setstate__(data, nd_state)
 
                 self.name = own_state[0]
-                self.offset = own_state[1]
+                self.freq = own_state[1]
                 self._tz = timezones.tz_standardize(own_state[2])
 
                 # provide numpy < 1.7 compat
@@ -1184,7 +1184,7 @@ def union(self, other):
                 result._tz = timezones.tz_standardize(this.tz)
                 if (result.freq is None and
                         (this.freq is not None or other.freq is not None)):
-                    result.offset = to_offset(result.inferred_freq)
+                    result.freq = to_offset(result.inferred_freq)
             return result
 
     def to_perioddelta(self, freq):
@@ -1232,7 +1232,7 @@ def union_many(self, others):
                     this._tz = timezones.tz_standardize(tz)
 
         if this.freq is None:
-            this.offset = to_offset(this.inferred_freq)
+            this.freq = to_offset(this.inferred_freq)
         return this
 
     def join(self, other, how='left', level=None, return_indexers=False,
@@ -1271,7 +1271,7 @@ def _maybe_utc_convert(self, other):
     def _wrap_joined_index(self, joined, other):
         name = self.name if self.name == other.name else None
         if (isinstance(other, DatetimeIndex) and
-                self.offset == other.offset and
+                self.freq == other.freq and
                 self._can_fast_union(other)):
             joined = self._shallow_copy(joined)
             joined.name = name
@@ -1284,9 +1284,9 @@ def _can_fast_union(self, other):
         if not isinstance(other, DatetimeIndex):
             return False
 
-        offset = self.offset
+        freq = self.freq
 
-        if offset is None or offset != other.offset:
+        if freq is None or freq != other.freq:
             return False
 
         if not self.is_monotonic or not other.is_monotonic:
@@ -1306,10 +1306,10 @@ def _can_fast_union(self, other):
 
         # Only need to "adjoin", not overlap
         try:
-            return (right_start == left_end + offset) or right_start in left
+            return (right_start == left_end + freq) or right_start in left
         except (ValueError):
 
-            # if we are comparing an offset that does not propagate timezones
+            # if we are comparing a freq that does not propagate timezones
             # this will raise
             return False
 
@@ -1329,7 +1329,7 @@ def _fast_union(self, other):
         left_start, left_end = left[0], left[-1]
         right_end = right[-1]
 
-        if not self.offset._should_cache():
+        if not self.freq._should_cache():
             # concatenate dates
             if left_end < right_end:
                 loc = right.searchsorted(left_end, side='right')
@@ -1341,7 +1341,7 @@ def _fast_union(self, other):
         else:
             return type(self)(start=left_start,
                               end=max(left_end, right_end),
-                              freq=left.offset)
+                              freq=left.freq)
 
     def __iter__(self):
         """
@@ -1393,18 +1393,18 @@ def intersection(self, other):
             result = Index.intersection(self, other)
             if isinstance(result, DatetimeIndex):
                 if result.freq is None:
-                    result.offset = to_offset(result.inferred_freq)
+                    result.freq = to_offset(result.inferred_freq)
             return result
 
-        elif (other.offset is None or self.offset is None or
-              other.offset != self.offset or
-              not other.offset.isAnchored() or
+        elif (other.freq is None or self.freq is None or
+              other.freq != self.freq or
+              not other.freq.isAnchored() or
               (not self.is_monotonic or not other.is_monotonic)):
             result = Index.intersection(self, other)
             result = self._shallow_copy(result._values, name=result.name,
                                         tz=result.tz, freq=None)
             if result.freq is None:
-                result.offset = to_offset(result.inferred_freq)
+                result.freq = to_offset(result.inferred_freq)
             return result
 
         if len(self) == 0:
@@ -1729,12 +1729,28 @@ def slice_indexer(self, start=None, end=None, step=None, kind=None):
     @property
     def freq(self):
         """get/set the frequency of the Index"""
-        return self.offset
+        return self._freq
 
     @freq.setter
     def freq(self, value):
         """get/set the frequency of the Index"""
-        self.offset = value
+        self._freq = value
+
+    @property
+    def offset(self):
+        """get/set the frequency of the Index"""
+        msg = ('DatetimeIndex.offset has been deprecated and will be removed '
+               'in a future version; use DatetimeIndex.freq instead.')
+        warnings.warn(msg, FutureWarning, stacklevel=2)
+        return self.freq
+
+    @offset.setter
+    def offset(self, value):
+        """get/set the frequency of the Index"""
+        msg = ('DatetimeIndex.offset has been deprecated and will be removed '
+               'in a future version; use DatetimeIndex.freq instead.')
+        warnings.warn(msg, FutureWarning, stacklevel=2)
+        self.freq = value
 
     year = _field_accessor('year', 'Y', "The year of the datetime")
     month = _field_accessor('month', 'M',
@@ -2525,9 +2541,9 @@ def day_name(self, locale=None):
 DatetimeIndex._add_datetimelike_methods()
 
 
-def _generate_regular_range(start, end, periods, offset):
-    if isinstance(offset, Tick):
-        stride = offset.nanos
+def _generate_regular_range(start, end, periods, freq):
+    if isinstance(freq, Tick):
+        stride = freq.nanos
         if periods is None:
             b = Timestamp(start).value
             # cannot just use e = Timestamp(end) + 1 because arange breaks when
@@ -2558,7 +2574,7 @@ def _generate_regular_range(start, end, periods, offset):
             end = end.to_pydatetime()
 
         xdr = generate_range(start=start, end=end,
-                             periods=periods, offset=offset)
+                             periods=periods, offset=freq)
 
         dates = list(xdr)
         # utc = len(dates) > 0 and dates[0].tzinfo is not None
@@ -2855,9 +2871,9 @@ def _in_range(start, end, rng_start, rng_end):
     return start > rng_start and end < rng_end
 
 
-def _use_cached_range(offset, _normalized, start, end):
-    return (offset._should_cache() and
-            not (offset._normalize_cache and not _normalized) and
+def _use_cached_range(freq, _normalized, start, end):
+    return (freq._should_cache() and
+            not (freq._normalize_cache and not _normalized) and
             _naive_in_cache_range(start, end))
 
 


Test patch:
diff --git a/pandas/tests/indexes/datetimes/test_construction.py b/pandas/tests/indexes/datetimes/test_construction.py
--- a/pandas/tests/indexes/datetimes/test_construction.py
+++ b/pandas/tests/indexes/datetimes/test_construction.py
@@ -598,16 +598,16 @@ def test_datetimeindex_constructor_misc(self):
         idx2 = DatetimeIndex(start=sdate, end=edate,
                              freq=offsets.Week(weekday=6))
         assert len(idx1) == len(idx2)
-        assert idx1.offset == idx2.offset
+        assert idx1.freq == idx2.freq
 
         idx1 = DatetimeIndex(start=sdate, end=edate, freq='QS')
         idx2 = DatetimeIndex(start=sdate, end=edate,
                              freq=offsets.QuarterBegin(startingMonth=1))
         assert len(idx1) == len(idx2)
-        assert idx1.offset == idx2.offset
+        assert idx1.freq == idx2.freq
 
         idx1 = DatetimeIndex(start=sdate, end=edate, freq='BQ')
         idx2 = DatetimeIndex(start=sdate, end=edate,
                              freq=offsets.BQuarterEnd(startingMonth=12))
         assert len(idx1) == len(idx2)
-        assert idx1.offset == idx2.offset
+        assert idx1.freq == idx2.freq
diff --git a/pandas/tests/indexes/datetimes/test_date_range.py b/pandas/tests/indexes/datetimes/test_date_range.py
--- a/pandas/tests/indexes/datetimes/test_date_range.py
+++ b/pandas/tests/indexes/datetimes/test_date_range.py
@@ -331,21 +331,21 @@ def test_naive_aware_conflicts(self):
             aware.join(naive)
 
     def test_cached_range(self):
-        DatetimeIndex._cached_range(START, END, offset=BDay())
-        DatetimeIndex._cached_range(START, periods=20, offset=BDay())
-        DatetimeIndex._cached_range(end=START, periods=20, offset=BDay())
+        DatetimeIndex._cached_range(START, END, freq=BDay())
+        DatetimeIndex._cached_range(START, periods=20, freq=BDay())
+        DatetimeIndex._cached_range(end=START, periods=20, freq=BDay())
 
-        with tm.assert_raises_regex(TypeError, "offset"):
+        with tm.assert_raises_regex(TypeError, "freq"):
             DatetimeIndex._cached_range(START, END)
 
         with tm.assert_raises_regex(TypeError, "specify period"):
-            DatetimeIndex._cached_range(START, offset=BDay())
+            DatetimeIndex._cached_range(START, freq=BDay())
 
         with tm.assert_raises_regex(TypeError, "specify period"):
-            DatetimeIndex._cached_range(end=END, offset=BDay())
+            DatetimeIndex._cached_range(end=END, freq=BDay())
 
         with tm.assert_raises_regex(TypeError, "start or end"):
-            DatetimeIndex._cached_range(periods=20, offset=BDay())
+            DatetimeIndex._cached_range(periods=20, freq=BDay())
 
     def test_cached_range_bug(self):
         rng = date_range('2010-09-01 05:00:00', periods=50,
@@ -393,7 +393,7 @@ def test_daterange_bug_456(self):
         # GH #456
         rng1 = bdate_range('12/5/2011', '12/5/2011')
         rng2 = bdate_range('12/2/2011', '12/5/2011')
-        rng2.offset = BDay()
+        rng2.freq = BDay()
 
         result = rng1.union(rng2)
         assert isinstance(result, DatetimeIndex)
@@ -605,27 +605,27 @@ def test_constructor(self):
             bdate_range('2011-1-1', '2012-1-1', 'C')
 
     def test_cached_range(self):
-        DatetimeIndex._cached_range(START, END, offset=CDay())
+        DatetimeIndex._cached_range(START, END, freq=CDay())
         DatetimeIndex._cached_range(START, periods=20,
-                                    offset=CDay())
+                                    freq=CDay())
         DatetimeIndex._cached_range(end=START, periods=20,
-                                    offset=CDay())
+                                    freq=CDay())
 
         # with pytest.raises(TypeError):
-        with tm.assert_raises_regex(TypeError, "offset"):
+        with tm.assert_raises_regex(TypeError, "freq"):
             DatetimeIndex._cached_range(START, END)
 
         # with pytest.raises(TypeError):
         with tm.assert_raises_regex(TypeError, "specify period"):
-            DatetimeIndex._cached_range(START, offset=CDay())
+            DatetimeIndex._cached_range(START, freq=CDay())
 
         # with pytest.raises(TypeError):
         with tm.assert_raises_regex(TypeError, "specify period"):
-            DatetimeIndex._cached_range(end=END, offset=CDay())
+            DatetimeIndex._cached_range(end=END, freq=CDay())
 
         # with pytest.raises(TypeError):
         with tm.assert_raises_regex(TypeError, "start or end"):
-            DatetimeIndex._cached_range(periods=20, offset=CDay())
+            DatetimeIndex._cached_range(periods=20, freq=CDay())
 
     def test_misc(self):
         end = datetime(2009, 5, 13)
@@ -640,7 +640,7 @@ def test_daterange_bug_456(self):
         # GH #456
         rng1 = bdate_range('12/5/2011', '12/5/2011', freq='C')
         rng2 = bdate_range('12/2/2011', '12/5/2011', freq='C')
-        rng2.offset = CDay()
+        rng2.freq = CDay()
 
         result = rng1.union(rng2)
         assert isinstance(result, DatetimeIndex)
diff --git a/pandas/tests/indexes/datetimes/test_indexing.py b/pandas/tests/indexes/datetimes/test_indexing.py
--- a/pandas/tests/indexes/datetimes/test_indexing.py
+++ b/pandas/tests/indexes/datetimes/test_indexing.py
@@ -53,10 +53,10 @@ def test_dti_business_getitem(self):
         exp = DatetimeIndex(rng.view(np.ndarray)[:5])
         tm.assert_index_equal(smaller, exp)
 
-        assert smaller.offset == rng.offset
+        assert smaller.freq == rng.freq
 
         sliced = rng[::5]
-        assert sliced.offset == BDay() * 5
+        assert sliced.freq == BDay() * 5
 
         fancy_indexed = rng[[4, 3, 2, 1, 0]]
         assert len(fancy_indexed) == 5
@@ -77,10 +77,10 @@ def test_dti_custom_getitem(self):
         smaller = rng[:5]
         exp = DatetimeIndex(rng.view(np.ndarray)[:5])
         tm.assert_index_equal(smaller, exp)
-        assert smaller.offset == rng.offset
+        assert smaller.freq == rng.freq
 
         sliced = rng[::5]
-        assert sliced.offset == CDay() * 5
+        assert sliced.freq == CDay() * 5
 
         fancy_indexed = rng[[4, 3, 2, 1, 0]]
         assert len(fancy_indexed) == 5
diff --git a/pandas/tests/indexes/datetimes/test_ops.py b/pandas/tests/indexes/datetimes/test_ops.py
--- a/pandas/tests/indexes/datetimes/test_ops.py
+++ b/pandas/tests/indexes/datetimes/test_ops.py
@@ -405,6 +405,18 @@ def test_equals(self):
         assert not idx.equals(list(idx3))
         assert not idx.equals(pd.Series(idx3))
 
+    def test_offset_deprecated(self):
+        # GH 20716
+        idx = pd.DatetimeIndex(['20180101', '20180102'])
+
+        # getter deprecated
+        with tm.assert_produces_warning(FutureWarning):
+            idx.offset
+
+        # setter deprecated
+        with tm.assert_produces_warning(FutureWarning):
+            idx.offset = BDay()
+
 
 class TestBusinessDatetimeIndex(object):
 
@@ -420,7 +432,7 @@ def test_comparison(self):
 
     def test_pickle_unpickle(self):
         unpickled = tm.round_trip_pickle(self.rng)
-        assert unpickled.offset is not None
+        assert unpickled.freq is not None
 
     def test_copy(self):
         cp = self.rng.copy()
@@ -430,15 +442,15 @@ def test_copy(self):
     def test_shift(self):
         shifted = self.rng.shift(5)
         assert shifted[0] == self.rng[5]
-        assert shifted.offset == self.rng.offset
+        assert shifted.freq == self.rng.freq
 
         shifted = self.rng.shift(-5)
         assert shifted[5] == self.rng[0]
-        assert shifted.offset == self.rng.offset
+        assert shifted.freq == self.rng.freq
 
         shifted = self.rng.shift(0)
         assert shifted[0] == self.rng[0]
-        assert shifted.offset == self.rng.offset
+        assert shifted.freq == self.rng.freq
 
         rng = date_range(START, END, freq=BMonthEnd())
         shifted = rng.shift(1, freq=BDay())
@@ -485,15 +497,15 @@ def test_shift(self):
 
         shifted = self.rng.shift(5)
         assert shifted[0] == self.rng[5]
-        assert shifted.offset == self.rng.offset
+        assert shifted.freq == self.rng.freq
 
         shifted = self.rng.shift(-5)
         assert shifted[5] == self.rng[0]
-        assert shifted.offset == self.rng.offset
+        assert shifted.freq == self.rng.freq
 
         shifted = self.rng.shift(0)
         assert shifted[0] == self.rng[0]
-        assert shifted.offset == self.rng.offset
+        assert shifted.freq == self.rng.freq
 
         # PerformanceWarning
         with warnings.catch_warnings(record=True):
@@ -503,7 +515,7 @@ def test_shift(self):
 
     def test_pickle_unpickle(self):
         unpickled = tm.round_trip_pickle(self.rng)
-        assert unpickled.offset is not None
+        assert unpickled.freq is not None
 
     def test_equals(self):
         assert not self.rng.equals(list(self.rng))
diff --git a/pandas/tests/indexes/datetimes/test_setops.py b/pandas/tests/indexes/datetimes/test_setops.py
--- a/pandas/tests/indexes/datetimes/test_setops.py
+++ b/pandas/tests/indexes/datetimes/test_setops.py
@@ -357,7 +357,7 @@ def test_intersection(self):
         expected = rng[10:25]
         tm.assert_index_equal(the_int, expected)
         assert isinstance(the_int, DatetimeIndex)
-        assert the_int.offset == rng.offset
+        assert the_int.freq == rng.freq
 
         the_int = rng1.intersection(rng2.view(DatetimeIndex))
         tm.assert_index_equal(the_int, expected)