Skip to content

Commit f8ee98b

Browse files
jbrockmendelhexgnu
authored andcommitted
BUG: DatetimeIndex + arraylike of DateOffsets (pandas-dev#18849)
1 parent 249505b commit f8ee98b

File tree

7 files changed

+143
-6
lines changed

7 files changed

+143
-6
lines changed

doc/source/whatsnew/v0.23.0.txt

+1
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,7 @@ Conversion
291291
- Bug in :class:`WeekOfMonth` and class:`Week` where addition and subtraction did not roll correctly (:issue:`18510`,:issue:`18672`,:issue:`18864`)
292292
- Bug in :meth:`DatetimeIndex.astype` when converting between timezone aware dtypes, and converting from timezone aware to naive (:issue:`18951`)
293293
- Bug in :class:`FY5253` where ``datetime`` addition and subtraction incremented incorrectly for dates on the year-end but not normalized to midnight (:issue:`18854`)
294+
- Bug in :class:`DatetimeIndex` where adding or subtracting an array-like of ``DateOffset`` objects either raised (``np.array``, ``pd.Index``) or broadcast incorrectly (``pd.Series``) (:issue:`18849`)
294295

295296

296297
Indexing

pandas/core/indexes/datetimelike.py

+23-5
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
is_list_like,
1919
is_scalar,
2020
is_bool_dtype,
21+
is_offsetlike,
2122
is_categorical_dtype,
2223
is_datetime_or_timedelta_dtype,
2324
is_float_dtype,
@@ -649,6 +650,14 @@ def _sub_datelike(self, other):
649650
def _sub_period(self, other):
650651
return NotImplemented
651652

653+
def _add_offset_array(self, other):
654+
# Array/Index of DateOffset objects
655+
return NotImplemented
656+
657+
def _sub_offset_array(self, other):
658+
# Array/Index of DateOffset objects
659+
return NotImplemented
660+
652661
@classmethod
653662
def _add_datetimelike_methods(cls):
654663
"""
@@ -671,7 +680,12 @@ def __add__(self, other):
671680
return self._add_delta(other)
672681
elif is_integer(other):
673682
return self.shift(other)
674-
elif isinstance(other, (Index, datetime, np.datetime64)):
683+
elif isinstance(other, (datetime, np.datetime64)):
684+
return self._add_datelike(other)
685+
elif is_offsetlike(other):
686+
# Array/Index of DateOffset objects
687+
return self._add_offset_array(other)
688+
elif isinstance(other, Index):
675689
return self._add_datelike(other)
676690
else: # pragma: no cover
677691
return NotImplemented
@@ -692,10 +706,6 @@ def __sub__(self, other):
692706
return self._add_delta(-other)
693707
elif isinstance(other, DatetimeIndex):
694708
return self._sub_datelike(other)
695-
elif isinstance(other, Index):
696-
raise TypeError("cannot subtract {typ1} and {typ2}"
697-
.format(typ1=type(self).__name__,
698-
typ2=type(other).__name__))
699709
elif isinstance(other, (DateOffset, timedelta)):
700710
return self._add_delta(-other)
701711
elif is_integer(other):
@@ -704,6 +714,14 @@ def __sub__(self, other):
704714
return self._sub_datelike(other)
705715
elif isinstance(other, Period):
706716
return self._sub_period(other)
717+
elif is_offsetlike(other):
718+
# Array/Index of DateOffset objects
719+
return self._sub_offset_array(other)
720+
elif isinstance(other, Index):
721+
raise TypeError("cannot subtract {typ1} and {typ2}"
722+
.format(typ1=type(self).__name__,
723+
typ2=type(other).__name__))
724+
707725
else: # pragma: no cover
708726
return NotImplemented
709727
cls.__sub__ = __sub__

pandas/core/indexes/datetimes.py

+26
Original file line numberDiff line numberDiff line change
@@ -893,6 +893,32 @@ def _add_offset(self, offset):
893893
"or DatetimeIndex", PerformanceWarning)
894894
return self.astype('O') + offset
895895

896+
def _add_offset_array(self, other):
897+
# Array/Index of DateOffset objects
898+
if isinstance(other, ABCSeries):
899+
return NotImplemented
900+
elif len(other) == 1:
901+
return self + other[0]
902+
else:
903+
warnings.warn("Adding/subtracting array of DateOffsets to "
904+
"{} not vectorized".format(type(self)),
905+
PerformanceWarning)
906+
return self.astype('O') + np.array(other)
907+
# TODO: This works for __add__ but loses dtype in __sub__
908+
909+
def _sub_offset_array(self, other):
910+
# Array/Index of DateOffset objects
911+
if isinstance(other, ABCSeries):
912+
return NotImplemented
913+
elif len(other) == 1:
914+
return self - other[0]
915+
else:
916+
warnings.warn("Adding/subtracting array of DateOffsets to "
917+
"{} not vectorized".format(type(self)),
918+
PerformanceWarning)
919+
res_values = self.astype('O').values - np.array(other)
920+
return self.__class__(res_values, freq='infer')
921+
896922
def _format_native_types(self, na_rep='NaT', date_format=None, **kwargs):
897923
from pandas.io.formats.format import _get_format_datetime64_from_values
898924
format = _get_format_datetime64_from_values(self, date_format)

pandas/core/ops.py

+4-1
Original file line numberDiff line numberDiff line change
@@ -757,7 +757,10 @@ def wrapper(left, right, name=name, na_op=na_op):
757757
rvalues = getattr(rvalues, 'values', rvalues)
758758
# _Op aligns left and right
759759
else:
760-
name = left.name
760+
if isinstance(rvalues, pd.Index):
761+
name = _maybe_match_name(left, rvalues)
762+
else:
763+
name = left.name
761764
if (hasattr(lvalues, 'values') and
762765
not isinstance(lvalues, pd.DatetimeIndex)):
763766
lvalues = lvalues.values

pandas/tests/indexes/datetimes/test_arithmetic.py

+45
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,51 @@ def test_datetimeindex_sub_timestamp_overflow(self):
363363
with pytest.raises(OverflowError):
364364
dtimin - variant
365365

366+
@pytest.mark.parametrize('box', [np.array, pd.Index])
367+
def test_dti_add_offset_array(self, tz, box):
368+
# GH#18849
369+
dti = pd.date_range('2017-01-01', periods=2, tz=tz)
370+
other = box([pd.offsets.MonthEnd(), pd.offsets.Day(n=2)])
371+
res = dti + other
372+
expected = DatetimeIndex([dti[n] + other[n] for n in range(len(dti))],
373+
name=dti.name, freq='infer')
374+
tm.assert_index_equal(res, expected)
375+
376+
res2 = other + dti
377+
tm.assert_index_equal(res2, expected)
378+
379+
@pytest.mark.parametrize('box', [np.array, pd.Index])
380+
def test_dti_sub_offset_array(self, tz, box):
381+
# GH#18824
382+
dti = pd.date_range('2017-01-01', periods=2, tz=tz)
383+
other = box([pd.offsets.MonthEnd(), pd.offsets.Day(n=2)])
384+
res = dti - other
385+
expected = DatetimeIndex([dti[n] - other[n] for n in range(len(dti))],
386+
name=dti.name, freq='infer')
387+
tm.assert_index_equal(res, expected)
388+
389+
@pytest.mark.parametrize('names', [(None, None, None),
390+
('foo', 'bar', None),
391+
('foo', 'foo', 'foo')])
392+
def test_dti_with_offset_series(self, tz, names):
393+
# GH#18849
394+
dti = pd.date_range('2017-01-01', periods=2, tz=tz, name=names[0])
395+
other = pd.Series([pd.offsets.MonthEnd(), pd.offsets.Day(n=2)],
396+
name=names[1])
397+
398+
expected_add = pd.Series([dti[n] + other[n] for n in range(len(dti))],
399+
name=names[2])
400+
res = dti + other
401+
tm.assert_series_equal(res, expected_add)
402+
res2 = other + dti
403+
tm.assert_series_equal(res2, expected_add)
404+
405+
expected_sub = pd.Series([dti[n] - other[n] for n in range(len(dti))],
406+
name=names[2])
407+
408+
res3 = dti - other
409+
tm.assert_series_equal(res3, expected_sub)
410+
366411

367412
# GH 10699
368413
@pytest.mark.parametrize('klass,assert_func', zip([Series, DatetimeIndex],

pandas/tests/indexes/period/test_arithmetic.py

+26
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,32 @@
1212

1313

1414
class TestPeriodIndexArithmetic(object):
15+
def test_pi_add_offset_array(self):
16+
# GH#18849
17+
pi = pd.PeriodIndex([pd.Period('2015Q1'), pd.Period('2016Q2')])
18+
offs = np.array([pd.offsets.QuarterEnd(n=1, startingMonth=12),
19+
pd.offsets.QuarterEnd(n=-2, startingMonth=12)])
20+
res = pi + offs
21+
expected = pd.PeriodIndex([pd.Period('2015Q2'), pd.Period('2015Q4')])
22+
tm.assert_index_equal(res, expected)
23+
24+
unanchored = np.array([pd.offsets.Hour(n=1),
25+
pd.offsets.Minute(n=-2)])
26+
with pytest.raises(period.IncompatibleFrequency):
27+
pi + unanchored
28+
with pytest.raises(TypeError):
29+
unanchored + pi
30+
31+
@pytest.mark.xfail(reason='GH#18824 radd doesnt implement this case')
32+
def test_pi_radd_offset_array(self):
33+
# GH#18849
34+
pi = pd.PeriodIndex([pd.Period('2015Q1'), pd.Period('2016Q2')])
35+
offs = np.array([pd.offsets.QuarterEnd(n=1, startingMonth=12),
36+
pd.offsets.QuarterEnd(n=-2, startingMonth=12)])
37+
res = offs + pi
38+
expected = pd.PeriodIndex([pd.Period('2015Q2'), pd.Period('2015Q4')])
39+
tm.assert_index_equal(res, expected)
40+
1541
def test_add_iadd(self):
1642
rng = pd.period_range('1/1/2000', freq='D', periods=5)
1743
other = pd.period_range('1/6/2000', freq='D', periods=5)

pandas/tests/indexes/timedeltas/test_arithmetic.py

+18
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,24 @@ def freq(request):
2828
class TestTimedeltaIndexArithmetic(object):
2929
_holder = TimedeltaIndex
3030

31+
@pytest.mark.xfail(reason='GH#18824 ufunc add cannot use operands...')
32+
def test_tdi_with_offset_array(self):
33+
# GH#18849
34+
tdi = pd.TimedeltaIndex(['1 days 00:00:00', '3 days 04:00:00'])
35+
offs = np.array([pd.offsets.Hour(n=1), pd.offsets.Minute(n=-2)])
36+
expected = pd.TimedeltaIndex(['1 days 01:00:00', '3 days 04:02:00'])
37+
38+
res = tdi + offs
39+
tm.assert_index_equal(res, expected)
40+
41+
res2 = offs + tdi
42+
tm.assert_index_equal(res2, expected)
43+
44+
anchored = np.array([pd.offsets.QuarterEnd(),
45+
pd.offsets.Week(weekday=2)])
46+
with pytest.raises(TypeError):
47+
tdi + anchored
48+
3149
# TODO: Split by ops, better name
3250
def test_numeric_compat(self):
3351
idx = self._holder(np.arange(5, dtype='int64'))

0 commit comments

Comments
 (0)