From 74efa5148c84a72742fdae9882b8e557b70232aa Mon Sep 17 00:00:00 2001 From: Lasloruhberg Date: Thu, 2 Jul 2026 19:14:08 +0200 Subject: [PATCH 01/12] include numpy specific clipping function that avoids typecasting --- src/array_api_compat/numpy/_aliases.py | 89 ++++++++++++++++++++++++-- tests/test_numpy.py | 32 ++++++++- 2 files changed, 113 insertions(+), 8 deletions(-) diff --git a/src/array_api_compat/numpy/_aliases.py b/src/array_api_compat/numpy/_aliases.py index 87b3c2f3..df9dd0eb 100644 --- a/src/array_api_compat/numpy/_aliases.py +++ b/src/array_api_compat/numpy/_aliases.py @@ -49,7 +49,6 @@ var = get_xp(np)(_aliases.var) cumulative_sum = get_xp(np)(_aliases.cumulative_sum) cumulative_prod = get_xp(np)(_aliases.cumulative_prod) -clip = get_xp(np)(_aliases.clip) permute_dims = get_xp(np)(_aliases.permute_dims) reshape = get_xp(np)(_aliases.reshape) argsort = get_xp(np)(_aliases.argsort) @@ -106,6 +105,80 @@ def astype( return x.astype(dtype=dtype, copy=copy) +def clip( + x: Array, + /, + min: float | Array | None = None, + max: float | Array | None = None, + *, + out: Array | None = None, +) -> Array: + """Array API compatible clip implementation for NumPy. + + NumPy's native ``clip`` is used directly after casting bounds to the + input dtype. This keeps the result dtype aligned with ``x.dtype`` and + avoids NumPy's default promotion behavior. + + Args: + x: Input array. + min: Minimum bound. If None, no lower bound is applied. + max: Maximum bound. If None, no upper bound is applied. + out: Optional output array to store the result, has to have dtype of x + """ + + def _bound_shape(a: object) -> tuple[int, ...]: + if a is None or np.isscalar(a): + return () + return np.asarray(a).shape + + dtype = x.dtype + out_dtype = out.dtype if out is not None else dtype + if out_dtype != dtype: + raise ValueError(f"Output array has dtype {out_dtype}, but input array has dtype {dtype}") + min_shape = _bound_shape(min) + max_shape = _bound_shape(max) + + # avoid shape broadcasting and copying when not necessary + if min_shape == () and max_shape == (): + result_shape = x.shape + else: + result_shape = np.broadcast_shapes(x.shape, min_shape, max_shape) + + # At least handle the case of Python integers correctly. + if np.issubdtype(dtype, np.integer): + if type(min) is int and min <= np.iinfo(dtype).min: + min = None + if type(max) is int and max >= np.iinfo(dtype).max: + max = None + + if min is None and max is None: + if out is None: + return x.copy()[()] + np.copyto(out, x) + return out[()] + + # Cast clip parameters to the input dtype and broadcast them to the result shape. + a_min = None + if min is not None: + a_min = np.asarray(min, dtype=dtype) + if a_min.shape != result_shape: + # Casting first keeps NumPy from promoting the output dtype. + a_min = np.broadcast_to(a_min, result_shape) + + a_max = None + if max is not None: + a_max = np.asarray(max, dtype=dtype) + if a_max.shape != result_shape: + # Casting first keeps NumPy from promoting the output dtype. + a_max = np.broadcast_to(a_max, result_shape) + + if out is None: + out = np.empty(result_shape, dtype=dtype) + + np.clip(x, a_min, a_max, out=out, casting="no") + return out[()] + + # count_nonzero returns a python int for axis=None and keepdims=False # https://github.com/numpy/numpy/issues/17562 def count_nonzero( @@ -115,7 +188,9 @@ def count_nonzero( ) -> Array: # NOTE: this is currently incorrectly typed in numpy, but will be fixed in # numpy 2.2.5 and 2.3.0: https://github.com/numpy/numpy/pull/28750 - result = cast("Any", np.count_nonzero(x, axis=axis, keepdims=keepdims)) # pyright: ignore[reportArgumentType, reportCallIssue] + result = cast( + "Any", np.count_nonzero(x, axis=axis, keepdims=keepdims) + ) # pyright: ignore[reportArgumentType, reportCallIssue] if axis is None and not keepdims: return np.asarray(result) return result @@ -128,20 +203,21 @@ def take_along_axis(x: Array, indices: Array, /, *, axis: int = -1) -> Array: # ceil, floor, and trunc return integers for integer inputs in NumPy < 2 + def ceil(x: Array, /) -> Array: - if np.__version__ < '2' and np.issubdtype(x.dtype, np.integer): + if np.__version__ < "2" and np.issubdtype(x.dtype, np.integer): return x.copy() return np.ceil(x) def floor(x: Array, /) -> Array: - if np.__version__ < '2' and np.issubdtype(x.dtype, np.integer): + if np.__version__ < "2" and np.issubdtype(x.dtype, np.integer): return x.copy() return np.floor(x) def trunc(x: Array, /) -> Array: - if np.__version__ < '2' and np.issubdtype(x.dtype, np.integer): + if np.__version__ < "2" and np.issubdtype(x.dtype, np.integer): return x.copy() return np.trunc(x) @@ -173,6 +249,7 @@ def trunc(x: Array, /) -> Array: "atan", "atan2", "atanh", + "clip", "ceil", "floor", "trunc", @@ -183,7 +260,7 @@ def trunc(x: Array, /) -> Array: "concat", "count_nonzero", "pow", - "take_along_axis" + "take_along_axis", ] diff --git a/tests/test_numpy.py b/tests/test_numpy.py index a139d428..efae7690 100644 --- a/tests/test_numpy.py +++ b/tests/test_numpy.py @@ -1,5 +1,5 @@ -"""Test "unspecified" behavior which we cannot easily test in the Array API test suite. -""" +"""Test "unspecified" behavior which we cannot easily test in the Array API test suite.""" + import warnings import pytest @@ -10,6 +10,34 @@ from array_api_compat import is_array_api_obj + +def test_numpy_clip_out_and_broadcast(): + from array_api_compat import numpy as xp + + x = xp.asarray([[10, 20, 30], [40, 50, 60]], dtype=np.uint8) + min_bound = xp.asarray([15, 35, 55], dtype=np.int16) + max_bound = xp.asarray([25, 45, 65], dtype=np.int16) + out = xp.empty_like(x) + + result = xp.clip(x, min_bound, max_bound, out=out) + + assert result is out + assert out.dtype == x.dtype + np.testing.assert_array_equal(out, xp.asarray([[15, 20, 30], [25, 45, 60]], dtype=np.uint8)) + + +def test_numpy_clip_returns_copy_when_unbounded(): + from array_api_compat import numpy as xp + + x = xp.arange(8, dtype=np.int64) + + y = xp.clip(x) + + assert y.dtype == x.dtype + assert not np.shares_memory(x, y) + np.testing.assert_array_equal(y, x) + + def test_matrix_is_not_array_api_obj(): assert is_array_api_obj(np.asarray(3)) assert is_array_api_obj(np.float64(3)) From 1f41450390385e8074ccfb77558d3fe4b490ef91 Mon Sep 17 00:00:00 2001 From: Lasloruhberg Date: Thu, 2 Jul 2026 19:37:43 +0200 Subject: [PATCH 02/12] pass kwargs to numpy clip --- src/array_api_compat/numpy/_aliases.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/array_api_compat/numpy/_aliases.py b/src/array_api_compat/numpy/_aliases.py index df9dd0eb..5171223a 100644 --- a/src/array_api_compat/numpy/_aliases.py +++ b/src/array_api_compat/numpy/_aliases.py @@ -110,8 +110,8 @@ def clip( /, min: float | Array | None = None, max: float | Array | None = None, - *, out: Array | None = None, + **kwargs, ) -> Array: """Array API compatible clip implementation for NumPy. @@ -175,7 +175,7 @@ def _bound_shape(a: object) -> tuple[int, ...]: if out is None: out = np.empty(result_shape, dtype=dtype) - np.clip(x, a_min, a_max, out=out, casting="no") + np.clip(x, a_min, a_max, out=out, casting="no", **kwargs) return out[()] From c166eb29b655d56117c26d2bb1bcc0a65c4e9fb9 Mon Sep 17 00:00:00 2001 From: Lasloruhberg Date: Fri, 3 Jul 2026 10:24:28 +0200 Subject: [PATCH 03/12] add a few further tests --- tests/test_numpy.py | 50 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/tests/test_numpy.py b/tests/test_numpy.py index efae7690..b8c7a450 100644 --- a/tests/test_numpy.py +++ b/tests/test_numpy.py @@ -26,6 +26,56 @@ def test_numpy_clip_out_and_broadcast(): np.testing.assert_array_equal(out, xp.asarray([[15, 20, 30], [25, 45, 60]], dtype=np.uint8)) +def test_numpy_clip_uint8_casts_bounds_outside_range(): + from array_api_compat import numpy as xp + + x = xp.asarray([0, 10, 250], dtype=np.uint8) + min_bound = np.int16(-1) + max_bound = np.int16(200) + + result = xp.clip(x, min_bound, max_bound) + + assert result.dtype == x.dtype + np.testing.assert_array_equal(result, xp.asarray([200, 200, 200], dtype=np.uint8)) + + +def test_numpy_clip_int64_casts_bounds_outside_range(): + from array_api_compat import numpy as xp + + x = xp.asarray([-(2**63), -1, 0, 2**63 - 1], dtype=np.int64) + min_bound = np.float64(-1e20) + max_bound = np.float64(1e20) + + result = xp.clip(x, min_bound, max_bound) + + assert result.dtype == x.dtype + np.testing.assert_array_equal( + result, + xp.asarray( + [ + np.iinfo(np.int64).min, + np.iinfo(np.int64).min, + np.iinfo(np.int64).min, + np.iinfo(np.int64).min, + ], + dtype=np.int64, + ), + ) + + +def test_numpy_clip_float16_casts_bounds_outside_range(): + from array_api_compat import numpy as xp + + x = xp.asarray([0.0, 1.5, 3.0], dtype=np.float16) + min_bound = np.float32(-1e10) + max_bound = np.float32(2.0) + + result = xp.clip(x, min_bound, max_bound) + + assert result.dtype == x.dtype + np.testing.assert_array_equal(result, xp.asarray([0.0, 1.5, 2.0], dtype=np.float16)) + + def test_numpy_clip_returns_copy_when_unbounded(): from array_api_compat import numpy as xp From 756d23a1deb84733f96b477a48518d05805ca28f Mon Sep 17 00:00:00 2001 From: Lasloruhberg Date: Fri, 3 Jul 2026 11:03:10 +0200 Subject: [PATCH 04/12] fix test expectations, to match broadcasting behaviour --- tests/test_numpy.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_numpy.py b/tests/test_numpy.py index b8c7a450..ae7ebf88 100644 --- a/tests/test_numpy.py +++ b/tests/test_numpy.py @@ -23,7 +23,7 @@ def test_numpy_clip_out_and_broadcast(): assert result is out assert out.dtype == x.dtype - np.testing.assert_array_equal(out, xp.asarray([[15, 20, 30], [25, 45, 60]], dtype=np.uint8)) + np.testing.assert_array_equal(out, xp.asarray([[15, 35, 55], [25, 45, 60]], dtype=np.uint8)) def test_numpy_clip_uint8_casts_bounds_outside_range(): From f62ae7126f5c9ccc3b3f3210d51cdaa141dc6124 Mon Sep 17 00:00:00 2001 From: Lasloruhberg Date: Fri, 3 Jul 2026 11:07:51 +0200 Subject: [PATCH 05/12] remove out from input parameters and keep in kwargs, fix line format --- src/array_api_compat/numpy/_aliases.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/array_api_compat/numpy/_aliases.py b/src/array_api_compat/numpy/_aliases.py index 5171223a..6ef3060c 100644 --- a/src/array_api_compat/numpy/_aliases.py +++ b/src/array_api_compat/numpy/_aliases.py @@ -110,7 +110,6 @@ def clip( /, min: float | Array | None = None, max: float | Array | None = None, - out: Array | None = None, **kwargs, ) -> Array: """Array API compatible clip implementation for NumPy. @@ -125,6 +124,13 @@ def clip( max: Maximum bound. If None, no upper bound is applied. out: Optional output array to store the result, has to have dtype of x """ + # out is a possible *kwarg for numpy.clip, but not in the array API spec. We handle it here to + # avoid having to add it to the array API spec, which would be a breaking change + # check if out in kwargs, if so pop it and use it as the out parameter + if "out" in kwargs: + out = kwargs.pop("out") + else: + out = None def _bound_shape(a: object) -> tuple[int, ...]: if a is None or np.isscalar(a): @@ -134,7 +140,9 @@ def _bound_shape(a: object) -> tuple[int, ...]: dtype = x.dtype out_dtype = out.dtype if out is not None else dtype if out_dtype != dtype: - raise ValueError(f"Output array has dtype {out_dtype}, but input array has dtype {dtype}") + raise ValueError( + f"Output array has dtype {out_dtype}, but input array has dtype {dtype}" + ) min_shape = _bound_shape(min) max_shape = _bound_shape(max) From 39595d10186baa37304dd413e3953e94d4745a29 Mon Sep 17 00:00:00 2001 From: Lasloruhberg Date: Fri, 3 Jul 2026 11:16:17 +0200 Subject: [PATCH 06/12] remove assert result is out, since view is returned instead --- tests/test_numpy.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_numpy.py b/tests/test_numpy.py index ae7ebf88..de8243f1 100644 --- a/tests/test_numpy.py +++ b/tests/test_numpy.py @@ -21,8 +21,8 @@ def test_numpy_clip_out_and_broadcast(): result = xp.clip(x, min_bound, max_bound, out=out) - assert result is out - assert out.dtype == x.dtype + np.testing.assert_array_equal(result, xp.asarray([[15, 35, 55], [25, 45, 60]], dtype=np.uint8)) + assert result.dtype == x.dtype np.testing.assert_array_equal(out, xp.asarray([[15, 35, 55], [25, 45, 60]], dtype=np.uint8)) From 5147acdf2f833b3010b860be6c22e9fceea2d274 Mon Sep 17 00:00:00 2001 From: Lasloruhberg Date: Mon, 6 Jul 2026 19:09:15 +0200 Subject: [PATCH 07/12] fix import in tests and roll back auto-formatting changes --- src/array_api_compat/numpy/_aliases.py | 10 ++++------ tests/test_numpy.py | 6 ++---- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/src/array_api_compat/numpy/_aliases.py b/src/array_api_compat/numpy/_aliases.py index 6ef3060c..25f91309 100644 --- a/src/array_api_compat/numpy/_aliases.py +++ b/src/array_api_compat/numpy/_aliases.py @@ -196,9 +196,7 @@ def count_nonzero( ) -> Array: # NOTE: this is currently incorrectly typed in numpy, but will be fixed in # numpy 2.2.5 and 2.3.0: https://github.com/numpy/numpy/pull/28750 - result = cast( - "Any", np.count_nonzero(x, axis=axis, keepdims=keepdims) - ) # pyright: ignore[reportArgumentType, reportCallIssue] + result = cast("Any", np.count_nonzero(x, axis=axis, keepdims=keepdims)) # pyright: ignore[reportArgumentType, reportCallIssue] if axis is None and not keepdims: return np.asarray(result) return result @@ -213,19 +211,19 @@ def take_along_axis(x: Array, indices: Array, /, *, axis: int = -1) -> Array: def ceil(x: Array, /) -> Array: - if np.__version__ < "2" and np.issubdtype(x.dtype, np.integer): + if np.__version__ < '2' and np.issubdtype(x.dtype, np.integer): return x.copy() return np.ceil(x) def floor(x: Array, /) -> Array: - if np.__version__ < "2" and np.issubdtype(x.dtype, np.integer): + if np.__version__ < '2' and np.issubdtype(x.dtype, np.integer): return x.copy() return np.floor(x) def trunc(x: Array, /) -> Array: - if np.__version__ < "2" and np.issubdtype(x.dtype, np.integer): + if np.__version__ < '2' and np.issubdtype(x.dtype, np.integer): return x.copy() return np.trunc(x) diff --git a/tests/test_numpy.py b/tests/test_numpy.py index de8243f1..cb894c0e 100644 --- a/tests/test_numpy.py +++ b/tests/test_numpy.py @@ -9,10 +9,10 @@ pytestmark = pytest.skip(allow_module_level=True, reason="numpy not found") from array_api_compat import is_array_api_obj - +from array_api_compat import numpy as xp def test_numpy_clip_out_and_broadcast(): - from array_api_compat import numpy as xp + x = xp.asarray([[10, 20, 30], [40, 50, 60]], dtype=np.uint8) min_bound = xp.asarray([15, 35, 55], dtype=np.int16) @@ -27,7 +27,6 @@ def test_numpy_clip_out_and_broadcast(): def test_numpy_clip_uint8_casts_bounds_outside_range(): - from array_api_compat import numpy as xp x = xp.asarray([0, 10, 250], dtype=np.uint8) min_bound = np.int16(-1) @@ -40,7 +39,6 @@ def test_numpy_clip_uint8_casts_bounds_outside_range(): def test_numpy_clip_int64_casts_bounds_outside_range(): - from array_api_compat import numpy as xp x = xp.asarray([-(2**63), -1, 0, 2**63 - 1], dtype=np.int64) min_bound = np.float64(-1e20) From 415ff988e4eaddb32517df71615bf230a5e0bfe1 Mon Sep 17 00:00:00 2001 From: Lasloruhberg Date: Mon, 6 Jul 2026 20:10:43 +0200 Subject: [PATCH 08/12] Make clipping more rigorous for integer arrays expand test suite for further type promotion --- src/array_api_compat/numpy/_aliases.py | 22 ++++++-- tests/test_numpy.py | 77 +++++++++++++++++++++----- 2 files changed, 80 insertions(+), 19 deletions(-) diff --git a/src/array_api_compat/numpy/_aliases.py b/src/array_api_compat/numpy/_aliases.py index 25f91309..0078f727 100644 --- a/src/array_api_compat/numpy/_aliases.py +++ b/src/array_api_compat/numpy/_aliases.py @@ -152,12 +152,27 @@ def _bound_shape(a: object) -> tuple[int, ...]: else: result_shape = np.broadcast_shapes(x.shape, min_shape, max_shape) - # At least handle the case of Python integers correctly. + # Handle cases where the bounds are outside the range of the integer input dtype + # this covers integer arrays for float and integer bounds + # Also handle cases where the min/max are arrays/lists (replace values below min with iinfo.min and above max with iinfo.max) if np.issubdtype(dtype, np.integer): - if type(min) is int and min <= np.iinfo(dtype).min: + if np.issubdtype(type(min), np.integer) and min <= np.iinfo(dtype).min: min = None - if type(max) is int and max >= np.iinfo(dtype).max: + elif np.issubdtype(type(min), np.floating) and min < np.iinfo(dtype).min: + min = np.iinfo(dtype).min + elif isinstance(min, (list, tuple, Array)): + min[min < np.iinfo(dtype).min] = np.iinfo(dtype).min + if np.issubdtype(type(max), np.integer) and max >= np.iinfo(dtype).max: max = None + + elif np.issubdtype(type(max), np.floating) and max > np.iinfo(dtype).max: + max = np.iinfo(dtype).max + + elif isinstance(max, (list, tuple, Array)): + max[max > np.iinfo(dtype).max] = np.iinfo(dtype).max + + # In the case of downcasting floats numpy replaces out of bounds with inf + # This automatically handles those cases if min is None and max is None: if out is None: @@ -209,7 +224,6 @@ def take_along_axis(x: Array, indices: Array, /, *, axis: int = -1) -> Array: # ceil, floor, and trunc return integers for integer inputs in NumPy < 2 - def ceil(x: Array, /) -> Array: if np.__version__ < '2' and np.issubdtype(x.dtype, np.integer): return x.copy() diff --git a/tests/test_numpy.py b/tests/test_numpy.py index cb894c0e..ab57af99 100644 --- a/tests/test_numpy.py +++ b/tests/test_numpy.py @@ -12,7 +12,6 @@ from array_api_compat import numpy as xp def test_numpy_clip_out_and_broadcast(): - x = xp.asarray([[10, 20, 30], [40, 50, 60]], dtype=np.uint8) min_bound = xp.asarray([15, 35, 55], dtype=np.int16) @@ -26,8 +25,8 @@ def test_numpy_clip_out_and_broadcast(): np.testing.assert_array_equal(out, xp.asarray([[15, 35, 55], [25, 45, 60]], dtype=np.uint8)) -def test_numpy_clip_uint8_casts_bounds_outside_range(): - +def test_numpy_clip_all_bounds_work_with_int_arrays(): + """Test that integer bounds outside the range of input dtype still work for integer arrays""" x = xp.asarray([0, 10, 250], dtype=np.uint8) min_bound = np.int16(-1) max_bound = np.int16(200) @@ -35,11 +34,12 @@ def test_numpy_clip_uint8_casts_bounds_outside_range(): result = xp.clip(x, min_bound, max_bound) assert result.dtype == x.dtype - np.testing.assert_array_equal(result, xp.asarray([200, 200, 200], dtype=np.uint8)) - - -def test_numpy_clip_int64_casts_bounds_outside_range(): + np.testing.assert_array_equal(result, xp.asarray([0, 10, 200], dtype=np.uint8)) + """ + min and max bounds are below what can be represented by int64, + so they should be clipped to the min/max of int64 + """ x = xp.asarray([-(2**63), -1, 0, 2**63 - 1], dtype=np.int64) min_bound = np.float64(-1e20) max_bound = np.float64(1e20) @@ -51,21 +51,69 @@ def test_numpy_clip_int64_casts_bounds_outside_range(): result, xp.asarray( [ - np.iinfo(np.int64).min, - np.iinfo(np.int64).min, - np.iinfo(np.int64).min, - np.iinfo(np.int64).min, + -(2**63), -1, 0, 2**63 - 1 ], dtype=np.int64, ), ) -def test_numpy_clip_float16_casts_bounds_outside_range(): - from array_api_compat import numpy as xp +def test_array_min_max_broadcasting_when_clipped(): + """ Tests a min as tuple list array of floats input for an integer array + Should be clipped by min/max of the integer array""" + x=xp.asarray([0, 10, 100], dtype=np.uint8) + min_bound = xp.asarray([np.float64(-1e20), 5.0, 200.0], dtype=np.float32) + max_bound = None + result=xp.clip(x, min_bound, max_bound) + assert result.dtype == x.dtype + np.testing.assert_array_equal(result, xp.asarray([0, 10, 200], dtype=np.uint8)) + + # now test with a tuple + min_bound = (np.float64(-1e20), 5.0, 200.0) + result=xp.clip(x, min_bound, max_bound) + assert result.dtype == x.dtype + np.testing.assert_array_equal(result, xp.asarray([0, 10, 200], dtype=np.uint8)) + + # test with a list + min_bound = [np.float64(-1e20), 5.0, 200.0] + result=xp.clip(x, min_bound, max_bound) + assert result.dtype == x.dtype + np.testing.assert_array_equal(result, xp.asarray([0, 10, 200], dtype=np.uint8)) + +def test_numpy_type_promotion(): + """ Added to adress comment from main alias file: + # np.clip does type promotion but the array API clip requires that the + # output have the same dtype as x. We do this instead of just downcasting + # the result of xp.clip() to handle some corner cases better (e.g., + # avoiding uint64 -> float64 promotion). + """ + # ensure clipping with float bounds + x = xp.asarray([-(2**63), -1, 0, 2**63 - 1], dtype=np.int64) + min_bound = np.float64(-1.0001) + max_bound = np.float64(1.0001) + result = xp.clip(x, min_bound, max_bound) + assert result.dtype == x.dtype + assert result == xp.asarray([-1, -1, 0, 1], dtype=np.int64) + + # ensure clipping with int16 bounds + min_bound = np.int16(-1) + max_bound = np.int16(1) + result = xp.clip(x, min_bound, max_bound) + assert result.dtype == x.dtype + assert result == xp.asarray([-1, -1, 0, 1], dtype=np.int64) + + # final test with uint8 image and int64 bounds + x = xp.asarray([0, 10, 250], dtype=np.uint8) + min_bound = np.int64(-1) + max_bound = np.int64(32) + result = xp.clip(x, min_bound, max_bound) + assert result.dtype == x.dtype + assert result == xp.asarray([0, 10, 32], dtype=np.uint8) +def test_numpy_clip_float16_casts_bounds_outside_range(): + """Test that float16 bounds outside the range of input dtype still work for float16 arrays""" x = xp.asarray([0.0, 1.5, 3.0], dtype=np.float16) - min_bound = np.float32(-1e10) + min_bound = np.float32(-1e10) # outside of float16 range max_bound = np.float32(2.0) result = xp.clip(x, min_bound, max_bound) @@ -75,7 +123,6 @@ def test_numpy_clip_float16_casts_bounds_outside_range(): def test_numpy_clip_returns_copy_when_unbounded(): - from array_api_compat import numpy as xp x = xp.arange(8, dtype=np.int64) From ce6709a619155e6563d68d96b8462fa0dc31bedb Mon Sep 17 00:00:00 2001 From: Lasloruhberg Date: Mon, 6 Jul 2026 20:16:25 +0200 Subject: [PATCH 09/12] fix typo in test docstring --- tests/test_numpy.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/tests/test_numpy.py b/tests/test_numpy.py index ab57af99..a8fd195e 100644 --- a/tests/test_numpy.py +++ b/tests/test_numpy.py @@ -36,10 +36,9 @@ def test_numpy_clip_all_bounds_work_with_int_arrays(): assert result.dtype == x.dtype np.testing.assert_array_equal(result, xp.asarray([0, 10, 200], dtype=np.uint8)) - """ - min and max bounds are below what can be represented by int64, - so they should be clipped to the min/max of int64 - """ + + # min and max bounds are below what can be represented by int64, + # so they should be clipped to the min/max of int64 x = xp.asarray([-(2**63), -1, 0, 2**63 - 1], dtype=np.int64) min_bound = np.float64(-1e20) max_bound = np.float64(1e20) @@ -81,7 +80,7 @@ def test_array_min_max_broadcasting_when_clipped(): np.testing.assert_array_equal(result, xp.asarray([0, 10, 200], dtype=np.uint8)) def test_numpy_type_promotion(): - """ Added to adress comment from main alias file: + """ Added to address comment from main alias file: # np.clip does type promotion but the array API clip requires that the # output have the same dtype as x. We do this instead of just downcasting # the result of xp.clip() to handle some corner cases better (e.g., From f267a4361805145eb986fb6f560f1bccc6e5f07f Mon Sep 17 00:00:00 2001 From: Lasloruhberg Date: Tue, 7 Jul 2026 09:23:56 +0200 Subject: [PATCH 10/12] slight formatting and comments --- src/array_api_compat/numpy/_aliases.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/array_api_compat/numpy/_aliases.py b/src/array_api_compat/numpy/_aliases.py index 0078f727..0dbcbaf1 100644 --- a/src/array_api_compat/numpy/_aliases.py +++ b/src/array_api_compat/numpy/_aliases.py @@ -122,6 +122,7 @@ def clip( x: Input array. min: Minimum bound. If None, no lower bound is applied. max: Maximum bound. If None, no upper bound is applied. + **kwargs: Additional keyword arguments passed to ``np.clip``. out: Optional output array to store the result, has to have dtype of x """ # out is a possible *kwarg for numpy.clip, but not in the array API spec. We handle it here to @@ -156,24 +157,25 @@ def _bound_shape(a: object) -> tuple[int, ...]: # this covers integer arrays for float and integer bounds # Also handle cases where the min/max are arrays/lists (replace values below min with iinfo.min and above max with iinfo.max) if np.issubdtype(dtype, np.integer): + if np.issubdtype(type(min), np.integer) and min <= np.iinfo(dtype).min: min = None elif np.issubdtype(type(min), np.floating) and min < np.iinfo(dtype).min: min = np.iinfo(dtype).min elif isinstance(min, (list, tuple, Array)): min[min < np.iinfo(dtype).min] = np.iinfo(dtype).min + if np.issubdtype(type(max), np.integer) and max >= np.iinfo(dtype).max: max = None - elif np.issubdtype(type(max), np.floating) and max > np.iinfo(dtype).max: max = np.iinfo(dtype).max - elif isinstance(max, (list, tuple, Array)): max[max > np.iinfo(dtype).max] = np.iinfo(dtype).max # In the case of downcasting floats numpy replaces out of bounds with inf # This automatically handles those cases - + + # Early return for simple cases if min is None and max is None: if out is None: return x.copy()[()] From 0f835d07954e3da3634b0947d4d4d374d1834e5f Mon Sep 17 00:00:00 2001 From: Lasloruhberg Date: Tue, 7 Jul 2026 09:29:48 +0200 Subject: [PATCH 11/12] fix filure for tuple bounds --- src/array_api_compat/numpy/_aliases.py | 11 +++++++++-- tests/test_numpy.py | 8 ++++---- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/array_api_compat/numpy/_aliases.py b/src/array_api_compat/numpy/_aliases.py index 0dbcbaf1..8ad41548 100644 --- a/src/array_api_compat/numpy/_aliases.py +++ b/src/array_api_compat/numpy/_aliases.py @@ -162,14 +162,21 @@ def _bound_shape(a: object) -> tuple[int, ...]: min = None elif np.issubdtype(type(min), np.floating) and min < np.iinfo(dtype).min: min = np.iinfo(dtype).min - elif isinstance(min, (list, tuple, Array)): + elif isinstance(min, (list, Array)): min[min < np.iinfo(dtype).min] = np.iinfo(dtype).min + elif isinstance(min, tuple): + min = np.asarray(min) + min[min < np.iinfo(dtype).min] = np.iinfo(dtype).min + if np.issubdtype(type(max), np.integer) and max >= np.iinfo(dtype).max: max = None elif np.issubdtype(type(max), np.floating) and max > np.iinfo(dtype).max: max = np.iinfo(dtype).max - elif isinstance(max, (list, tuple, Array)): + elif isinstance(max, (list, Array)): + max[max > np.iinfo(dtype).max] = np.iinfo(dtype).max + elif isinstance(max, tuple): + max = np.asarray(max) max[max > np.iinfo(dtype).max] = np.iinfo(dtype).max # In the case of downcasting floats numpy replaces out of bounds with inf diff --git a/tests/test_numpy.py b/tests/test_numpy.py index a8fd195e..9af4e3b4 100644 --- a/tests/test_numpy.py +++ b/tests/test_numpy.py @@ -92,14 +92,14 @@ def test_numpy_type_promotion(): max_bound = np.float64(1.0001) result = xp.clip(x, min_bound, max_bound) assert result.dtype == x.dtype - assert result == xp.asarray([-1, -1, 0, 1], dtype=np.int64) + np.testing.assert_array_equal(result, xp.asarray([-1, -1, 0, 1], dtype=np.int64)) # ensure clipping with int16 bounds min_bound = np.int16(-1) max_bound = np.int16(1) result = xp.clip(x, min_bound, max_bound) assert result.dtype == x.dtype - assert result == xp.asarray([-1, -1, 0, 1], dtype=np.int64) + np.testing.assert_array_equal(result, xp.asarray([-1, -1, 0, 1], dtype=np.int64)) # final test with uint8 image and int64 bounds x = xp.asarray([0, 10, 250], dtype=np.uint8) @@ -107,8 +107,8 @@ def test_numpy_type_promotion(): max_bound = np.int64(32) result = xp.clip(x, min_bound, max_bound) assert result.dtype == x.dtype - assert result == xp.asarray([0, 10, 32], dtype=np.uint8) - + np.testing.assert_array_equal(result, xp.asarray([0, 10, 32], dtype=np.uint8)) + def test_numpy_clip_float16_casts_bounds_outside_range(): """Test that float16 bounds outside the range of input dtype still work for float16 arrays""" x = xp.asarray([0.0, 1.5, 3.0], dtype=np.float16) From 029e8c6d81e23a08f8eae3840190a4a14119f2db Mon Sep 17 00:00:00 2001 From: Lasloruhberg Date: Tue, 7 Jul 2026 09:32:43 +0200 Subject: [PATCH 12/12] convert both lists and tuples to arrays --- src/array_api_compat/numpy/_aliases.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/array_api_compat/numpy/_aliases.py b/src/array_api_compat/numpy/_aliases.py index 8ad41548..fe7912f6 100644 --- a/src/array_api_compat/numpy/_aliases.py +++ b/src/array_api_compat/numpy/_aliases.py @@ -162,9 +162,9 @@ def _bound_shape(a: object) -> tuple[int, ...]: min = None elif np.issubdtype(type(min), np.floating) and min < np.iinfo(dtype).min: min = np.iinfo(dtype).min - elif isinstance(min, (list, Array)): + elif isinstance(min, Array): min[min < np.iinfo(dtype).min] = np.iinfo(dtype).min - elif isinstance(min, tuple): + elif isinstance(min, (list,tuple)): min = np.asarray(min) min[min < np.iinfo(dtype).min] = np.iinfo(dtype).min @@ -173,9 +173,9 @@ def _bound_shape(a: object) -> tuple[int, ...]: max = None elif np.issubdtype(type(max), np.floating) and max > np.iinfo(dtype).max: max = np.iinfo(dtype).max - elif isinstance(max, (list, Array)): + elif isinstance(max, Array): max[max > np.iinfo(dtype).max] = np.iinfo(dtype).max - elif isinstance(max, tuple): + elif isinstance(max, (list,tuple)): max = np.asarray(max) max[max > np.iinfo(dtype).max] = np.iinfo(dtype).max