Skip to content

Commit 14eaa19

Browse files
committed
ENH: consistent exception messages for arithmetic
1 parent 23926b5 commit 14eaa19

File tree

14 files changed

+234
-107
lines changed

14 files changed

+234
-107
lines changed

pandas/core/arraylike.py

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@
88
from __future__ import annotations
99

1010
import operator
11-
from typing import Any
11+
from typing import (
12+
TYPE_CHECKING,
13+
Any,
14+
)
1215

1316
import numpy as np
1417

@@ -21,6 +24,9 @@
2124
from pandas.core.construction import extract_array
2225
from pandas.core.ops.common import unpack_zerodim_and_defer
2326

27+
if TYPE_CHECKING:
28+
from pandas._typing import ArrayLike
29+
2430
REDUCTION_ALIASES = {
2531
"maximum": "max",
2632
"minimum": "min",
@@ -30,6 +36,41 @@
3036

3137

3238
class OpsMixin:
39+
def _supports_scalar_op(self, other, op_name: str):
40+
"""
41+
Return False to have unpack_zerodim_and_defer raise a TypeError with
42+
standardized exception message.
43+
44+
Parameters
45+
----------
46+
other : scalar
47+
The type(other).__name__ will be used for the exception message.
48+
op_name : str
49+
50+
Returns
51+
-------
52+
bool
53+
"""
54+
return True
55+
56+
def _supports_array_op(self, other: ArrayLike, op_name: str):
57+
"""
58+
Return False to have unpack_zerodim_and_defer raise a TypeError with
59+
standardized exception message.
60+
61+
Parameters
62+
----------
63+
other : np.ndarray or ExtensionArray
64+
The other.dtype will be used for the exception message.
65+
op_name : str
66+
67+
Returns
68+
-------
69+
bool
70+
71+
"""
72+
return True
73+
3374
# -------------------------------------------------------------
3475
# Comparisons
3576

@@ -220,7 +261,7 @@ def __rtruediv__(self, other):
220261
def __floordiv__(self, other):
221262
return self._arith_method(other, operator.floordiv)
222263

223-
@unpack_zerodim_and_defer("__rfloordiv")
264+
@unpack_zerodim_and_defer("__rfloordiv__")
224265
def __rfloordiv__(self, other):
225266
return self._arith_method(other, roperator.rfloordiv)
226267

pandas/core/arrays/arrow/array.py

Lines changed: 8 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -877,16 +877,6 @@ def _cmp_method(self, other, op) -> ArrowExtensionArray:
877877
)
878878
return ArrowExtensionArray(result)
879879

880-
def _op_method_error_message(self, other, op) -> str:
881-
if hasattr(other, "dtype"):
882-
other_type = f"dtype '{other.dtype}'"
883-
else:
884-
other_type = f"object of type {type(other)}"
885-
return (
886-
f"operation '{op.__name__}' not supported for "
887-
f"dtype '{self.dtype}' with {other_type}"
888-
)
889-
890880
def _evaluate_op_method(self, other, op, arrow_funcs) -> Self:
891881
pa_type = self._pa_array.type
892882
other_original = other
@@ -905,9 +895,10 @@ def _evaluate_op_method(self, other, op, arrow_funcs) -> Self:
905895
elif op is roperator.radd:
906896
result = pc.binary_join_element_wise(other, self._pa_array, sep)
907897
except pa.ArrowNotImplementedError as err:
908-
raise TypeError(
909-
self._op_method_error_message(other_original, op)
910-
) from err
898+
msg = ops.get_op_exception_message(
899+
op.__name__, self, other_original
900+
)
901+
raise TypeError(msg) from err
911902
return self._from_pyarrow_array(result)
912903
elif op in [operator.mul, roperator.rmul]:
913904
binary = self._pa_array
@@ -940,13 +931,15 @@ def _evaluate_op_method(self, other, op, arrow_funcs) -> Self:
940931
pc_func = arrow_funcs[op.__name__]
941932
if pc_func is NotImplemented:
942933
if pa.types.is_string(pa_type) or pa.types.is_large_string(pa_type):
943-
raise TypeError(self._op_method_error_message(other_original, op))
934+
msg = ops.get_op_exception_message(op.__name__, self, other_original)
935+
raise TypeError(msg)
944936
raise NotImplementedError(f"{op.__name__} not implemented.")
945937

