ENH: add NEP-50 style semantics for string scalars and StringDType#32040
ENH: add NEP-50 style semantics for string scalars and StringDType#32040ngoldbaum wants to merge 4 commits intonumpy:mainnumpy/numpy:mainfrom ngoldbaum:fix-trailing-null-scalarsngoldbaum/numpy:fix-trailing-null-scalarsCopy head branch name to clipboard
Conversation
| || (inputs_tup != NULL | ||
| && (PyArray_FLAGS(operands[i]) | ||
| & NPY_ARRAY_WAS_PYTHON_STR))) { |
There was a problem hiding this comment.
format like the stanza above
seberg
left a comment
There was a problem hiding this comment.
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.
| /* `resolve_descriptors_with_scalars` decides the descr */ | ||
| if ((PyArray_FLAGS(operands[i]) & NPY_ARRAY_WAS_PYTHON_LITERAL) || | ||
| /* str is only replaced if the resolver was passed the scalar */ | ||
| (input_scalars[i] != NULL && |
There was a problem hiding this comment.
Is this needed (since the above doesn't have it)?
There was a problem hiding this comment.
The numeric case uses NULL to indicate that the value isn't available. The string case needs the original string so NULL doesn't make sense here.
But actually looking closer there's possible NULL-pointer dereferences associated with this behavior in the numeric cases. Inside npy_update_operand_for_scalar:
numpy/numpy/_core/src/multiarray/abstractdtypes.c
Lines 386 to 400 in e503c0e
In the error path, scalar is accessed to get the name of the scalar type, but that happens before the null-check below.
Also here:
numpy/numpy/_core/src/umath/ufunc_object.c
Lines 4215 to 4232 in e503c0e
The safety should be checked before accessing dtypes[i], since it might not be initialized if resolve_descriptors_with_scalars fails.
Those are both preexisting issues thoush so I'll send in a followup for those...
ngoldbaum
left a comment
There was a problem hiding this comment.
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.
| /* `resolve_descriptors_with_scalars` decides the descr */ | ||
| if ((PyArray_FLAGS(operands[i]) & NPY_ARRAY_WAS_PYTHON_LITERAL) || | ||
| /* str is only replaced if the resolver was passed the scalar */ | ||
| (input_scalars[i] != NULL && |
There was a problem hiding this comment.
The numeric case uses NULL to indicate that the value isn't available. The string case needs the original string so NULL doesn't make sense here.
But actually looking closer there's possible NULL-pointer dereferences associated with this behavior in the numeric cases. Inside npy_update_operand_for_scalar:
numpy/numpy/_core/src/multiarray/abstractdtypes.c
Lines 386 to 400 in e503c0e
In the error path, scalar is accessed to get the name of the scalar type, but that happens before the null-check below.
Also here:
numpy/numpy/_core/src/umath/ufunc_object.c
Lines 4215 to 4232 in e503c0e
The safety should be checked before accessing dtypes[i], since it might not be initialized if resolve_descriptors_with_scalars fails.
Those are both preexisting issues thoush so I'll send in a followup for those...
| 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 |
There was a problem hiding this comment.
let me know if you think this test is over-engineered
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
@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).
| 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. */ |
There was a problem hiding this comment.
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.
| /* | ||
| * If we are working with Python literals/scalars, deal with them. | ||
| * If needed, we create new array with the right descriptor. | ||
| * A str operand is only replaced when `inputs_tup` provides the |
There was a problem hiding this comment.
This describes what the if statement does, but not why, and I'm slightly confused. Why should it be skipped when not from inputs_tup, while other scalar cases are not skipped? (I probably mostly do not understand in what case we get here for any scalar when inputs_tup == NULL...)
| goto fail; | ||
| } | ||
| Py_SETREF(full_args.in, new_in); | ||
| original_inputs = full_args.in; |
There was a problem hiding this comment.
Hmm, this had me really think why it is necessary. The reason would seem to be that now a scalar is promoted to an array before testing whether it is a python scaler. But this is not only a problem for strings:
a = np.arange(10.0, dtype="f4").reshape(5, 2)
np.add(1.0, a).dtype
# dtype('float32')
np.add.outer(1.0, a).dtype
# dtype('float64')
See #32076.
It may be an idea to try to fix this separately from this PR, and solve it together...
| goto fail; | ||
| } | ||
|
|
||
| if (original_inputs != NULL) { |
There was a problem hiding this comment.
See comment above; would seem better to fix this with the general problem of ufunc.outer
| if (resolve_descriptors(nop, ufunc, ufuncimpl, | ||
| operands, operation_descrs, signature, operand_DTypes, | ||
| full_args.in, casting) < 0) { | ||
| original_inputs != NULL ? original_inputs : full_args.in, |
| assert_array_equal(np.strings.find(arr, "c\0"), [2, -1]) | ||
| assert_array_equal(np.strings.replace(arr, "\0", "!"), ["abc!", "abc"]) | ||
|
|
||
| outer = np.add.outer(np.array(["x"], dtype="T"), "y\0") |
There was a problem hiding this comment.
Total nitpick, but I'd make this a different test function (and same for at below), especially as the test itself does not use the same input arrays.
| sep = ArrayLike("-") | ||
| result = func(a, sep) | ||
| for actual, expected in zip(result, [["a"], ["-"], ["b"]]): | ||
| assert_array_equal(actual, np.array(expected, dtype=dt)) |
There was a problem hiding this comment.
Do you want to add strict=True in the comparison to be sure the dtype itself is checked too?
| 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 |
There was a problem hiding this comment.
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.
|
|
||
| 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": |
There was a problem hiding this comment.
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...)
PR summary
While working on ByteStringDType, I noticed that trailing nulls are currently stripped when StringDType operations use python scalars. For example:
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.