diff --git a/pandas/core/arrays/_mixins.py b/pandas/core/arrays/_mixins.py index 11928e79ffc62..22727ac4a4a3d 100644 --- a/pandas/core/arrays/_mixins.py +++ b/pandas/core/arrays/_mixins.py @@ -362,7 +362,16 @@ def fillna(self, value, limit: int | None = None, copy: bool = True) -> Self: new_values = self.copy() else: new_values = self[:] - new_values[mask] = value + if self.dtype.kind == "O" and isinstance(value, tuple): + # Ensure tuple values are treated as scalars for masked assignment. + # Otherwise NumPy may interpret the tuple as a sequence. + fill_values = np.empty( + np.asarray(mask, dtype=np.bool_).sum(), dtype=object + ) + fill_values[:] = [value] + new_values[mask] = fill_values + else: + new_values[mask] = value else: # We validate the fill_value even if there is nothing to fill self._validate_setitem_value(value) diff --git a/pandas/tests/extension/base/missing.py b/pandas/tests/extension/base/missing.py index 0277bb59bc7f5..7664274fa4375 100644 --- a/pandas/tests/extension/base/missing.py +++ b/pandas/tests/extension/base/missing.py @@ -6,6 +6,8 @@ class BaseMissingTests: + _respects_fillna_copy_false = True + def test_isna(self, data_missing): expected = np.array([True, False]) @@ -123,18 +125,25 @@ def test_fillna_no_op_returns_copy(self, data): tm.assert_extension_array_equal(result, data) def test_fillna_readonly(self, data_missing): + fill_value = data_missing[1] data = data_missing.copy() data._readonly = True # by default fillna(copy=True), then this works fine - result = data.fillna(data_missing[1]) - assert result[0] == data_missing[1] + res_copy = data.fillna(fill_value, copy=True) + assert res_copy[0] == fill_value tm.assert_extension_array_equal(data, data_missing) - # but with copy=False, this raises for EAs that respect the copy keyword - with pytest.raises(ValueError, match="Cannot modify read-only array"): - data.fillna(data_missing[1], copy=False) - tm.assert_extension_array_equal(data, data_missing) + if self._respects_fillna_copy_false: + with pytest.raises(ValueError, match="Cannot modify read-only array"): + data.fillna(fill_value, copy=False) + tm.assert_extension_array_equal(data, data_missing) + else: + # EAs that do not respect the copy keyword, copy=False is ignored + res_no_copy = data.fillna(fill_value, copy=False) + assert res_no_copy[0] == fill_value + tm.assert_extension_array_equal(res_no_copy, res_copy) + tm.assert_extension_array_equal(data, data_missing) def test_fillna_series(self, data_missing): fill_value = data_missing[1] diff --git a/pandas/tests/extension/decimal/test_decimal.py b/pandas/tests/extension/decimal/test_decimal.py index 5374121a37f63..3ab18c3a7935b 100644 --- a/pandas/tests/extension/decimal/test_decimal.py +++ b/pandas/tests/extension/decimal/test_decimal.py @@ -67,6 +67,8 @@ def data_for_grouping(): class TestDecimalArray(base.ExtensionTests): + _respects_fillna_copy_false = False + def _get_expected_exception( self, op_name: str, obj, other ) -> type[Exception] | tuple[type[Exception], ...] | None: diff --git a/pandas/tests/extension/test_arrow.py b/pandas/tests/extension/test_arrow.py index 3237831ece24d..bf47e52d99171 100644 --- a/pandas/tests/extension/test_arrow.py +++ b/pandas/tests/extension/test_arrow.py @@ -271,6 +271,17 @@ def data_for_twos(data): class TestArrowArray(base.ExtensionTests): + _respects_fillna_copy_false = False + + def test_fillna_readonly(self, data_missing, monkeypatch): + pa_dtype = data_missing.dtype.pyarrow_dtype + if pa.types.is_duration(pa_dtype): + with monkeypatch.context() as m: + m.setattr(self, "_respects_fillna_copy_false", False) + super().test_fillna_readonly(data_missing) + else: + super().test_fillna_readonly(data_missing) + def _construct_for_combine_add(self, left, right): dtype = left.dtype @@ -687,21 +698,6 @@ def test_fillna_no_op_returns_copy(self, data): assert result is not data tm.assert_extension_array_equal(result, data) - def test_fillna_readonly(self, data_missing): - data = data_missing.copy() - data._readonly = True - - # by default fillna(copy=True), then this works fine - result = data.fillna(data_missing[1]) - assert result[0] == data_missing[1] - tm.assert_extension_array_equal(data, data_missing) - - # fillna(copy=False) is generally not honored by Arrow-backed array, - # but always returns new data -> same result as above - result = data.fillna(data_missing[1]) - assert result[0] == data_missing[1] - tm.assert_extension_array_equal(data, data_missing) - @pytest.mark.xfail( reason="GH 45419: pyarrow.ChunkedArray does not support views", run=False ) diff --git a/pandas/tests/extension/test_interval.py b/pandas/tests/extension/test_interval.py index ada34e7ace680..14c8b7ca252e0 100644 --- a/pandas/tests/extension/test_interval.py +++ b/pandas/tests/extension/test_interval.py @@ -79,6 +79,7 @@ def data_for_grouping(): class TestIntervalArray(base.ExtensionTests): divmod_exc = TypeError + _respects_fillna_copy_false = False def _supports_reduction(self, ser: pd.Series, op_name: str) -> bool: return op_name in ["min", "max"] diff --git a/pandas/tests/extension/test_numpy.py b/pandas/tests/extension/test_numpy.py index c3f619e4263df..ba71e5c963de0 100644 --- a/pandas/tests/extension/test_numpy.py +++ b/pandas/tests/extension/test_numpy.py @@ -342,11 +342,6 @@ def test_fillna_frame(self, data_missing): # Non-scalar "scalar" values. super().test_fillna_frame(data_missing) - @skip_nested - def test_fillna_readonly(self, data_missing): - # Non-scalar "scalar" values. - super().test_fillna_readonly(data_missing) - @skip_nested def test_setitem_invalid(self, data, invalid_scalar): # object dtype can hold anything, so doesn't raise diff --git a/pandas/tests/extension/test_sparse.py b/pandas/tests/extension/test_sparse.py index 4cbbda990b294..3b27e498b1cfa 100644 --- a/pandas/tests/extension/test_sparse.py +++ b/pandas/tests/extension/test_sparse.py @@ -97,6 +97,8 @@ def data_for_compare(request): class TestSparseArray(base.ExtensionTests): + _respects_fillna_copy_false = False + def _supports_reduction(self, obj, op_name: str) -> bool: return True @@ -237,21 +239,6 @@ def test_isna(self, data_missing): def test_fillna_no_op_returns_copy(self, data, request): super().test_fillna_no_op_returns_copy(data) - def test_fillna_readonly(self, data_missing): - # copy keyword is ignored by SparseArray.fillna - # -> copy=True vs False doesn't make a difference - data = data_missing.copy() - data._readonly = True - - result = data.fillna(data_missing[1]) - assert result[0] == data_missing[1] - tm.assert_extension_array_equal(data, data_missing) - - # fillna(copy=False) is ignored -> so same result as above - result = data.fillna(data_missing[1], copy=False) - assert result[0] == data_missing[1] - tm.assert_extension_array_equal(data, data_missing) - @pytest.mark.xfail(reason="Unsupported") def test_fillna_series(self, data_missing): # this one looks doable. diff --git a/pandas/tests/extension/test_string.py b/pandas/tests/extension/test_string.py index 07c957beef652..1cdc49b2e6930 100644 --- a/pandas/tests/extension/test_string.py +++ b/pandas/tests/extension/test_string.py @@ -170,23 +170,14 @@ def test_fillna_no_op_returns_copy(self, data): tm.assert_extension_array_equal(result, data) def test_fillna_readonly(self, data_missing): - data = data_missing.copy() - data._readonly = True - - # by default fillna(copy=True), then this works fine - result = data.fillna(data_missing[1]) - assert result[0] == data_missing[1] - tm.assert_extension_array_equal(data, data_missing) - - # fillna(copy=False) is generally not honored by Arrow-backed array, - # but always returns new data -> same result as above - if data.dtype.storage == "pyarrow": - result = data.fillna(data_missing[1]) - assert result[0] == data_missing[1] + if data_missing.dtype.storage == "pyarrow": + # pyarrow-backed strings are immutable, copy=False is ignored, + # always returns a new array without raising. + self._respects_fillna_copy_false = False else: - with pytest.raises(ValueError, match="Cannot modify read-only array"): - data.fillna(data_missing[1], copy=False) - tm.assert_extension_array_equal(data, data_missing) + # python-backed strings respect copy=False and raise on read-only. + self._respects_fillna_copy_false = True + super().test_fillna_readonly(data_missing) def _get_expected_exception( self, op_name: str, obj, other