946938
try:
947939
result = pc_func(self._pa_array, other)
948940
except pa.ArrowNotImplementedError as err:
949-
raise TypeError(self._op_method_error_message(other_original, op)) from err
941+
msg = ops.get_op_exception_message(op.__name__, self, other_original)
942+
raise TypeError(msg) from err
950943
return self._from_pyarrow_array(result)
951944

952945
def _logical_method(self, other, op) -> Self:

pandas/core/arrays/boolean.py

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212

1313
from pandas._libs import (
1414
lib,
15-
missing as libmissing,
1615
)
1716
from pandas.util._decorators import set_module
1817

@@ -385,14 +384,9 @@ def _logical_method(self, other, op):
385384
elif isinstance(other, np.bool_):
386385
other = other.item()
387386

388-
if other_is_scalar and other is not libmissing.NA and not lib.is_bool(other):
389-
raise TypeError(
390-
"'other' should be pandas.NA or a bool. "
391-
f"Got {type(other).__name__} instead."
392-
)
393-
394387
if not other_is_scalar and len(self) != len(other):
395-
raise ValueError("Lengths must match")
388+
msg = ops.get_shape_exception_message(self, other)
389+
raise ValueError(msg)
396390

397391
if op.__name__ in {"or_", "ror_"}:
398392
result, mask = ops.kleene_or(self._data, other, self._mask, mask)

