Skip to content

Commit a3e56f2

Browse files
jbrockmendeljreback
authored andcommitted
API: PeriodIndex subtraction to return object Index of DateOffsets (#20049)
1 parent 5afb953 commit a3e56f2

File tree

5 files changed

+116
-25
lines changed

5 files changed

+116
-25
lines changed

doc/source/whatsnew/v0.24.0.txt

+1
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ Datetimelike API Changes
118118

119119
- 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`)
120120
- :class:`DateOffset` objects are now immutable. Attempting to alter one of these will now raise ``AttributeError`` (:issue:`21341`)
121+
- :class:`PeriodIndex` subtraction of another ``PeriodIndex`` will now return an object-dtype :class:`Index` of :class:`DateOffset` objects instead of raising a ``TypeError`` (:issue:`20049`)
121122

122123
.. _whatsnew_0240.api.other:
123124

pandas/core/indexes/datetimelike.py

+41-2
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@
1313
import numpy as np
1414

1515
from pandas._libs import lib, iNaT, NaT, Timedelta
16-
from pandas._libs.tslibs.period import Period
16+
from pandas._libs.tslibs.period import (Period, IncompatibleFrequency,
17+
_DIFFERENT_FREQ_INDEX)
1718
from pandas._libs.tslibs.timedeltas import delta_to_nanoseconds
1819
from pandas._libs.tslibs.timestamps import round_ns
1920

@@ -784,6 +785,41 @@ def _sub_nat(self):
784785
def _sub_period(self, other):
785786
return NotImplemented
786787

788+
def _sub_period_array(self, other):
789+
"""
790+
Subtract one PeriodIndex from another. This is only valid if they
791+
have the same frequency.
792+
793+
Parameters
794+
----------
795+
other : PeriodIndex
796+
797+
Returns
798+
-------
799+
result : np.ndarray[object]
800+
Array of DateOffset objects; nulls represented by NaT
801+
"""
802+
if not is_period_dtype(self):
803+
raise TypeError("cannot subtract {dtype}-dtype to {cls}"
804+
.format(dtype=other.dtype,
805+
cls=type(self).__name__))
806+
807+
if not len(self) == len(other):
808+
raise ValueError("cannot subtract indices of unequal length")
809+
if self.freq != other.freq:
810+
msg = _DIFFERENT_FREQ_INDEX.format(self.freqstr, other.freqstr)
811+
raise IncompatibleFrequency(msg)
812+
813+
new_values = checked_add_with_arr(self.asi8, -other.asi8,
814+
arr_mask=self._isnan,
815+
b_mask=other._isnan)
816+
817+
new_values = np.array([self.freq * x for x in new_values])
818+
if self.hasnans or other.hasnans:
819+
mask = (self._isnan) | (other._isnan)
820+
new_values[mask] = NaT
821+
return new_values
822+
787823
def _add_offset(self, offset):
788824
raise com.AbstractMethodError(self)
789825

@@ -894,7 +930,7 @@ def __add__(self, other):
894930
return self._add_datelike(other)
895931
elif is_integer_dtype(other):
896932
result = self._addsub_int_array(other, operator.add)
897-
elif is_float_dtype(other):
933+
elif is_float_dtype(other) or is_period_dtype(other):
898934
# Explicitly catch invalid dtypes
899935
raise TypeError("cannot add {dtype}-dtype to {cls}"
900936
.format(dtype=other.dtype,
@@ -955,6 +991,9 @@ def __sub__(self, other):
955991
elif is_datetime64_dtype(other) or is_datetime64tz_dtype(other):
956992
# DatetimeIndex, ndarray[datetime64]
957993
result = self._sub_datelike(other)
994+
elif is_period_dtype(other):
995+
# PeriodIndex
996+
result = self._sub_period_array(other)
958997
elif is_integer_dtype(other):
959998
result = self._addsub_int_array(other, operator.sub)
960999
elif isinstance(other, Index):

pandas/tests/indexes/datetimes/test_arithmetic.py

+11
Original file line numberDiff line numberDiff line change
@@ -766,6 +766,17 @@ def test_sub_period(self, freq):
766766
with pytest.raises(TypeError):
767767
p - idx
768768

769+
@pytest.mark.parametrize('op', [operator.add, ops.radd,
770+
operator.sub, ops.rsub])
771+
@pytest.mark.parametrize('pi_freq', ['D', 'W', 'Q', 'H'])
772+
@pytest.mark.parametrize('dti_freq', [None, 'D'])
773+
def test_dti_sub_pi(self, dti_freq, pi_freq, op):
774+
# GH#20049 subtracting PeriodIndex should raise TypeError
775+
dti = pd.DatetimeIndex(['2011-01-01', '2011-01-02'], freq=dti_freq)
776+
pi = dti.to_period(pi_freq)
777+
with pytest.raises(TypeError):
778+
op(dti, pi)
779+
769780
def test_ufunc_coercions(self):
770781
idx = date_range('2011-01-01', periods=3, freq='2D', name='x')
771782

pandas/tests/indexes/period/test_arithmetic.py

+51-23
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,57 @@ def test_comp_nat(self, dtype):
258258

259259

260260
class TestPeriodIndexArithmetic(object):
261+
# ---------------------------------------------------------------
262+
# __add__/__sub__ with PeriodIndex
263+
# PeriodIndex + other is defined for integers and timedelta-like others
264+
# PeriodIndex - other is defined for integers, timedelta-like others,
265+
# and PeriodIndex (with matching freq)
266+
267+
def test_pi_add_iadd_pi_raises(self):
268+
rng = pd.period_range('1/1/2000', freq='D', periods=5)
269+
other = pd.period_range('1/6/2000', freq='D', periods=5)
270+
271+
# An earlier implementation of PeriodIndex addition performed
272+
# a set operation (union). This has since been changed to
273+
# raise a TypeError. See GH#14164 and GH#13077 for historical
274+
# reference.
275+
with pytest.raises(TypeError):
276+
rng + other
277+
278+
with pytest.raises(TypeError):
279+
rng += other
280+
281+
def test_pi_sub_isub_pi(self):
282+
# GH#20049
283+
# For historical reference see GH#14164, GH#13077.
284+
# PeriodIndex subtraction originally performed set difference,
285+
# then changed to raise TypeError before being implemented in GH#20049
286+
rng = pd.period_range('1/1/2000', freq='D', periods=5)
287+
other = pd.period_range('1/6/2000', freq='D', periods=5)
288+
289+
off = rng.freq
290+
expected = pd.Index([-5 * off] * 5)
291+
result = rng - other
292+
tm.assert_index_equal(result, expected)
293+
294+
rng -= other
295+
tm.assert_index_equal(rng, expected)
296+
297+
def test_pi_sub_pi_with_nat(self):
298+
rng = pd.period_range('1/1/2000', freq='D', periods=5)
299+
other = rng[1:].insert(0, pd.NaT)
300+
assert other[1:].equals(rng[1:])
301+
302+
result = rng - other
303+
off = rng.freq
304+
expected = pd.Index([pd.NaT, 0 * off, 0 * off, 0 * off, 0 * off])
305+
tm.assert_index_equal(result, expected)
306+
307+
def test_pi_sub_pi_mismatched_freq(self):
308+
rng = pd.period_range('1/1/2000', freq='D', periods=5)
309+
other = pd.period_range('1/6/2000', freq='H', periods=5)
310+
with pytest.raises(period.IncompatibleFrequency):
311+
rng - other
261312

262313
# -------------------------------------------------------------
263314
# Invalid Operations
@@ -379,17 +430,6 @@ def test_pi_sub_offset_array(self, box):
379430
with tm.assert_produces_warning(PerformanceWarning):
380431
anchored - pi
381432

382-
def test_pi_add_iadd_pi_raises(self):
383-
rng = pd.period_range('1/1/2000', freq='D', periods=5)
384-
other = pd.period_range('1/6/2000', freq='D', periods=5)
385-
386-
# previously performed setop union, now raises TypeError (GH14164)
387-
with pytest.raises(TypeError):
388-
rng + other
389-
390-
with pytest.raises(TypeError):
391-
rng += other
392-
393433
def test_pi_add_iadd_int(self, one):
394434
# Variants of `one` for #19012
395435
rng = pd.period_range('2000-01-01 09:00', freq='H', periods=10)
@@ -419,18 +459,6 @@ def test_pi_sub_intlike(self, five):
419459
exp = rng + (-five)
420460
tm.assert_index_equal(result, exp)
421461

422-
def test_pi_sub_isub_pi_raises(self):
423-
# previously performed setop, now raises TypeError (GH14164)
424-
# TODO needs to wait on #13077 for decision on result type
425-
rng = pd.period_range('1/1/2000', freq='D', periods=5)
426-
other = pd.period_range('1/6/2000', freq='D', periods=5)
427-
428-
with pytest.raises(TypeError):
429-
rng - other
430-
431-
with pytest.raises(TypeError):
432-
rng -= other
433-
434462
def test_pi_sub_isub_offset(self):
435463
# offset
436464
# DateOffset

pandas/tests/indexes/timedeltas/test_arithmetic.py

+12
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,18 @@ def test_tdi_sub_period(self, freq):
305305
with pytest.raises(TypeError):
306306
p - idx
307307

308+
@pytest.mark.parametrize('op', [operator.add, ops.radd,
309+
operator.sub, ops.rsub])
310+
@pytest.mark.parametrize('pi_freq', ['D', 'W', 'Q', 'H'])
311+
@pytest.mark.parametrize('tdi_freq', [None, 'H'])
312+
def test_dti_sub_pi(self, tdi_freq, pi_freq, op):
313+
# GH#20049 subtracting PeriodIndex should raise TypeError
314+
tdi = pd.TimedeltaIndex(['1 hours', '2 hours'], freq=tdi_freq)
315+
dti = pd.Timestamp('2018-03-07 17:16:40') + tdi
316+
pi = dti.to_period(pi_freq)
317+
with pytest.raises(TypeError):
318+
op(dti, pi)
319+
308320
# -------------------------------------------------------------
309321
# TimedeltaIndex.shift is used by __add__/__sub__
310322

0 commit comments

Comments
 (0)