diff --git a/.github/workflows/static_analysis.yml b/.github/workflows/static_analysis.yml index 61b3cb6..151b4bb 100644 --- a/.github/workflows/static_analysis.yml +++ b/.github/workflows/static_analysis.yml @@ -78,7 +78,6 @@ jobs: ruff: runs-on: ubuntu-latest - continue-on-error: true steps: - name: Checkout the code uses: actions/checkout@v4 @@ -86,7 +85,7 @@ jobs: - name: Linting with ruff run: | pip install ruff - ruff check src/ || true + ruff check src/ pylint: runs-on: ubuntu-latest diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 0c86b54..2b15a25 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -14,6 +14,11 @@ jobs: build: runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.8", "3.10", "3.13"] + steps: # Checkout the repository - name: Checkout code @@ -23,16 +28,16 @@ jobs: - name: Set up Python uses: actions/setup-python@v4 with: - python-version: '3.10' # Adjust as needed + python-version: ${{ matrix.python-version }} # Cache pip dependencies - name: Cache pip uses: actions/cache@v3 with: path: ~/.cache/pip - key: ${{ runner.os }}-pip-${{ hashFiles('**/pyproject.toml') }} + key: ${{ runner.os }}-pip-${{ matrix.python-version }}-${{ hashFiles('**/pyproject.toml') }} restore-keys: | - ${{ runner.os }}-pip- + ${{ runner.os }}-pip-${{ matrix.python-version }}- - name: Install dependencies run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index ec7876c..4634055 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ All notable changes to the `cunumpy` library are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.1.3] - 2026-07-31 + +### Added +- `xp.set_device(device_id)`: Select the active CUDA device for the current process (no-op on the NumPy backend). +- `xp.__version__`: Reports the installed `cunumpy` package version. + +### Fixed +- `ArrayBackend` no longer silently reports `"cupy"` as the active backend when CuPy was requested but is unavailable; it now correctly falls back to reporting `"numpy"` so `xp.cupy_backend`/`xp.numpy_backend` reflect what actually loaded. +- `to_numpy()` no longer misdetects CPU objects that merely expose a `.get` method (e.g. dict-like objects) as CuPy arrays; it now checks `get_backend()` instead of `hasattr(array, "get")`. +- Invalid backend names now raise `ValueError` instead of relying on a bare `assert`, which was previously stripped under `python -O`. + +### Changed +- 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. + ## [0.1.2] - 2026-05-27 ### Added diff --git a/README.md b/README.md index 0fe4acd..9fd85b7 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,8 @@ export ARRAY_BACKEND=cupy ```python import cunumpy as xp -arr = xp.array([1,2]) + +arr = xp.array([1, 2]) print(type(arr)) print(xp.__version__) diff --git a/docs/source/quickstart.md b/docs/source/quickstart.md index 773224c..99970f8 100644 --- a/docs/source/quickstart.md +++ b/docs/source/quickstart.md @@ -41,7 +41,7 @@ xp.set_backend("cupy") # Scoped backend switching with xp.use_backend("numpy"): arr_cpu = xp.zeros(10) - print(xp.is_cpu(arr_cpu)) # True + print(xp.is_cpu(arr_cpu)) # True # Explicit conversion arr_np = xp.to_numpy(arr) diff --git a/pyproject.toml b/pyproject.toml index e5c7bfa..fea3ee8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ requires = [ "setuptools", "wheel" ] [project] name = "cunumpy" -version = "0.1.2" +version = "0.1.3" description = "Simple wrapper for numpy and cupy. Replace `import numpy as np` with `import cunumpy as xp`." readme = "README.md" keywords = [ "python" ] diff --git a/src/cunumpy/__init__.py b/src/cunumpy/__init__.py index 383f32a..a5f42ab 100644 --- a/src/cunumpy/__init__.py +++ b/src/cunumpy/__init__.py @@ -1,4 +1,6 @@ # cunumpy/__init__.py +from importlib.metadata import PackageNotFoundError, version + from . import xp from .xp import ( cupy_available, @@ -6,6 +8,7 @@ is_cpu, is_gpu, set_backend, + set_device, synchronize, to_cunumpy, to_cupy, @@ -13,20 +16,27 @@ use_backend, ) +try: + __version__ = version("cunumpy") +except PackageNotFoundError: + __version__ = "0.0.0+unknown" + __all__ = [ - "xp", + "__version__", "cupy_available", - "to_numpy", - "to_cupy", - "to_cunumpy", + "cupy_backend", "get_backend", - "is_gpu", "is_cpu", - "use_backend", + "is_gpu", + "numpy_backend", "set_backend", + "set_device", "synchronize", - "numpy_backend", - "cupy_backend", + "to_cunumpy", + "to_cupy", + "to_numpy", + "use_backend", + "xp", ] diff --git a/src/cunumpy/__init__.pyi b/src/cunumpy/__init__.pyi index 8f51ee6..8379680 100644 --- a/src/cunumpy/__init__.pyi +++ b/src/cunumpy/__init__.pyi @@ -7,7 +7,7 @@ from typing import Any, Generator import numpy as np from numpy import * -from . import xp +from . import xp as xp def to_numpy(array: Any) -> np.ndarray: ... def to_cupy(array: Any) -> Any: ... @@ -17,9 +17,11 @@ def get_backend(array: Any) -> str: ... def is_gpu(array: Any) -> bool: ... def is_cpu(array: Any) -> bool: ... @contextmanager -def use_backend(backend: str) -> Generator[None, None, None]: ... +def use_backend(backend: str) -> Generator[None]: ... def set_backend(backend: str) -> None: ... +def set_device(device_id: int) -> None: ... def synchronize() -> None: ... numpy_backend: bool cupy_backend: bool +__version__: str diff --git a/src/cunumpy/xp.py b/src/cunumpy/xp.py index 8e92e9a..718cea1 100644 --- a/src/cunumpy/xp.py +++ b/src/cunumpy/xp.py @@ -1,4 +1,5 @@ import os +import warnings from contextlib import contextmanager from types import ModuleType from typing import TYPE_CHECKING, Any, Generator, Literal @@ -23,21 +24,27 @@ def cupy_available() -> bool: # Check if a GPU is available _CUPY_AVAILABLE_CACHE = cp.is_available() return _CUPY_AVAILABLE_CACHE - except (ImportError, Exception): + except Exception: # noqa: BLE001 - tolerate any driver/runtime failure _CUPY_AVAILABLE_CACHE = False return False class ArrayBackend: + """Holds the process-wide active backend (NumPy or CuPy). + + Not thread-safe: `set_backend`/`use_backend` mutate this single shared + instance in place, so concurrent code (threads, async tasks) switching + backends independently will race. Safe for the typical single-threaded + script/notebook usage this library targets. + """ + def __init__( self, backend: BackendType = "numpy", verbose: bool = False, ) -> None: - assert backend.lower() in [ - "numpy", - "cupy", - ], "Array backend must be either 'numpy' or 'cupy'." + if backend.lower() not in ("numpy", "cupy"): + raise ValueError("Array backend must be either 'numpy' or 'cupy'.") self._backend: BackendType = "cupy" if backend.lower() == "cupy" else "numpy" self._xp: ModuleType = np # Placeholder @@ -50,21 +57,22 @@ def _load_backend(self, backend: BackendType, verbose: bool = False) -> ModuleTy if cupy_available(): import cupy as cp + self._backend = "cupy" return cp else: if verbose: - print("CuPy not available or not functional.") + print( + "CuPy not available or not functional. Falling back to NumPy." + ) + self._backend = "numpy" return np import numpy as np_mod + self._backend = "numpy" return np_mod - def __init_post__(self, verbose: bool = False) -> None: - # This is now redundant but kept for compatibility if called - self._xp = self._load_backend(self._backend, verbose) - assert isinstance(self._xp, ModuleType) - if verbose: - print(f"Using {self._xp.__name__} backend.") + def __repr__(self) -> str: + return f"ArrayBackend(backend={self._backend!r}, module={self._xp.__name__!r})" @property def backend(self) -> BackendType: @@ -90,15 +98,12 @@ def use_backend(self, backend: BackendType) -> Generator[None, None, None]: self._xp = old_xp -# TODO: Make this configurable via environment variable or config file. array_backend = ArrayBackend( backend=( "cupy" if os.getenv("ARRAY_BACKEND", "numpy").lower() == "cupy" else "numpy" ), verbose=False, ) -# Re-run initialization logic properly after backend selection -array_backend.__init_post__(verbose=False) def use_backend(backend: BackendType) -> Generator[None, None, None]: @@ -122,6 +127,14 @@ def _numpy_backend() -> bool: return array_backend.backend == "numpy" +def set_device(device_id: int) -> None: + """Select the active CUDA device for the current process (no-op on NumPy).""" + if array_backend.backend == "cupy": + import cupy as cp + + cp.cuda.Device(device_id).use() + + def synchronize() -> None: """Wait for all kernels in all streams on current device to complete.""" if array_backend.backend == "cupy": @@ -129,13 +142,20 @@ def synchronize() -> None: import cupy as cp cp.cuda.Device().synchronize() - except (ImportError, AttributeError): + except ImportError: pass + except AttributeError as e: + warnings.warn( + f"CuPy synchronize() failed unexpectedly, this may indicate a " + f"CuPy API mismatch: {e}", + RuntimeWarning, + stacklevel=2, + ) def to_numpy(array: Any) -> np.ndarray: """Convert an array to a NumPy array.""" - if hasattr(array, "get"): + if get_backend(array) == "cupy": return array.get() return np.asarray(array) @@ -177,7 +197,7 @@ def is_cpu(array: Any) -> bool: # TYPE_CHECKING is True when type checking (e.g., mypy), but False at runtime. # This allows us to use autocompletion for xp (i.e., numpy/cupy) as if numpy was imported. if TYPE_CHECKING: - import numpy as xp + import numpy as xp # noqa: F401 - type-checker-only alias for autocompletion else: # Use module-level __getattr__ for dynamic xp (Python 3.7+) def __getattr__(name): diff --git a/tests/unit/test_benchmarks.py b/tests/unit/test_benchmarks.py index f9a9e35..4f4e165 100644 --- a/tests/unit/test_benchmarks.py +++ b/tests/unit/test_benchmarks.py @@ -1,6 +1,5 @@ import time -import numpy as np import pytest import cunumpy as xp @@ -19,7 +18,7 @@ def test_benchmark_matmul(): b_np = xp.random.rand(size, size).astype(xp.float32) start_np = time.perf_counter() - c_np = a_np @ b_np + _ = a_np @ b_np # No sync needed for NumPy as it is synchronous end_np = time.perf_counter() t_np = end_np - start_np @@ -34,7 +33,7 @@ def test_benchmark_matmul(): xp.synchronize() start_cp = time.perf_counter() - c_cp = a_cp @ b_cp + _ = a_cp @ b_cp xp.synchronize() # CRITICAL for benchmarking GPU end_cp = time.perf_counter() t_cp = end_cp - start_cp @@ -42,7 +41,7 @@ def test_benchmark_matmul(): print(f"\n[Benchmark] Size: {size}x{size}") print(f"NumPy time: {t_np:.4f}s") print(f"CuPy time: {t_cp:.4f}s") - print(f"Speedup: {t_np/t_cp:.2f}x") + print(f"Speedup: {t_np / t_cp:.2f}x") # On a real GPU (A100/A30), CuPy should be significantly faster # We use a conservative threshold of 1.5x for the test to pass on various hardware @@ -76,5 +75,5 @@ def test_benchmark_fft(): print(f"\n[Benchmark] FFT Size: {size}") print(f"NumPy time: {t_np:.4f}s") print(f"CuPy time: {t_cp:.4f}s") - print(f"Speedup: {t_np/t_cp:.2f}x") + print(f"Speedup: {t_np / t_cp:.2f}x") assert t_cp < t_np diff --git a/tests/unit/test_cunumpy.py b/tests/unit/test_cunumpy.py index 8f07ed5..4c3a8b1 100644 --- a/tests/unit/test_cunumpy.py +++ b/tests/unit/test_cunumpy.py @@ -1,7 +1,10 @@ +import sys + import numpy as np import pytest import cunumpy as xp +import cunumpy.xp as cxp # the internal submodule, to inspect array_backend directly def test_to_numpy(): @@ -56,3 +59,120 @@ def test_backend_bools(): with xp.use_backend("numpy"): assert xp.numpy_backend is True assert xp.cupy_backend is False + + +def test_version_is_own_package_version(): + # __version__ must resolve to cunumpy's own version, not be silently + # proxied to the active backend module's __version__ via __getattr__. + assert isinstance(xp.__version__, str) + assert xp.__version__ != "" + assert xp.__version__ != np.__version__ + + +def test_set_backend_cupy_fallback_reports_effective_backend(): + """array_backend.backend must reflect what actually loaded, not what was requested.""" + try: + import cupy # noqa: F401 + + cupy_installed = True + except ImportError: + cupy_installed = False + + xp.set_backend("cupy") + + if cupy_installed: + # Only true in CI on MPCDF, where CuPy is actually available. + assert cxp.array_backend.backend == "cupy" + assert xp.cupy_backend is True + else: + # No silent lie: requesting cupy without it installed must fall + # back to numpy *and* report "numpy", not "cupy". + assert cxp.array_backend.backend == "numpy" + assert xp.numpy_backend is True + assert xp.cupy_backend is False + + xp.set_backend("numpy") + + +def test_use_backend_cupy_fallback_restores_correctly(): + try: + import cupy # noqa: F401 + + pytest.skip("CuPy is installed; fallback behaviour is not exercised here") + except ImportError: + pass + + assert cxp.array_backend.backend == "numpy" + + with xp.use_backend("cupy"): + # Falls back to numpy since CuPy isn't available here. + assert cxp.array_backend.backend == "numpy" + + # And the previous state is restored afterwards. + assert cxp.array_backend.backend == "numpy" + + +def test_to_numpy_does_not_misdetect_get_method_as_gpu_array(): + """Objects exposing a `.get` method (e.g. dict-like objects) must not be + mistaken for CuPy arrays just because they happen to have a `.get`.""" + + class MappingLike: + def get(self, key, default=None): + raise AssertionError(".get() should not be called for non-cupy objects") + + def __array__(self): + return np.array([1, 2, 3]) + + arr = xp.to_numpy(MappingLike()) + assert isinstance(arr, np.ndarray) + assert np.array_equal(arr, [1, 2, 3]) + + +def test_invalid_backend_raises_value_error(): + with pytest.raises(ValueError): + cxp.ArrayBackend(backend="tensorflow") + + +def test_set_device_is_noop_on_numpy(): + with xp.use_backend("numpy"): + # Must not raise even though there's no GPU to select on the CPU backend. + xp.set_device(0) + + +def test_set_device_selects_cuda_device(): + try: + import cupy as cp + except ImportError: + pytest.skip("CuPy not installed") + + with xp.use_backend("cupy"): + xp.set_device(0) + assert cp.cuda.Device().id == 0 + + +def test_array_backend_repr_reports_active_backend(): + with xp.use_backend("numpy"): + assert ( + repr(cxp.array_backend) == "ArrayBackend(backend='numpy', module='numpy')" + ) + + +def test_synchronize_warns_on_attribute_error(monkeypatch): + """An AttributeError from CuPy's synchronize call (e.g. API mismatch) + must surface as a warning, not be swallowed silently.""" + + class BrokenDevice: + def synchronize(self): + raise AttributeError("simulated CuPy API mismatch") + + class FakeCupy: + cuda = type("cuda", (), {"Device": staticmethod(lambda: BrokenDevice())}) + + monkeypatch.setitem(sys.modules, "cupy", FakeCupy) + monkeypatch.setattr(cxp.array_backend, "_backend", "cupy") + + try: + with pytest.warns(RuntimeWarning, match="CuPy API mismatch"): + xp.synchronize() + finally: + monkeypatch.setattr(cxp.array_backend, "_backend", "numpy") diff --git a/tests/unit/test_integration.py b/tests/unit/test_integration.py index d13764f..9cb4486 100644 --- a/tests/unit/test_integration.py +++ b/tests/unit/test_integration.py @@ -38,6 +38,7 @@ def test_synchronize_logic(): a = xp.random.rand(100) xp.synchronize() assert xp.is_gpu(a) + assert isinstance(a, cp.ndarray) def test_fft_interop(): @@ -67,12 +68,15 @@ def test_mixed_backend_errors(): a_cpu = np.array([1, 2, 3]) a_gpu = xp.to_cupy(a_cpu) - # This should fail because you can't add CPU and GPU arrays directly - with pytest.raises(Exception): + # This should fail because you can't add CPU and GPU arrays directly. + # The exact exception type is NumPy/CuPy-version dependent, hence the broad catch. + with pytest.raises(Exception): # noqa: B017 _ = a_cpu + a_gpu - # But to_cunumpy should fix it - a_gpu_fixed = xp.to_cunumpy(a_cpu) + # But to_cunumpy should fix it: it must run inside the cupy backend context + # so it actually converts a_cpu to a CuPy array, not whatever the global + # backend happened to be left as by an earlier test. with xp.use_backend("cupy"): + a_gpu_fixed = xp.to_cunumpy(a_cpu) res = a_gpu + a_gpu_fixed assert xp.is_gpu(res) diff --git a/tests/unit/test_numpy.py b/tests/unit/test_numpy.py index 30f00d7..2247f10 100644 --- a/tests/unit/test_numpy.py +++ b/tests/unit/test_numpy.py @@ -1,5 +1,4 @@ import numpy as np -import pytest import cunumpy as xp