pandas/core/arrays/categorical.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1612,6 +1612,12 @@ def map(
16121612
__le__ = _cat_compare_op(operator.le)
16131613
__ge__ = _cat_compare_op(operator.ge)
16141614

1615+
def _supports_scalar_op(self, other, op_name: str):
1616+
return True
1617+
1618+
def _supports_array_op(self, other, op_name: str):
1619+
return True
1620+
16151621
# -------------------------------------------------------------
16161622
# Validators; ideally these can be de-duplicated
16171623

pandas/core/arrays/datetimelike.py

Lines changed: 29 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -550,7 +550,8 @@ def _validate_comparison_value(self, other):
550550
raise InvalidComparison(other)
551551

552552
elif len(other) != len(self):
553-
raise ValueError("Lengths must match")
553+
msg = ops.get_shape_exception_message(self, other)
554+
raise ValueError(msg)
554555

555556
else:
556557
try:
@@ -963,6 +964,12 @@ def _is_unique(self) -> bool:
963964
# ------------------------------------------------------------------
964965
# Arithmetic Methods
965966

967+
def _supports_scalar_op(self, other, op_name):
968+
return True
969+
970+
def _supports_array_op(self, other, op_name):
971+
return True
972+
966973
def _cmp_method(self, other, op):
967974
if self.ndim > 1 and getattr(other, "shape", None) == self.shape:
968975
# TODO: handle 2D-like listlikes
@@ -1099,9 +1106,8 @@ def _get_arithmetic_result_freq(self, other) -> BaseOffset | None:
10991106
@final
11001107
def _add_datetimelike_scalar(self, other) -> DatetimeArray:
11011108
if not lib.is_np_dtype(self.dtype, "m"):
1102-
raise TypeError(
1103-
f"cannot add {type(self).__name__} and {type(other).__name__}"
1104-
)
1109+
msg = ops.get_op_exception_message(self, other)
1110+
raise TypeError(msg)
11051111

11061112
self = cast("TimedeltaArray", self)
11071113

@@ -1133,9 +1139,8 @@ def _add_datetimelike_scalar(self, other) -> DatetimeArray:
11331139
@final
11341140
def _add_datetime_arraylike(self, other: DatetimeArray) -> DatetimeArray:
11351141
if not lib.is_np_dtype(self.dtype, "m"):
1136-
raise TypeError(
1137-
f"cannot add {type(self).__name__} and {type(other).__name__}"
1138-
)
1142+
msg = ops.get_op_exception_message(self, other)
1143+
raise TypeError(msg)
11391144

11401145
# defer to DatetimeArray.__add__
11411146
return other + self
@@ -1145,7 +1150,8 @@ def _sub_datetimelike_scalar(
11451150
self, other: datetime | np.datetime64
11461151
) -> TimedeltaArray:
11471152
if self.dtype.kind != "M":
1148-
raise TypeError(f"cannot subtract a datelike from a {type(self).__name__}")
1153+
msg = ops.get_op_exception_message(self, other)
1154+
raise TypeError(msg)
11491155

11501156
self = cast("DatetimeArray", self)
11511157
# subtract a datetime from myself, yielding a ndarray[timedelta64[ns]]
@@ -1162,10 +1168,8 @@ def _sub_datetimelike_scalar(
11621168
@final
11631169
def _sub_datetime_arraylike(self, other: DatetimeArray) -> TimedeltaArray:
11641170
if self.dtype.kind != "M":
1165-
raise TypeError(f"cannot subtract a datelike from a {type(self).__name__}")
1166-
1167-
if len(self) != len(other):
1168-
raise ValueError("cannot add indices of unequal length")
1171+
msg = ops.get_op_exception_message(self, other)
1172+
raise TypeError(msg)
11691173

11701174
self = cast("DatetimeArray", self)
11711175

@@ -1195,7 +1199,8 @@ def _sub_datetimelike(self, other: Timestamp | DatetimeArray) -> TimedeltaArray:
11951199
@final
11961200
def _add_period(self, other: Period) -> PeriodArray:
11971201
if not lib.is_np_dtype(self.dtype, "m"):
1198-
raise TypeError(f"cannot add Period to a {type(self).__name__}")
1202+
msg = ops.get_op_exception_message(self, other)
1203+
raise TypeError(msg)
11991204

12001205
# We will wrap in a PeriodArray and defer to the reversed operation
12011206
from pandas.core.arrays.period import PeriodArray
@@ -1239,7 +1244,8 @@ def _add_timedelta_arraylike(self, other: TimedeltaArray) -> Self:
12391244
# overridden by PeriodArray
12401245

12411246
if len(self) != len(other):
1242-
raise ValueError("cannot add indices of unequal length")
1247+
msg = ops.get_shape_exception_message(self, other)
1248+
raise ValueError(msg)
12431249

12441250
self, other = cast(
12451251
"DatetimeArray | TimedeltaArray", self
@@ -1267,9 +1273,8 @@ def _add_nat(self) -> Self:
12671273
Add pd.NaT to self
12681274
"""
12691275
if isinstance(self.dtype, PeriodDtype):
1270-
raise TypeError(
1271-
f"Cannot add {type(self).__name__} and {type(NaT).__name__}"
1272-
)
1276+
msg = ops.get_op_exception_message(self, NaT)
1277+
raise TypeError(msg)
12731278

12741279
# GH#19124 pd.NaT is treated like a timedelta for both timedelta
12751280
# and datetime dtypes
@@ -1308,9 +1313,8 @@ def _sub_periodlike(self, other: Period | PeriodArray) -> npt.NDArray[np.object_
13081313
# If the operation is well-defined, we return an object-dtype ndarray
13091314
# of DateOffsets. Null entries are filled with pd.NaT
13101315
if not isinstance(self.dtype, PeriodDtype):
1311-
raise TypeError(
1312-
f"cannot subtract {type(other).__name__} from {type(self).__name__}"
1313-
)
1316+
msg = ops.get_op_exception_message(self, other)
1317+
raise TypeError(msg)
13141318

13151319
self = cast("PeriodArray", self)
13161320
self._check_compatible_with(other)
@@ -1519,13 +1523,13 @@ def __rsub__(self, other):
15191523
elif self.dtype.kind == "M" and hasattr(other, "dtype") and not other_is_dt64:
15201524
# GH#19959 datetime - datetime is well-defined as timedelta,
15211525
# but any other type - datetime is not well-defined.
1522-
raise TypeError(
1523-
f"cannot subtract {type(self).__name__} from "
1524-
f"{type(other).__name__}[{other.dtype}]"
1525-
)
1526+
msg = ops.get_op_exception_message(self, other)
1527+
raise TypeError(msg)
1528+
15261529
elif isinstance(self.dtype, PeriodDtype) and lib.is_np_dtype(other_dtype, "m"):
15271530
# TODO: Can we simplify/generalize these cases at all?
1528-
raise TypeError(f"cannot subtract {type(self).__name__} from {other.dtype}")
1531+
msg = ops.get_op_exception_message(self, other)
1532+
raise TypeError(msg)
15291533
elif lib.is_np_dtype(self.dtype, "m"):
15301534
self = cast("TimedeltaArray", self)
15311535
return (-self) + other

pandas/core/arrays/interval.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@
9191
)
9292
from pandas.core.indexers import check_array_indexer
9393
from pandas.core.ops import (
94+
get_shape_exception_message,
9495
invalid_comparison,
9596
unpack_zerodim_and_defer,
9697
)
@@ -733,11 +734,18 @@ def __setitem__(self, key, value) -> None:
733734
self._left[key] = value_left
734735
self._right[key] = value_right
735736

737+
def _supports_scalar_op(self, other, op_name):
738+
return True
739+
740+
def _supports_array_op(self, other, op_name):
741+
return True
742+
736743
def _cmp_method(self, other, op):
737744
# ensure pandas array for list-like and eliminate non-interval scalars
738745
if is_list_like(other):
739746
if len(self) != len(other):
740-
raise ValueError("Lengths must match to compare")
747+
msg = get_shape_exception_message(self, other)
748+
raise ValueError(msg)
741749
other = pd_array(other)
742750
elif not isinstance(other, Interval):
743751
# non-interval scalar -> no matches

pandas/core/arrays/masked.py

Lines changed: 39 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -731,6 +731,43 @@ def _propagate_mask(
731731
mask = self._mask | mask
732732
return mask
733733

734+
def _supports_scalar_op(self, other, op_name: str) -> bool:
735+
if self.dtype.kind == "b":
736+
if op_name.strip("_") in {"or", "ror", "and", "rand", "xor", "rxor"}:
737+
if other is not libmissing.NA and not lib.is_bool(other):
738+
return False
739+
740+
if other is libmissing.NA and op_name.strip("_") in {
741+
"floordiv",
742+
"rfloordiv",
743+
"pow",
744+
"rpow",
745+
"truediv",
746+
"rtruediv",
747+
}:
748+
# GH#41165 Try to match non-masked Series behavior
749+
# This is still imperfect GH#46043
750+
return False
751+
752+
return True
753+
754+
def _supports_array_op(self, other, op_name: str) -> bool:
755+
if self.dtype.kind == "b":
756+
if op_name.strip("_") in {
757+
"floordiv",
758+
"rfloordiv",
759+
"pow",
760+
"rpow",
761+
"truediv",
762+
"rtruediv",
763+
"sub",
764+
"rsub",
765+
}:
766+
# GH#41165 Try to match non-masked Series behavior
767+
# This is still imperfect GH#46043
768+
return False
769+
return True
770+
734771
def _arith_method(self, other, op):
735772
op_name = op.__name__
736773
omask = None
@@ -770,19 +807,6 @@ def _arith_method(self, other, op):
770807
if other is libmissing.NA:
771808
result = np.ones_like(self._data)
772809
if self.dtype.kind == "b":
773-
if op_name in {
774-
"floordiv",
775-
"rfloordiv",
776-
"pow",
777-
"rpow",
778-
"truediv",
779-
"rtruediv",
780-
}:
781-
# GH#41165 Try to match non-masked Series behavior
782-
# This is still imperfect GH#46043
783-
raise NotImplementedError(
784-
f"operator '{op_name}' not implemented for bool dtypes"
785-
)
786810
if op_name in {"mod", "rmod"}:
787811
dtype = "int8"
788812
else:
@@ -843,7 +867,8 @@ def _cmp_method(self, other, op) -> BooleanArray:
843867
if other.ndim > 1:
844868
raise NotImplementedError("can only perform ops with 1-d structures")
845869
if len(self) != len(other):
846-
raise ValueError("Lengths must match to compare")
870+
msg = ops.get_shape_exception_message(self, other)
871+
raise ValueError(msg)
847872

848873
if other is libmissing.NA:
849874
# numpy does not handle pd.NA well as "other" scalar (it returns

0 commit comments

Comments
 (0)