Repository: pydata/sparse
Pull request #873 (2025-05-19 12:18:51)

feat, fix: Increase support of array API standard.
<!--
# Thanks for contributing a pull request!
## Please make sure you see our contribution guidelines: https://github.com/pydata/sparse/blob/main/docs/contributing.md

Your PR title should start with any of these abbreviatons: `build`, `chore`, `ci`, `depr`, `docs`, `feat`, `fix`, `perf`, `refactor`, `release`, `test`. Add a `!`at the end, if it is a breaking change.
-->

## What type of PR is this? (check all applicable)

- [ ] 💾 Refactor
- [x] 🪄 Feature
- [x] 🐞 Bug Fix
- [ ] 🔧 Optimization
- [ ] 📚 Documentation
- [ ] 🧪 Test
- [ ] 🛠️ Other

## Related issues

- Related issue #
- Closes #

## Checklist

- [ ] Code follows style guide
- [ ] Tests added
- [ ] Documented the changes

***

## Please explain your changes below.


hameerabbasi: Merging based on https://github.com/pydata/sparse/pull/873#pullrequestreview-2850569556 and the fact that the Finch array API tests are currently broken.

Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index ab349e4..124f520 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -18,9 +18,9 @@ repos:
       exclude: ".ipynb"
 
 - repo: https://github.com/astral-sh/ruff-pre-commit
-  rev: v0.11.5
+  rev: v0.11.10
   hooks:
-    - id: ruff
+    - id: ruff-check
       args: ["--fix"]
       types_or: [ python, pyi, jupyter ]
     - id: ruff-format
diff --git a/ci/Numba-array-api-skips.txt b/ci/Numba-array-api-skips.txt
index 1a96264..e69de29 100644
--- a/ci/Numba-array-api-skips.txt
+++ b/ci/Numba-array-api-skips.txt
@@ -1,1 +0,0 @@
-array_api_tests/test_operators_and_elementwise_functions.py::test_floor
diff --git a/ci/Numba-array-api-xfails.txt b/ci/Numba-array-api-xfails.txt
index 7d257d9..d745a73 100644
--- a/ci/Numba-array-api-xfails.txt
+++ b/ci/Numba-array-api-xfails.txt
@@ -5,7 +5,6 @@ array_api_tests/test_creation_functions.py::test_arange
 array_api_tests/test_creation_functions.py::test_linspace
 array_api_tests/test_creation_functions.py::test_meshgrid
 array_api_tests/test_data_type_functions.py::test_finfo[float32]
-array_api_tests/test_data_type_functions.py::test_isdtype
 array_api_tests/test_has_names.py::test_has_names[linalg-cholesky]
 array_api_tests/test_has_names.py::test_has_names[linalg-cross]
 array_api_tests/test_has_names.py::test_has_names[linalg-det]
@@ -36,7 +35,6 @@ array_api_tests/test_has_names.py::test_has_names[creation-from_dlpack]
 array_api_tests/test_has_names.py::test_has_names[creation-linspace]
 array_api_tests/test_has_names.py::test_has_names[creation-meshgrid]
 array_api_tests/test_has_names.py::test_has_names[sorting-argsort]
-array_api_tests/test_has_names.py::test_has_names[data_type-isdtype]
 array_api_tests/test_has_names.py::test_has_names[array_method-__dlpack__]
 array_api_tests/test_has_names.py::test_has_names[array_method-__dlpack_device__]
 array_api_tests/test_has_names.py::test_has_names[array_method-__setitem__]
@@ -50,7 +48,6 @@ array_api_tests/test_signatures.py::test_func_signature[from_dlpack]
 array_api_tests/test_signatures.py::test_func_signature[linspace]
 array_api_tests/test_signatures.py::test_func_signature[meshgrid]
 array_api_tests/test_signatures.py::test_func_signature[argsort]
