diff --git a/doc/source/whatsnew/v0.23.0.txt b/doc/source/whatsnew/v0.23.0.txt index 99a3773603fc4..72487faf933c7 100644 --- a/doc/source/whatsnew/v0.23.0.txt +++ b/doc/source/whatsnew/v0.23.0.txt @@ -586,6 +586,7 @@ Datetimelike API Changes - Operations between a :class:`Series` with dtype ``dtype='datetime64[ns]'`` and a :class:`PeriodIndex` will correctly raises ``TypeError`` (:issue:`18850`) - Subtraction of :class:`Series` with timezone-aware ``dtype='datetime64[ns]'`` with mis-matched timezones will raise ``TypeError`` instead of ``ValueError`` (:issue:`18817`) - :func:`pandas.merge` provides a more informative error message when trying to merge on timezone-aware and timezone-naive columns (:issue:`15800`) +- For :class:`DatetimeIndex` and :class:`TimedeltaIndex` with ``freq=None``, addition or subtraction of integer-dtyped array or ``Index`` will raise ``NullFrequencyError`` instead of ``TypeError`` (:issue:`19895`) .. _whatsnew_0230.api.other: diff --git a/pandas/core/indexes/datetimelike.py b/pandas/core/indexes/datetimelike.py index 9411428b2e68d..8e56fc2775a56 100644 --- a/pandas/core/indexes/datetimelike.py +++ b/pandas/core/indexes/datetimelike.py @@ -32,6 +32,7 @@ is_object_dtype, is_string_dtype, is_datetime64_dtype, + is_datetime64tz_dtype, is_period_dtype, is_timedelta64_dtype) from pandas.core.dtypes.generic import ( @@ -200,8 +201,9 @@ def _evaluate_compare(self, other, op): if is_bool_dtype(result): result[mask] = False return result + + result[mask] = iNaT try: - result[mask] = iNaT return Index(result) except TypeError: return result @@ -349,7 +351,7 @@ def _nat_new(self, box=True): return result attribs = self._get_attributes_dict() - if not isinstance(self, ABCPeriodIndex): + if not is_period_dtype(self): attribs['freq'] = None return self._simple_new(result, **attribs) @@ -631,9 +633,9 @@ def _convert_scalar_indexer(self, key, kind=None): ._convert_scalar_indexer(key, kind=kind)) def _add_datelike(self, other): - raise TypeError("cannot add {0} and {1}" - .format(type(self).__name__, - type(other).__name__)) + raise TypeError("cannot add {cls} and {typ}" + .format(cls=type(self).__name__, + typ=type(other).__name__)) def _sub_datelike(self, other): raise com.AbstractMethodError(self) @@ -677,7 +679,7 @@ def _add_datetimelike_methods(cls): """ def __add__(self, other): - from pandas import Index, DatetimeIndex, TimedeltaIndex, DateOffset + from pandas import DateOffset other = lib.item_from_zerodim(other) if isinstance(other, ABCSeries): @@ -700,18 +702,9 @@ def __add__(self, other): elif is_offsetlike(other): # Array/Index of DateOffset objects result = self._addsub_offset_array(other, operator.add) - elif isinstance(self, TimedeltaIndex) and isinstance(other, Index): - if hasattr(other, '_add_delta'): - # i.e. DatetimeIndex, TimedeltaIndex, or PeriodIndex - result = other._add_delta(self) - else: - raise TypeError("cannot add TimedeltaIndex and {typ}" - .format(typ=type(other))) - elif isinstance(other, Index): - result = self._add_datelike(other) - elif is_datetime64_dtype(other): - # ndarray[datetime64]; note DatetimeIndex is caught above - return self + DatetimeIndex(other) + elif is_datetime64_dtype(other) or is_datetime64tz_dtype(other): + # DatetimeIndex, ndarray[datetime64] + return self._add_datelike(other) elif is_integer_dtype(other) and self.freq is None: # GH#19123 raise NullFrequencyError("Cannot shift with no freq") @@ -731,7 +724,7 @@ def __radd__(self, other): cls.__radd__ = __radd__ def __sub__(self, other): - from pandas import Index, DatetimeIndex, TimedeltaIndex, DateOffset + from pandas import Index, DateOffset other = lib.item_from_zerodim(other) if isinstance(other, ABCSeries): @@ -756,20 +749,13 @@ def __sub__(self, other): elif is_offsetlike(other): # Array/Index of DateOffset objects result = self._addsub_offset_array(other, operator.sub) - elif isinstance(self, TimedeltaIndex) and isinstance(other, Index): - # We checked above for timedelta64_dtype(other) so this - # must be invalid. - raise TypeError("cannot subtract TimedeltaIndex and {typ}" - .format(typ=type(other).__name__)) - elif isinstance(other, DatetimeIndex): + elif is_datetime64_dtype(other) or is_datetime64tz_dtype(other): + # DatetimeIndex, ndarray[datetime64] result = self._sub_datelike(other) - elif is_datetime64_dtype(other): - # ndarray[datetime64]; note we caught DatetimeIndex earlier - return self - DatetimeIndex(other) elif isinstance(other, Index): - raise TypeError("cannot subtract {typ1} and {typ2}" - .format(typ1=type(self).__name__, - typ2=type(other).__name__)) + raise TypeError("cannot subtract {cls} and {typ}" + .format(cls=type(self).__name__, + typ=type(other).__name__)) elif is_integer_dtype(other) and self.freq is None: # GH#19123 raise NullFrequencyError("Cannot shift with no freq") diff --git a/pandas/core/indexes/datetimes.py b/pandas/core/indexes/datetimes.py index 36ea2bffb9531..55d8b7c18a622 100644 --- a/pandas/core/indexes/datetimes.py +++ b/pandas/core/indexes/datetimes.py @@ -864,11 +864,16 @@ def _add_datelike(self, other): def _sub_datelike(self, other): # subtract a datetime from myself, yielding a TimedeltaIndex from pandas import TimedeltaIndex - if isinstance(other, DatetimeIndex): + + if isinstance(other, (DatetimeIndex, np.ndarray)): + # if other is an ndarray, we assume it is datetime64-dtype + other = DatetimeIndex(other) + # require tz compat if not self._has_same_tz(other): - raise TypeError("DatetimeIndex subtraction must have the same " - "timezones or no timezones") + raise TypeError("{cls} subtraction must have the same " + "timezones or no timezones" + .format(cls=type(self).__name__)) result = self._sub_datelike_dti(other) elif isinstance(other, (datetime, np.datetime64)): other = Timestamp(other) @@ -885,8 +890,9 @@ def _sub_datelike(self, other): result = self._maybe_mask_results(result, fill_value=libts.iNaT) else: - raise TypeError("cannot subtract DatetimeIndex and {typ}" - .format(typ=type(other).__name__)) + raise TypeError("cannot subtract {cls} and {typ}" + .format(cls=type(self).__name__, + typ=type(other).__name__)) return TimedeltaIndex(result) def _sub_datelike_dti(self, other): diff --git a/pandas/core/indexes/timedeltas.py b/pandas/core/indexes/timedeltas.py index 6f80962eab079..eebd52d7fb801 100644 --- a/pandas/core/indexes/timedeltas.py +++ b/pandas/core/indexes/timedeltas.py @@ -59,30 +59,28 @@ def _td_index_cmp(opname, cls): nat_result = True if opname == '__ne__' else False def wrapper(self, other): - msg = "cannot compare a TimedeltaIndex with type {0}" + msg = "cannot compare a {cls} with type {typ}" func = getattr(super(TimedeltaIndex, self), opname) if _is_convertible_to_td(other) or other is NaT: try: other = _to_m8(other) except ValueError: # failed to parse as timedelta - raise TypeError(msg.format(type(other))) + raise TypeError(msg.format(cls=type(self).__name__, + typ=type(other).__name__)) result = func(other) if isna(other): result.fill(nat_result) - else: - if not is_list_like(other): - raise TypeError(msg.format(type(other))) + elif not is_list_like(other): + raise TypeError(msg.format(cls=type(self).__name__, + typ=type(other).__name__)) + else: other = TimedeltaIndex(other).values result = func(other) result = com._values_from_object(result) - if isinstance(other, Index): - o_mask = other.values.view('i8') == iNaT - else: - o_mask = other.view('i8') == iNaT - + o_mask = np.array(isna(other)) if o_mask.any(): result[o_mask] = nat_result @@ -416,9 +414,15 @@ def _evaluate_with_timedelta_like(self, other, op): def _add_datelike(self, other): # adding a timedeltaindex to a datetimelike from pandas import Timestamp, DatetimeIndex + if other is NaT: # GH#19124 pd.NaT is treated like a timedelta return self._nat_new() + elif isinstance(other, (DatetimeIndex, np.ndarray)): + # if other is an ndarray, we assume it is datetime64-dtype + # defer to implementation in DatetimeIndex + other = DatetimeIndex(other) + return other + self else: other = Timestamp(other) i8 = self.asi8 @@ -434,7 +438,8 @@ def _sub_datelike(self, other): if other is NaT: return self._nat_new() else: - raise TypeError("cannot subtract a datelike from a TimedeltaIndex") + raise TypeError("cannot subtract a datelike from a {cls}" + .format(cls=type(self).__name__)) def _addsub_offset_array(self, other, op): # Add or subtract Array-like of DateOffset objects @@ -962,8 +967,7 @@ def _is_convertible_to_index(other): def _is_convertible_to_td(key): - # TODO: Not all DateOffset objects are convertible to Timedelta - return isinstance(key, (DateOffset, timedelta, Timedelta, + return isinstance(key, (Tick, timedelta, np.timedelta64, compat.string_types)) diff --git a/pandas/tests/indexes/datetimes/test_arithmetic.py b/pandas/tests/indexes/datetimes/test_arithmetic.py index 5a7ea44f3698c..0c56c6b16fb2f 100644 --- a/pandas/tests/indexes/datetimes/test_arithmetic.py +++ b/pandas/tests/indexes/datetimes/test_arithmetic.py @@ -508,7 +508,7 @@ def test_dti_sub_tdi(self, tz): result = dti - tdi tm.assert_index_equal(result, expected) - msg = 'cannot subtract TimedeltaIndex and DatetimeIndex' + msg = 'cannot subtract .*TimedeltaIndex' with tm.assert_raises_regex(TypeError, msg): tdi - dti @@ -531,7 +531,7 @@ def test_dti_isub_tdi(self, tz): result -= tdi tm.assert_index_equal(result, expected) - msg = 'cannot subtract TimedeltaIndex and DatetimeIndex' + msg = 'cannot subtract .*TimedeltaIndex' with tm.assert_raises_regex(TypeError, msg): tdi -= dti diff --git a/pandas/tests/indexes/timedeltas/test_arithmetic.py b/pandas/tests/indexes/timedeltas/test_arithmetic.py index 6a80b995b6ee9..9ffffb6ff06d5 100644 --- a/pandas/tests/indexes/timedeltas/test_arithmetic.py +++ b/pandas/tests/indexes/timedeltas/test_arithmetic.py @@ -792,7 +792,7 @@ def test_addition_ops(self): pytest.raises(ValueError, lambda: tdi[0:1] + dti) # random indexes - pytest.raises(TypeError, lambda: tdi + Int64Index([1, 2, 3])) + pytest.raises(NullFrequencyError, lambda: tdi + Int64Index([1, 2, 3])) # this is a union! # pytest.raises(TypeError, lambda : Int64Index([1,2,3]) + tdi)