This is an automated email from the ASF dual-hosted git repository.
jorisvandenbossche pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/arrow.git
The following commit(s) were added to refs/heads/main by this push:
new 17be9c7caa9 GH-51652: [Python] Add NumPy StringDType to Arrow
conversion (#51157)
17be9c7caa9 is described below
commit 17be9c7caa928cb3ec5a1dfbd2c2037e78720637
Author: Nathan Goldbaum <[email protected]>
AuthorDate: Thu Oct 1 16:24:28 2026 +0300
GH-51652: [Python] Add NumPy StringDType to Arrow conversion (#51157)
### Rationale for this change
NumPy 2.0 added `StringDType`, a variable-width UTF-8 string dtype.
`pa.array` currently rejects it with `Unsupported numpy type 2056`. Now that
pyarrow requires NumPy 2.0 the conversion can use the public `NpyString_*` C
API.
This supersedes #50951. The first commit is @ alippai's first commit from
that PR, unchanged. His later commits added batching and new bulk builder APIs
in Arrow C++; those can come later in their own PR with benchmarks, as
discussed there. Nothing outside the NumPy conversion code changes here.
### What changes are included in this PR?
- `NumPyDtypeToArrow` maps `StringDType` to `string`, so it is also the
inferred type for `pa.array`, `pa.infer_type`, `pa.from_numpy_dtype` and lists
of such arrays. `large_string` and `string_view` can be requested with `type`.
Any other type raises a `TypeError` naming the requested type.
- The conversion holds the dtype's allocator lock for the whole array and
never holds the GIL while taking it.
- A null entry in a StringDType array becomes an Arrow null unless the
dtype's `na_object` is a `str`. NumPy reports such entries as that string from
`__getitem__`, in ufuncs and comparisons, and in casts, and there is no way to
tell them apart from a regular entry holding the same string, so the conversion
writes the string too. NaN-like sentinels such as `np.nan` and `pd.NA`, `None`,
and arbitrary objects all become arrow nulls.
- A short section in `docs/source/python/numpy.rst` describing the support
and the semantics for converting missing data.
If left unfixed, https://github.com/apache/arrow/issues/51156 also becomes
possible to trigger from Python by creating a StringDType array with a very
large entry.
### Are these changes tested?
Yes. Tests cover the three target types, strings crossing NumPy's short,
medium and long storage thresholds, non-ASCII input, strided input, `mask`,
`None`, NaN and string sentinels, the rejected target type, and list inference.
I also ran a threaded test that converts an array while other threads hit
NumPy's null-comparison error path on the same array to check the lock ordering.
### Are there any user-facing changes?
Yes. `pa.array` and the inference functions accept `StringDType` arrays.
AI disclosure: I used an AI model to work on the follow-up commits on top
of @ alippai's first commit and for code review.
* GitHub Issue: #42018
* GitHub Issue: #51652
Lead-authored-by: Nathan Goldbaum <[email protected]>
Co-authored-by: Joris Van den Bossche <[email protected]>
Co-authored-by: Adam Lippai <[email protected]>
Signed-off-by: Joris Van den Bossche <[email protected]>
---
docs/source/python/numpy.rst | 23 ++++++-
python/pyarrow/src/arrow/python/numpy_convert.cc | 1 +
python/pyarrow/src/arrow/python/numpy_to_arrow.cc | 66 +++++++++++++++++++
python/pyarrow/tests/test_array.py | 79 +++++++++++++++++++++++
python/pyarrow/tests/test_schema.py | 1 +
5 files changed, 169 insertions(+), 1 deletion(-)
diff --git a/docs/source/python/numpy.rst b/docs/source/python/numpy.rst
index 07a6aa803f8..10a70159c8e 100644
--- a/docs/source/python/numpy.rst
+++ b/docs/source/python/numpy.rst
@@ -51,7 +51,28 @@ factory function.
]
Converting from NumPy supports a wide range of input dtypes, including
-structured dtypes or strings.
+structured dtypes and both fixed-width (``S`` and ``U``) and variable-width
+(:class:`numpy.dtypes.StringDType`) strings.
+
+A ``StringDType`` array converts to :func:`~pyarrow.string` unless
+:func:`~pyarrow.large_string` or :func:`~pyarrow.string_view` is requested with
+``type``. Missing entries become nulls:
+
+.. code-block:: python
+
+ >>> dtype = np.dtypes.StringDType(na_object=np.nan)
+ >>> arr = pa.array(np.array(["some", np.nan, "strings"], dtype=dtype))
+ >>> arr
+ <pyarrow.lib.StringArray object at ...>
+ [
+ "some",
+ null,
+ "strings"
+ ]
+
+When the ``na_object`` is a string, NumPy treats missing entries as that string
+in every operation, and so does the conversion. Pass ``mask`` to mark values as
+null explicitly.
Arrow to NumPy
--------------
diff --git a/python/pyarrow/src/arrow/python/numpy_convert.cc
b/python/pyarrow/src/arrow/python/numpy_convert.cc
index 6e59835286d..882e9e47e5b 100644
--- a/python/pyarrow/src/arrow/python/numpy_convert.cc
+++ b/python/pyarrow/src/arrow/python/numpy_convert.cc
@@ -151,6 +151,7 @@ Result<std::shared_ptr<DataType>>
NumPyDtypeToArrow(PyArray_Descr* descr) {
TO_ARROW_TYPE_CASE(FLOAT64, float64);
TO_ARROW_TYPE_CASE(STRING, binary);
TO_ARROW_TYPE_CASE(UNICODE, utf8);
+ TO_ARROW_TYPE_CASE(VSTRING, utf8);
case NPY_DATETIME: {
auto date_dtype =
reinterpret_cast<PyArray_DatetimeDTypeMetaData*>(PyDataType_C_METADATA(descr));
diff --git a/python/pyarrow/src/arrow/python/numpy_to_arrow.cc
b/python/pyarrow/src/arrow/python/numpy_to_arrow.cc
index 5647e895d0f..d4e859d1c70 100644
--- a/python/pyarrow/src/arrow/python/numpy_to_arrow.cc
+++ b/python/pyarrow/src/arrow/python/numpy_to_arrow.cc
@@ -27,6 +27,7 @@
#include <limits>
#include <memory>
#include <string>
+#include <string_view>
#include <utility>
#include <vector>
@@ -295,6 +296,9 @@ class NumPyConverter {
template <typename T>
Status VisitString(T* builder);
+ template <typename T>
+ Status VisitStringDType(T* builder);
+
Status TypeNotImplemented(std::string type_name) {
return Status::NotImplemented("NumPyConverter doesn't implement <",
type_name,
"> conversion. ");
@@ -342,6 +346,11 @@ Status NumPyConverter::Convert() {
return Status::Invalid("Must pass data type for non-object arrays");
}
+ if (dtype_->type_num == NPY_VSTRING &&
!is_string_or_string_view(type_->id())) {
+ return Status::TypeError("Expected an Arrow string type for NumPy
StringDType, got ",
+ type_->ToString());
+ }
+
// Visit the type to perform conversion
return VisitTypeInline(*type_, this);
}
@@ -697,8 +706,65 @@ Status AppendUTF32(const char* data, int64_t itemsize, int
byteorder, T* builder
} // namespace
+namespace {
+
+std::string_view ToStringView(const npy_static_string& value) {
+ return value.buf == nullptr ? std::string_view()
+ : std::string_view(value.buf, value.size);
+}
+
+} // namespace
+
+template <typename T>
+Status NumPyConverter::VisitStringDType(T* builder) {
+ auto* descr = reinterpret_cast<PyArray_StringDTypeObject*>(dtype_);
+ // Use the na_object itself when na_object is a string
+ const bool null_is_missing = descr->na_object != nullptr &&
!descr->has_string_na;
+ const std::string_view null_string = ToStringView(descr->default_string);
+
+ const char* data = PyArray_BYTES(arr_);
+ Ndarray1DIndexer<uint8_t> mask_values;
+ if (mask_ != nullptr) {
+ mask_values = Ndarray1DIndexer<uint8_t>(mask_);
+ }
+
+ // Acquiring the allocator lock, so do not acquire the GIL or lock other
+ // mutexes below or risk deadlocks
+ auto* allocator = NpyString_acquire_allocator(descr);
+ std::unique_ptr<npy_string_allocator, decltype(&NpyString_release_allocator)>
+ allocator_guard(allocator, &NpyString_release_allocator);
+
+ npy_static_string value = {0, nullptr};
+ for (int64_t i = 0; i < length_; ++i, data += stride_) {
+ if (mask_ != nullptr && mask_values[i]) {
+ RETURN_NOT_OK(builder->AppendNull());
+ continue;
+ }
+ const auto* packed = reinterpret_cast<const
npy_packed_static_string*>(data);
+ const int is_null = NpyString_load(allocator, packed, &value);
+ if (is_null == -1) {
+ return Status::Invalid("Failed to load NumPy StringDType value");
+ }
+ if (is_null) {
+ if (null_is_missing) {
+ RETURN_NOT_OK(builder->AppendNull());
+ } else {
+ RETURN_NOT_OK(builder->Append(null_string));
+ }
+ continue;
+ }
+ RETURN_NOT_OK(builder->Append(ToStringView(value)));
+ }
+ return Status::OK();
+}
+
template <typename T>
Status NumPyConverter::VisitString(T* builder) {
+ if (dtype_->type_num == NPY_VSTRING) {
+ // Acquires a lock, so must stay ahead of the gil_lock below
+ return VisitStringDType(builder);
+ }
+
auto data = reinterpret_cast<const uint8_t*>(PyArray_DATA(arr_));
char numpy_byteorder = dtype_->byteorder;
diff --git a/python/pyarrow/tests/test_array.py
b/python/pyarrow/tests/test_array.py
index 87040c0800f..38a131f5d95 100644
--- a/python/pyarrow/tests/test_array.py
+++ b/python/pyarrow/tests/test_array.py
@@ -2983,6 +2983,85 @@ def test_array_from_numpy_unicode(string_type):
assert arrow_arr.equals(expected)
[email protected]
+def numpy_string_dtype():
+ dtypes = pytest.importorskip("numpy.dtypes")
+ return dtypes.StringDType
+
+
[email protected]
[email protected]('string_type', [
+ None,
+ pa.string(),
+ pa.large_string(),
+ pa.string_view()])
+def test_array_from_numpy_string_dtype(numpy_string_dtype, string_type):
+ values = [
+ "short",
+ "a" * 100,
+ "b" * 300,
+ "árvíztűrő tükörfúrógép 🥐 你好",
+ "🥐" * 200,
+ "",
+ ]
+ arr = np.array(values, dtype=numpy_string_dtype())
+
+ arrow_arr = pa.array(arr, type=string_type)
+
+ arrow_arr.validate(full=True)
+ assert arrow_arr.type == (string_type or pa.string())
+ assert arrow_arr.to_pylist() == arr.tolist()
+
+ strided = np.array(list(itertools.chain.from_iterable(
+ zip(values, itertools.repeat("skip")))),
+ dtype=numpy_string_dtype())[::2]
+ arrow_arr = pa.array(strided, type=string_type)
+ arrow_arr.validate(full=True)
+ assert arrow_arr.to_pylist() == values
+
+
[email protected]
[email protected]('na_object, expected', [
+ (None, None),
+ (float("nan"), None),
+ ("__placeholder__", "__placeholder__"),
+])
+def test_array_from_numpy_string_dtype_na_object(
+ numpy_string_dtype, na_object, expected):
+ arr = np.array(["some", na_object, "strings"],
+ dtype=numpy_string_dtype(na_object=na_object))
+
+ arrow_arr = pa.array(arr)
+ arrow_arr.validate(full=True)
+ assert arrow_arr.to_pylist() == ["some", expected, "strings"]
+
+ mask = np.array([False, False, True])
+ arrow_arr = pa.array(arr, mask=mask)
+ arrow_arr.validate(full=True)
+ assert arrow_arr.to_pylist() == ["some", expected, None]
+
+
[email protected]
+def test_array_from_numpy_string_dtype_rejects_non_string_type(
+ numpy_string_dtype):
+ arr = np.array(["some", "strings"], dtype=numpy_string_dtype())
+
+ msg = "Expected an Arrow string type.*got binary"
+ with pytest.raises(TypeError, match=msg):
+ pa.array(arr, type=pa.binary())
+
+
[email protected]
+def test_array_from_list_of_numpy_string_dtype_arrays(numpy_string_dtype):
+ values = [["a", "bb"], ["ccc"]]
+ arrays = [np.array(v, dtype=numpy_string_dtype()) for v in values]
+
+ result = pa.array(arrays)
+
+ assert result.type == pa.list_(pa.string())
+ assert result.to_pylist() == values
+
+
@pytest.mark.numpy
def test_array_string_from_non_string():
# ARROW-5682 - when converting to string raise on non string-like dtype
diff --git a/python/pyarrow/tests/test_schema.py
b/python/pyarrow/tests/test_schema.py
index 93519fc0173..b35b5350a81 100644
--- a/python/pyarrow/tests/test_schema.py
+++ b/python/pyarrow/tests/test_schema.py
@@ -209,6 +209,7 @@ def test_from_numpy_dtype():
(np.dtype('timedelta64[ms]'), pa.duration('ms')),
(np.dtype('timedelta64[us]'), pa.duration('us')),
(np.dtype('timedelta64[ns]'), pa.duration('ns')),
+ (np.dtypes.StringDType(), pa.string()),
]
for dt, pt in cases: