diff --git a/doc/source/whatsnew/v3.0.0.rst b/doc/source/whatsnew/v3.0.0.rst index 80e5e89b79690..041a082bc4e16 100644 --- a/doc/source/whatsnew/v3.0.0.rst +++ b/doc/source/whatsnew/v3.0.0.rst @@ -587,6 +587,7 @@ Sparse ExtensionArray ^^^^^^^^^^^^^^ +- A default, unoptimised :meth:`ExtensionArray.round` method is now provided for numeric ExtensionArrays (:issue:`49387`) - Bug in :meth:`.arrays.ArrowExtensionArray.__setitem__` which caused wrong behavior when using an integer array with repeated values as a key (:issue:`58530`) - Bug in :meth:`api.types.is_datetime64_any_dtype` where a custom :class:`ExtensionDtype` would return ``False`` for array-likes (:issue:`57055`) diff --git a/pandas/core/arrays/arrow/array.py b/pandas/core/arrays/arrow/array.py index d073cf0b11c6b..40a113f8303df 100644 --- a/pandas/core/arrays/arrow/array.py +++ b/pandas/core/arrays/arrow/array.py @@ -1204,7 +1204,10 @@ def round(self, decimals: int = 0, *args, **kwargs) -> Self: DataFrame.round : Round values of a DataFrame. Series.round : Round values of a Series. """ - return type(self)(pc.round(self._pa_array, ndigits=decimals)) + if not self.dtype._is_numeric or self.dtype._is_boolean: + raise TypeError("Cannot round non-numeric type.") + result = pc.round(self._pa_array, ndigits=decimals) + return type(self)(result.cast(self._pa_array.type)) @doc(ExtensionArray.searchsorted) def searchsorted( diff --git a/pandas/core/arrays/base.py b/pandas/core/arrays/base.py index f83fdcd46b371..342c005c3ceba 100644 --- a/pandas/core/arrays/base.py +++ b/pandas/core/arrays/base.py @@ -2290,6 +2290,22 @@ def _mode(self, dropna: bool = True) -> Self: # ndarray[Any, Any]]", expected "Self") return mode(self, dropna=dropna) # type: ignore[return-value] + def round(self, decimals: int = 0, *args, **kwargs) -> Self: + # Implementer note: This is a non-optimized default implementation. + # Implementers are encouraged to override this method to avoid + # elementwise rounding. + if self.dtype._is_boolean: + return self + if not self.dtype._is_numeric: + raise TypeError(f"Cannot round {type(self)} dtype as it is non-numeric") + return self._from_sequence( + [ + round(element) if not element_isna else element + for (element, element_isna) in zip(self, self.isna()) + ], + dtype=self.dtype, + ) + def __array_ufunc__(self, ufunc: np.ufunc, method: str, *inputs, **kwargs): if any( isinstance(other, (ABCSeries, ABCIndex, ABCDataFrame)) for other in inputs diff --git a/pandas/core/arrays/datetimelike.py b/pandas/core/arrays/datetimelike.py index 673001337767b..b30ba734760a4 100644 --- a/pandas/core/arrays/datetimelike.py +++ b/pandas/core/arrays/datetimelike.py @@ -2168,7 +2168,7 @@ def _round(self, freq, mode, ambiguous, nonexistent): return self._simple_new(result, dtype=self.dtype) @Appender((_round_doc + _round_example).format(op="round")) - def round( + def round( # type: ignore[override] self, freq, ambiguous: TimeAmbiguous = "raise", diff --git a/pandas/core/arrays/sparse/array.py b/pandas/core/arrays/sparse/array.py index adf8f44377e62..0ecfc7013fe76 100644 --- a/pandas/core/arrays/sparse/array.py +++ b/pandas/core/arrays/sparse/array.py @@ -1623,6 +1623,15 @@ def argmin(self, skipna: bool = True) -> int: raise ValueError("Encountered an NA value with skipna=False") return self._argmin_argmax("argmin") + def round(self, decimals: int = 0, *args, **kwargs) -> Self: + new_values = np.array( + [ + round(element) if not isna(element) else element + for element in self.sp_values + ] + ) + return self._simple_new(new_values, self._sparse_index, self.dtype) + # ------------------------------------------------------------------------ # Ufuncs # ------------------------------------------------------------------------ diff --git a/pandas/core/internals/blocks.py b/pandas/core/internals/blocks.py index cffb1f658a640..54fc0edf0e959 100644 --- a/pandas/core/internals/blocks.py +++ b/pandas/core/internals/blocks.py @@ -1509,11 +1509,7 @@ def round(self, decimals: int) -> Self: """ if not self.is_numeric or self.is_bool: return self.copy(deep=False) - # TODO: round only defined on BaseMaskedArray - # Series also does this, so would need to fix both places - # error: Item "ExtensionArray" of "Union[ndarray[Any, Any], ExtensionArray]" - # has no attribute "round" - values = self.values.round(decimals) # type: ignore[union-attr] + values = self.values.round(decimals) refs = None if values is self.values: diff --git a/pandas/tests/extension/base/methods.py b/pandas/tests/extension/base/methods.py index b951d4c35d208..b1b5daad41ef6 100644 --- a/pandas/tests/extension/base/methods.py +++ b/pandas/tests/extension/base/methods.py @@ -728,3 +728,19 @@ def test_equals(self, data, na_value, as_series, box): def test_equals_same_data_different_object(self, data): # https://github.com/pandas-dev/pandas/issues/34660 assert pd.Series(data).equals(pd.Series(data)) + + def test_round(self, data): + if not data.dtype._is_numeric: + with pytest.raises(TypeError): + data.round() + elif data.dtype._is_boolean: + result = pd.Series(data).round() + expected = pd.Series(data) + tm.assert_series_equal(result, expected) + else: + result = pd.Series(data).round() + expected = pd.Series( + [round(element) if pd.notna(element) else element for element in data], + dtype=data.dtype, + ) + tm.assert_series_equal(result, expected) diff --git a/pandas/tests/extension/test_datetime.py b/pandas/tests/extension/test_datetime.py index a42fa6088d9c8..f3d46a6315bd7 100644 --- a/pandas/tests/extension/test_datetime.py +++ b/pandas/tests/extension/test_datetime.py @@ -138,6 +138,10 @@ def check_reduce(self, ser: pd.Series, op_name: str, skipna: bool): else: return super().check_reduce(ser, op_name, skipna) + @pytest.mark.skip("DatetimeArray uses a different function signature for round") + def test_round(self): + pass + class Test2DCompat(base.NDArrayBacked2DTests): pass