diff --git a/CHANGELOG.md b/CHANGELOG.md index 201ceb51716..94433303ebc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -90,6 +90,7 @@ This release is compatible with NumPy 2.5. * Fixed `dpnp.all` and `dpnp.any` aborting when reducing over an empty axis (e.g. an array with a zero-length dimension) [#3021](https://github.com/IntelPython/dpnp/pull/3021) * Released the GIL before the blocking OneMKL DFT calls in the FFT extension [#3040](https://github.com/IntelPython/dpnp/pull/3040) * Fixed `astype` casting an out-of-range floating point value to a signed narrow integer type saturating to the destination min/max instead of wrapping like NumPy, generalizing the earlier unsigned-only fix [#3033](https://github.com/IntelPython/dpnp/pull/3033) +* Fixed `dpnp.insert` silently ignoring out-of-bounds negative indices in a multi-element `obj`, so a mix of in-bounds and out-of-bounds indices now consistently raises `IndexError` [#3041](https://github.com/IntelPython/dpnp/pull/3041) ### Security diff --git a/dpnp/dpnp_iface_manipulation.py b/dpnp/dpnp_iface_manipulation.py index b2046ffc494..1edfdce7982 100644 --- a/dpnp/dpnp_iface_manipulation.py +++ b/dpnp/dpnp_iface_manipulation.py @@ -240,6 +240,35 @@ def _calc_parameters(a, axis, obj, values=None): ) +def _check_index_bounds(obj, indices, n, axis): + """ + Raise ``IndexError`` if any index in `obj` is out of bounds for `axis`. + + Mirrors the size-1 path in ``_insert_singleton_index``: when `obj` lives on + the host (a Python sequence/scalar or NumPy array) the bounds are validated + with NumPy, avoiding a device sync entirely. A slice cannot be out of bounds + (``obj.indices(n)`` is clamped to ``[0, n]``), so it is skipped. Only a + device array `obj` needs a single host transfer to read its extremes. + + """ + + if isinstance(obj, slice) or indices.size == 0: + return + + if dpnp.is_supported_array_type(obj): + min_idx, max_idx = dpnp.stack([indices.min(), indices.max()]).asnumpy() + else: + host_obj = numpy.asarray(obj) + min_idx, max_idx = host_obj.min(), host_obj.max() + + min_idx, max_idx = int(min_idx), int(max_idx) + if min_idx < -n or max_idx > n: + oob = min_idx if min_idx < -n else max_idx + raise IndexError( + f"index {oob} is out of bounds for axis {axis} with size {n}" + ) + + def _insert_array_indices(parameters, indices, values, obj): """ Utility function for ``dpnp.insert`` when indices is an array with @@ -2435,6 +2464,13 @@ def insert(arr, obj, values, axis=None): does not occur in-place: a new array is returned. If `axis` is ``None``, `out` is a flattened array. + Warnings + -------- + This function might synchronize in order to validate that the indices are + within bounds. This may harm performance in some applications. To avoid + synchronization, pass `obj` as a Python scalar or sequence, or as a NumPy + array. + See Also -------- :obj:`dpnp.append` : Append elements at the end of an array. @@ -2522,8 +2558,10 @@ def insert(arr, obj, values, axis=None): ) if indices.size == 1: + # the size-1 path validates the bounds itself while reading the index return _insert_singleton_index(params, indices, values, obj) + _check_index_bounds(obj, indices, params.n, params.axis) return _insert_array_indices(params, indices, values, obj) diff --git a/dpnp/tests/test_manipulation.py b/dpnp/tests/test_manipulation.py index 3dbd9691d4c..7245c6712a3 100644 --- a/dpnp/tests/test_manipulation.py +++ b/dpnp/tests/test_manipulation.py @@ -815,11 +815,32 @@ def test_error(self): with pytest.raises(TypeError): dpnp.insert(a, [], 2, axis="nonsense") - @pytest.mark.parametrize("idx", [4, -4]) - def test_index_out_of_bounds(self, idx): - a = dpnp.array([0, 1, 2]) + @testing.with_requires("numpy>=2.6") + @pytest.mark.parametrize("xp", [numpy, dpnp]) + @pytest.mark.parametrize( + "idx, values", + [ + # single-element obj -> singleton path + ([4], [3, 4]), + ([-4], [3, 4]), + # multi-element obj -> array path + ([-6, 0], [9, 8]), + ([0, 6], [9, 8]), + ([4, 4], [3, 4]), + ([-4, -5], [3, 4]), + ], + ) + def test_index_out_of_bounds(self, xp, idx, values): + a = xp.array([0, 1, 2]) + with pytest.raises(IndexError, match="out of bounds"): + xp.insert(a, idx, values) + + @pytest.mark.parametrize("xp", [numpy, dpnp]) + @pytest.mark.parametrize("axis", [0, 1]) + def test_index_out_of_bounds_ndim(self, xp, axis): + a = xp.ones((3, 3)) with pytest.raises(IndexError, match="out of bounds"): - dpnp.insert(a, [idx], [3, 4]) + xp.insert(a, [5, 0], 9, axis=axis) # array_split has more comprehensive test of splitting.