Skip to content

Commit badd26b

Browse files
jbrockmendelPingviinituutti
authored andcommitted
BUG/TST/REF: Datetimelike Arithmetic Methods (pandas-dev#23215)
1 parent 30374a2 commit badd26b

File tree

15 files changed

+360
-327
lines changed

15 files changed

+360
-327
lines changed

doc/source/whatsnew/v0.24.0.txt

+4-2
Original file line numberDiff line numberDiff line change
@@ -1028,6 +1028,7 @@ Datetimelike
10281028
- Bug in :func:`date_range` when decrementing a start date to a past end date by a negative frequency (:issue:`23270`)
10291029
- Bug in :func:`DataFrame.combine` with datetimelike values raising a TypeError (:issue:`23079`)
10301030
- Bug in :func:`date_range` with frequency of ``Day`` or higher where dates sufficiently far in the future could wrap around to the past instead of raising ``OutOfBoundsDatetime`` (:issue:`14187`)
1031+
- Bug in :class:`PeriodIndex` with attribute ``freq.n`` greater than 1 where adding a :class:`DateOffset` object would return incorrect results (:issue:`23215`)
10311032

10321033
Timedelta
10331034
^^^^^^^^^
@@ -1039,7 +1040,8 @@ Timedelta
10391040
- Bug in :class:`TimedeltaIndex` incorrectly allowing indexing with ``Timestamp`` object (:issue:`20464`)
10401041
- Fixed bug where subtracting :class:`Timedelta` from an object-dtyped array would raise ``TypeError`` (:issue:`21980`)
10411042
- Fixed bug in adding a :class:`DataFrame` with all-`timedelta64[ns]` dtypes to a :class:`DataFrame` with all-integer dtypes returning incorrect results instead of raising ``TypeError`` (:issue:`22696`)
1042-
1043+
- Bug in :class:`TimedeltaIndex` where adding a timezone-aware datetime scalar incorrectly returned a timezone-naive :class:`DatetimeIndex` (:issue:`23215`)
1044+
- Bug in :class:`TimedeltaIndex` where adding ``np.timedelta64('NaT')`` incorrectly returned an all-`NaT` :class:`DatetimeIndex` instead of an all-`NaT` :class:`TimedeltaIndex` (:issue:`23215`)
10431045

10441046
Timezones
10451047
^^^^^^^^^
@@ -1069,7 +1071,7 @@ Offsets
10691071

10701072
- Bug in :class:`FY5253` where date offsets could incorrectly raise an ``AssertionError`` in arithmetic operatons (:issue:`14774`)
10711073
- Bug in :class:`DateOffset` where keyword arguments ``week`` and ``milliseconds`` were accepted and ignored. Passing these will now raise ``ValueError`` (:issue:`19398`)
1072-
-
1074+
- Bug in adding :class:`DateOffset` with :class:`DataFrame` or :class:`PeriodIndex` incorrectly raising ``TypeError`` (:issue:`23215`)
10731075

10741076
Numeric
10751077
^^^^^^^

pandas/_libs/tslibs/offsets.pyx

+2-2
Original file line numberDiff line numberDiff line change
@@ -344,8 +344,8 @@ class _BaseOffset(object):
344344
return {name: kwds[name] for name in kwds if kwds[name] is not None}
345345

346346
def __add__(self, other):
347-
if getattr(other, "_typ", None) in ["datetimeindex",
348-
"series", "period"]:
347+
if getattr(other, "_typ", None) in ["datetimeindex", "periodindex",
348+
"series", "period", "dataframe"]:
349349
# defer to the other class's implementation
350350
return other + self
351351
try:

pandas/core/arrays/datetimelike.py

+54-37
Original file line numberDiff line numberDiff line change
@@ -221,11 +221,12 @@ def hasnans(self):
221221
""" return if I have any nans; enables various perf speedups """
222222
return bool(self._isnan.any())
223223

224-
def _maybe_mask_results(self, result, fill_value=None, convert=None):
224+
def _maybe_mask_results(self, result, fill_value=iNaT, convert=None):
225225
"""
226226
Parameters
227227
----------
228228
result : a ndarray
229+
fill_value : object, default iNaT
229230
convert : string/dtype or None
230231
231232
Returns
@@ -246,27 +247,6 @@ def _maybe_mask_results(self, result, fill_value=None, convert=None):
246247
result[self._isnan] = fill_value
247248
return result
248249

249-
def _nat_new(self, box=True):
250-
"""
251-
Return Array/Index or ndarray filled with NaT which has the same
252-
length as the caller.
253-
254-
Parameters
255-
----------
256-
box : boolean, default True
257-
- If True returns a Array/Index as the same as caller.
258-
- If False returns ndarray of np.int64.
259-
"""
260-
result = np.zeros(len(self), dtype=np.int64)
261-
result.fill(iNaT)
262-
if not box:
263-
return result
264-
265-
attribs = self._get_attributes_dict()
266-
if not is_period_dtype(self):
267-
attribs['freq'] = None
268-
return self._simple_new(result, **attribs)
269-
270250
# ------------------------------------------------------------------
271251
# Frequency Properties/Methods
272252

@@ -346,41 +326,74 @@ def _validate_frequency(cls, index, freq, **kwargs):
346326
# ------------------------------------------------------------------
347327
# Arithmetic Methods
348328

349-
def _add_datelike(self, other):
329+
def _add_datetimelike_scalar(self, other):
330+
# Overriden by TimedeltaArray
350331
raise TypeError("cannot add {cls} and {typ}"
351332
.format(cls=type(self).__name__,
352333
typ=type(other).__name__))
353334

354-
def _sub_datelike(self, other):
355-
raise com.AbstractMethodError(self)
335+
_add_datetime_arraylike = _add_datetimelike_scalar
336+
337+
def _sub_datetimelike_scalar(self, other):
338+
# Overridden by DatetimeArray
339+
assert other is not NaT
340+
raise TypeError("cannot subtract a datelike from a {cls}"
341+
.format(cls=type(self).__name__))
342+
343+
_sub_datetime_arraylike = _sub_datetimelike_scalar
356344

357345
def _sub_period(self, other):
358-
return NotImplemented
346+
# Overriden by PeriodArray
347+
raise TypeError("cannot subtract Period from a {cls}"
348+
.format(cls=type(self).__name__))
359349

360350
def _add_offset(self, offset):
361351
raise com.AbstractMethodError(self)
362352

363353
def _add_delta(self, other):
364-
return NotImplemented
354+
"""
355+
Add a timedelta-like, Tick or TimedeltaIndex-like object
356+
to self, yielding an int64 numpy array
357+
358+
Parameters
359+
----------
360+
delta : {timedelta, np.timedelta64, Tick,
361+
TimedeltaIndex, ndarray[timedelta64]}
362+
363+
Returns
364+
-------
365+
result : ndarray[int64]
365366
366-
def _add_delta_td(self, other):
367+
Notes
368+
-----
369+
The result's name is set outside of _add_delta by the calling
370+
method (__add__ or __sub__), if necessary (i.e. for Indexes).
371+
"""
372+
if isinstance(other, (Tick, timedelta, np.timedelta64)):
373+
new_values = self._add_timedeltalike_scalar(other)
374+
elif is_timedelta64_dtype(other):
375+
# ndarray[timedelta64] or TimedeltaArray/index
376+
new_values = self._add_delta_tdi(other)
377+
378+
return new_values
379+
380+
def _add_timedeltalike_scalar(self, other):
367381
"""
368382
Add a delta of a timedeltalike
369383
return the i8 result view
370384
"""
371385
inc = delta_to_nanoseconds(other)
372386
new_values = checked_add_with_arr(self.asi8, inc,
373387
arr_mask=self._isnan).view('i8')
374-
if self.hasnans:
375-
new_values[self._isnan] = iNaT
388+
new_values = self._maybe_mask_results(new_values)
376389
return new_values.view('i8')
377390

378391
def _add_delta_tdi(self, other):
379392
"""
380393
Add a delta of a TimedeltaIndex
381394
return the i8 result view
382395
"""
383-
if not len(self) == len(other):
396+
if len(self) != len(other):
384397
raise ValueError("cannot add indices of unequal length")
385398

386399
if isinstance(other, np.ndarray):
@@ -407,7 +420,9 @@ def _add_nat(self):
407420

408421
# GH#19124 pd.NaT is treated like a timedelta for both timedelta
409422
# and datetime dtypes
410-
return self._nat_new(box=True)
423+
result = np.zeros(len(self), dtype=np.int64)
424+
result.fill(iNaT)
425+
return self._shallow_copy(result, freq=None)
411426

412427
def _sub_nat(self):
413428
"""Subtract pd.NaT from self"""
@@ -441,7 +456,7 @@ def _sub_period_array(self, other):
441456
.format(dtype=other.dtype,
442457
cls=type(self).__name__))
443458

444-
if not len(self) == len(other):
459+
if len(self) != len(other):
445460
raise ValueError("cannot subtract arrays/indices of "
446461
"unequal length")
447462
if self.freq != other.freq:
@@ -473,6 +488,8 @@ def _addsub_int_array(self, other, op):
473488
-------
474489
result : same class as self
475490
"""
491+
# _addsub_int_array is overriden by PeriodArray
492+
assert not is_period_dtype(self)
476493
assert op in [operator.add, operator.sub]
477494

478495
if self.freq is None:
@@ -613,7 +630,7 @@ def __add__(self, other):
613630
# specifically _not_ a Tick
614631
result = self._add_offset(other)
615632
elif isinstance(other, (datetime, np.datetime64)):
616-
result = self._add_datelike(other)
633+
result = self._add_datetimelike_scalar(other)
617634
elif lib.is_integer(other):
618635
# This check must come after the check for np.timedelta64
619636
# as is_integer returns True for these
@@ -628,7 +645,7 @@ def __add__(self, other):
628645
result = self._addsub_offset_array(other, operator.add)
629646
elif is_datetime64_dtype(other) or is_datetime64tz_dtype(other):
630647
# DatetimeIndex, ndarray[datetime64]
631-
return self._add_datelike(other)
648+
return self._add_datetime_arraylike(other)
632649
elif is_integer_dtype(other):
633650
result = self._addsub_int_array(other, operator.add)
634651
elif is_float_dtype(other):
@@ -671,7 +688,7 @@ def __sub__(self, other):
671688
# specifically _not_ a Tick
672689
result = self._add_offset(-other)
673690
elif isinstance(other, (datetime, np.datetime64)):
674-
result = self._sub_datelike(other)
691+
result = self._sub_datetimelike_scalar(other)
675692
elif lib.is_integer(other):
676693
# This check must come after the check for np.timedelta64
677694
# as is_integer returns True for these
@@ -688,7 +705,7 @@ def __sub__(self, other):
688705
result = self._addsub_offset_array(other, operator.sub)
689706
elif is_datetime64_dtype(other) or is_datetime64tz_dtype(other):
690707
# DatetimeIndex, ndarray[datetime64]
691-
result = self._sub_datelike(other)
708+
result = self._sub_datetime_arraylike(other)
692709
elif is_period_dtype(other):
693710
# PeriodIndex
694711
result = self._sub_period_array(other)

pandas/core/arrays/datetimes.py

+40-63
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# -*- coding: utf-8 -*-
2-
from datetime import datetime, timedelta, time
2+
from datetime import datetime, time
33
import warnings
44

55
import numpy as np
@@ -21,7 +21,6 @@
2121
is_object_dtype,
2222
is_datetime64tz_dtype,
2323
is_datetime64_dtype,
24-
is_timedelta64_dtype,
2524
ensure_int64)
2625
from pandas.core.dtypes.dtypes import DatetimeTZDtype
2726
from pandas.core.dtypes.missing import isna
@@ -76,11 +75,12 @@ def f(self):
7675