-array_api_tests/test_signatures.py::test_func_signature[isdtype]
 array_api_tests/test_signatures.py::test_array_method_signature[__dlpack__]
 array_api_tests/test_signatures.py::test_array_method_signature[__dlpack_device__]
 array_api_tests/test_signatures.py::test_array_method_signature[__setitem__]
@@ -74,71 +71,28 @@ array_api_tests/test_data_type_functions.py::test_finfo[complex64]
 array_api_tests/test_manipulation_functions.py::test_squeeze
 array_api_tests/test_has_names.py::test_has_names[utility-diff]
 array_api_tests/test_has_names.py::test_has_names[manipulation-repeat]
-array_api_tests/test_has_names.py::test_has_names[elementwise-copysign]
 array_api_tests/test_has_names.py::test_has_names[manipulation-tile]
 array_api_tests/test_has_names.py::test_has_names[manipulation-unstack]
-array_api_tests/test_has_names.py::test_has_names[elementwise-hypot]
-array_api_tests/test_has_names.py::test_has_names[elementwise-maximum]
-array_api_tests/test_has_names.py::test_has_names[elementwise-minimum]
-array_api_tests/test_has_names.py::test_has_names[elementwise-nextafter]
-array_api_tests/test_has_names.py::test_has_names[elementwise-reciprocal]
-array_api_tests/test_has_names.py::test_has_names[elementwise-signbit]
 array_api_tests/test_has_names.py::test_has_names[statistical-cumulative_sum]
 array_api_tests/test_has_names.py::test_has_names[statistical-cumulative_prod]
 array_api_tests/test_has_names.py::test_has_names[indexing-take_along_axis]
-array_api_tests/test_has_names.py::test_has_names[info-__array_namespace_info__]
 array_api_tests/test_has_names.py::test_has_names[searching-count_nonzero]
 array_api_tests/test_has_names.py::test_has_names[searching-searchsorted]
-array_api_tests/test_inspection_functions.py::test_array_namespace_info
-array_api_tests/test_inspection_functions.py::test_array_namespace_info_dtypes
 array_api_tests/test_signatures.py::test_func_signature[diff]
 array_api_tests/test_signatures.py::test_func_signature[repeat]
 array_api_tests/test_signatures.py::test_func_signature[tile]
 array_api_tests/test_signatures.py::test_func_signature[unstack]
-array_api_tests/test_signatures.py::test_func_signature[astype]
-array_api_tests/test_signatures.py::test_func_signature[copysign]
-array_api_tests/test_signatures.py::test_func_signature[hypot]
-array_api_tests/test_signatures.py::test_func_signature[maximum]
-array_api_tests/test_signatures.py::test_func_signature[minimum]
-array_api_tests/test_signatures.py::test_func_signature[nextafter]
-array_api_tests/test_signatures.py::test_func_signature[reciprocal]
-array_api_tests/test_signatures.py::test_func_signature[signbit]
 array_api_tests/test_signatures.py::test_func_signature[take_along_axis]
-array_api_tests/test_signatures.py::test_func_signature[__array_namespace_info__]
-array_api_tests/test_signatures.py::test_info_func_signature[capabilities]
-array_api_tests/test_signatures.py::test_info_func_signature[default_device]
-array_api_tests/test_signatures.py::test_info_func_signature[default_dtypes]
-array_api_tests/test_signatures.py::test_info_func_signature[devices]
-array_api_tests/test_signatures.py::test_info_func_signature[dtypes]
-array_api_tests/test_special_cases.py::test_unary[clip(x_i is NaN) -> NaN]
-array_api_tests/test_special_cases.py::test_unary[signbit(x_i is +0) -> False]
-array_api_tests/test_special_cases.py::test_unary[signbit(x_i is -0) -> True]
-array_api_tests/test_special_cases.py::test_unary[signbit(x_i is +infinity) -> False]
-array_api_tests/test_special_cases.py::test_unary[signbit(x_i is -infinity) -> True]
-array_api_tests/test_special_cases.py::test_unary[signbit(isfinite(x_i) and x_i > 0) -> False]
-array_api_tests/test_special_cases.py::test_unary[signbit(isfinite(x_i) and x_i < 0) -> True]
-array_api_tests/test_special_cases.py::test_unary[signbit(x_i is +NaN) -> False]
-array_api_tests/test_special_cases.py::test_unary[signbit(x_i is -NaN) -> True]
-array_api_tests/test_special_cases.py::test_binary[copysign(x1_i is NaN and x2_i < 0) -> NaN]
-array_api_tests/test_special_cases.py::test_binary[copysign(x1_i is NaN and x2_i is -0) -> NaN]
-array_api_tests/test_special_cases.py::test_binary[copysign(x1_i is NaN and x2_i is +0) -> NaN]
-array_api_tests/test_special_cases.py::test_binary[copysign(x1_i is NaN and x2_i > 0) -> NaN]
 array_api_tests/test_special_cases.py::test_binary[floor_divide(x1_i is +infinity and isfinite(x2_i) and x2_i > 0) -> +infinity]
 array_api_tests/test_special_cases.py::test_binary[floor_divide(x1_i is +infinity and isfinite(x2_i) and x2_i < 0) -> -infinity]
 array_api_tests/test_special_cases.py::test_binary[floor_divide(x1_i is -infinity and isfinite(x2_i) and x2_i > 0) -> -infinity]
 array_api_tests/test_special_cases.py::test_binary[floor_divide(x1_i is -infinity and isfinite(x2_i) and x2_i < 0) -> +infinity]
 array_api_tests/test_special_cases.py::test_binary[floor_divide(isfinite(x1_i) and x1_i > 0 and x2_i is -infinity) -> -0]
-array_api_tests/test_operators_and_elementwise_functions.py::test_nextafter
 array_api_tests/test_special_cases.py::test_binary[__floordiv__(x1_i is +infinity and isfinite(x2_i) and x2_i > 0) -> +infinity]
 array_api_tests/test_special_cases.py::test_binary[floor_divide(isfinite(x1_i) and x1_i < 0 and x2_i is +infinity) -> -0]
 array_api_tests/test_special_cases.py::test_binary[__floordiv__(x1_i is +infinity and isfinite(x2_i) and x2_i < 0) -> -infinity]
-array_api_tests/test_special_cases.py::test_binary[maximum(x1_i is NaN or x2_i is NaN) -> NaN]
 array_api_tests/test_special_cases.py::test_binary[__floordiv__(x1_i is -infinity and isfinite(x2_i) and x2_i > 0) -> -infinity]
-array_api_tests/test_special_cases.py::test_binary[minimum(x1_i is NaN or x2_i is NaN) -> NaN]
 array_api_tests/test_special_cases.py::test_binary[__floordiv__(x1_i is -infinity and isfinite(x2_i) and x2_i < 0) -> +infinity]
-array_api_tests/test_special_cases.py::test_binary[nextafter(x1_i is NaN or x2_i is NaN) -> NaN]
-array_api_tests/test_special_cases.py::test_binary[nextafter(x1_i is -0 and x2_i is +0) -> +0]
-array_api_tests/test_special_cases.py::test_binary[nextafter(x1_i is +0 and x2_i is -0) -> -0]
 array_api_tests/test_special_cases.py::test_binary[__floordiv__(isfinite(x1_i) and x1_i > 0 and x2_i is -infinity) -> -0]
 array_api_tests/test_special_cases.py::test_binary[__floordiv__(isfinite(x1_i) and x1_i < 0 and x2_i is +infinity) -> -0]
 array_api_tests/test_special_cases.py::test_iop[__ifloordiv__(x1_i is +infinity and isfinite(x2_i) and x2_i > 0) -> +infinity]
