Skip to content

Commit 938832b

Browse files
BUG (string dtype): replace with non-string to fall back to object dtype (#60285)
Co-authored-by: Matthew Roeschke <[email protected]>
1 parent 2d116df commit 938832b

File tree

7 files changed

+60
-46
lines changed

7 files changed

+60
-46
lines changed

doc/source/whatsnew/v2.3.0.rst

+1-1
Original file line numberDiff line numberDiff line change
@@ -106,10 +106,10 @@ Conversion
106106
Strings
107107
^^^^^^^
108108
- Bug in :meth:`Series.rank` for :class:`StringDtype` with ``storage="pyarrow"`` incorrectly returning integer results in case of ``method="average"`` and raising an error if it would truncate results (:issue:`59768`)
109+
- Bug in :meth:`Series.replace` with :class:`StringDtype` when replacing with a non-string value was not upcasting to ``object`` dtype (:issue:`60282`)
109110
- Bug in :meth:`Series.str.replace` when ``n < 0`` for :class:`StringDtype` with ``storage="pyarrow"`` (:issue:`59628`)
110111
- Bug in ``ser.str.slice`` with negative ``step`` with :class:`ArrowDtype` and :class:`StringDtype` with ``storage="pyarrow"`` giving incorrect results (:issue:`59710`)
111112
- Bug in the ``center`` method on :class:`Series` and :class:`Index` object ``str`` accessors with pyarrow-backed dtype not matching the python behavior in corner cases with an odd number of fill characters (:issue:`54792`)
112-
-
113113

114114
Interval
115115
^^^^^^^^

pandas/core/arrays/string_.py

+25-18
Original file line numberDiff line numberDiff line change
@@ -730,20 +730,9 @@ def _values_for_factorize(self) -> tuple[np.ndarray, libmissing.NAType | float]:
730730

731731
return arr, self.dtype.na_value
732732

733-
def __setitem__(self, key, value) -> None:
734-
value = extract_array(value, extract_numpy=True)
735-
if isinstance(value, type(self)):
736-
# extract_array doesn't extract NumpyExtensionArray subclasses
737-
value = value._ndarray
738-
739-
key = check_array_indexer(self, key)
740-
scalar_key = lib.is_scalar(key)
741-
scalar_value = lib.is_scalar(value)
742-
if scalar_key and not scalar_value:
743-
raise ValueError("setting an array element with a sequence.")
744-
745-
# validate new items
746-
if scalar_value:
733+
def _maybe_convert_setitem_value(self, value):
734+
"""Maybe convert value to be pyarrow compatible."""
735+
if lib.is_scalar(value):
747736
if isna(value):
748737
value = self.dtype.na_value
749738
elif not isinstance(value, str):
@@ -753,8 +742,11 @@ def __setitem__(self, key, value) -> None:
753742
"instead."
754743
)
755744
else:
745+
value = extract_array(value, extract_numpy=True)
756746
if not is_array_like(value):
757747
value = np.asarray(value, dtype=object)
748+
elif isinstance(value.dtype, type(self.dtype)):
749+
return value
758750
else:
759751
# cast categories and friends to arrays to see if values are
760752
# compatible, compatibility with arrow backed strings
@@ -764,11 +756,26 @@ def __setitem__(self, key, value) -> None:
764756
"Invalid value for dtype 'str'. Value should be a "
765757
"string or missing value (or array of those)."
766758
)
759+
return value
767760

768-
mask = isna(value)
769-
if mask.any():
770-
value = value.copy()
771-
value[isna(value)] = self.dtype.na_value
761+
def __setitem__(self, key, value) -> None:
762+
value = self._maybe_convert_setitem_value(value)
763+
764+
key = check_array_indexer(self, key)
765+
scalar_key = lib.is_scalar(key)
766+
scalar_value = lib.is_scalar(value)
767+
if scalar_key and not scalar_value:
768+
raise ValueError("setting an array element with a sequence.")
769+
770+
if not scalar_value:
771+
if value.dtype == self.dtype:
772+
value = value._ndarray
773+
else:
774+
value = np.asarray(value)
775+
mask = isna(value)
776+
if mask.any():
777+
value = value.copy()
778+
value[isna(value)] = self.dtype.na_value
772779

773780
super().__setitem__(key, value)
774781

pandas/core/dtypes/cast.py

+7
Original file line numberDiff line numberDiff line change
@@ -1749,6 +1749,13 @@ def can_hold_element(arr: ArrayLike, element: Any) -> bool:
17491749
except (ValueError, TypeError):
17501750
return False
17511751

1752+
if dtype == "string":
1753+
try:
1754+
arr._maybe_convert_setitem_value(element) # type: ignore[union-attr]
1755+
return True
1756+
except (ValueError, TypeError):
1757+
return False
1758+
17521759
# This is technically incorrect, but maintains the behavior of
17531760
# ExtensionBlock._can_hold_element
17541761
return True

pandas/core/internals/blocks.py