7776
if field in self._object_ops:
7877
result = fields.get_date_name_field(values, field)
79-
result = self._maybe_mask_results(result)
78+
result = self._maybe_mask_results(result, fill_value=None)
8079

8180
else:
8281
result = fields.get_date_field(values, field)
83-
result = self._maybe_mask_results(result, convert='float64')
82+
result = self._maybe_mask_results(result, fill_value=None,
83+
convert='float64')
8484

8585
return result
8686

@@ -424,11 +424,21 @@ def _assert_tzawareness_compat(self, other):
424424
# -----------------------------------------------------------------
425425
# Arithmetic Methods
426426

427-
def _sub_datelike_dti(self, other):
428-
"""subtraction of two DatetimeIndexes"""
429-
if not len(self) == len(other):
427+
def _sub_datetime_arraylike(self, other):
428+
"""subtract DatetimeArray/Index or ndarray[datetime64]"""
429+
if len(self) != len(other):
430430
raise ValueError("cannot add indices of unequal length")
431431

432+
if isinstance(other, np.ndarray):
433+
assert is_datetime64_dtype(other)
434+
other = type(self)(other)
435+
436+
if not self._has_same_tz(other):
437+
# require tz compat
438+
raise TypeError("{cls} subtraction must have the same "
439+
"timezones or no timezones"
440+
.format(cls=type(self).__name__))
441+
432442
self_i8 = self.asi8
433443
other_i8 = other.asi8
434444
new_values = checked_add_with_arr(self_i8, -other_i8,
@@ -456,74 +466,41 @@ def _add_offset(self, offset):
456466

457467
return type(self)(result, freq='infer')
458468

459-
def _sub_datelike(self, other):
469+
def _sub_datetimelike_scalar(self, other):
460470
# subtract a datetime from myself, yielding a ndarray[timedelta64[ns]]
461-
if isinstance(other, (DatetimeArrayMixin, np.ndarray)):
462-
if isinstance(other, np.ndarray):
463-
# if other is an ndarray, we assume it is datetime64-dtype
464-
other = type(self)(other)
465-
if not self._has_same_tz(other):
466-
# require tz compat
467-
raise TypeError("{cls} subtraction must have the same "
468-
"timezones or no timezones"
469-
.format(cls=type(self).__name__))
470-
result = self._sub_datelike_dti(other)
471-
elif isinstance(other, (datetime, np.datetime64)):
472-
assert other is not NaT
473-
other = Timestamp(other)
474-
if other is NaT:
475-
return self - NaT
471+
assert isinstance(other, (datetime, np.datetime64))
472+
assert other is not NaT
473+
other = Timestamp(other)
474+
if other is NaT:
475+
return self - NaT
476+
477+
if not self._has_same_tz(other):
476478
# require tz compat
477-
elif not self._has_same_tz(other):
478-
raise TypeError("Timestamp subtraction must have the same "
479-
"timezones or no timezones")
480-
else:
481-
i8 = self.asi8
482-
result = checked_add_with_arr(i8, -other.value,
483-
arr_mask=self._isnan)
484-
result = self._maybe_mask_results(result,
485-
fill_value=iNaT)
486-
else:
487-
raise TypeError("cannot subtract {cls} and {typ}"
488-
.format(cls=type(self).__name__,
489-
typ=type(other).__name__))
479+
raise TypeError("Timestamp subtraction must have the same "
480+
"timezones or no timezones")
481+
482+
i8 = self.asi8
483+
result = checked_add_with_arr(i8, -other.value,
484+
arr_mask=self._isnan)
485+
result = self._maybe_mask_results(result)
490486
return result.view('timedelta64[ns]')
491487

492488
def _add_delta(self, delta):
493489
"""
494-
Add a timedelta-like, DateOffset, or TimedeltaIndex-like object
495-
to self.
490+
Add a timedelta-like, Tick, or TimedeltaIndex-like object
491+
to self, yielding a new DatetimeArray
496492
497493
Parameters
498494
----------
499-
delta : {timedelta, np.timedelta64, DateOffset,
495+
other : {timedelta, np.timedelta64, Tick,
500496
TimedeltaIndex, ndarray[timedelta64]}
501497
502498
Returns
503499
-------
504-
result : same type as self
505-
506-
Notes
507-
-----
508-
The result's name is set outside of _add_delta by the calling
509-
method (__add__ or __sub__)
500+
result : DatetimeArray
510501
"""
511-
from pandas.core.arrays import TimedeltaArrayMixin
512-
513-
if isinstance(delta, (Tick, timedelta, np.timedelta64)):
514-
new_values = self._add_delta_td(delta)
515-
elif is_timedelta64_dtype(delta):
516-
if not isinstance(delta, TimedeltaArrayMixin):
517-
delta = TimedeltaArrayMixin(delta)
518-
new_values = self._add_delta_tdi(delta)
519-
else:
520-
new_values = self.astype('O') + delta
521-
522-
tz = 'UTC' if self.tz is not None else None
523-
result = type(self)(new_values, tz=tz, freq='infer')
524-
if self.tz is not None and self.tz is not utc:
525-
result = result.tz_convert(self.tz)
526-
return result
502+
new_values = dtl.DatetimeLikeArrayMixin._add_delta(self, delta)
503+
return type(self)(new_values, tz=self.tz, freq='infer')
527504

528505
# -----------------------------------------------------------------
529506
# Timezone Conversion and Localization Methods
@@ -904,7 +881,7 @@ def month_name(self, locale=None):
904881

905882
result = fields.get_date_name_field(values, 'month_name',
906883
locale=locale)
907-
result = self._maybe_mask_results(result)
884+
result = self._maybe_mask_results(result, fill_value=None)
908885
return result
909886

910887
def day_name(self, locale=None):
@@ -940,7 +917,7 @@ def day_name(self, locale=None):
940917

941918
result = fields.get_date_name_field(values, 'day_name',
942919
locale=locale)
943-
result = self._maybe_mask_results(result)
920+
result = self._maybe_mask_results(result, fill_value=None)
944921
return result
945922

946923
@property

0 commit comments

Comments
 (0)