@@ -147,30 +101,18 @@ array_api_tests/test_special_cases.py::test_iop[__ifloordiv__(x1_i is +infinity
 array_api_tests/test_special_cases.py::test_iop[__ifloordiv__(isfinite(x1_i) and x1_i > 0 and x2_i is -infinity) -> -0]
 array_api_tests/test_special_cases.py::test_iop[__ifloordiv__(x1_i is -infinity and isfinite(x2_i) and x2_i > 0) -> -infinity]
 array_api_tests/test_special_cases.py::test_iop[__ifloordiv__(isfinite(x1_i) and x1_i < 0 and x2_i is +infinity) -> -0]
-array_api_tests/test_operators_and_elementwise_functions.py::test_reciprocal
 array_api_tests/test_array_object.py::test_getitem_arrays_and_ints_1[1]
 array_api_tests/test_statistical_functions.py::test_cumulative_prod
 array_api_tests/test_statistical_functions.py::test_cumulative_sum
 array_api_tests/test_array_object.py::test_getitem_arrays_and_ints_1[None]
-array_api_tests/test_operators_and_elementwise_functions.py::test_signbit
 array_api_tests/test_array_object.py::test_getitem_arrays_and_ints_2[1]
 array_api_tests/test_array_object.py::test_getitem_arrays_and_ints_2[None]
 array_api_tests/test_manipulation_functions.py::test_repeat
 array_api_tests/test_searching_functions.py::test_count_nonzero
-array_api_tests/test_operators_and_elementwise_functions.py::test_copysign
-array_api_tests/test_operators_and_elementwise_functions.py::test_maximum
 array_api_tests/test_searching_functions.py::test_searchsorted
 array_api_tests/test_manipulation_functions.py::test_tile
 array_api_tests/test_signatures.py::test_func_signature[cumulative_sum]
 array_api_tests/test_signatures.py::test_func_signature[cumulative_prod]
 array_api_tests/test_manipulation_functions.py::test_unstack
-array_api_tests/test_operators_and_elementwise_functions.py::test_minimum
-array_api_tests/test_operators_and_elementwise_functions.py::test_hypot
-array_api_tests/test_operators_and_elementwise_functions.py::test_clip
 array_api_tests/test_signatures.py::test_func_signature[count_nonzero]
 array_api_tests/test_signatures.py::test_func_signature[searchsorted]
-array_api_tests/test_operators_and_elementwise_functions.py::test_binary_with_scalars_real[copysign]
-array_api_tests/test_operators_and_elementwise_functions.py::test_binary_with_scalars_real[hypot]
-array_api_tests/test_operators_and_elementwise_functions.py::test_binary_with_scalars_real[nextafter]
-array_api_tests/test_operators_and_elementwise_functions.py::test_binary_with_scalars_real[maximum]
-array_api_tests/test_operators_and_elementwise_functions.py::test_binary_with_scalars_real[minimum]
diff --git a/sparse/__init__.py b/sparse/__init__.py
index 05d67a3..696ebc5 100644
--- a/sparse/__init__.py
+++ b/sparse/__init__.py
@@ -48,6 +48,7 @@ else:
     from sparse.numba_backend import *  # noqa: F403
     from sparse.numba_backend import (  # noqa: F401
         __all__,
+        __array_namespace_info__,
         _common,
         _compressed,
         _coo,
diff --git a/sparse/numba_backend/__init__.py b/sparse/numba_backend/__init__.py
index 9f731cc..0702f5d 100644
--- a/sparse/numba_backend/__init__.py
+++ b/sparse/numba_backend/__init__.py
@@ -8,6 +8,7 @@ from numpy import (
     complex64,
     complex128,
     conj,
+    copysign,
     cos,
     cosh,
     divide,
@@ -22,6 +23,7 @@ from numpy import (
     floor_divide,
     greater,
     greater_equal,
+    hypot,
     iinfo,
     inf,
     int8,
@@ -40,15 +42,20 @@ from numpy import (
     logical_not,
     logical_or,
     logical_xor,
+    maximum,
+    minimum,
     multiply,
     nan,
     negative,
     newaxis,
+    nextafter,
     not_equal,
     pi,
     positive,
+    reciprocal,
     remainder,
     sign,
+    signbit,
     sin,
     sinh,
     sqrt,
@@ -158,6 +165,8 @@ from ._coo.common import (
 )
 from ._dok import DOK
 from ._io import load_npz, save_npz
+from ._settings import IS_NUMPY2 as _IS_NUMPY2
+from ._settings import __array_namespace_info__  # noqa: F401
 from ._umath import elemwise
 from ._utils import random
 
@@ -203,6 +212,7 @@ __all__ = [
     "concat",
     "concatenate",
     "conj",
+    "copysign",
     "cos",
     "cosh",
     "diagonal",
@@ -230,6 +240,7 @@ __all__ = [
     "full_like",
     "greater",
     "greater_equal",
+    "hypot",
     "iinfo",
     "imag",
     "inf",
@@ -258,8 +269,10 @@ __all__ = [
     "matmul",
     "matrix_transpose",
     "max",
+    "maximum",
     "mean",
     "min",
+    "minimum",
     "moveaxis",
     "multiply",
     "nan",
@@ -271,6 +284,7 @@ __all__ = [
     "nansum",
     "negative",
     "newaxis",
+    "nextafter",
     "nonzero",
     "not_equal",
     "ones",
@@ -284,6 +298,7 @@ __all__ = [
     "prod",
     "random",
     "real",
+    "reciprocal",
     "remainder",
     "reshape",
     "result_type",
@@ -291,6 +306,7 @@ __all__ = [
     "round",
     "save_npz",
     "sign",
+    "signbit",
     "sin",
     "sinh",
     "sort",
@@ -320,3 +336,13 @@ __all__ = [
     "zeros",
     "zeros_like",
 ]
+
+
+if _IS_NUMPY2:
+    from numpy import isdtype
+
+    __all__ += [
+        "isdtype",
+    ]
+
+__all__.sort()
diff --git a/sparse/numba_backend/_common.py b/sparse/numba_backend/_common.py
index a571e26..23f07c6 100644
--- a/sparse/numba_backend/_common.py
+++ b/sparse/numba_backend/_common.py
@@ -2726,7 +2726,8 @@ def reshape(x, /, shape, *, copy=None):
     return x.reshape(shape=shape)
 
 
-def astype(x, dtype, /, *, copy=True):
+@_check_device
+def astype(x, dtype, /, *, copy=True, device=None):
     """
     Copies an array to a specified data type irrespective of type-promotion rules.
 
diff --git a/sparse/numba_backend/_settings.py b/sparse/numba_backend/_settings.py
index 6bc8c72..28b2d40 100644
--- a/sparse/numba_backend/_settings.py
+++ b/sparse/numba_backend/_settings.py
@@ -4,6 +4,7 @@ import numpy as np
 
 AUTO_DENSIFY = bool(int(os.environ.get("SPARSE_AUTO_DENSIFY", "0")))
 WARN_ON_TOO_DENSE = bool(int(os.environ.get("SPARSE_WARN_ON_TOO_DENSE", "0")))
+IS_NUMPY2 = np.lib.NumpyVersion(np.__version__) >= "2.0.0a1"
 
 
 def _is_nep18_enabled():
@@ -18,3 +19,32 @@ def _is_nep18_enabled():
 
 
 NEP18_ENABLED = _is_nep18_enabled()
+
+
+class ArrayNamespaceInfo:
+    def __init__(self):
+        self.np_info = np.__array_namespace_info__()
+
+    def capabilities(self):
+        np_capabilities = self.np_info.capabilities()
+        return {
+            "boolean indexing": True,
+            "data-dependent shapes": True,
+            "max dimensions": np_capabilities.get("max dimensions", 64) - 1,
+        }
+
+    def default_device(self):
+        return self.np_info.default_device()
+
+    def default_dtypes(self, *, device=None):
+        return self.np_info.default_dtypes(device=device)
+
+    def devices(self):
+        return self.np_info.devices()
+
+    def dtypes(self, *, device=None, kind=None):
+        return self.np_info.dtypes(device=device, kind=kind)
+
+
+def __array_namespace_info__() -> ArrayNamespaceInfo:
+    return ArrayNamespaceInfo()
diff --git a/sparse/numba_backend/_sparse_array.py b/sparse/numba_backend/_sparse_array.py
index 628c5d8..a5862ae 100644
--- a/sparse/numba_backend/_sparse_array.py
+++ b/sparse/numba_backend/_sparse_array.py
@@ -609,8 +609,6 @@ class SparseArray:
         - [sparse.clip][] : For full documentation and more details.
         - [`numpy.clip`][] : Equivalent NumPy function.
         """
-        if min is None and max is None:
-            raise ValueError("One of max or min must be given.")
         if out is not None and not isinstance(out, tuple):
             out = (out,)
         return self.__array_ufunc__(np.clip, "__call__", self, a_min=min, a_max=max, out=out)


Test patch:
diff --git a/sparse/numba_backend/tests/test_coo.py b/sparse/numba_backend/tests/test_coo.py
index fef578f..c31555f 100644
--- a/sparse/numba_backend/tests/test_coo.py
+++ b/sparse/numba_backend/tests/test_coo.py
@@ -1130,9 +1130,6 @@ def test_clip():
 
     assert_eq(np.clip(s, 1, 3), np.clip(x, 1, 3))
 
-    with pytest.raises(ValueError):
-        s.clip()
-
     out = sparse.COO.from_numpy(np.zeros_like(x))
     out2 = s.clip(min=1, max=3, out=out)
     assert out is out2
diff --git a/sparse/numba_backend/tests/test_namespace.py b/sparse/numba_backend/tests/test_namespace.py
index 39556f9..4e86673 100644
--- a/sparse/numba_backend/tests/test_namespace.py
+++ b/sparse/numba_backend/tests/test_namespace.py
@@ -2,7 +2,9 @@ import sparse
 
 
 def test_namespace():
-    assert set(sparse.__all__) == {
+    from sparse.numba_backend._settings import IS_NUMPY2
+
+    all_set = {
         "COO",
         "DOK",
         "GCXS",
@@ -44,6 +46,7 @@ def test_namespace():
         "concat",
         "concatenate",
         "conj",
+        "copysign",
         "cos",
         "cosh",
         "diagonal",
@@ -71,6 +74,7 @@ def test_namespace():
         "full_like",
         "greater",
         "greater_equal",
+        "hypot",
         "iinfo",
         "imag",
         "inf",
@@ -99,8 +103,10 @@ def test_namespace():
         "matrix_transpose",
         "matmul",
         "max",
+        "maximum",
         "mean",
         "min",
+        "minimum",
         "moveaxis",
         "multiply",
         "nan",
@@ -112,6 +118,7 @@ def test_namespace():
         "nansum",
         "negative",
         "newaxis",
+        "nextafter",
         "nonzero",
         "not_equal",
         "ones",
@@ -125,6 +132,7 @@ def test_namespace():
         "prod",
         "random",
         "real",
+        "reciprocal",
         "remainder",
         "reshape",
         "result_type",
@@ -132,6 +140,7 @@ def test_namespace():
         "round",
         "save_npz",
         "sign",
+        "signbit",
         "sin",
         "sinh",
         "sort",
@@ -162,6 +171,11 @@ def test_namespace():
         "zeros_like",
     }
 
+    if IS_NUMPY2:
+        all_set.update({"isdtype"})
+
+    assert set(sparse.__all__) == all_set
+
     for attr in sparse.__all__:
         assert hasattr(sparse, attr)