diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index c59b8ea..5c9d160 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -2,7 +2,7 @@ name: Deploy docs to GitHub Pages on: push: - branches: ["devel", "main"] # TODO: Set to main only after release + branches: ["devel"] workflow_dispatch: permissions: diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..3fae537 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,58 @@ +# Changelog + +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.2] - 2026-05-27 + +### Added +- **Backend Management Helpers**: + - `xp.to_numpy(arr)`: Explicitly move an array to CPU (NumPy). + - `xp.to_cupy(arr)`: Explicitly move an array to GPU (CuPy). + - `xp.to_cunumpy(arr)`: Normalize an array to the currently active backend. + - `xp.get_backend(arr)`: Identify if an array is `"numpy"` or `"cupy"`. + - `xp.is_gpu(arr)` & `xp.is_cpu(arr)`: Quick boolean checks for array residence. +- **Global Backend Control**: + - `xp.set_backend(name)`: Globally switch the active backend at runtime. + - `xp.use_backend(name)`: Context manager for temporary, scoped backend switching. + - `xp.numpy_backend` & `xp.cupy_backend`: Boolean properties to check the globally active backend. +- **Synchronization**: + - `xp.synchronize()`: Blocks until GPU operations are complete (no-op on CPU). Essential for accurate benchmarking. +- **Developer Experience**: + - Added `isort` configuration to `pyproject.toml` with `black` profile compatibility. + +### Changed +- **Dynamic Dispatch Architecture**: Refactored `src/cunumpy/xp.py` to use module-level `__getattr__`. This ensures that `cunumpy.` calls always resolve to the currently active backend module, enabling seamless runtime switching via `set_backend`. +- **Type Safety**: Updated `src/cunumpy/__init__.pyi` stubs to provide full IDE autocompletion and type-checking for all new API methods. +- **Documentation**: + - Simplified `README.md` and documentation to exclusively focus on PyPI installation (`pip install cunumpy`). + - Enhanced `quickstart.md` and `api.md` with usage examples for the new backend control and synchronization features. +- **CI/CD**: Restricted GitHub Pages documentation deployment to the `devel` branch only. + +### Fixed +- Improved `ArrayBackend` initialization to fallback gracefully to NumPy if CuPy is requested but not installed. +- Fixed symbol accessibility tests to correctly handle custom helper methods in the `cunumpy` namespace. + +## [0.1.1] - 2026-05-27 + +### Added +- **Type Inspection Support**: Added `.pyi` stub files to enable proper symbol inspection and autocompletion for Pylance/VS Code. +- **CI/CD**: Added GitHub Action for automated publishing to PyPI. + +### Changed +- Improved import structure in `src/cunumpy/__init__.py` to allow direct access to `xp`. + +## [0.1.0] - 2026-05-27 + +### Added +- **Initial Core Functionality**: + - `ArrayBackend` class for dispatching between NumPy and CuPy. + - Environment variable support (`ARRAY_BACKEND`) for selecting the backend. + - Basic module structure and `src/cunumpy/xp.py`. +- **Project Scaffold**: + - Documentation setup with Sphinx and nbsphinx. + - Initial unit tests and testing infrastructure. + - Basic tutorial notebook and README instructions. +- **Metadata**: Initial configuration of `pyproject.toml` and project metadata. diff --git a/README.md b/README.md index debfdf1..0fe4acd 100644 --- a/README.md +++ b/README.md @@ -4,18 +4,8 @@ Simple wrapper for numpy and cupy. Replace `import numpy as np` with `import cun # Install -Create and activate python environment - -``` -python -m venv env -source env/bin/activate -pip install --upgrade pip -``` - -Install the code and requirements with pip - -``` -pip install -e . +```bash +pip install cunumpy ``` Example usage: @@ -30,6 +20,28 @@ arr = xp.array([1,2]) print(type(arr)) print(xp.__version__) + +# Convert to NumPy +arr_np = xp.to_numpy(arr) + +# Convert to active backend +arr_xp = xp.to_cunumpy(arr) + +# Inspect backend +print(xp.get_backend(arr)) +print(xp.is_gpu(arr)) +print(xp.is_cpu(arr)) + +# Temporarily switch backend +with xp.use_backend("numpy"): + # This code runs on CPU even if ARRAY_BACKEND=cupy + arr_cpu = xp.zeros(100) + +# Set backend globally +xp.set_backend("cupy") + +# Synchronize GPU operations (no-op on CPU) +xp.synchronize() ``` # Build docs diff --git a/docs/source/api.md b/docs/source/api.md index a1c08b6..232518d 100644 --- a/docs/source/api.md +++ b/docs/source/api.md @@ -1,6 +1,48 @@ # API -Description of the API +The `cunumpy` package exposes the following helper functions in addition to the standard NumPy/CuPy API. -```{toctree} -:maxdepth: 1 \ No newline at end of file +## Backend Management + +### `to_numpy(array)` +Converts an array to a NumPy array on the CPU. + +### `to_cupy(array)` +Converts an array to a CuPy array on the GPU. Raises `ImportError` if CuPy is not available. + +### `to_cunumpy(array)` +Converts an array to the currently active backend. + +### `get_backend(array)` +Returns the name of the backend (`"numpy"` or `"cupy"`) for the given array. + +### `is_gpu(array)` +Returns `True` if the array is stored on a GPU (CuPy). + +### `is_cpu(array)` +Returns `True` if the array is stored on a CPU (NumPy). + +## Global Configuration + +### `numpy_backend` +Boolean property that returns `True` if the currently active global backend is NumPy. + +### `cupy_backend` +Boolean property that returns `True` if the currently active global backend is CuPy. + +### `set_backend(backend_name)` +Globally sets the active backend for all `cunumpy` operations. `backend_name` should be `"numpy"` or `"cupy"`. + +### `use_backend(backend_name)` +A context manager that temporarily sets the active backend. + +```python +with xp.use_backend("numpy"): + # Operations here use NumPy + pass +``` + +## Hardware Control + +### `synchronize()` +Blocks until all preceding GPU operations are complete. This is a no-op when using the NumPy backend. diff --git a/docs/source/index.md b/docs/source/index.md index d082474..0be983f 100644 --- a/docs/source/index.md +++ b/docs/source/index.md @@ -1,11 +1,28 @@ -# Welcome +# CuNumpy -Write the documentation of your python package here +Simple wrapper for NumPy and CuPy. + +## Quick Start + +```python +import cunumpy as xp + +# Use it exactly like NumPy +arr = xp.array([1, 2, 3]) +print(type(arr)) +``` ```{toctree} :maxdepth: 2 -:caption: Contents +:caption: Contents: + +quickstart +tutorials +api +``` + +## Installation -quickstart.md -tutorials.md -api.md +```bash +pip install cunumpy +``` diff --git a/docs/source/quickstart.md b/docs/source/quickstart.md index f42c8bd..773224c 100644 --- a/docs/source/quickstart.md +++ b/docs/source/quickstart.md @@ -1,46 +1,68 @@ # Quickstart -Clone the repo +## Installation -``` -git clone ... +Install `cunumpy` directly from PyPI: + +```bash +pip install cunumpy ``` -# Install +## Basic Usage -Create and activate python environment +Replace `import numpy as np` with `import cunumpy as xp`. By default, it will use NumPy if CuPy is not installed or configured. -``` -python -m venv env -source env/bin/activate -pip install --upgrade pip -``` +```python +import cunumpy as xp -Install the code and requirements with pip +# Create an array (automatically uses the active backend) +arr = xp.array([1, 2, 3]) -``` -pip install -e . +print(f"Array type: {type(arr)}") +print(f"Active backend: {xp.get_backend(arr)}") ``` -Example usage: +## Backend Control -``` +You can control the backend using environment variables: + +```bash export ARRAY_BACKEND=cupy ``` +Or programmatically: + ```python import cunumpy as xp -arr = xp.array([1,2]) -print(type(arr)) -print(xp.__version__) +# Globally set backend +xp.set_backend("cupy") + +# Scoped backend switching +with xp.use_backend("numpy"): + arr_cpu = xp.zeros(10) + print(xp.is_cpu(arr_cpu)) # True + +# Explicit conversion +arr_np = xp.to_numpy(arr) +arr_cp = xp.to_cupy(arr) ``` -# Build docs +## Synchronization +When using the GPU, it's important to synchronize for accurate timing: -``` -make html -cd ../ -open docs/_build/html/index.html +```python +import time +import cunumpy as xp + +xp.set_backend("cupy") +a = xp.random.rand(1000, 1000) + +start = time.time() +b = xp.dot(a, a) +xp.synchronize() # Wait for GPU +end = time.time() + +print(f"Elapsed: {end - start:.4f}s") ``` diff --git a/pyproject.toml b/pyproject.toml index c519bfd..e5c7bfa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ requires = [ "setuptools", "wheel" ] [project] name = "cunumpy" -version = "0.1.1" +version = "0.1.2" description = "Simple wrapper for numpy and cupy. Replace `import numpy as np` with `import cunumpy as xp`." readme = "README.md" keywords = [ "python" ] @@ -51,3 +51,6 @@ where = [ "src" ] [tool.setuptools.package-data] cunumpy = [ "py.typed", "*.pyi" ] + +[tool.isort] +profile = "black" diff --git a/src/cunumpy/__init__.py b/src/cunumpy/__init__.py index 32c5da8..c6592bd 100644 --- a/src/cunumpy/__init__.py +++ b/src/cunumpy/__init__.py @@ -1,9 +1,37 @@ # cunumpy/__init__.py from . import xp +from .xp import ( + get_backend, + is_cpu, + is_gpu, + set_backend, + synchronize, + to_cunumpy, + to_cupy, + to_numpy, + use_backend, +) -__all__ = ["xp"] +__all__ = [ + "xp", + "to_numpy", + "to_cupy", + "to_cunumpy", + "get_backend", + "is_gpu", + "is_cpu", + "use_backend", + "set_backend", + "synchronize", + "numpy_backend", + "cupy_backend", +] def __getattr__(name: str): """Set cunumpy. to cunumpy.xp. (NumPy/CuPy).""" + if name == "numpy_backend": + return xp.numpy_backend + if name == "cupy_backend": + return xp.cupy_backend return getattr(xp.xp, name) diff --git a/src/cunumpy/__init__.pyi b/src/cunumpy/__init__.pyi index b5fe885..2a4fb33 100644 --- a/src/cunumpy/__init__.pyi +++ b/src/cunumpy/__init__.pyi @@ -1,7 +1,24 @@ # Stub file for Pylance/mypy: exposes all numpy symbols so that # `import cunumpy as xp` followed by `xp.` shows numpy completions. # At runtime the real __init__.py dispatches to numpy or cupy via __getattr__. +from contextlib import contextmanager +from typing import Any, Generator + +import numpy as np from numpy import * -from numpy import __config__, __version__ from . import xp + +def to_numpy(array: Any) -> np.ndarray: ... +def to_cupy(array: Any) -> Any: ... +def to_cunumpy(array: Any) -> Any: ... +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 set_backend(backend: str) -> None: ... +def synchronize() -> None: ... + +numpy_backend: bool +cupy_backend: bool diff --git a/src/cunumpy/xp.py b/src/cunumpy/xp.py index 6438575..36d3b9c 100644 --- a/src/cunumpy/xp.py +++ b/src/cunumpy/xp.py @@ -1,6 +1,9 @@ import os +from contextlib import contextmanager from types import ModuleType -from typing import TYPE_CHECKING, Literal +from typing import TYPE_CHECKING, Any, Generator, Literal + +import numpy as np BackendType = Literal["numpy", "cupy"] @@ -17,27 +20,31 @@ def __init__( ], "Array backend must be either 'numpy' or 'cupy'." self._backend: BackendType = "cupy" if backend.lower() == "cupy" else "numpy" + self._xp: ModuleType = np # Placeholder # Import numpy/cupy - if self.backend == "cupy": + self._xp = self._load_backend(self._backend, verbose) + + def _load_backend(self, backend: BackendType, verbose: bool = False) -> ModuleType: + if backend == "cupy": try: import cupy as cp - self._xp = cp + return cp except ImportError: if verbose: print("CuPy not available.") - self._backend = "numpy" - - if self.backend == "numpy": - import numpy as np + return np + import numpy as np_mod - self._xp = np - - assert isinstance(self.xp, ModuleType) + 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.") + print(f"Using {self._xp.__name__} backend.") @property def backend(self) -> BackendType: @@ -47,6 +54,21 @@ def backend(self) -> BackendType: def xp(self) -> ModuleType: return self._xp + @contextmanager + def use_backend(self, backend: BackendType) -> Generator[None, None, None]: + """Temporarily change the backend.""" + old_backend = self._backend + old_xp = self._xp + + self._backend = backend + self._xp = self._load_backend(backend) + + try: + yield + finally: + self._backend = old_backend + self._xp = old_xp + # TODO: Make this configurable via environment variable or config file. array_backend = ArrayBackend( @@ -55,10 +77,94 @@ def xp(self) -> ModuleType: ), 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]: + """Temporarily change the backend.""" + return array_backend.use_backend(backend) + + +def set_backend(backend: BackendType) -> None: + """Set the backend globally.""" + array_backend._backend = backend + array_backend._xp = array_backend._load_backend(backend) + + +def _cupy_backend() -> bool: + """Check if the active global backend is CuPy.""" + return array_backend.backend == "cupy" + + +def _numpy_backend() -> bool: + """Check if the active global backend is NumPy.""" + return array_backend.backend == "numpy" + + +def synchronize() -> None: + """Wait for all kernels in all streams on current device to complete.""" + if array_backend.backend == "cupy": + try: + import cupy as cp + + cp.cuda.Device().synchronize() + except (ImportError, AttributeError): + pass + + +def to_numpy(array: Any) -> np.ndarray: + """Convert an array to a NumPy array.""" + if hasattr(array, "get"): + return array.get() + + return np.asarray(array) + + +def to_cupy(array: Any) -> Any: + """Convert an array to a CuPy array.""" + try: + import cupy as cp + + return cp.asarray(array) + except ImportError: + raise ImportError("CuPy is not available.") + + +def to_cunumpy(array: Any) -> Any: + """Convert an array to the currently active backend.""" + if array_backend.backend == "cupy": + return to_cupy(array) + return to_numpy(array) + + +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" + + +def is_gpu(array: Any) -> bool: + """Check if the array is stored on a GPU (CuPy).""" + return get_backend(array) == "cupy" + + +def is_cpu(array: Any) -> bool: + """Check if the array is stored on a CPU (NumPy).""" + return get_backend(array) == "numpy" + # 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 else: - xp = array_backend.xp + # Use module-level __getattr__ for dynamic xp (Python 3.7+) + def __getattr__(name): + if name == "xp": + return array_backend.xp + if name == "numpy_backend": + return _numpy_backend() + if name == "cupy_backend": + return _cupy_backend() + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/tests/unit/test_app.py b/tests/unit/test_app.py index ce6b9d0..53e095e 100644 --- a/tests/unit/test_app.py +++ b/tests/unit/test_app.py @@ -1,4 +1,5 @@ import numpy as np +import pytest import cunumpy as xp @@ -17,10 +18,114 @@ def test_numpy_symbols_accessible(): This validates the runtime behaviour that the stub file (__init__.pyi) declares to Pylance so that `xp.` shows numpy completions in VS Code. """ - missing = [name for name in np.__all__ if not hasattr(xp, name)] + # Exclude our custom methods from the numpy check + custom_methods = [ + "to_numpy", + "to_cupy", + "to_cunumpy", + "get_backend", + "is_gpu", + "is_cpu", + "use_backend", + "set_backend", + "synchronize", + "numpy_backend", + "cupy_backend", + "xp", + ] + missing = [ + name + for name in np.__all__ + if not hasattr(xp, name) and name not in custom_methods + ] assert missing == [], f"Symbols not accessible via cunumpy: {missing}" +def test_to_numpy(): + arr = xp.array([1, 2, 3]) + # Even if it's already numpy, to_numpy should work + arr_np = xp.to_numpy(arr) + assert isinstance(arr_np, np.ndarray) + assert np.array_equal(arr_np, [1, 2, 3]) + + +def test_to_cupy_not_available(): + try: + import cupy + + pytest.skip("CuPy is installed, cannot test missing cupy error") + except ImportError: + pass + + arr = np.array([1, 2, 3]) + + with pytest.raises(ImportError): + xp.to_cupy(arr) + + +def test_to_cunumpy(): + arr = np.array([1, 2, 3]) + arr_xp = xp.to_cunumpy(arr) + # Backend is numpy in tests usually + assert isinstance(arr_xp, (np.ndarray, xp.ndarray)) + + +def test_get_backend_and_is_gpu_cpu(): + arr = np.array([1, 2, 3]) + assert xp.get_backend(arr) == "numpy" + assert xp.is_gpu(arr) is False + assert xp.is_cpu(arr) is True + + +def test_use_backend(): + # Initial backend should be numpy (default) in this test environment + # Accessing xp.xp triggers the dynamic __getattr__ in xp.py + assert "numpy" in xp.xp.__name__ + + with xp.use_backend("numpy"): + assert "numpy" in xp.xp.__name__ + arr = xp.zeros(10) + assert isinstance(arr, np.ndarray) + + assert "numpy" in xp.xp.__name__ + + +def test_set_backend(): + # Set to numpy + xp.set_backend("numpy") + assert "numpy" in xp.xp.__name__ + arr = xp.array([1]) + assert isinstance(arr, np.ndarray) + + # Set to cupy (falls back to numpy if not available) + xp.set_backend("cupy") + # If cupy is not installed, xp.xp will be numpy module + # We just verify it doesn't crash and we can still call things + arr2 = xp.array([2]) + assert arr2 is not None + + +def test_synchronize(): + # Should not crash on any backend + xp.synchronize() + + with xp.use_backend("numpy"): + xp.synchronize() + + with xp.use_backend("cupy"): + xp.synchronize() + + +def test_backend_bools(): + with xp.use_backend("numpy"): + assert xp.numpy_backend is True + assert xp.cupy_backend is False + + # Note: in test env without cupy, cupy_backend might be false + # even inside use_backend('cupy') if fallback occurs. + # Our implementation of use_backend calls _load_backend which returns np if cp missing. + + if __name__ == "__main__": test_xp_array() test_numpy_symbols_accessible()