Skip to content

Commit 7191af9

Browse files
jbrockmendeljorisvandenbossche
authored andcommitted
BUG/TST: timedelta-like with Index/Series/DataFrame ops (#23320)
1 parent 353a0f9 commit 7191af9

File tree

10 files changed

+77
-25
lines changed

10 files changed

+77
-25
lines changed

pandas/core/frame.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -4944,7 +4944,7 @@ def _combine_match_columns(self, other, func, level=None):
49444944
assert left.columns.equals(right.index)
49454945
return ops.dispatch_to_series(left, right, func, axis="columns")
49464946

4947-
def _combine_const(self, other, func, errors='raise'):
4947+
def _combine_const(self, other, func):
49484948
assert lib.is_scalar(other) or np.ndim(other) == 0
49494949
return ops.dispatch_to_series(self, other, func)
49504950

pandas/core/indexes/base.py

+7
Original file line numberDiff line numberDiff line change
@@ -4702,6 +4702,13 @@ def dropna(self, how='any'):
47024702
def _evaluate_with_timedelta_like(self, other, op):
47034703
# Timedelta knows how to operate with np.array, so dispatch to that
47044704
# operation and then wrap the results
4705+
if self._is_numeric_dtype and op.__name__ in ['add', 'sub',
4706+
'radd', 'rsub']:
4707+
raise TypeError("Operation {opname} between {cls} and {other} "
4708+
"is invalid".format(opname=op.__name__,
4709+
cls=type(self).__name__,
4710+
other=type(other).__name__))
4711+
47054712
other = Timedelta(other)
47064713
values = self.values
47074714

pandas/core/indexes/range.py

+6-2
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@
1414
from pandas.core.dtypes import concat as _concat
1515
from pandas.core.dtypes.common import (
1616
is_int64_dtype, is_integer, is_scalar, is_timedelta64_dtype)
17-
from pandas.core.dtypes.generic import ABCSeries, ABCTimedeltaIndex
17+
from pandas.core.dtypes.generic import (
18+
ABCDataFrame, ABCSeries, ABCTimedeltaIndex)
1819

1920
from pandas.core import ops
2021
import pandas.core.common as com
@@ -558,6 +559,9 @@ def __getitem__(self, key):
558559
return super_getitem(key)
559560

560561
def __floordiv__(self, other):
562+
if isinstance(other, (ABCSeries, ABCDataFrame)):
563+
return NotImplemented
564+
561565
if is_integer(other) and other != 0:
562566
if (len(self) == 0 or
563567
self._start % other == 0 and
@@ -589,7 +593,7 @@ def _make_evaluate_binop(op, step=False):
589593
"""
590594

591595
def _evaluate_numeric_binop(self, other):
592-
if isinstance(other, ABCSeries):
596+
if isinstance(other, (ABCSeries, ABCDataFrame)):
593597
return NotImplemented
594598
elif isinstance(other, ABCTimedeltaIndex):
595599
# Defer to TimedeltaIndex implementation

pandas/core/ops.py

+13-6
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,13 @@ def maybe_upcast_for_op(obj):
130130
# implementation; otherwise operation against numeric-dtype
131131
# raises TypeError
132132
return pd.Timedelta(obj)
133+
elif isinstance(obj, np.timedelta64) and not isna(obj):
134+
# In particular non-nanosecond timedelta64 needs to be cast to
135+
# nanoseconds, or else we get undesired behavior like
136+
# np.timedelta64(3, 'D') / 2 == np.timedelta64(1, 'D')
137+
# The isna check is to avoid casting timedelta64("NaT"), which would
138+
# return NaT and incorrectly be treated as a datetime-NaT.
139+
return pd.Timedelta(obj)
133140
elif isinstance(obj, np.ndarray) and is_timedelta64_dtype(obj):
134141
# GH#22390 Unfortunately we need to special-case right-hand
135142
# timedelta64 dtypes because numpy casts integer dtypes to
@@ -1405,11 +1412,12 @@ def wrapper(left, right):
14051412
index=left.index, name=res_name,
14061413
dtype=result.dtype)
14071414

1408-
elif is_timedelta64_dtype(right) and not is_scalar(right):
1409-
# i.e. exclude np.timedelta64 object
1415+
elif is_timedelta64_dtype(right):
1416+
# We should only get here with non-scalar or timedelta64('NaT')
1417+
# values for right
14101418
# Note: we cannot use dispatch_to_index_op because
1411-
# that may incorrectly raise TypeError when we
1412-
# should get NullFrequencyError
1419+
# that may incorrectly raise TypeError when we
1420+
# should get NullFrequencyError
14131421
result = op(pd.Index(left), right)
14141422
return construct_result(left, result,
14151423
index=left.index, name=res_name,
@@ -1941,8 +1949,7 @@ def f(self, other):
19411949

19421950
# straight boolean comparisons we want to allow all columns
19431951
# (regardless of dtype to pass thru) See #4537 for discussion.
1944-
res = self._combine_const(other, func,
1945-
errors='ignore')
1952+
res = self._combine_const(other, func)
19461953
return res.fillna(True).astype(bool)
19471954

19481955
f.__name__ = op_name

pandas/core/sparse/frame.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -620,7 +620,7 @@ def _combine_match_columns(self, other, func, level=None):
620620
new_data, index=left.index, columns=left.columns,
621621
default_fill_value=self.default_fill_value).__finalize__(self)
622622

623-
def _combine_const(self, other, func, errors='raise'):
623+
def _combine_const(self, other, func):
624624
return self._apply_columns(lambda x: func(x, other))
625625

626626
def _get_op_result_fill_value(self, other, func):

pandas/tests/arithmetic/conftest.py

+4-2
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,8 @@ def scalar_td(request):
7070
pd.Timedelta(days=3).to_pytimedelta(),
7171
pd.Timedelta('72:00:00'),
7272
np.timedelta64(3, 'D'),
73-
np.timedelta64(72, 'h')])
73+
np.timedelta64(72, 'h')],
74+
ids=lambda x: type(x).__name__)
7475
def three_days(request):
7576
"""
7677
Several timedelta-like and DateOffset objects that each represent
@@ -84,7 +85,8 @@ def three_days(request):
8485
pd.Timedelta(hours=2).to_pytimedelta(),
8586
pd.Timedelta(seconds=2 * 3600),
8687
np.timedelta64(2, 'h'),
87-
np.timedelta64(120, 'm')])
88+
np.timedelta64(120, 'm')],
89+
ids=lambda x: type(x).__name__)
8890
def two_hours(request):
8991
"""
9092
Several timedelta-like and DateOffset objects that each represent

pandas/tests/arithmetic/test_numeric.py

+24-8
Original file line numberDiff line numberDiff line change
@@ -148,15 +148,11 @@ def test_numeric_arr_mul_tdscalar(self, scalar_td, numeric_idx, box):
148148
tm.assert_equal(commute, expected)
149149

150150
def test_numeric_arr_rdiv_tdscalar(self, three_days, numeric_idx, box):
151-
index = numeric_idx[1:3]
152151

153-
broken = (isinstance(three_days, np.timedelta64) and
154-
three_days.dtype != 'm8[ns]')
155-
broken = broken or isinstance(three_days, pd.offsets.Tick)
156-
if box is not pd.Index and broken:
157-
# np.timedelta64(3, 'D') / 2 == np.timedelta64(1, 'D')
158-
raise pytest.xfail("timedelta64 not converted to nanos; "
159-
"Tick division not implemented")
152+
if box is not pd.Index and isinstance(three_days, pd.offsets.Tick):
153+
raise pytest.xfail("Tick division not implemented")
154+
155+
index = numeric_idx[1:3]
160156

161157
expected = TimedeltaIndex(['3 Days', '36 Hours'])
162158

@@ -169,6 +165,26 @@ def test_numeric_arr_rdiv_tdscalar(self, three_days, numeric_idx, box):
169165
with pytest.raises(TypeError):
170166
index / three_days
171167

168+
@pytest.mark.parametrize('other', [
169+
pd.Timedelta(hours=31),
170+
pd.Timedelta(hours=31).to_pytimedelta(),
171+
pd.Timedelta(hours=31).to_timedelta64(),
172+
pd.Timedelta(hours=31).to_timedelta64().astype('m8[h]'),
173+
np.timedelta64('NaT'),
174+
np.timedelta64('NaT', 'D'),
175+
pd.offsets.Minute(3),
176+
pd.offsets.Second(0)])
177+
def test_add_sub_timedeltalike_invalid(self, numeric_idx, other, box):
178+
left = tm.box_expected(numeric_idx, box)
179+
with pytest.raises(TypeError):
180+
left + other
181+
with pytest.raises(TypeError):
182+
other + left
183+
with pytest.raises(TypeError):
184+
left - other
185+
with pytest.raises(TypeError):
186+
other - left
187+
172188

173189
# ------------------------------------------------------------------
174190
# Arithmetic

pandas/tests/arithmetic/test_timedelta64.py

+2-4
Original file line numberDiff line numberDiff line change
@@ -1051,10 +1051,8 @@ def test_tdi_mul_float_series(self, box_df_fail):
10511051
pd.Float64Index(range(1, 11)),
10521052
pd.RangeIndex(1, 11)
10531053
], ids=lambda x: type(x).__name__)
1054-
def test_tdi_rmul_arraylike(self, other, box_df_fail):
1055-
# RangeIndex fails to return NotImplemented, for others
1056-
# DataFrame tries to broadcast incorrectly
1057-
box = box_df_fail
1054+
def test_tdi_rmul_arraylike(self, other, box_df_broadcast_failure):
1055+
box = box_df_broadcast_failure
10581056

10591057
tdi = TimedeltaIndex(['1 Day'] * 10)
10601058
expected = timedelta_range('1 days', '10 days')

pandas/tests/indexes/test_range.py

+19
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,25 @@ def test_constructor_name(self):
185185
assert copy.name == 'copy'
186186
assert new.name == 'new'
187187

188+
# TODO: mod, divmod?
189+
@pytest.mark.parametrize('op', [operator.add, operator.sub,
190+
operator.mul, operator.floordiv,
191+
operator.truediv, operator.pow])
192+
def test_arithmetic_with_frame_or_series(self, op):
193+
# check that we return NotImplemented when operating with Series
194+
# or DataFrame
195+
index = pd.RangeIndex(5)
196+
other = pd.Series(np.random.randn(5))
197+
198+
expected = op(pd.Series(index), other)
199+
result = op(index, other)
200+
tm.assert_series_equal(result, expected)
201+
202+
other = pd.DataFrame(np.random.randn(2, 5))
203+
expected = op(pd.DataFrame([index, index]), other)
204+
result = op(index, other)
205+
tm.assert_frame_equal(result, expected)
206+
188207
def test_numeric_compat2(self):
189208
# validate that we are handling the RangeIndex overrides to numeric ops
190209
# and returning RangeIndex where possible

pandas/tests/internals/test_internals.py

-1
Original file line numberDiff line numberDiff line change
@@ -1243,7 +1243,6 @@ def test_binop_other(self, op, value, dtype):
12431243
(operator.mul, '<M8[ns]'),
12441244
(operator.add, '<M8[ns]'),
12451245
(operator.pow, '<m8[ns]'),
1246-
(operator.mod, '<m8[ns]'),
12471246
(operator.mul, '<m8[ns]')}
12481247

12491248
if (op, dtype) in invalid:

0 commit comments

Comments
 (0)