Skip to content

ENH: add NEP-50 style semantics for string scalars and StringDType - #32040

Merged
ngoldbaum merged 3 commits into
numpy:mainfrom
ngoldbaum:fix-trailing-null-scalars
Aug 13, 2026
Merged

ENH: add NEP-50 style semantics for string scalars and StringDType#32040
ngoldbaum merged 3 commits into
numpy:mainfrom
ngoldbaum:fix-trailing-null-scalars

Conversation

@ngoldbaum

Copy link
Copy Markdown
Member

PR summary

While working on ByteStringDType, I noticed that trailing nulls are currently stripped when StringDType operations use python scalars. For example:

>>> np.array(["hello", "world"], dtype="T") + "hello\0\0"
array(['hellohello', 'worldhello'], dtype=StringDType())

This is happening because "hello\0\0" is coerced to a fixed-width unicode array, which strips the trailing NULLs.

@seberg let me know that the infrastructure in NumPy to do this is already in NumPy to support NEP-50. Somewhat differently from the other NEP-50 cases, I don't think it's necessary to add a new abstract dtype to represent python strings because we don't want string scalars and string arrays to have different semantics.

While working on this I realized that partition and rpartition need a promoter to be able to work with this new functionality, so this includes that as well.

AI Disclosure

I iterated on the implementation with an AI model.

Comment thread numpy/_core/src/umath/ufunc_object.c Outdated

@seberg seberg left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, this looks nice and straight-forward enough. As you said, dunno if that one test has much use, but I don't mind keeping it.

May make sense to do that (yes annoying) ternary to avoid double-conversion, I think, but otherwise this seems good and pragmatic.

Comment thread numpy/_core/src/umath/ufunc_object.c Outdated
Comment thread numpy/_core/tests/test_stringdtype.py Outdated
Comment thread numpy/_core/strings.py

@ngoldbaum ngoldbaum left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I responded to all your comments. I also fixed some additional issues I missed on the first pass for unary ufuncs, ufunc.at, and ufunc.outer.

Comment thread numpy/_core/src/umath/ufunc_object.c Outdated
Comment thread numpy/_core/tests/test_strings.py Outdated
result = np.strings.replace(a, old, new)
assert_array_equal(result, np.array(["a+b"], dtype=dt))
assert a.array_calls == old.array_calls == new.array_calls == 1
assert a.ufunc_calls == old.ufunc_calls == new.ufunc_calls == 0

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let me know if you think this test is over-engineered

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I quite like it, though perhaps would just raise NotImplementedError in __array_ufunc__, in which case you do not need ufunc_calls and its checks.

@mhvk mhvk left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ngoldbaum - this is rather nice! But while reviewing the special-casing for .outer, I realized there was a bigger problem there (see #32076).

I also somewhat wonder whether it would not be better to treat the python str scalars more similar to the numerical ones (see comment about the tests).

Comment thread numpy/_core/src/umath/ufunc_object.c Outdated
out_op_DTypes[i] = NPY_DTYPE(PyArray_DESCR(out_op[i]));
Py_INCREF(out_op_DTypes[i]);

/* Does not affect promotion, only conversion after resolution. */

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this can be moved inside the scalar-case if block on original l.688, since it would be a scalar and does not seem relevant for a ufunc with nin=1. Has the advantage of not causing any delay for non-string inputs.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unary ufuncs have an early exit in convert_ufunc_arguments so implementing this also requires getting rid of that early exit. Which is more correct anyway, it just wasn't necessary for NEP-50 semantics.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, but I htink we can still move it in the else branch of PyArray_Check -- since this obviously wasn't an array. (I'm not sure what "line l.688" I referred to in my initial comment...).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, thanks for clarifying. This is simpler.

Comment thread numpy/_core/src/umath/ufunc_object.c Outdated
Comment thread numpy/_core/src/umath/ufunc_object.c Outdated
Comment thread numpy/_core/src/umath/ufunc_object.c Outdated
Comment thread numpy/_core/src/umath/ufunc_object.c Outdated
Comment thread numpy/_core/tests/test_stringdtype.py Outdated
Comment thread numpy/_core/tests/test_strings.py Outdated
Comment thread numpy/_core/tests/test_strings.py Outdated
result = np.strings.replace(a, old, new)
assert_array_equal(result, np.array(["a+b"], dtype=dt))
assert a.array_calls == old.array_calls == new.array_calls == 1
assert a.ufunc_calls == old.ufunc_calls == new.ufunc_calls == 0

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I quite like it, though perhaps would just raise NotImplementedError in __array_ufunc__, in which case you do not need ufunc_calls and its checks.

Comment thread numpy/_core/strings.py

if np.result_type(arr, old, new).char == "T":
return _replace(arr, old, new, count)
if np.result_type(arr, old_arr, new_arr).char == "T":

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, it would be nice if one could avoid asanyarray in case of str input and then let the rest still work. But right now,

np.result_type(np.array(123, "f4"), 1.0)
# dtype('float32')
np.result_type(np.array("123", "T"), "1")
# TypeError: data type '' not understood

Is this because of not introducing an abstract dtype class for python strings?

Or could this be fixed more easily? (Anyway, not the biggest deal...)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think an abstract string dtype is needed. Python strings and string arrays have the same promotion semantics. We're only adding the scalar tracking so that we don't lose trailing NULL bytes in python strings because there's no way to override the detour via the U dtype default.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, I was just hoping to avoid this whole "create an array and then only use it if not str" dance, which would be possible if result_type just worked with str just like it does with float.

And looking at the code in multiarraymodule.c, this is actually not hard in principle, but in practice strings get interpreted as dtypes, and one cannot break that easily.

An alternative would be to use _array_converter, which is easier to adjust (I just tried) and would help to remove boilerplate.

But arguably also beyond what is needed here, especially as it still needlessly converts the string into an array, so maybe better done as follow-up....

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, let's do that in a followup.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See #32297

@ngoldbaum
ngoldbaum force-pushed the fix-trailing-null-scalars branch from 2ce5efc to 2a45797 Compare August 5, 2026 22:09

@mhvk mhvk left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only a small suggestion left... (and a larger comment with no action required).

Comment thread numpy/_core/src/umath/ufunc_object.c Outdated
out_op_DTypes[i] = NPY_DTYPE(PyArray_DESCR(out_op[i]));
Py_INCREF(out_op_DTypes[i]);

/* Does not affect promotion, only conversion after resolution. */

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, but I htink we can still move it in the else branch of PyArray_Check -- since this obviously wasn't an array. (I'm not sure what "line l.688" I referred to in my initial comment...).

Comment thread numpy/_core/strings.py

if np.result_type(arr, old, new).char == "T":
return _replace(arr, old, new, count)
if np.result_type(arr, old_arr, new_arr).char == "T":

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, I was just hoping to avoid this whole "create an array and then only use it if not str" dance, which would be possible if result_type just worked with str just like it does with float.

And looking at the code in multiarraymodule.c, this is actually not hard in principle, but in practice strings get interpreted as dtypes, and one cannot break that easily.

An alternative would be to use _array_converter, which is easier to adjust (I just tried) and would help to remove boilerplate.

But arguably also beyond what is needed here, especially as it still needlessly converts the string into an array, so maybe better done as follow-up....

@ngoldbaum

Copy link
Copy Markdown
Member Author

Marten approved the bulk of this and Sebastian was also OK with it, so I'm bringing this in to unblock my big ByteStringDType PR and ByteStringDType NEP PR I'd like to get done this week.

Thanks for looking this over both of you!

@ngoldbaum
ngoldbaum merged commit 620518d into numpy:main Aug 13, 2026
91 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants