Skip to content

Commit a2fb11e

Browse files
String dtype: use 'str' string alias and representation for NaN-variant of the dtype (#59388)
1 parent 7147203 commit a2fb11e

File tree

79 files changed

+305
-191
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

79 files changed

+305
-191
lines changed

pandas/_testing/__init__.py

+5-1
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
import numpy as np
1414

15+
from pandas._config import using_string_dtype
1516
from pandas._config.localization import (
1617
can_set_locale,
1718
get_locales,
@@ -106,7 +107,10 @@
106107
ALL_FLOAT_DTYPES: list[Dtype] = [*FLOAT_NUMPY_DTYPES, *FLOAT_EA_DTYPES]
107108

108109
COMPLEX_DTYPES: list[Dtype] = [complex, "complex64", "complex128"]
109-
STRING_DTYPES: list[Dtype] = [str, "str", "U"]
110+
if using_string_dtype():
111+
STRING_DTYPES: list[Dtype] = [str, "U"]
112+
else:
113+
STRING_DTYPES: list[Dtype] = [str, "str", "U"] # type: ignore[no-redef]
110114
COMPLEX_FLOAT_DTYPES: list[Dtype] = [*COMPLEX_DTYPES, *FLOAT_NUMPY_DTYPES]
111115

112116
DATETIME64_DTYPES: list[Dtype] = ["datetime64[ns]", "M8[ns]"]

pandas/core/arrays/arrow/array.py

+4-1
Original file line numberDiff line numberDiff line change
@@ -575,7 +575,10 @@ def __getitem__(self, item: PositionalIndexer):
575575
if isinstance(item, np.ndarray):
576576
if not len(item):
577577
# Removable once we migrate StringDtype[pyarrow] to ArrowDtype[string]
578-
if self._dtype.name == "string" and self._dtype.storage == "pyarrow":
578+
if (
579+
isinstance(self._dtype, StringDtype)
580+
and self._dtype.storage == "pyarrow"
581+
):
579582
# TODO(infer_string) should this be large_string?
580583
pa_dtype = pa.string()
581584
else:

pandas/core/arrays/string_.py

+18-6
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
from typing import (
55
TYPE_CHECKING,
66
Any,
7-
ClassVar,
87
Literal,
98
cast,
109
)
@@ -118,9 +117,12 @@ class StringDtype(StorageExtensionDtype):
118117
string[pyarrow]
119118
"""
120119

121-
# error: Cannot override instance variable (previously declared on
122-
# base class "StorageExtensionDtype") with class variable
123-
name: ClassVar[str] = "string" # type: ignore[misc]
120+
@property
121+
def name(self) -> str: # type: ignore[override]
122+
if self._na_value is libmissing.NA:
123+
return "string"
124+
else:
125+
return "str"
124126

125127
#: StringDtype().na_value uses pandas.NA except the implementation that
126128
# follows NumPy semantics, which uses nan.
@@ -137,7 +139,7 @@ def __init__(
137139
) -> None:
138140
# infer defaults
139141
if storage is None:
140-
if using_string_dtype() and na_value is not libmissing.NA:
142+
if na_value is not libmissing.NA:
141143
if HAS_PYARROW:
142144
storage = "pyarrow"
143145
else:
@@ -170,11 +172,19 @@ def __init__(
170172
self.storage = storage
171173
self._na_value = na_value
172174

175+
def __repr__(self) -> str:
176+
if self._na_value is libmissing.NA:
177+
return f"{self.name}[{self.storage}]"
178+
else:
179+
# TODO add more informative repr
180+
return self.name
181+
173182
def __eq__(self, other: object) -> bool:
174183
# we need to override the base class __eq__ because na_value (NA or NaN)
175184
# cannot be checked with normal `==`
176185
if isinstance(other, str):
177-
if other == self.name:
186+
# TODO should dtype == "string" work for the NaN variant?
187+
if other == "string" or other == self.name: # noqa: PLR1714
178188
return True
179189
try:
180190
other = self.construct_from_string(other)
@@ -231,6 +241,8 @@ def construct_from_string(cls, string) -> Self:
231241
)
232242
if string == "string":
233243
return cls()
244+
elif string == "str" and using_string_dtype():
245+
return cls(na_value=np.nan)
234246
elif string == "string[python]":
235247
return cls(storage="python")
236248
elif string == "string[pyarrow]":

pandas/core/frame.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -4807,7 +4807,9 @@ def select_dtypes(self, include=None, exclude=None) -> DataFrame:
48074807
-----
48084808
* To select all *numeric* types, use ``np.number`` or ``'number'``
48094809
* To select strings you must use the ``object`` dtype, but note that
4810-
this will return *all* object dtype columns
4810+
this will return *all* object dtype columns. With
4811+
``pd.options.future.infer_string`` enabled, using ``"str"`` will
4812+
work to select all string columns.
48114813
* See the `numpy dtype hierarchy
48124814
<https://numpy.org/doc/stable/reference/arrays.scalars.html>`__
48134815
* To select datetimes, use ``np.datetime64``, ``'datetime'`` or

pandas/core/interchange/utils.py

+6-1
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,12 @@ def dtype_to_arrow_c_fmt(dtype: DtypeObj) -> str:
135135
if format_str is not None:
136136
return format_str
137137

138-
if lib.is_np_dtype(dtype, "M"):
138+
if isinstance(dtype, pd.StringDtype):
139+
# TODO(infer_string) this should be LARGE_STRING for pyarrow storage,
140+
# but current tests don't cover this distinction
141+
return ArrowCTypes.STRING
142+
143+
elif lib.is_np_dtype(dtype, "M"):
139144
# Selecting the first char of resolution string:
140145
# dtype.str -> '<M8[ns]' -> 'n'
141146
resolution = np.datetime_data(dtype)[0][0]

pandas/tests/apply/test_numba.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ def test_numba_unsupported_dtypes(apply_axis):
110110

111111
with pytest.raises(
112112
ValueError,
113-
match="Column b must have a numeric dtype. Found 'object|string' instead",
113+
match="Column b must have a numeric dtype. Found 'object|str' instead",
114114
):
115115
df.apply(f, engine="numba", axis=apply_axis)
116116

pandas/tests/apply/test_series_apply.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,7 @@ def test_apply_categorical(by_row, using_infer_string):
224224
result = ser.apply(lambda x: "A")
225225
exp = Series(["A"] * 7, name="XX", index=list("abcdefg"))
226226
tm.assert_series_equal(result, exp)
227-
assert result.dtype == object if not using_infer_string else "string[pyarrow_numpy]"
227+
assert result.dtype == object if not using_infer_string else "str"
228228

229229

230230
@pytest.mark.parametrize("series", [["1-1", "1-1", np.nan], ["1-1", "1-2", np.nan]])

pandas/tests/arrays/boolean/test_astype.py

+9-3
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import pandas._testing as tm
66

77

8-
def test_astype():
8+
def test_astype(using_infer_string):
99
# with missing values
1010
arr = pd.array([True, False, None], dtype="boolean")
1111

@@ -20,8 +20,14 @@ def test_astype():
2020
tm.assert_numpy_array_equal(result, expected)
2121

2222
result = arr.astype("str")
23-
expected = np.array(["True", "False", "<NA>"], dtype=f"{tm.ENDIAN}U5")
24-
tm.assert_numpy_array_equal(result, expected)
23+
if using_infer_string:
24+
expected = pd.array(
25+
["True", "False", None], dtype=pd.StringDtype(na_value=np.nan)
26+
)
27+
tm.assert_extension_array_equal(result, expected)
28+
else:
29+
expected = np.array(["True", "False", "<NA>"], dtype=f"{tm.ENDIAN}U5")
30+
tm.assert_numpy_array_equal(result, expected)
2531

2632
# no missing values
2733
arr = pd.array([True, False, True], dtype="boolean")

pandas/tests/arrays/categorical/test_astype.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ def test_astype(self, ordered):
8888
expected = np.array(cat)
8989
tm.assert_numpy_array_equal(result, expected)
9090

91-
msg = r"Cannot cast object|string dtype to float64"
91+
msg = r"Cannot cast object|str dtype to float64"
9292
with pytest.raises(ValueError, match=msg):
9393
cat.astype(float)
9494

pandas/tests/arrays/categorical/test_repr.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ def test_print(self, using_infer_string):
2222
if using_infer_string:
2323
expected = [
2424
"['a', 'b', 'b', 'a', 'a', 'c', 'c', 'c']",
25-
"Categories (3, string): [a < b < c]",
25+
"Categories (3, str): [a < b < c]",
2626
]
2727
else:
2828
expected = [

pandas/tests/arrays/floating/test_astype.py

+13-4
Original file line numberDiff line numberDiff line change
@@ -63,12 +63,21 @@ def test_astype_to_integer_array():
6363
tm.assert_extension_array_equal(result, expected)
6464

6565

66-
def test_astype_str():
66+
def test_astype_str(using_infer_string):
6767
a = pd.array([0.1, 0.2, None], dtype="Float64")
68-
expected = np.array(["0.1", "0.2", "<NA>"], dtype="U32")
6968

70-
tm.assert_numpy_array_equal(a.astype(str), expected)
71-
tm.assert_numpy_array_equal(a.astype("str"), expected)
69+
if using_infer_string:
70+
expected = pd.array(["0.1", "0.2", None], dtype=pd.StringDtype(na_value=np.nan))
71+
tm.assert_extension_array_equal(a.astype("str"), expected)
72+
73+
# TODO(infer_string) this should also be a string array like above
74+
expected = np.array(["0.1", "0.2", "<NA>"], dtype="U32")
75+
tm.assert_numpy_array_equal(a.astype(str), expected)
76+
else:
77+
expected = np.array(["0.1", "0.2", "<NA>"], dtype="U32")
78+
79+
tm.assert_numpy_array_equal(a.astype(str), expected)
80+
tm.assert_numpy_array_equal(a.astype("str"), expected)
7281

7382

7483
def test_astype_copy():

pandas/tests/arrays/integer/test_dtypes.py

+13-4
Original file line numberDiff line numberDiff line change
@@ -276,12 +276,21 @@ def test_to_numpy_na_raises(dtype):
276276
a.to_numpy(dtype=dtype)
277277

278278

279-
def test_astype_str():
279+
def test_astype_str(using_infer_string):
280280
a = pd.array([1, 2, None], dtype="Int64")
281-
expected = np.array(["1", "2", "<NA>"], dtype=f"{tm.ENDIAN}U21")
282281

283-
tm.assert_numpy_array_equal(a.astype(str), expected)
284-
tm.assert_numpy_array_equal(a.astype("str"), expected)
282+
if using_infer_string:
283+
expected = pd.array(["1", "2", None], dtype=pd.StringDtype(na_value=np.nan))
284+
tm.assert_extension_array_equal(a.astype("str"), expected)
285+
286+
# TODO(infer_string) this should also be a string array like above
287+
expected = np.array(["1", "2", "<NA>"], dtype=f"{tm.ENDIAN}U21")
288+
tm.assert_numpy_array_equal(a.astype(str), expected)
289+
else:
290+
expected = np.array(["1", "2", "<NA>"], dtype=f"{tm.ENDIAN}U21")
291+
292+
tm.assert_numpy_array_equal(a.astype(str), expected)
293+
tm.assert_numpy_array_equal(a.astype("str"), expected)
285294

286295

287296
def test_astype_boolean():

pandas/tests/arrays/interval/test_interval_pyarrow.py

-3
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
import numpy as np
22
import pytest
33

4-
from pandas._config import using_string_dtype
5-
64
import pandas as pd
75
import pandas._testing as tm
86
from pandas.core.arrays import IntervalArray
@@ -82,7 +80,6 @@ def test_arrow_array_missing():
8280
assert result.storage.equals(expected)
8381

8482

85-
@pytest.mark.xfail(using_string_dtype(), reason="TODO(infer_string)")
8683
@pytest.mark.filterwarnings(
8784
"ignore:Passing a BlockManager to DataFrame:DeprecationWarning"
8885
)

pandas/tests/arrays/period/test_arrow_compat.py

-4
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
import pytest
22

3-
from pandas._config import using_string_dtype
4-
53
from pandas.compat.pyarrow import pa_version_under10p1
64

75
from pandas.core.dtypes.dtypes import PeriodDtype
@@ -79,7 +77,6 @@ def test_arrow_array_missing():
7977
assert result.storage.equals(expected)
8078

8179

82-
@pytest.mark.xfail(using_string_dtype(), reason="TODO(infer_string)")
8380
def test_arrow_table_roundtrip():
8481
from pandas.core.arrays.arrow.extension_types import ArrowPeriodType
8582

@@ -99,7 +96,6 @@ def test_arrow_table_roundtrip():
9996
tm.assert_frame_equal(result, expected)
10097

10198

102-
@pytest.mark.xfail(using_string_dtype(), reason="TODO(infer_string)")
10399
def test_arrow_load_from_zero_chunks():
104100
# GH-41040
105101

pandas/tests/arrays/string_/test_string.py

+21-14
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ def test_repr(dtype):
6666
assert repr(df) == expected
6767

6868
if dtype.na_value is np.nan:
69-
expected = "0 a\n1 NaN\n2 b\nName: A, dtype: string"
69+
expected = "0 a\n1 NaN\n2 b\nName: A, dtype: str"
7070
else:
7171
expected = "0 a\n1 <NA>\n2 b\nName: A, dtype: string"
7272
assert repr(df.A) == expected
@@ -76,10 +76,10 @@ def test_repr(dtype):
7676
expected = f"<{arr_name}>\n['a', <NA>, 'b']\nLength: 3, dtype: string"
7777
elif dtype.storage == "pyarrow" and dtype.na_value is np.nan:
7878
arr_name = "ArrowStringArrayNumpySemantics"
79-
expected = f"<{arr_name}>\n['a', nan, 'b']\nLength: 3, dtype: string"
79+
expected = f"<{arr_name}>\n['a', nan, 'b']\nLength: 3, dtype: str"
8080
elif dtype.storage == "python" and dtype.na_value is np.nan:
8181
arr_name = "StringArrayNumpySemantics"
82-
expected = f"<{arr_name}>\n['a', nan, 'b']\nLength: 3, dtype: string"
82+
expected = f"<{arr_name}>\n['a', nan, 'b']\nLength: 3, dtype: str"
8383
else:
8484
arr_name = "StringArray"
8585
expected = f"<{arr_name}>\n['a', <NA>, 'b']\nLength: 3, dtype: string"
@@ -500,7 +500,7 @@ def test_fillna_args(dtype):
500500
tm.assert_extension_array_equal(res, expected)
501501

502502
if dtype.storage == "pyarrow":
503-
msg = "Invalid value '1' for dtype string"
503+
msg = "Invalid value '1' for dtype str"
504504
else:
505505
msg = "Cannot set non-string value '1' into a StringArray."
506506
with pytest.raises(TypeError, match=msg):
@@ -522,7 +522,7 @@ def test_arrow_array(dtype):
522522
assert arr.equals(expected)
523523

524524

525-
@pytest.mark.xfail(using_string_dtype(), reason="TODO(infer_string)")
525+
@pytest.mark.xfail(using_string_dtype(), reason="TODO(infer_string)", strict=False)
526526
@pytest.mark.filterwarnings("ignore:Passing a BlockManager:DeprecationWarning")
527527
def test_arrow_roundtrip(dtype, string_storage, using_infer_string):
528528
# roundtrip possible from arrow 1.0.0
@@ -537,14 +537,17 @@ def test_arrow_roundtrip(dtype, string_storage, using_infer_string):
537537
assert table.field("a").type == "large_string"
538538
with pd.option_context("string_storage", string_storage):
539539
result = table.to_pandas()
540-
assert isinstance(result["a"].dtype, pd.StringDtype)
541-
expected = df.astype(f"string[{string_storage}]")
542-
tm.assert_frame_equal(result, expected)
543-
# ensure the missing value is represented by NA and not np.nan or None
544-
assert result.loc[2, "a"] is result["a"].dtype.na_value
540+
if dtype.na_value is np.nan and not using_string_dtype():
541+
assert result["a"].dtype == "object"
542+
else:
543+
assert isinstance(result["a"].dtype, pd.StringDtype)
544+
expected = df.astype(f"string[{string_storage}]")
545+
tm.assert_frame_equal(result, expected)
546+
# ensure the missing value is represented by NA and not np.nan or None
547+
assert result.loc[2, "a"] is result["a"].dtype.na_value
545548

546549

547-
@pytest.mark.xfail(using_string_dtype(), reason="TODO(infer_string)")
550+
@pytest.mark.xfail(using_string_dtype(), reason="TODO(infer_string)", strict=False)
548551
@pytest.mark.filterwarnings("ignore:Passing a BlockManager:DeprecationWarning")
549552
def test_arrow_load_from_zero_chunks(dtype, string_storage, using_infer_string):
550553
# GH-41040
@@ -561,9 +564,13 @@ def test_arrow_load_from_zero_chunks(dtype, string_storage, using_infer_string):
561564
table = pa.table([pa.chunked_array([], type=pa.string())], schema=table.schema)
562565
with pd.option_context("string_storage", string_storage):
563566
result = table.to_pandas()
564-
assert isinstance(result["a"].dtype, pd.StringDtype)
565-
expected = df.astype(f"string[{string_storage}]")
566-
tm.assert_frame_equal(result, expected)
567+
568+
if dtype.na_value is np.nan and not using_string_dtype():
569+
assert result["a"].dtype == "object"
570+
else:
571+
assert isinstance(result["a"].dtype, pd.StringDtype)
572+
expected = df.astype(f"string[{string_storage}]")
573+
tm.assert_frame_equal(result, expected)
567574

568575

569576
def test_value_counts_na(dtype):

pandas/tests/arrays/string_/test_string_arrow.py

+4-2
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import numpy as np
55
import pytest
66

7+
from pandas.compat import HAS_PYARROW
78
import pandas.util._test_decorators as td
89

910
import pandas as pd
@@ -27,8 +28,9 @@ def test_eq_all_na():
2728

2829

2930
def test_config(string_storage, request, using_infer_string):
30-
if using_infer_string and string_storage == "python":
31-
# python string storage with na_value=NaN is not yet implemented
31+
if using_infer_string and string_storage == "python" and HAS_PYARROW:
32+
# string storage with na_value=NaN always uses pyarrow if available
33+
# -> does not yet honor the option
3234
request.applymarker(pytest.mark.xfail(reason="TODO(infer_string)"))
3335

3436
with pd.option_context("string_storage", string_storage):

pandas/tests/arrays/test_datetimelike.py

+5-2
Original file line numberDiff line numberDiff line change
@@ -297,7 +297,9 @@ def test_searchsorted(self):
297297
assert result == 10
298298

299299
@pytest.mark.parametrize("box", [None, "index", "series"])
300-
def test_searchsorted_castable_strings(self, arr1d, box, string_storage):
300+
def test_searchsorted_castable_strings(
301+
self, arr1d, box, string_storage, using_infer_string
302+
):
301303
arr = arr1d
302304
if box is None:
303305
pass
@@ -333,7 +335,8 @@ def test_searchsorted_castable_strings(self, arr1d, box, string_storage):
333335
TypeError,
334336
match=re.escape(
335337
f"value should be a '{arr1d._scalar_type.__name__}', 'NaT', "
336-
"or array of those. Got string array instead."
338+
"or array of those. Got "
339+
f"{'str' if using_infer_string else 'string'} array instead."
337340
),
338341
):
339342
arr.searchsorted([str(arr[1]), "baz"])

0 commit comments

Comments
 (0)