+18-5
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@
7777
ABCNumpyExtensionArray,
7878
ABCSeries,
7979
)
80+
from pandas.core.dtypes.inference import is_re
8081
from pandas.core.dtypes.missing import (
8182
is_valid_na_for_dtype,
8283
isna,
@@ -706,7 +707,7 @@ def replace(
706707
# bc _can_hold_element is incorrect.
707708
return [self.copy(deep=False)]
708709

709-
elif self._can_hold_element(value):
710+
elif self._can_hold_element(value) or (self.dtype == "string" and is_re(value)):
710711
# TODO(CoW): Maybe split here as well into columns where mask has True
711712
# and rest?
712713
blk = self._maybe_copy(inplace)
@@ -766,14 +767,24 @@ def _replace_regex(
766767
-------
767768
List[Block]
768769
"""
769-
if not self._can_hold_element(to_replace):
770+
if not is_re(to_replace) and not self._can_hold_element(to_replace):
770771
# i.e. only if self.is_object is True, but could in principle include a
771772
# String ExtensionBlock
772773
return [self.copy(deep=False)]
773774

774-
rx = re.compile(to_replace)
775+
if is_re(to_replace) and self.dtype not in [object, "string"]:
776+
# only object or string dtype can hold strings, and a regex object
777+
# will only match strings
778+
return [self.copy(deep=False)]
775779

776-
block = self._maybe_copy(inplace)
780+
if not (
781+
self._can_hold_element(value) or (self.dtype == "string" and is_re(value))
782+
):
783+
block = self.astype(np.dtype(object))
784+
else:
785+
block = self._maybe_copy(inplace)
786+
787+
rx = re.compile(to_replace)
777788

778789
replace_regex(block.values, rx, value, mask)
779790
return [block]
@@ -793,7 +804,9 @@ def replace_list(
793804

794805
# Exclude anything that we know we won't contain
795806
pairs = [
796-
(x, y) for x, y in zip(src_list, dest_list) if self._can_hold_element(x)
807+
(x, y)
808+
for x, y in zip(src_list, dest_list)
809+
if (self._can_hold_element(x) or (self.dtype == "string" and is_re(x)))
797810
]
798811
if not len(pairs):
799812
return [self.copy(deep=False)]

pandas/tests/frame/methods/test_replace.py

-3
Original file line numberDiff line numberDiff line change
@@ -889,7 +889,6 @@ def test_replace_input_formats_listlike(self):
889889
with pytest.raises(ValueError, match=msg):
890890
df.replace(to_rep, values[1:])
891891

892-
@pytest.mark.xfail(using_string_dtype(), reason="can't set float into string")
893892
def test_replace_input_formats_scalar(self):
894893
df = DataFrame(
895894
{"A": [np.nan, 0, np.inf], "B": [0, 2, 5], "C": ["", "asdf", "fd"]}
@@ -940,7 +939,6 @@ def test_replace_dict_no_regex(self):
940939
result = answer.replace(weights)
941940
tm.assert_series_equal(result, expected)
942941

943-
@pytest.mark.xfail(using_string_dtype(), reason="can't set float into string")
944942
def test_replace_series_no_regex(self):
945943
answer = Series(
946944
{
@@ -1176,7 +1174,6 @@ def test_replace_commutative(self, df, to_replace, exp):
11761174
result = df.replace(to_replace)
11771175
tm.assert_frame_equal(result, expected)
11781176

1179-
@pytest.mark.xfail(using_string_dtype(), reason="can't set float into string")
11801177
@pytest.mark.parametrize(
11811178
"replacer",
11821179
[

pandas/tests/series/indexing/test_setitem.py

+5-13
Original file line numberDiff line numberDiff line change
@@ -860,24 +860,16 @@ def test_index_where(self, obj, key, expected, raises, val):
860860
mask = np.zeros(obj.shape, dtype=bool)
861861
mask[key] = True
862862

863-
if raises and obj.dtype == "string":
864-
with pytest.raises(TypeError, match="Invalid value"):
865-
Index(obj).where(~mask, val)
866-
else:
867-
res = Index(obj).where(~mask, val)
868-
expected_idx = Index(expected, dtype=expected.dtype)
869-
tm.assert_index_equal(res, expected_idx)
863+
res = Index(obj).where(~mask, val)
864+
expected_idx = Index(expected, dtype=expected.dtype)
865+
tm.assert_index_equal(res, expected_idx)
870866

871867
def test_index_putmask(self, obj, key, expected, raises, val):
872868
mask = np.zeros(obj.shape, dtype=bool)
873869
mask[key] = True
874870

875-
if raises and obj.dtype == "string":
876-
with pytest.raises(TypeError, match="Invalid value"):
877-
Index(obj).putmask(mask, val)
878-
else:
879-
res = Index(obj).putmask(mask, val)
880-
tm.assert_index_equal(res, Index(expected, dtype=expected.dtype))
871+
res = Index(obj).putmask(mask, val)
872+
tm.assert_index_equal(res, Index(expected, dtype=expected.dtype))
881873

882874

883875
@pytest.mark.parametrize(

pandas/tests/series/methods/test_replace.py

+4-6
Original file line numberDiff line numberDiff line change
@@ -635,13 +635,11 @@ def test_replace_regex_dtype_series(self, regex):
635635
tm.assert_series_equal(result, expected)
636636

637637
@pytest.mark.parametrize("regex", [False, True])
638-
def test_replace_regex_dtype_series_string(self, regex, using_infer_string):
639-
if not using_infer_string:
640-
# then this is object dtype which is already tested above
641-
return
638+
def test_replace_regex_dtype_series_string(self, regex):
642639
series = pd.Series(["0"], dtype="str")
643-
with pytest.raises(TypeError, match="Invalid value"):
644-
series.replace(to_replace="0", value=1, regex=regex)
640+
expected = pd.Series([1], dtype=object)
641+
result = series.replace(to_replace="0", value=1, regex=regex)
642+
tm.assert_series_equal(result, expected)
645643

646644
def test_replace_different_int_types(self, any_int_numpy_dtype):
647645
# GH#45311

0 commit comments

Comments
 (0)