diff --git a/doc/source/timeseries.rst b/doc/source/timeseries.rst index 2a6249bef112b..e0de41708324d 100644 --- a/doc/source/timeseries.rst +++ b/doc/source/timeseries.rst @@ -408,7 +408,7 @@ In practice this becomes very cumbersome because we often need a very long index with a large number of timestamps. If we need timestamps on a regular frequency, we can use the :func:`date_range` and :func:`bdate_range` functions to create a ``DatetimeIndex``. The default frequency for ``date_range`` is a -**day** while the default for ``bdate_range`` is a **business day**: +**calendar day** while the default for ``bdate_range`` is a **business day**: .. ipython:: python @@ -927,27 +927,6 @@ in the operation). .. _relativedelta documentation: https://dateutil.readthedocs.io/en/stable/relativedelta.html -.. _timeseries.dayvscalendarday: - -Day vs. CalendarDay -~~~~~~~~~~~~~~~~~~~ - -:class:`Day` (``'D'``) is a timedelta-like offset that respects absolute time -arithmetic and is an alias for 24 :class:`Hour`. This offset is the default -argument to many pandas time related function like :func:`date_range` and :func:`timedelta_range`. - -:class:`CalendarDay` (``'CD'``) is a relativedelta-like offset that respects -calendar time arithmetic. :class:`CalendarDay` is useful preserving calendar day -semantics with date times with have day light savings transitions, i.e. :class:`CalendarDay` -will preserve the hour before the day light savings transition. - -.. ipython:: python - - ts = pd.Timestamp('2016-10-30 00:00:00', tz='Europe/Helsinki') - ts + pd.offsets.Day(1) - ts + pd.offsets.CalendarDay(1) - - Parametric Offsets ~~~~~~~~~~~~~~~~~~ @@ -1243,8 +1222,7 @@ frequencies. We will refer to these aliases as *offset aliases*. "B", "business day frequency" "C", "custom business day frequency" - "D", "day frequency" - "CD", "calendar day frequency" + "D", "calendar day frequency" "W", "weekly frequency" "M", "month end frequency" "SM", "semi-month end frequency (15th and end of month)" diff --git a/doc/source/whatsnew/v0.24.0.rst b/doc/source/whatsnew/v0.24.0.rst index cc40e6d42a70b..d01ec56e36e08 100644 --- a/doc/source/whatsnew/v0.24.0.rst +++ b/doc/source/whatsnew/v0.24.0.rst @@ -589,46 +589,6 @@ that the dates have been converted to UTC pd.to_datetime(["2015-11-18 15:30:00+05:30", "2015-11-18 16:30:00+06:30"], utc=True) -.. _whatsnew_0240.api_breaking.calendarday: - -CalendarDay Offset -^^^^^^^^^^^^^^^^^^ - -:class:`Day` and associated frequency alias ``'D'`` were documented to represent -a calendar day; however, arithmetic and operations with :class:`Day` sometimes -respected absolute time instead (i.e. ``Day(n)`` and acted identically to ``Timedelta(days=n)``). - -*Previous Behavior*: - -.. code-block:: ipython - - - In [2]: ts = pd.Timestamp('2016-10-30 00:00:00', tz='Europe/Helsinki') - - # Respects calendar arithmetic - In [3]: pd.date_range(start=ts, freq='D', periods=3) - Out[3]: - DatetimeIndex(['2016-10-30 00:00:00+03:00', '2016-10-31 00:00:00+02:00', - '2016-11-01 00:00:00+02:00'], - dtype='datetime64[ns, Europe/Helsinki]', freq='D') - - # Respects absolute arithmetic - In [4]: ts + pd.tseries.frequencies.to_offset('D') - Out[4]: Timestamp('2016-10-30 23:00:00+0200', tz='Europe/Helsinki') - -:class:`CalendarDay` and associated frequency alias ``'CD'`` are now available -and respect calendar day arithmetic while :class:`Day` and frequency alias ``'D'`` -will now respect absolute time (:issue:`22274`, :issue:`20596`, :issue:`16980`, :issue:`8774`) -See the :ref:`documentation here ` for more information. - -Addition with :class:`CalendarDay` across a daylight savings time transition: - -.. ipython:: python - - ts = pd.Timestamp('2016-10-30 00:00:00', tz='Europe/Helsinki') - ts + pd.offsets.Day(1) - ts + pd.offsets.CalendarDay(1) - .. _whatsnew_0240.api_breaking.period_end_time: Time values in ``dt.end_time`` and ``to_timestamp(how='end')`` @@ -1132,7 +1092,7 @@ Deprecations - Passing a string alias like ``'datetime64[ns, UTC]'`` as the `unit` parameter to :class:`DatetimeTZDtype` is deprecated. Use :class:`DatetimeTZDtype.construct_from_string` instead (:issue:`23990`). - In :meth:`Series.where` with Categorical data, providing an ``other`` that is not present in the categories is deprecated. Convert the categorical to a different dtype or add the ``other`` to the categories first (:issue:`24077`). - :meth:`Series.clip_lower`, :meth:`Series.clip_upper`, :meth:`DataFrame.clip_lower` and :meth:`DataFrame.clip_upper` are deprecated and will be removed in a future version. Use ``Series.clip(lower=threshold)``, ``Series.clip(upper=threshold)`` and the equivalent ``DataFrame`` methods (:issue:`24203`) - +- Operations where the offset alias ``'D'`` or :class:`Day` acts as a fixed, absolute duration of 24 hours are deprecated. This includes :class:`Timestamp` arithmetic and all operations with :class:`Timedelta`. :class:`Day` will always represent a calendar day in a future version (:issue:`22864`) .. _whatsnew_0240.deprecations.datetimelike_int_ops: diff --git a/pandas/_libs/tslibs/timestamps.pyx b/pandas/_libs/tslibs/timestamps.pyx index 472ac0ee6d45c..82fdb5b0d6dbd 100644 --- a/pandas/_libs/tslibs/timestamps.pyx +++ b/pandas/_libs/tslibs/timestamps.pyx @@ -360,6 +360,9 @@ cdef class _Timestamp(datetime): elif PyDelta_Check(other) or hasattr(other, 'delta'): # delta --> offsets.Tick + if self.tz is not None and getattr(other, '_prefix') == 'D': + warnings.warn("Day arithmetic will respect calendar day in a" + "future release", DeprecationWarning) nanos = delta_to_nanoseconds(other) result = Timestamp(self.value + nanos, tz=self.tzinfo, freq=self.freq) diff --git a/pandas/core/arrays/datetimes.py b/pandas/core/arrays/datetimes.py index 4849ee1e3e665..38840ff190f08 100644 --- a/pandas/core/arrays/datetimes.py +++ b/pandas/core/arrays/datetimes.py @@ -27,7 +27,7 @@ import pandas.core.common as com from pandas.tseries.frequencies import get_period_alias, to_offset -from pandas.tseries.offsets import Tick, generate_range +from pandas.tseries.offsets import Day, Tick, _Day, generate_range _midnight = time(0, 0) @@ -289,6 +289,9 @@ def _generate_range(cls, start, end, periods, freq, tz=None, end, end.tz, start.tz, freq, tz ) if freq is not None: + # TODO: Remove when _Day replaces Day + if isinstance(freq, Day) and tz is not None: + freq = _Day(freq.n) # TODO: consider re-implementing _cached_range; GH#17914 index = _generate_regular_range(cls, start, end, periods, freq) @@ -322,6 +325,9 @@ def _generate_range(cls, start, end, periods, freq, tz=None, if not right_closed and len(index) and index[-1] == end: index = index[:-1] + # TODO: Remove when _Day replaces Day + if isinstance(freq, _Day): + freq = Day(freq.n) return cls._simple_new(index.asi8, freq=freq, tz=tz) # ----------------------------------------------------------------- diff --git a/pandas/core/indexes/datetimes.py b/pandas/core/indexes/datetimes.py index ee5f0820a7b3e..60c6585075870 100644 --- a/pandas/core/indexes/datetimes.py +++ b/pandas/core/indexes/datetimes.py @@ -1353,7 +1353,7 @@ def date_range(start=None, end=None, periods=None, freq=None, tz=None, Right bound for generating dates. periods : integer, optional Number of periods to generate. - freq : str or DateOffset, default 'D' + freq : str or DateOffset, default 'D' (calendar daily) Frequency strings can have multiples, e.g. '5H'. See :ref:`here ` for a list of frequency aliases. diff --git a/pandas/core/indexes/interval.py b/pandas/core/indexes/interval.py index 14e73b957d519..4a610db1d1fc3 100644 --- a/pandas/core/indexes/interval.py +++ b/pandas/core/indexes/interval.py @@ -1174,7 +1174,7 @@ def interval_range(start=None, end=None, periods=None, freq=None, freq : numeric, string, or DateOffset, default None The length of each interval. Must be consistent with the type of start and end, e.g. 2 for numeric, or '5H' for datetime-like. Default is 1 - for numeric and 'D' for datetime-like. + for numeric and 'D' (calendar daily) for datetime-like. name : string, default None Name of the resulting IntervalIndex closed : {'left', 'right', 'both', 'neither'}, default 'right' diff --git a/pandas/core/indexes/period.py b/pandas/core/indexes/period.py index dc1cb29c1ae59..7f75bbb43f94b 100644 --- a/pandas/core/indexes/period.py +++ b/pandas/core/indexes/period.py @@ -984,7 +984,7 @@ def period_range(start=None, end=None, periods=None, freq='D', name=None): Right bound for generating periods periods : integer, default None Number of periods to generate - freq : string or DateOffset, default 'D' + freq : string or DateOffset, default 'D' (calendar daily) Frequency alias name : string, default None Name of the resulting PeriodIndex diff --git a/pandas/core/resample.py b/pandas/core/resample.py index 6d80d747f21b3..563c9d6fa5f91 100644 --- a/pandas/core/resample.py +++ b/pandas/core/resample.py @@ -1589,8 +1589,16 @@ def _get_range_edges(first, last, offset, closed='left', base=0): # #1165 and #24127 if (is_day and not offset.nanos % day_nanos) or not is_day: - return _adjust_dates_anchored(first, last, offset, - closed=closed, base=base) + first, last = _adjust_dates_anchored(first, last, offset, + closed=closed, base=base) + # TODO: Remove when _Day replaces Day and just return first, last + if offset == 'D' and first.tz is not None: + # We need to make Tick 'D' flexible to DST (23H, 24H, or 25H) + # _adjust_dates_anchored assumes 'D' means 24H, so ensure + # first and last snap to midnight. + first = first.normalize() + last = last.normalize() + return first, last else: first = first.normalize() diff --git a/pandas/tests/indexes/datetimes/test_date_range.py b/pandas/tests/indexes/datetimes/test_date_range.py index 11cefec4f34cf..74335ea7c4d51 100644 --- a/pandas/tests/indexes/datetimes/test_date_range.py +++ b/pandas/tests/indexes/datetimes/test_date_range.py @@ -351,18 +351,18 @@ def test_range_tz_pytz(self): Timestamp(datetime(2013, 11, 6), tz='US/Eastern')] ]) def test_range_tz_dst_straddle_pytz(self, start, end): - dr = date_range(start, end, freq='CD') + dr = date_range(start, end, freq='D') assert dr[0] == start assert dr[-1] == end assert np.all(dr.hour == 0) - dr = date_range(start, end, freq='CD', tz='US/Eastern') + dr = date_range(start, end, freq='D', tz='US/Eastern') assert dr[0] == start assert dr[-1] == end assert np.all(dr.hour == 0) dr = date_range(start.replace(tzinfo=None), end.replace( - tzinfo=None), freq='CD', tz='US/Eastern') + tzinfo=None), freq='D', tz='US/Eastern') assert dr[0] == start assert dr[-1] == end assert np.all(dr.hour == 0) @@ -596,9 +596,9 @@ def test_mismatching_tz_raises_err(self, start, end): with pytest.raises(TypeError): pd.date_range(start, end, freq=BDay()) - def test_CalendarDay_range_with_dst_crossing(self): + def test_date_range_with_dst_crossing(self): # GH 20596 - result = date_range('2018-10-23', '2018-11-06', freq='7CD', + result = date_range('2018-10-23', '2018-11-06', freq='7D', tz='Europe/Paris') expected = date_range('2018-10-23', '2018-11-06', freq=pd.DateOffset(days=7), tz='Europe/Paris') @@ -758,8 +758,7 @@ def test_cdaterange_weekmask_and_holidays(self): holidays=['2013-05-01']) @pytest.mark.parametrize('freq', [freq for freq in prefix_mapping - if freq.startswith('C') - and freq != 'CD']) # CalendarDay + if freq.startswith('C')]) def test_all_custom_freq(self, freq): # should not raise bdate_range(START, END, freq=freq, weekmask='Mon Wed Fri', diff --git a/pandas/tests/indexes/datetimes/test_indexing.py b/pandas/tests/indexes/datetimes/test_indexing.py index 944c925dabe3e..7650770568c32 100644 --- a/pandas/tests/indexes/datetimes/test_indexing.py +++ b/pandas/tests/indexes/datetimes/test_indexing.py @@ -134,47 +134,46 @@ def test_where_tz(self): class TestTake(object): - def test_take(self): - # GH#10295 - idx1 = pd.date_range('2011-01-01', '2011-01-31', freq='D', name='idx') - idx2 = pd.date_range('2011-01-01', '2011-01-31', freq='D', - tz='Asia/Tokyo', name='idx') - - for idx in [idx1, idx2]: - result = idx.take([0]) - assert result == Timestamp('2011-01-01', tz=idx.tz) - result = idx.take([0, 1, 2]) - expected = pd.date_range('2011-01-01', '2011-01-03', freq='D', - tz=idx.tz, name='idx') - tm.assert_index_equal(result, expected) - assert result.freq == expected.freq + @pytest.mark.parametrize('tz', [None, 'Asia/Tokyo']) + def test_take(self, tz): + # GH#10295 + idx = pd.date_range('2011-01-01', '2011-01-31', freq='D', name='idx', + tz=tz) + result = idx.take([0]) + assert result == Timestamp('2011-01-01', tz=idx.tz) + + result = idx.take([0, 1, 2]) + expected = pd.date_range('2011-01-01', '2011-01-03', freq='D', + tz=idx.tz, name='idx') + tm.assert_index_equal(result, expected) + assert result.freq == expected.freq - result = idx.take([0, 2, 4]) - expected = pd.date_range('2011-01-01', '2011-01-05', freq='2D', - tz=idx.tz, name='idx') - tm.assert_index_equal(result, expected) - assert result.freq == expected.freq + result = idx.take([0, 2, 4]) + expected = pd.date_range('2011-01-01', '2011-01-05', freq='2D', + tz=idx.tz, name='idx') + tm.assert_index_equal(result, expected) + assert result.freq == expected.freq - result = idx.take([7, 4, 1]) - expected = pd.date_range('2011-01-08', '2011-01-02', freq='-3D', - tz=idx.tz, name='idx') - tm.assert_index_equal(result, expected) - assert result.freq == expected.freq + result = idx.take([7, 4, 1]) + expected = pd.date_range('2011-01-08', '2011-01-02', freq='-3D', + tz=idx.tz, name='idx') + tm.assert_index_equal(result, expected) + assert result.freq == expected.freq - result = idx.take([3, 2, 5]) - expected = DatetimeIndex(['2011-01-04', '2011-01-03', - '2011-01-06'], - freq=None, tz=idx.tz, name='idx') - tm.assert_index_equal(result, expected) - assert result.freq is None + result = idx.take([3, 2, 5]) + expected = DatetimeIndex(['2011-01-04', '2011-01-03', + '2011-01-06'], + freq=None, tz=idx.tz, name='idx') + tm.assert_index_equal(result, expected) + assert result.freq is None - result = idx.take([-3, 2, 5]) - expected = DatetimeIndex(['2011-01-29', '2011-01-03', - '2011-01-06'], - freq=None, tz=idx.tz, name='idx') - tm.assert_index_equal(result, expected) - assert result.freq is None + result = idx.take([-3, 2, 5]) + expected = DatetimeIndex(['2011-01-29', '2011-01-03', + '2011-01-06'], + freq=None, tz=idx.tz, name='idx') + tm.assert_index_equal(result, expected) + assert result.freq is None def test_take_invalid_kwargs(self): idx = pd.date_range('2011-01-01', '2011-01-31', freq='D', name='idx') diff --git a/pandas/tests/indexes/datetimes/test_timezones.py b/pandas/tests/indexes/datetimes/test_timezones.py index 8c7d20684fd8c..42385127f0dad 100644 --- a/pandas/tests/indexes/datetimes/test_timezones.py +++ b/pandas/tests/indexes/datetimes/test_timezones.py @@ -436,7 +436,7 @@ def test_dti_tz_localize_utc_conversion(self, tz): @pytest.mark.parametrize('idx', [ date_range(start='2014-01-01', end='2014-12-31', freq='M'), - date_range(start='2014-01-01', end='2014-12-31', freq='CD'), + date_range(start='2014-01-01', end='2014-12-31', freq='D'), date_range(start='2014-01-01', end='2014-03-01', freq='H'), date_range(start='2014-08-01', end='2014-10-31', freq='T') ]) @@ -1072,7 +1072,7 @@ def test_date_range_span_dst_transition(self, tzstr): dr = date_range('2012-11-02', periods=10, tz=tzstr) result = dr.hour - expected = Index([0, 0, 0, 23, 23, 23, 23, 23, 23, 23]) + expected = Index([0] * 10) tm.assert_index_equal(result, expected) @pytest.mark.parametrize('tzstr', ['US/Eastern', 'dateutil/US/Eastern']) diff --git a/pandas/tests/indexes/timedeltas/test_timedelta_range.py b/pandas/tests/indexes/timedeltas/test_timedelta_range.py index 238fd861a92ab..428c62dca0bc7 100644 --- a/pandas/tests/indexes/timedeltas/test_timedelta_range.py +++ b/pandas/tests/indexes/timedeltas/test_timedelta_range.py @@ -4,7 +4,7 @@ import pandas as pd import pandas.util.testing as tm from pandas import timedelta_range, to_timedelta -from pandas.tseries.offsets import Day, Second +from pandas.tseries.offsets import Second class TestTimedeltas(object): @@ -19,7 +19,8 @@ def test_timedelta_range(self): result = timedelta_range('0 days', '10 days', freq='D') tm.assert_index_equal(result, expected) - expected = to_timedelta(np.arange(5), unit='D') + Second(2) + Day() + expected = to_timedelta(np.arange(5), unit='D') + expected = expected + Second(2) + pd.Timedelta(days=1) result = timedelta_range('1 days, 00:00:02', '5 days, 00:00:02', freq='D') tm.assert_index_equal(result, expected) @@ -49,9 +50,7 @@ def test_timedelta_range(self): result = df.loc['0s':, :] tm.assert_frame_equal(expected, result) - with pytest.raises(ValueError): - # GH 22274: CalendarDay is a relative time measurement - timedelta_range('1day', freq='CD', periods=2) + timedelta_range('1day', freq='D', periods=2) @pytest.mark.parametrize('periods, freq', [ (3, '2D'), (5, 'D'), (6, '19H12T'), (7, '16H'), (9, '12H')]) diff --git a/pandas/tests/resample/test_datetime_index.py b/pandas/tests/resample/test_datetime_index.py index 71f94f9398758..833fb666713a4 100644 --- a/pandas/tests/resample/test_datetime_index.py +++ b/pandas/tests/resample/test_datetime_index.py @@ -1278,7 +1278,7 @@ def test_resample_dst_anchor(self): # 5172 dti = DatetimeIndex([datetime(2012, 11, 4, 23)], tz='US/Eastern') df = DataFrame([5], index=dti) - assert_frame_equal(df.resample(rule='CD').sum(), + assert_frame_equal(df.resample(rule='D').sum(), DataFrame([5], index=df.index.normalize())) df.resample(rule='MS').sum() assert_frame_equal( @@ -1332,14 +1332,14 @@ def test_resample_dst_anchor(self): df_daily = df['10/26/2013':'10/29/2013'] assert_frame_equal( - df_daily.resample("CD").agg({"a": "min", "b": "max", "c": "count"}) + df_daily.resample("D").agg({"a": "min", "b": "max", "c": "count"}) [["a", "b", "c"]], DataFrame({"a": [1248, 1296, 1346, 1394], "b": [1295, 1345, 1393, 1441], "c": [48, 50, 48, 48]}, index=date_range('10/26/2013', '10/29/2013', - freq='CD', tz='Europe/Paris')), - 'CD Frequency') + freq='D', tz='Europe/Paris')), + 'D Frequency') def test_downsample_across_dst(self): # GH 8531 diff --git a/pandas/tests/resample/test_period_index.py b/pandas/tests/resample/test_period_index.py index 7cb3185ccbbaf..2cfaba29455d9 100644 --- a/pandas/tests/resample/test_period_index.py +++ b/pandas/tests/resample/test_period_index.py @@ -276,6 +276,8 @@ def test_with_local_timezone_dateutil(self): expected = Series(1, index=expected_index) assert_series_equal(result, expected) + @pytest.mark.xfail(reason='Day as calendar day will raise on ' + 'NonExistentTimeError') def test_resample_nonexistent_time_bin_edge(self): # GH 19375 index = date_range('2017-03-12', '2017-03-12 1:45:00', freq='15T') diff --git a/pandas/tests/scalar/timedelta/test_arithmetic.py b/pandas/tests/scalar/timedelta/test_arithmetic.py index b6ad251d598ab..d3c92614c7962 100644 --- a/pandas/tests/scalar/timedelta/test_arithmetic.py +++ b/pandas/tests/scalar/timedelta/test_arithmetic.py @@ -44,7 +44,7 @@ def test_td_add_sub_ten_seconds(self, ten_seconds): Timedelta('1 days, 00:00:10'), timedelta(days=1, seconds=10), np.timedelta64(1, 'D') + np.timedelta64(10, 's'), - pd.offsets.Day() + pd.offsets.Second(10)]) + pd.offsets.Hour(24) + pd.offsets.Second(10)]) def test_td_add_sub_one_day_ten_seconds(self, one_day_ten_secs): # GH#6808 base = Timestamp('20130102 09:01:12.123456') diff --git a/pandas/tests/series/test_timezones.py b/pandas/tests/series/test_timezones.py index bdf5944cab408..2e52f7ddbac9c 100644 --- a/pandas/tests/series/test_timezones.py +++ b/pandas/tests/series/test_timezones.py @@ -343,7 +343,7 @@ def test_getitem_pydatetime_tz(self, tzstr): def test_series_truncate_datetimeindex_tz(self): # GH 9243 - idx = date_range('4/1/2005', '4/30/2005', freq='CD', tz='US/Pacific') + idx = date_range('4/1/2005', '4/30/2005', freq='D', tz='US/Pacific') s = Series(range(len(idx)), index=idx) result = s.truncate(datetime(2005, 4, 2), datetime(2005, 4, 4)) expected = Series([1, 2, 3], index=idx[1:4]) diff --git a/pandas/tests/tseries/offsets/test_offsets.py b/pandas/tests/tseries/offsets/test_offsets.py index 030887ac731f3..c40af49078f82 100644 --- a/pandas/tests/tseries/offsets/test_offsets.py +++ b/pandas/tests/tseries/offsets/test_offsets.py @@ -25,10 +25,11 @@ import pandas.tseries.offsets as offsets from pandas.tseries.offsets import ( FY5253, BDay, BMonthBegin, BMonthEnd, BQuarterBegin, BQuarterEnd, - BusinessHour, BYearBegin, BYearEnd, CalendarDay, CBMonthBegin, CBMonthEnd, - CDay, CustomBusinessHour, DateOffset, Day, Easter, FY5253Quarter, + BusinessHour, BYearBegin, BYearEnd, CBMonthBegin, CBMonthEnd, CDay, + CustomBusinessHour, DateOffset, Day, Easter, FY5253Quarter, LastWeekOfMonth, MonthBegin, MonthEnd, Nano, QuarterBegin, QuarterEnd, - SemiMonthBegin, SemiMonthEnd, Tick, Week, WeekOfMonth, YearBegin, YearEnd) + SemiMonthBegin, SemiMonthEnd, Tick, Week, WeekOfMonth, YearBegin, YearEnd, + _Day) from .common import assert_offset_equal, assert_onOffset @@ -198,7 +199,6 @@ class TestCommon(Base): # are applied to 2011/01/01 09:00 (Saturday) # used for .apply and .rollforward expecteds = {'Day': Timestamp('2011-01-02 09:00:00'), - 'CalendarDay': Timestamp('2011-01-02 09:00:00'), 'DateOffset': Timestamp('2011-01-02 09:00:00'), 'BusinessDay': Timestamp('2011-01-03 09:00:00'), 'CustomBusinessDay': Timestamp('2011-01-03 09:00:00'), @@ -367,7 +367,7 @@ def test_rollforward(self, offset_types): # result will not be changed if the target is on the offset no_changes = ['Day', 'MonthBegin', 'SemiMonthBegin', 'YearBegin', 'Week', 'Hour', 'Minute', 'Second', 'Milli', 'Micro', - 'Nano', 'DateOffset', 'CalendarDay'] + 'Nano', 'DateOffset'] for n in no_changes: expecteds[n] = Timestamp('2011/01/01 09:00') @@ -380,7 +380,6 @@ def test_rollforward(self, offset_types): norm_expected[k] = Timestamp(norm_expected[k].date()) normalized = {'Day': Timestamp('2011-01-02 00:00:00'), - 'CalendarDay': Timestamp('2011-01-02 00:00:00'), 'DateOffset': Timestamp('2011-01-02 00:00:00'), 'MonthBegin': Timestamp('2011-02-01 00:00:00'), 'SemiMonthBegin': Timestamp('2011-01-15 00:00:00'), @@ -433,7 +432,7 @@ def test_rollback(self, offset_types): # result will not be changed if the target is on the offset for n in ['Day', 'MonthBegin', 'SemiMonthBegin', 'YearBegin', 'Week', 'Hour', 'Minute', 'Second', 'Milli', 'Micro', 'Nano', - 'DateOffset', 'CalendarDay']: + 'DateOffset']: expecteds[n] = Timestamp('2011/01/01 09:00') # but be changed when normalize=True @@ -442,7 +441,6 @@ def test_rollback(self, offset_types): norm_expected[k] = Timestamp(norm_expected[k].date()) normalized = {'Day': Timestamp('2010-12-31 00:00:00'), - 'CalendarDay': Timestamp('2010-12-31 00:00:00'), 'DateOffset': Timestamp('2010-12-31 00:00:00'), 'MonthBegin': Timestamp('2010-12-01 00:00:00'), 'SemiMonthBegin': Timestamp('2010-12-15 00:00:00'), @@ -3154,16 +3152,16 @@ def test_last_week_of_month_on_offset(): assert fast == slow -class TestCalendarDay(object): +class TestDay(object): def test_add_across_dst_scalar(self): # GH 22274 ts = Timestamp('2016-10-30 00:00:00+0300', tz='Europe/Helsinki') expected = Timestamp('2016-10-31 00:00:00+0200', tz='Europe/Helsinki') - result = ts + CalendarDay(1) + result = ts + _Day(1) assert result == expected - result = result - CalendarDay(1) + result = result - _Day(1) assert result == ts @pytest.mark.parametrize('box', [DatetimeIndex, Series]) @@ -3173,10 +3171,10 @@ def test_add_across_dst_array(self, box): expected = Timestamp('2016-10-31 00:00:00+0200', tz='Europe/Helsinki') arr = box([ts]) expected = box([expected]) - result = arr + CalendarDay(1) + result = arr + _Day(1) tm.assert_equal(result, expected) - result = result - CalendarDay(1) + result = result - _Day(1) tm.assert_equal(arr, result) @pytest.mark.parametrize('arg', [ @@ -3186,7 +3184,7 @@ def test_add_across_dst_array(self, box): def test_raises_AmbiguousTimeError(self, arg): # GH 22274 with pytest.raises(pytz.AmbiguousTimeError): - arg + CalendarDay(1) + arg + _Day(1) @pytest.mark.parametrize('arg', [ Timestamp("2019-03-09 02:00:00", tz='US/Pacific'), @@ -3195,7 +3193,7 @@ def test_raises_AmbiguousTimeError(self, arg): def test_raises_NonExistentTimeError(self, arg): # GH 22274 with pytest.raises(pytz.NonExistentTimeError): - arg + CalendarDay(1) + arg + _Day(1) @pytest.mark.parametrize('arg, exp', [ [1, 2], @@ -3204,13 +3202,12 @@ def test_raises_NonExistentTimeError(self, arg): ]) def test_arithmetic(self, arg, exp): # GH 22274 - result = CalendarDay(1) + CalendarDay(arg) - expected = CalendarDay(exp) + result = _Day(1) + _Day(arg) + expected = _Day(exp) assert result == expected @pytest.mark.parametrize('arg', [ timedelta(1), - Day(1), Timedelta(1), TimedeltaIndex([timedelta(1)]) ]) @@ -3219,4 +3216,14 @@ def test_invalid_arithmetic(self, arg): # CalendarDay (relative time) cannot be added to Timedelta-like objects # (absolute time) with pytest.raises(TypeError): - CalendarDay(1) + arg + _Day(1) + arg + + def test_day_tick_arithmetic_deprecation(self): + with tm.assert_produces_warning(DeprecationWarning): + Day(1) + Tick(24) + Tick(24) + Day(1) + + def test_day_timestamptz_arithmetic_deprecation(self): + with tm.assert_produces_warning(DeprecationWarning): + Timestamp("2012-10-28", tz='Europe/Brussels') + Day() + Day() + Timestamp("2012-10-28", tz='Europe/Brussels') \ No newline at end of file diff --git a/pandas/tests/tseries/offsets/test_ticks.py b/pandas/tests/tseries/offsets/test_ticks.py index d56a8b1cda628..a1940241b4c56 100644 --- a/pandas/tests/tseries/offsets/test_ticks.py +++ b/pandas/tests/tseries/offsets/test_ticks.py @@ -11,8 +11,7 @@ from pandas import Timedelta, Timestamp from pandas.tseries import offsets -from pandas.tseries.offsets import ( - Day, Hour, Micro, Milli, Minute, Nano, Second) +from pandas.tseries.offsets import Hour, Micro, Milli, Minute, Nano, Second from .common import assert_offset_equal @@ -213,13 +212,6 @@ def test_Nanosecond(): assert Micro(5) + Nano(1) == Nano(5001) -def test_Day_equals_24_Hours(): - ts = Timestamp('2016-10-30 00:00:00+0300', tz='Europe/Helsinki') - result = ts + Day(1) - expected = ts + Hour(24) - assert result == expected - - @pytest.mark.parametrize('kls, expected', [(Hour, Timedelta(hours=5)), (Minute, Timedelta(hours=2, minutes=3)), diff --git a/pandas/tseries/offsets.py b/pandas/tseries/offsets.py index 45f10a2f06fa2..f3473b917a5ab 100644 --- a/pandas/tseries/offsets.py +++ b/pandas/tseries/offsets.py @@ -2,6 +2,7 @@ from datetime import date, datetime, timedelta import functools import operator +import warnings from dateutil.easter import easter import numpy as np @@ -32,7 +33,7 @@ 'LastWeekOfMonth', 'FY5253Quarter', 'FY5253', 'Week', 'WeekOfMonth', 'Easter', 'Hour', 'Minute', 'Second', 'Milli', 'Micro', 'Nano', - 'DateOffset', 'CalendarDay'] + 'DateOffset'] # convert to/from datetime/timestamp to allow invalid Timestamp ranges to # pass thru @@ -2217,14 +2218,18 @@ def onOffset(self, dt): return date(dt.year, dt.month, dt.day) == easter(dt.year) -class CalendarDay(SingleConstructorOffset): +class _Day(SingleConstructorOffset): """ - Calendar day offset. Respects calendar arithmetic as opposed to Day which - respects absolute time. + Day offset representing a calendar day. Respects calendar day arithmetic + in the midst of daylight savings time transitions; therefore, Day does not + necessarily equate to 24 hours. + + TODO: Replace _Day with Day """ _adjust_dst = True _inc = Timedelta(days=1) - _prefix = 'CD' + # TODO: Replace '_D' with 'D' when _Day replaces Day + _prefix = '_D' _attributes = frozenset(['n', 'normalize']) def __init__(self, n=1, normalize=False): @@ -2233,11 +2238,11 @@ def __init__(self, n=1, normalize=False): @apply_wraps def apply(self, other): """ - Apply scalar arithmetic with CalendarDay offset. Incoming datetime + Apply scalar arithmetic with Day offset. Incoming datetime objects can be tz-aware or naive. """ if type(other) == type(self): - # Add other CalendarDays + # Add other Days return type(self)(self.n + other.n, normalize=self.normalize) tzinfo = getattr(other, 'tzinfo', None) if tzinfo is not None: @@ -2253,12 +2258,12 @@ def apply(self, other): return as_timestamp(other) except TypeError: raise TypeError("Cannot perform arithmetic between {other} and " - "CalendarDay".format(other=type(other))) + "Day".format(other=type(other))) @apply_index_wraps def apply_index(self, i): """ - Apply the CalendarDay offset to a DatetimeIndex. Incoming DatetimeIndex + Apply the Day offset to a DatetimeIndex. Incoming DatetimeIndex objects are assumed to be tz_naive """ return i + self.n * self._inc @@ -2302,6 +2307,10 @@ def __init__(self, n=1, normalize=False): def __add__(self, other): if isinstance(other, Tick): + if self._prefix == 'D' or other._prefix == 'D': + warnings.warn("Arithmetic with Day is deprecated. Day will " + "become a non-fixed offset.", + DeprecationWarning, stacklevel=2) if type(self) == type(other): return type(self)(self.n + other.n) else: @@ -2374,6 +2383,10 @@ def apply(self, other): result = other.__add__(self) if result == NotImplemented: raise OverflowError + if other.tz is not None and self._prefix == 'D': + warnings.warn("Day arithmetic will respect calendar day in a " + "future release", DeprecationWarning, + stacklevel=3) return result elif isinstance(other, (datetime, np.datetime64, date)): return as_timestamp(other) + self @@ -2413,6 +2426,7 @@ def _delta_to_tick(delta): class Day(Tick): + # TODO: Remove when _Day replaces Day _inc = Timedelta(days=1) _prefix = 'D' @@ -2565,5 +2579,4 @@ def generate_range(start=None, end=None, periods=None, WeekOfMonth, # 'WOM' FY5253, FY5253Quarter, - CalendarDay # 'CD' ]}