From 6c2d01be696bbd7b785cfa3f1a1271fd23102c4b Mon Sep 17 00:00:00 2001 From: Max Date: Fri, 31 Jul 2026 09:24:18 +0200 Subject: [PATCH 1/3] Use array-api-compat --- CHANGELOG.md | 3 +++ pyproject.toml | 1 + src/cunumpy/xp.py | 14 ++++++-------- tests/unit/test_cunumpy.py | 4 ++-- 4 files changed, 12 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4634055..779b8fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Removed the redundant `ArrayBackend.__init_post__` double-initialization path. - `ArrayBackend` documents that it is not thread-safe (global mutable backend state). - CI now runs the test suite across a Python 3.8/3.10/3.13 matrix instead of only 3.10, and `ruff` is now an enforced check rather than advisory. +- Added [`array-api-compat`](https://github.com/data-apis/array-api-compat) as a core dependency: + - `get_backend()`/`is_gpu()`/`is_cpu()` now use `array_api_compat.is_cupy_array()` instead of sniffing `type(array).__module__` for the substring `"cupy"`. + - The active backend module (`xp.xp`) and array conversions (`to_numpy()`, `to_cupy()`) now resolve through `array_api_compat.numpy`/`array_api_compat.cupy` instead of the raw modules, for standard-conformant behavior across backends (e.g. `xp.xp.__name__` is now `"array_api_compat.numpy"`/`"array_api_compat.cupy"` rather than `"numpy"`/`"cupy"`). Device/synchronization control (`set_device()`, `synchronize()`, `cupy_available()`) still uses raw `cupy`, since CUDA device management isn't part of the Array API standard. ## [0.1.2] - 2026-05-27 diff --git a/pyproject.toml b/pyproject.toml index fea3ee8..2177863 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,7 @@ classifiers = [ "Programming Language :: Python :: 3.13", ] dependencies = [ + "array-api-compat", "numpy", ] diff --git a/src/cunumpy/xp.py b/src/cunumpy/xp.py index 718cea1..7acd44c 100644 --- a/src/cunumpy/xp.py +++ b/src/cunumpy/xp.py @@ -4,7 +4,8 @@ from types import ModuleType from typing import TYPE_CHECKING, Any, Generator, Literal -import numpy as np +import array_api_compat +import array_api_compat.numpy as np BackendType = Literal["numpy", "cupy"] @@ -55,7 +56,7 @@ def __init__( def _load_backend(self, backend: BackendType, verbose: bool = False) -> ModuleType: if backend == "cupy": if cupy_available(): - import cupy as cp + import array_api_compat.cupy as cp self._backend = "cupy" return cp @@ -66,10 +67,8 @@ def _load_backend(self, backend: BackendType, verbose: bool = False) -> ModuleTy ) self._backend = "numpy" return np - import numpy as np_mod - self._backend = "numpy" - return np_mod + return np def __repr__(self) -> str: return f"ArrayBackend(backend={self._backend!r}, module={self._xp.__name__!r})" @@ -166,7 +165,7 @@ def to_cupy(array: Any) -> Any: if not cupy_available(): raise ImportError("CuPy is not available or not functional.") - import cupy as cp + import array_api_compat.cupy as cp return cp.asarray(array) @@ -180,8 +179,7 @@ def to_cunumpy(array: Any) -> Any: def get_backend(array: Any) -> BackendType: """Return 'cupy' or 'numpy' depending on the array type.""" - module = getattr(type(array), "__module__", "") - return "cupy" if "cupy" in module else "numpy" + return "cupy" if array_api_compat.is_cupy_array(array) else "numpy" def is_gpu(array: Any) -> bool: diff --git a/tests/unit/test_cunumpy.py b/tests/unit/test_cunumpy.py index 4c3a8b1..d57b92d 100644 --- a/tests/unit/test_cunumpy.py +++ b/tests/unit/test_cunumpy.py @@ -152,8 +152,8 @@ def test_set_device_selects_cuda_device(): def test_array_backend_repr_reports_active_backend(): with xp.use_backend("numpy"): - assert ( - repr(cxp.array_backend) == "ArrayBackend(backend='numpy', module='numpy')" + assert repr(cxp.array_backend) == ( + "ArrayBackend(backend='numpy', module='array_api_compat.numpy')" ) From ae2de04a94156bfe1d09725a35c7e7b26595c73d Mon Sep 17 00:00:00 2001 From: Max Date: Fri, 31 Jul 2026 09:28:19 +0200 Subject: [PATCH 2/3] Added tests/unit/test_array_api_compat_backend.py --- tests/unit/test_array_api_compat_backend.py | 190 ++++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 tests/unit/test_array_api_compat_backend.py diff --git a/tests/unit/test_array_api_compat_backend.py b/tests/unit/test_array_api_compat_backend.py new file mode 100644 index 0000000..6d497cd --- /dev/null +++ b/tests/unit/test_array_api_compat_backend.py @@ -0,0 +1,190 @@ +"""Verify that swapping `xp.xp` to `array_api_compat.numpy`/`array_api_compat.cupy` +(instead of raw `numpy`/`cupy`) actually behaves as intended on both backends. + +The CuPy-side assertions here can only be meaningfully exercised on the MPCDF +GPU CI runner (no GPU is available in most dev/local environments), but the +NumPy-side assertions run everywhere so local runs still get partial coverage. +""" + +import array_api_compat +import numpy as np +import pytest + +import cunumpy as xp +import cunumpy.xp as cxp + + +def _skip_without_cupy(): + if not xp.cupy_available(): + pytest.skip("CuPy not installed or not functional") + + +@pytest.mark.parametrize("backend", ["numpy", "cupy"]) +def test_backend_module_is_array_api_compat_wrapper(backend): + if backend == "cupy": + _skip_without_cupy() + + with xp.use_backend(backend): + # NB: `xp.xp` (via the top-level cunumpy package) is the internal + # `cunumpy.xp` submodule, not the active backend module -- go + # through the ArrayBackend instance directly for that. + assert cxp.array_backend.xp.__name__ == f"array_api_compat.{backend}" + + +def test_array_backend_repr_on_cupy(): + _skip_without_cupy() + + with xp.use_backend("cupy"): + assert repr(cxp.array_backend) == ( + "ArrayBackend(backend='cupy', module='array_api_compat.cupy')" + ) + + +def test_to_cupy_returns_real_cupy_ndarray(): + _skip_without_cupy() + + import cupy as cp + + arr = xp.to_cupy(np.array([1, 2, 3])) + assert type(arr) is cp.ndarray + assert type(arr).__module__.startswith("cupy") + + +@pytest.mark.parametrize( + "dtype", [np.float32, np.float64, np.int32, np.int64, np.complex64] +) +def test_round_trip_preserves_values_and_dtype(dtype): + _skip_without_cupy() + + original = np.arange(12, dtype=dtype).reshape(3, 4) + gpu = xp.to_cupy(original) + back = xp.to_numpy(gpu) + + assert back.dtype == original.dtype + assert back.shape == original.shape + assert np.array_equal(back, original) + + +@pytest.mark.parametrize("shape", [(), (1,), (5,), (3, 4), (2, 3, 4)]) +def test_round_trip_preserves_shape(shape): + _skip_without_cupy() + + original = np.random.rand(*shape).astype(np.float64) + gpu = xp.to_cupy(original) + back = xp.to_numpy(gpu) + + assert back.shape == shape + assert np.allclose(back, original) + + +def test_get_backend_is_gpu_is_cpu_consistency(): + _skip_without_cupy() + + arr_cpu = np.array([1, 2, 3]) + arr_gpu = xp.to_cupy(arr_cpu) + + assert xp.get_backend(arr_cpu) == "numpy" + assert xp.is_cpu(arr_cpu) is True + assert xp.is_gpu(arr_cpu) is False + + assert xp.get_backend(arr_gpu) == "cupy" + assert xp.is_gpu(arr_gpu) is True + assert xp.is_cpu(arr_gpu) is False + + +def test_array_api_compat_agrees_with_cunumpy_on_real_gpu_array(): + """Sanity-check our get_backend() against array_api_compat's own + detector directly, on a real (non-mocked) GPU array.""" + _skip_without_cupy() + + arr_gpu = xp.to_cupy(np.array([1, 2, 3])) + assert array_api_compat.is_cupy_array(arr_gpu) is True + assert array_api_compat.is_numpy_array(arr_gpu) is False + + +def test_asarray_device_kwarg_accepted_on_both_backends(): + """The wrapped asarray must accept `device=` on both backends, even + though raw CuPy's asarray historically has a narrower signature.""" + with xp.use_backend("numpy"): + arr = xp.asarray([1, 2, 3], device=None) + assert isinstance(arr, np.ndarray) + + _skip_without_cupy() + + with xp.use_backend("cupy"): + arr = xp.asarray([1, 2, 3], device=None) + assert xp.is_gpu(arr) + + +def test_asarray_copy_semantics_parity_between_backends(): + """`copy=False` must avoid copying when no copy is needed, and + `copy=True` must always copy -- consistently on both backends.""" + with xp.use_backend("numpy"): + base = np.array([1.0, 2.0, 3.0]) + no_copy = xp.asarray(base, copy=False) + assert np.shares_memory(no_copy, base) + + forced_copy = xp.asarray(base, copy=True) + assert not np.shares_memory(forced_copy, base) + + _skip_without_cupy() + + import cupy as cp + + with xp.use_backend("cupy"): + base_gpu = cp.array([1.0, 2.0, 3.0]) + no_copy_gpu = xp.asarray(base_gpu, copy=False) + assert no_copy_gpu.data.ptr == base_gpu.data.ptr + + forced_copy_gpu = xp.asarray(base_gpu, copy=True) + assert forced_copy_gpu.data.ptr != base_gpu.data.ptr + + +def test_asarray_dtype_kwarg_consistent_across_backends(): + for dtype in (np.float32, np.float64, np.int32, np.complex64): + with xp.use_backend("numpy"): + arr = xp.asarray([1, 2, 3], dtype=dtype) + assert arr.dtype == dtype + + if not xp.cupy_available(): + continue + + with xp.use_backend("cupy"): + arr = xp.asarray([1, 2, 3], dtype=dtype) + assert arr.dtype == dtype + + +def test_set_device_and_synchronize_work_with_wrapped_backend(): + """set_device()/synchronize() still use raw CuPy internally for CUDA + control (device management isn't part of the Array API standard) -- + verify they keep working correctly alongside the wrapped `xp.xp`.""" + _skip_without_cupy() + + import cupy as cp + + with xp.use_backend("cupy"): + xp.set_device(0) + assert cp.cuda.Device().id == 0 + + arr = xp.asarray([1, 2, 3]) + xp.synchronize() + assert xp.is_gpu(arr) + + +def test_cross_backend_arithmetic_matches_after_round_trip(): + """Values computed on the GPU via the wrapped backend must match the + equivalent NumPy computation once brought back to the CPU.""" + _skip_without_cupy() + + data = np.random.rand(50).astype(np.float64) + + with xp.use_backend("numpy"): + expected = xp.sin(xp.array(data)) ** 2 + xp.cos(xp.array(data)) ** 2 + + with xp.use_backend("cupy"): + gpu_data = xp.to_cupy(data) + result_gpu = xp.sin(gpu_data) ** 2 + xp.cos(gpu_data) ** 2 + result = xp.to_numpy(result_gpu) + + assert np.allclose(result, expected) + assert np.allclose(result, 1.0) From 23a87306bfa62a85c96541393597f55a2380ca8e Mon Sep 17 00:00:00 2001 From: Max Date: Fri, 31 Jul 2026 09:28:48 +0200 Subject: [PATCH 3/3] bump version number to 0.1.4 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 2177863..4b8ab64 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ requires = [ "setuptools", "wheel" ] [project] name = "cunumpy" -version = "0.1.3" +version = "0.1.4" description = "Simple wrapper for numpy and cupy. Replace `import numpy as np` with `import cunumpy as xp`." readme = "README.md" keywords = [ "python" ]