Skip to content

Commit 57d7768

Browse files
authored
BUG: NumericArray * td64_array (#45622)
1 parent 76d412f commit 57d7768

File tree

13 files changed

+156
-113
lines changed

13 files changed

+156
-113
lines changed

doc/source/whatsnew/v1.5.0.rst

+1
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,7 @@ Timezones
234234
Numeric
235235
^^^^^^^
236236
- Bug in operations with array-likes with ``dtype="boolean"`` and :attr:`NA` incorrectly altering the array in-place (:issue:`45421`)
237+
- Bug in multiplying a :class:`Series` with ``IntegerDtype`` or ``FloatingDtype`` by an arraylike with ``timedelta64[ns]`` dtype incorrectly raising (:issue:`45622`)
237238
-
238239

239240
Conversion

pandas/core/arrays/boolean.py

+4-20
Original file line numberDiff line numberDiff line change
@@ -349,11 +349,10 @@ def _coerce_to_array(
349349
def _logical_method(self, other, op):
350350

351351
assert op.__name__ in {"or_", "ror_", "and_", "rand_", "xor", "rxor"}
352-
other_is_booleanarray = isinstance(other, BooleanArray)
353352
other_is_scalar = lib.is_scalar(other)
354353
mask = None
355354

356-
if other_is_booleanarray:
355+
if isinstance(other, BooleanArray):
357356
other, mask = other._data, other._mask
358357
elif is_list_like(other):
359358
other = np.asarray(other, dtype="bool")
@@ -370,7 +369,7 @@ def _logical_method(self, other, op):
370369
)
371370

372371
if not other_is_scalar and len(self) != len(other):
373-
raise ValueError("Lengths must match to compare")
372+
raise ValueError("Lengths must match")
374373

375374
if op.__name__ in {"or_", "ror_"}:
376375
result, mask = ops.kleene_or(self._data, other, self._mask, mask)
@@ -387,7 +386,7 @@ def _arith_method(self, other, op):
387386
mask = None
388387
op_name = op.__name__
389388

390-
if isinstance(other, BooleanArray):
389+
if isinstance(other, BaseMaskedArray):
391390
other, mask = other._data, other._mask
392391

393392
elif is_list_like(other):
@@ -397,14 +396,7 @@ def _arith_method(self, other, op):
397396
if len(self) != len(other):
398397
raise ValueError("Lengths must match")
399398

400-
# nans propagate
401-
if mask is None:
402-
mask = self._mask
403-
if other is libmissing.NA:
404-
# GH#45421 don't alter inplace
405-
mask = mask | True
406-
else:
407-
mask = self._mask | mask
399+
mask = self._propagate_mask(mask, other)
408400

409401
if other is libmissing.NA:
410402
# if other is NA, the result will be all NA and we can't run the
@@ -425,14 +417,6 @@ def _arith_method(self, other, op):
425417
with np.errstate(all="ignore"):
426418
result = op(self._data, other)
427419

428-
# divmod returns a tuple
429-
if op_name == "divmod":
430-
div, mod = result
431-
return (
432-
self._maybe_mask_result(div, mask, other, "floordiv"),
433-
self._maybe_mask_result(mod, mask, other, "mod"),
434-
)
435-
436420
return self._maybe_mask_result(result, mask, other, op_name)
437421

438422
def __abs__(self):

pandas/core/arrays/masked.py

+26-9
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
import numpy as np
1414

1515
from pandas._libs import (
16-
iNaT,
1716
lib,
1817
missing as libmissing,
1918
)
@@ -582,6 +581,18 @@ def _hasna(self) -> bool:
582581
# error: Incompatible return value type (got "bool_", expected "bool")
583582
return self._mask.any() # type: ignore[return-value]
584583

584+
def _propagate_mask(
585+
self, mask: npt.NDArray[np.bool_] | None, other
586+
) -> npt.NDArray[np.bool_]:
587+
if mask is None:
588+
mask = self._mask.copy() # TODO: need test for BooleanArray needing a copy
589+
if other is libmissing.NA:
590+
# GH#45421 don't alter inplace
591+
mask = mask | True
592+
else:
593+
mask = self._mask | mask
594+
return mask
595+
585596
def _cmp_method(self, other, op) -> BooleanArray:
586597
from pandas.core.arrays import BooleanArray
587598

@@ -619,12 +630,7 @@ def _cmp_method(self, other, op) -> BooleanArray:
619630
if result is NotImplemented:
620631
result = invalid_comparison(self._data, other, op)
621632

622-
# nans propagate
623-
if mask is None:
624-
mask = self._mask.copy()
625-
else:
626-
mask = self._mask | mask
627-
633+
mask = self._propagate_mask(mask, other)
628634
return BooleanArray(result, mask, copy=False)
629635

630636
def _maybe_mask_result(self, result, mask, other, op_name: str):
@@ -636,6 +642,14 @@ def _maybe_mask_result(self, result, mask, other, op_name: str):
636642
other : scalar or array-like
637643
op_name : str
638644
"""
645+
if op_name == "divmod":
646+
# divmod returns a tuple
647+
div, mod = result
648+
return (
649+
self._maybe_mask_result(div, mask, other, "floordiv"),
650+
self._maybe_mask_result(mod, mask, other, "mod"),
651+
)
652+
639653
# if we have a float operand we are by-definition
640654
# a float result
641655
# or our op is a divide
@@ -657,8 +671,11 @@ def _maybe_mask_result(self, result, mask, other, op_name: str):
657671
# e.g. test_numeric_arr_mul_tdscalar_numexpr_path
658672
from pandas.core.arrays import TimedeltaArray
659673

660-
result[mask] = iNaT
661-
return TimedeltaArray._simple_new(result)
674+
if not isinstance(result, TimedeltaArray):
675+
result = TimedeltaArray._simple_new(result)
676+
677+
result[mask] = result.dtype.type("NaT")
678+
return result
662679

663680
elif is_integer_dtype(result):
664681
from pandas.core.arrays import IntegerArray

pandas/core/arrays/numeric.py

+28-42
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
from __future__ import annotations
22

3-
import datetime
43
import numbers
54
from typing import (
65
TYPE_CHECKING,
@@ -10,7 +9,6 @@
109
import numpy as np
1110

1211
from pandas._libs import (
13-
Timedelta,
1412
lib,
1513
missing as libmissing,
1614
)
@@ -23,20 +21,21 @@
2321

2422
from pandas.core.dtypes.common import (
2523
is_bool_dtype,
26-
is_float,
2724
is_float_dtype,
28-
is_integer,
2925
is_integer_dtype,
3026
is_list_like,
3127
is_object_dtype,
3228
is_string_dtype,
3329
pandas_dtype,
3430
)
3531

32+
from pandas.core import ops
33+
from pandas.core.arrays.base import ExtensionArray
3634
from pandas.core.arrays.masked import (
3735
BaseMaskedArray,
3836
BaseMaskedDtype,
3937
)
38+
from pandas.core.construction import ensure_wrapped_if_datetimelike
4039

4140
if TYPE_CHECKING:
4241
import pyarrow
@@ -219,34 +218,40 @@ def _arith_method(self, other, op):
219218
op_name = op.__name__
220219
omask = None
221220

222-
if getattr(other, "ndim", 0) > 1:
223-
raise NotImplementedError("can only perform ops with 1-d structures")
224-
225-
if isinstance(other, NumericArray):
221+
if isinstance(other, BaseMaskedArray):
226222
other, omask = other._data, other._mask
227223

228224
elif is_list_like(other):
229-
other = np.asarray(other)
225+
if not isinstance(other, ExtensionArray):
226+
other = np.asarray(other)
230227
if other.ndim > 1:
231228
raise NotImplementedError("can only perform ops with 1-d structures")
232-
if len(self) != len(other):
233-
raise ValueError("Lengths must match")
234-
if not (is_float_dtype(other) or is_integer_dtype(other)):
235-
raise TypeError("can only perform ops with numeric values")
236229

237-
elif isinstance(other, (datetime.timedelta, np.timedelta64)):
238-
other = Timedelta(other)
230+
# We wrap the non-masked arithmetic logic used for numpy dtypes
231+
# in Series/Index arithmetic ops.
232+
other = ops.maybe_prepare_scalar_for_op(other, (len(self),))
233+
pd_op = ops.get_array_op(op)
234+
other = ensure_wrapped_if_datetimelike(other)
239235

240-
else:
241-
if not (is_float(other) or is_integer(other) or other is libmissing.NA):
242-
raise TypeError("can only perform ops with numeric values")
236+
mask = self._propagate_mask(omask, other)
243237

244-
if omask is None:
245-
mask = self._mask.copy()
246-
if other is libmissing.NA:
247-
mask |= True
238+
if other is libmissing.NA:
239+
result = np.ones_like(self._data)
240+
if "truediv" in op_name and self.dtype.kind != "f":
241+
# The actual data here doesn't matter since the mask
242+
# will be all-True, but since this is division, we want
243+
# to end up with floating dtype.
244+
result = result.astype(np.float64)
248245
else:
249-
mask = self._mask | omask
246+
# Make sure we do this before the "pow" mask checks
247+
# to get an expected exception message on shape mismatch.
248+
if self.dtype.kind in ["i", "u"] and op_name in ["floordiv", "mod"]:
249+
# ATM we don't match the behavior of non-masked types with
250+
# respect to floordiv-by-zero
251+
pd_op = op
252+
253+
with np.errstate(all="ignore"):
254+
result = pd_op(self._data, other)
250255

251256
if op_name == "pow":
252257
# 1 ** x is 1.
@@ -266,25 +271,6 @@ def _arith_method(self, other, op):
266271
# x ** 0 is 1.
267272
mask = np.where((self._data == 0) & ~self._mask, False, mask)
268273

269-
if other is libmissing.NA:
270-
result = np.ones_like(self._data)
271-
if "truediv" in op_name and self.dtype.kind != "f":
272-
# The actual data here doesn't matter since the mask
273-
# will be all-True, but since this is division, we want
274-
# to end up with floating dtype.
275-
result = result.astype(np.float64)
276-
else:
277-
with np.errstate(all="ignore"):
278-
result = op(self._data, other)
279-
280-
# divmod returns a tuple
281-
if op_name == "divmod":
282-
div, mod = result
283-
return (
284-
self._maybe_mask_result(div, mask, other, "floordiv"),
285-
self._maybe_mask_result(mod, mask, other, "mod"),
286-
)
287-
288274
return self._maybe_mask_result(result, mask, other, op_name)
289275

290276
_HANDLED_TYPES = (np.ndarray, numbers.Number)

pandas/tests/arithmetic/test_datetime64.py

+1
Original file line numberDiff line numberDiff line change
@@ -1097,6 +1097,7 @@ def test_dt64arr_addsub_intlike(
10971097
# IntegerArray
10981098
"can only perform ops with numeric values",
10991099
"unsupported operand type.*Categorical",
1100+
r"unsupported operand type\(s\) for -: 'int' and 'Timestamp'",
11001101
]
11011102
)
11021103
assert_invalid_addsub_type(obj, 1, msg)

pandas/tests/arrays/boolean/test_arithmetic.py

+11-6
Original file line numberDiff line numberDiff line change
@@ -103,19 +103,24 @@ def test_error_invalid_values(data, all_arithmetic_operators):
103103
)
104104
with pytest.raises(TypeError, match=msg):
105105
ops("foo")
106-
msg = (
107-
r"unsupported operand type\(s\) for|"
108-
"Concatenation operation is not implemented for NumPy arrays"
106+
msg = "|".join(
107+
[
108+
r"unsupported operand type\(s\) for",
109+
"Concatenation operation is not implemented for NumPy arrays",
110+
]
109111
)
110112
with pytest.raises(TypeError, match=msg):
111113
ops(pd.Timestamp("20180101"))
112114

113115
# invalid array-likes
114116
if op not in ("__mul__", "__rmul__"):
115117
# TODO(extension) numpy's mul with object array sees booleans as numbers
116-
msg = (
117-
r"unsupported operand type\(s\) for|can only concatenate str|"
118-
"not all arguments converted during string formatting"
118+
msg = "|".join(
119+
[
120+
r"unsupported operand type\(s\) for",
121+
"can only concatenate str",
122+
"not all arguments converted during string formatting",
123+
]
119124
)
120125
with pytest.raises(TypeError, match=msg):
121126
ops(pd.Series("foo", index=s.index))

pandas/tests/arrays/boolean/test_logical.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ def test_eq_mismatched_type(self, other):
6363
def test_logical_length_mismatch_raises(self, all_logical_operators):
6464
op_name = all_logical_operators
6565
a = pd.array([True, False, None], dtype="boolean")
66-
msg = "Lengths must match to compare"
66+
msg = "Lengths must match"
6767

6868
with pytest.raises(ValueError, match=msg):
6969
getattr(a, op_name)([True, False])

pandas/tests/arrays/floating/test_arithmetic.py

+20-3
Original file line numberDiff line numberDiff line change
@@ -129,9 +129,19 @@ def test_error_invalid_values(data, all_arithmetic_operators):
129129
ops = getattr(s, op)
130130

131131
# invalid scalars
132-
msg = (
133-
r"(:?can only perform ops with numeric values)"
134-
r"|(:?FloatingArray cannot perform the operation mod)"
132+
msg = "|".join(
133+
[
134+
r"can only perform ops with numeric values",
135+
r"FloatingArray cannot perform the operation mod",
136+
"unsupported operand type",
137+
"not all arguments converted during string formatting",
138+
"can't multiply sequence by non-int of type 'float'",
139+
"ufunc 'subtract' cannot use operands with types dtype",
140+
r"can only concatenate str \(not \"float\"\) to str",
141+
"ufunc '.*' not supported for the input types, and the inputs could not",
142+
"ufunc '.*' did not contain a loop with signature matching types",
143+
"Concatenation operation is not implemented for NumPy arrays",
144+
]
135145
)
136146
with pytest.raises(TypeError, match=msg):
137147
ops("foo")
@@ -148,6 +158,13 @@ def test_error_invalid_values(data, all_arithmetic_operators):
148158
"cannot perform .* with this index type: DatetimeArray",
149159
"Addition/subtraction of integers and integer-arrays "
150160
"with DatetimeArray is no longer supported. *",
161+
"unsupported operand type",
162+
"not all arguments converted during string formatting",
163+
"can't multiply sequence by non-int of type 'float'",
164+
"ufunc 'subtract' cannot use operands with types dtype",
165+
r"ufunc 'add' cannot use operands with types dtype\('<M8\[ns\]'\)",
166+
r"ufunc 'add' cannot use operands with types dtype\('float\d{2}'\)",
167+
"cannot subtract DatetimeArray from ndarray",
151168
]
152169
)
153170
with pytest.raises(TypeError, match=msg):

0 commit comments

Comments
 (0)