diff --git a/decent_array/_array.py b/decent_array/_array.py index cc73f1c..5580c4a 100644 --- a/decent_array/_array.py +++ b/decent_array/_array.py @@ -355,23 +355,23 @@ def __len__(self) -> int: def __float__(self) -> float: """Coerce a scalar array to a Python float.""" - return float(self._backend.squeeze(self).value) + return float(self.item()) def __bool__(self) -> bool: """Coerce a scalar array to a Python bool.""" - return bool(self._backend.squeeze(self).value) + return bool(self.item()) def __int__(self) -> int: """Coerce a scalar array to a Python int.""" - return int(self._backend.squeeze(self).value) + return int(self.item()) def __complex__(self) -> complex: """Coerce a scalar array to a Python complex.""" - return complex(self._backend.squeeze(self).value) + return complex(self.item()) def __index__(self) -> int: """Coerce a scalar array to a Python int.""" - return int(self._backend.squeeze(self).value) + return int(self.item()) # Repr ----------------------------------------------------------------- @@ -444,3 +444,13 @@ def device(self) -> Devices: def numpy(self) -> NDArray[Any]: """Return a NumPy array view of the array's data.""" return self._backend.to_numpy(self) + + def item(self) -> Any: # noqa: ANN401 + """ + Convert 0-dim array to Python scalar. + + Raises: + TypeError: if ``x`` is not 0-dimensional. + + """ + return self._backend.to_scalar(self) diff --git a/decent_array/_errors.py b/decent_array/_errors.py new file mode 100644 index 0000000..cb6abd7 --- /dev/null +++ b/decent_array/_errors.py @@ -0,0 +1,37 @@ +"""Common errors used across backends.""" + +from typing import Any + +from mypy_extensions import mypyc_attr + + +@mypyc_attr(native_class=False) +class NotScalarError(TypeError): + def __init__(self, ndim: int): + super().__init__(f"Only 0-dim arrays can be converted to Python scalars, got {ndim}-dim array.") + + +class NDimError(ValueError): + def __init__(self, required_ndim: int, actual_ndim: int): + super().__init__(f"A {required_ndim}-dim array is required, got {actual_ndim}-dim array.") + + +class MatrixTransposeError(ValueError): + def __init__(self, ndim: int): + super().__init__(f"An aray with at least 2 dimensions is required, got {ndim}-dim array.") + + +class UnsupportedDTypeCreationError(ValueError): + def __init__(self, dtype: Any, backend_name: str, device_name: str): # noqa: ANN401 + super().__init__(f"Unsupported dtype '{dtype}' for {backend_name} on {device_name}.") + + +class UnsupportedDeviceError(ValueError): + def __init__(self, backend_name: str, device_name: str): + super().__init__(f"{backend_name} does not support device '{device_name}'.") + + +stack_empty_error = ValueError("Cannot stack an empty sequence of arrays.") + + +no_backend_error = RuntimeError("No backend active: call 'set_backend' with a supported framework to activate one.") diff --git a/decent_array/_utils.py b/decent_array/_utils.py index 559e3d5..adb0462 100644 --- a/decent_array/_utils.py +++ b/decent_array/_utils.py @@ -12,3 +12,8 @@ def unwrap(x: Any) -> Any: # noqa: ANN401 site without runtime benefit. """ return x.value if type(x) is Array else x + + +def is_scalar(x: Array) -> bool: + """Return True if ``x`` is a 0-dim Array.""" + return x.ndim == 0 diff --git a/decent_array/interoperability/_abstracts/backend.py b/decent_array/interoperability/_abstracts/backend.py index 1912a02..80e084c 100644 --- a/decent_array/interoperability/_abstracts/backend.py +++ b/decent_array/interoperability/_abstracts/backend.py @@ -92,6 +92,14 @@ def from_numpy_like(self, x: NDArray[Any], like: Array) -> Array: def asarray(self, x: bool | int | float | complex) -> Array: """Convert a Python scalar to an :class:`Array` on this backend.""" + @abstractmethod + def to_scalar(self, x: Array) -> Any: # noqa: ANN401 + """ + Convert a 0-dim array to a scalar. + + This method must use ``is_scalar(x)`` to raise when ``x`` is not 0-dim. + """ + @abstractmethod def stack(self, arrays: Sequence[Array], axis: int = 0) -> Array: """Stack a sequence of arrays along a new dimension.""" diff --git a/decent_array/interoperability/_iop/bit_operators.py b/decent_array/interoperability/_iop/bit_operators.py index 48362bd..0bbbfc5 100644 --- a/decent_array/interoperability/_iop/bit_operators.py +++ b/decent_array/interoperability/_iop/bit_operators.py @@ -15,6 +15,7 @@ from typing import TYPE_CHECKING +from decent_array._errors import no_backend_error from decent_array.interoperability._backend_manager import register_backend_listener if TYPE_CHECKING: @@ -36,40 +37,40 @@ def _update_backend(backend: Backend | None) -> None: def bitwise_and(x1: bool | int | Array, x2: bool | int | Array) -> Array: """Element-wise bitwise/logical AND.""" if _BACKEND_INSTANCE is None: - raise _error + raise no_backend_error return _BACKEND_INSTANCE.bitwise_and(x1, x2) def bitwise_invert(x: Array) -> Array: """Element-wise bitwise/logical NOT.""" if _BACKEND_INSTANCE is None: - raise _error + raise no_backend_error return _BACKEND_INSTANCE.bitwise_invert(x) def bitwise_or(x1: bool | int | Array, x2: bool | int | Array) -> Array: """Element-wise bitwise/logical OR.""" if _BACKEND_INSTANCE is None: - raise _error + raise no_backend_error return _BACKEND_INSTANCE.bitwise_or(x1, x2) def bitwise_xor(x1: bool | int | Array, x2: bool | int | Array) -> Array: """Element-wise bitwise/logical XOR.""" if _BACKEND_INSTANCE is None: - raise _error + raise no_backend_error return _BACKEND_INSTANCE.bitwise_xor(x1, x2) def bitwise_left_shift(x1: int | Array, x2: int | Array) -> Array: """Element-wise bitwise left shift.""" if _BACKEND_INSTANCE is None: - raise _error + raise no_backend_error return _BACKEND_INSTANCE.bitwise_left_shift(x1, x2) def bitwise_right_shift(x1: int | Array, x2: int | Array) -> Array: """Element-wise bitwise right shift.""" if _BACKEND_INSTANCE is None: - raise _error + raise no_backend_error return _BACKEND_INSTANCE.bitwise_right_shift(x1, x2) diff --git a/decent_array/interoperability/_iop/comparison.py b/decent_array/interoperability/_iop/comparison.py index 64d176c..5b28f1c 100644 --- a/decent_array/interoperability/_iop/comparison.py +++ b/decent_array/interoperability/_iop/comparison.py @@ -15,6 +15,7 @@ from typing import TYPE_CHECKING +from decent_array._errors import no_backend_error from decent_array.interoperability._backend_manager import register_backend_listener if TYPE_CHECKING: @@ -22,7 +23,6 @@ from decent_array.interoperability._abstracts import Backend _BACKEND_INSTANCE: Backend | None = None -_error = RuntimeError("No backend active: call 'set_backend' with a supported framework to activate one.") def _update_backend(backend: Backend | None) -> None: @@ -36,40 +36,40 @@ def _update_backend(backend: Backend | None) -> None: def equal(x1: int | float | complex | Array, x2: int | float | complex | Array) -> Array: """Element-wise equality. Returns an :class:`~decent_array.Array` of bools.""" if _BACKEND_INSTANCE is None: - raise _error + raise no_backend_error return _BACKEND_INSTANCE.equal(x1, x2) def not_equal(x1: int | float | complex | Array, x2: int | float | complex | Array) -> Array: """Element-wise inequality. Returns an :class:`~decent_array.Array` of bools.""" if _BACKEND_INSTANCE is None: - raise _error + raise no_backend_error return _BACKEND_INSTANCE.not_equal(x1, x2) def less(x1: int | float | complex | Array, x2: int | float | complex | Array) -> Array: """Element-wise less-than. Returns an :class:`~decent_array.Array` of bools.""" if _BACKEND_INSTANCE is None: - raise _error + raise no_backend_error return _BACKEND_INSTANCE.less(x1, x2) def less_equal(x1: int | float | complex | Array, x2: int | float | complex | Array) -> Array: """Element-wise less-than-or-equal. Returns an :class:`~decent_array.Array` of bools.""" if _BACKEND_INSTANCE is None: - raise _error + raise no_backend_error return _BACKEND_INSTANCE.less_equal(x1, x2) def greater(x1: int | float | complex | Array, x2: int | float | complex | Array) -> Array: """Element-wise greater-than. Returns an :class:`~decent_array.Array` of bools.""" if _BACKEND_INSTANCE is None: - raise _error + raise no_backend_error return _BACKEND_INSTANCE.greater(x1, x2) def greater_equal(x1: int | float | complex | Array, x2: int | float | complex | Array) -> Array: """Element-wise greater-than-or-equal. Returns an :class:`~decent_array.Array` of bools.""" if _BACKEND_INSTANCE is None: - raise _error + raise no_backend_error return _BACKEND_INSTANCE.greater_equal(x1, x2) diff --git a/decent_array/interoperability/_iop/creation.py b/decent_array/interoperability/_iop/creation.py index 1ebcc74..b849204 100644 --- a/decent_array/interoperability/_iop/creation.py +++ b/decent_array/interoperability/_iop/creation.py @@ -15,6 +15,7 @@ from typing import TYPE_CHECKING +from decent_array._errors import no_backend_error from decent_array.interoperability._backend_manager import register_backend_listener if TYPE_CHECKING: @@ -22,7 +23,6 @@ from decent_array.interoperability._abstracts import Backend _BACKEND_INSTANCE: Backend | None = None -_error = RuntimeError("No backend active: call 'set_backend' with a supported framework to activate one.") def _update_backend(backend: Backend | None) -> None: @@ -36,33 +36,33 @@ def _update_backend(backend: Backend | None) -> None: def zeros(shape: int | tuple[int, ...]) -> Array: """Create an array of zeros with the given shape.""" if _BACKEND_INSTANCE is None: - raise _error + raise no_backend_error return _BACKEND_INSTANCE.zeros(shape) def zeros_like(x: Array) -> Array: """Create an array of zeros matching the shape and type of ``x``.""" if _BACKEND_INSTANCE is None: - raise _error + raise no_backend_error return _BACKEND_INSTANCE.zeros_like(x) def ones(shape: int | tuple[int, ...]) -> Array: """Create an array of ones with the given shape.""" if _BACKEND_INSTANCE is None: - raise _error + raise no_backend_error return _BACKEND_INSTANCE.ones(shape) def ones_like(x: Array) -> Array: """Create an array of ones matching the shape and type of ``x``.""" if _BACKEND_INSTANCE is None: - raise _error + raise no_backend_error return _BACKEND_INSTANCE.ones_like(x) def eye(n: int) -> Array: """Create an ``n x n`` identity matrix.""" if _BACKEND_INSTANCE is None: - raise _error + raise no_backend_error return _BACKEND_INSTANCE.eye(n) diff --git a/decent_array/interoperability/_iop/linalg.py b/decent_array/interoperability/_iop/linalg.py index c10e0f0..44b1792 100644 --- a/decent_array/interoperability/_iop/linalg.py +++ b/decent_array/interoperability/_iop/linalg.py @@ -15,6 +15,7 @@ from typing import TYPE_CHECKING +from decent_array._errors import no_backend_error from decent_array.interoperability._backend_manager import register_backend_listener if TYPE_CHECKING: @@ -22,7 +23,6 @@ from decent_array.interoperability._abstracts import Backend _BACKEND_INSTANCE: Backend | None = None -_error = RuntimeError("No backend active: call 'set_backend' with a supported framework to activate one.") def _update_backend(backend: Backend | None) -> None: @@ -36,7 +36,7 @@ def _update_backend(backend: Backend | None) -> None: def vecdot(x1: Array, x2: Array) -> Array: """Vector dot product of two arrays.""" if _BACKEND_INSTANCE is None: - raise _error + raise no_backend_error return _BACKEND_INSTANCE.vecdot(x1, x2) @@ -47,14 +47,14 @@ def dot(x1: Array, x2: Array) -> Array: Alias for :func:`vecdot`. """ if _BACKEND_INSTANCE is None: - raise _error + raise no_backend_error return _BACKEND_INSTANCE.vecdot(x1, x2) def matmul(x1: Array, x2: Array) -> Array: """Matrix multiplication of two arrays.""" if _BACKEND_INSTANCE is None: - raise _error + raise no_backend_error return _BACKEND_INSTANCE.matmul(x1, x2) @@ -66,7 +66,7 @@ def vector_norm( ) -> Array: """Vector norm of ``x``.""" if _BACKEND_INSTANCE is None: - raise _error + raise no_backend_error return _BACKEND_INSTANCE.vector_norm(x, axis, keepdims, ord) @@ -82,5 +82,5 @@ def norm( Alias for :func:`vector_norm`. """ if _BACKEND_INSTANCE is None: - raise _error + raise no_backend_error return _BACKEND_INSTANCE.vector_norm(x, axis, keepdims, ord) diff --git a/decent_array/interoperability/_iop/manipulations.py b/decent_array/interoperability/_iop/manipulations.py index 629a2f8..e04bf28 100644 --- a/decent_array/interoperability/_iop/manipulations.py +++ b/decent_array/interoperability/_iop/manipulations.py @@ -16,6 +16,7 @@ from collections.abc import Sequence from typing import TYPE_CHECKING, Any +from decent_array._errors import no_backend_error from decent_array.interoperability._backend_manager import register_backend_listener if TYPE_CHECKING: @@ -27,7 +28,6 @@ from decent_array.types._dtypes import dtype _BACKEND_INSTANCE: Backend | None = None -_error = RuntimeError("No backend active: call 'set_backend' with a supported framework to activate one.") def _update_backend(backend: Backend | None) -> None: @@ -41,124 +41,124 @@ def _update_backend(backend: Backend | None) -> None: def copy(x: Array) -> Array: """Return a copy of ``x``.""" if _BACKEND_INSTANCE is None: - raise _error + raise no_backend_error return _BACKEND_INSTANCE.copy(x) def to_numpy(x: ArrayTypes | Array) -> NDArray[Any]: """Convert ``x`` to a NumPy array on CPU.""" if _BACKEND_INSTANCE is None: - raise _error + raise no_backend_error return _BACKEND_INSTANCE.to_numpy(x) def from_numpy(x: NDArray[Any]) -> Array: """Convert a NumPy array on CPU to an :class:`~decent_array.Array` on the active backend.""" if _BACKEND_INSTANCE is None: - raise _error + raise no_backend_error return _BACKEND_INSTANCE.from_numpy(x) def from_numpy_like(x: NDArray[Any], like: Array) -> Array: """Convert a NumPy array to an :class:`~decent_array.Array` matching ``like``'s dtype and device.""" if _BACKEND_INSTANCE is None: - raise _error + raise no_backend_error return _BACKEND_INSTANCE.from_numpy_like(x, like) def asarray(x: float | bool) -> Array: """Convert a Python scalar to an :class:`~decent_array.Array` on the active backend.""" if _BACKEND_INSTANCE is None: - raise _error + raise no_backend_error return _BACKEND_INSTANCE.asarray(x) def stack(arrays: Sequence[Array], axis: int = 0) -> Array: """Stack a sequence of arrays along a new dimension.""" if _BACKEND_INSTANCE is None: - raise _error + raise no_backend_error return _BACKEND_INSTANCE.stack(arrays, axis) def reshape(x: Array, shape: tuple[int, ...]) -> Array: """Reshape ``x`` to ``shape``.""" if _BACKEND_INSTANCE is None: - raise _error + raise no_backend_error return _BACKEND_INSTANCE.reshape(x, shape) def transpose(x: Array, axis: tuple[int, ...] | None = None) -> Array: """Transpose ``x``; ``None`` reverses dimensions.""" if _BACKEND_INSTANCE is None: - raise _error + raise no_backend_error return _BACKEND_INSTANCE.transpose(x, axis) def matrix_transpose(x: Array) -> Array: """Transpose the innermost two dimensions of ``x``.""" if _BACKEND_INSTANCE is None: - raise _error + raise no_backend_error return _BACKEND_INSTANCE.matrix_transpose(x) def shape(x: Array) -> tuple[int, ...]: """Return the shape of ``x``.""" if _BACKEND_INSTANCE is None: - raise _error + raise no_backend_error return _BACKEND_INSTANCE.shape(x) def size(x: Array) -> int: """Return the total number of elements in ``x``.""" if _BACKEND_INSTANCE is None: - raise _error + raise no_backend_error return _BACKEND_INSTANCE.size(x) def ndim(x: Array) -> int: """Return the number of dimensions of ``x``.""" if _BACKEND_INSTANCE is None: - raise _error + raise no_backend_error return _BACKEND_INSTANCE.ndim(x) def squeeze(x: Array, axis: int | tuple[int, ...] | None = None) -> Array: """Remove single-dimensional entries from ``x``.""" if _BACKEND_INSTANCE is None: - raise _error + raise no_backend_error return _BACKEND_INSTANCE.squeeze(x, axis) def expand_dims(x: Array, axis: int) -> Array: """Insert a singleton dimension at ``axis``.""" if _BACKEND_INSTANCE is None: - raise _error + raise no_backend_error return _BACKEND_INSTANCE.unsqueeze(x, axis) def unsqueeze(x: Array, axis: int) -> Array: """Insert a singleton dimension at ``axis``.""" if _BACKEND_INSTANCE is None: - raise _error + raise no_backend_error return _BACKEND_INSTANCE.unsqueeze(x, axis) def diag(x: Array) -> Array: """Build a diagonal matrix from a 1-D vector.""" if _BACKEND_INSTANCE is None: - raise _error + raise no_backend_error return _BACKEND_INSTANCE.diag(x) def diagonal(x: Array, offset: int = 0) -> Array: """Extract the diagonal entries from a 2-D matrix at the given ``offset``.""" if _BACKEND_INSTANCE is None: - raise _error + raise no_backend_error return _BACKEND_INSTANCE.diagonal(x, offset) def astype(x: Array, dtype: dtype) -> Array: """Cast ``x`` to a different dtype.""" if _BACKEND_INSTANCE is None: - raise _error + raise no_backend_error return _BACKEND_INSTANCE.astype(x, dtype) diff --git a/decent_array/interoperability/_iop/math.py b/decent_array/interoperability/_iop/math.py index 1b6bcc7..5ff6f6c 100644 --- a/decent_array/interoperability/_iop/math.py +++ b/decent_array/interoperability/_iop/math.py @@ -15,6 +15,7 @@ from typing import TYPE_CHECKING +from decent_array._errors import no_backend_error from decent_array.interoperability._backend_manager import register_backend_listener if TYPE_CHECKING: @@ -22,7 +23,6 @@ from decent_array.interoperability._abstracts import Backend _BACKEND_INSTANCE: Backend | None = None -_error = RuntimeError("No backend active: call 'set_backend' with a supported framework to activate one.") def _update_backend(backend: Backend | None) -> None: @@ -36,98 +36,98 @@ def _update_backend(backend: Backend | None) -> None: def add(x1: Array | float, x2: Array | float) -> Array: """Element-wise addition.""" if _BACKEND_INSTANCE is None: - raise _error + raise no_backend_error return _BACKEND_INSTANCE.add(x1, x2) def iadd[T: Array](x1: T, x2: Array | float) -> T: """In-place element-wise addition.""" if _BACKEND_INSTANCE is None: - raise _error + raise no_backend_error return _BACKEND_INSTANCE.iadd(x1, x2) def subtract(x1: int | float | complex | Array, x2: int | float | complex | Array) -> Array: """Element-wise subtraction.""" if _BACKEND_INSTANCE is None: - raise _error + raise no_backend_error return _BACKEND_INSTANCE.subtract(x1, x2) def isubtract[T: Array](x1: T, x2: int | float | complex | Array) -> T: """In-place element-wise subtraction.""" if _BACKEND_INSTANCE is None: - raise _error + raise no_backend_error return _BACKEND_INSTANCE.isubtract(x1, x2) def multiply(x1: Array | float, x2: int | float | complex | Array) -> Array: """Element-wise multiplication.""" if _BACKEND_INSTANCE is None: - raise _error + raise no_backend_error return _BACKEND_INSTANCE.multiply(x1, x2) def imultiply[T: Array](x1: T, x2: int | float | complex | Array) -> T: """In-place element-wise multiplication.""" if _BACKEND_INSTANCE is None: - raise _error + raise no_backend_error return _BACKEND_INSTANCE.imultiply(x1, x2) def divide(x1: Array | float, x2: int | float | complex | Array) -> Array: """Element-wise division.""" if _BACKEND_INSTANCE is None: - raise _error + raise no_backend_error return _BACKEND_INSTANCE.divide(x1, x2) def idivide[T: Array](x1: T, x2: int | float | complex | Array) -> T: """In-place element-wise division.""" if _BACKEND_INSTANCE is None: - raise _error + raise no_backend_error return _BACKEND_INSTANCE.idivide(x1, x2) def floor_divide(x1: int | float | Array, x2: int | float | Array) -> Array: """Element-wise floor division.""" if _BACKEND_INSTANCE is None: - raise _error + raise no_backend_error return _BACKEND_INSTANCE.floor_divide(x1, x2) def remainder(x1: int | float | Array, x2: int | float | Array) -> Array: """Element-wise remainder after floor division.""" if _BACKEND_INSTANCE is None: - raise _error + raise no_backend_error return _BACKEND_INSTANCE.remainder(x1, x2) def pow(x1: int | float | complex | Array, x2: int | float | complex | Array) -> Array: # noqa: A001 """Raise ``x`` to power ``p``.""" if _BACKEND_INSTANCE is None: - raise _error + raise no_backend_error return _BACKEND_INSTANCE.pow(x1, x2) def negative(x: Array) -> Array: """Element-wise negation.""" if _BACKEND_INSTANCE is None: - raise _error + raise no_backend_error return _BACKEND_INSTANCE.negative(x) def positive(x: Array) -> Array: """Return the array itself.""" if _BACKEND_INSTANCE is None: - raise _error + raise no_backend_error return x def absolute(x: Array) -> Array: """Element-wise absolute value.""" if _BACKEND_INSTANCE is None: - raise _error + raise no_backend_error return _BACKEND_INSTANCE.absolute(x) @@ -138,12 +138,12 @@ def abs(x: Array) -> Array: # noqa: A001 Alias for :func:`absolute`. """ if _BACKEND_INSTANCE is None: - raise _error + raise no_backend_error return _BACKEND_INSTANCE.absolute(x) def sqrt(x: Array) -> Array: """Element-wise square root.""" if _BACKEND_INSTANCE is None: - raise _error + raise no_backend_error return _BACKEND_INSTANCE.sqrt(x) diff --git a/decent_array/interoperability/_iop/operators.py b/decent_array/interoperability/_iop/operators.py index b72c6ab..8843edd 100644 --- a/decent_array/interoperability/_iop/operators.py +++ b/decent_array/interoperability/_iop/operators.py @@ -15,6 +15,7 @@ from typing import TYPE_CHECKING +from decent_array._errors import no_backend_error from decent_array.interoperability._backend_manager import register_backend_listener if TYPE_CHECKING: @@ -22,7 +23,6 @@ from decent_array.interoperability._abstracts import Backend _BACKEND_INSTANCE: Backend | None = None -_error = RuntimeError("No backend active: call 'set_backend' with a supported framework to activate one.") def _update_backend(backend: Backend | None) -> None: @@ -36,26 +36,26 @@ def _update_backend(backend: Backend | None) -> None: def sign(x: Array) -> Array: """Element-wise sign.""" if _BACKEND_INSTANCE is None: - raise _error + raise no_backend_error return _BACKEND_INSTANCE.sign(x) def maximum(x1: Array | float, x2: Array | float) -> Array: """Element-wise maximum.""" if _BACKEND_INSTANCE is None: - raise _error + raise no_backend_error return _BACKEND_INSTANCE.maximum(x1, x2) def argmax(x: Array, axis: int | None = None, keepdims: bool = False) -> Array: """Index of maximum value along ``axis``.""" if _BACKEND_INSTANCE is None: - raise _error + raise no_backend_error return _BACKEND_INSTANCE.argmax(x, axis, keepdims) def argmin(x: Array, axis: int | None = None, keepdims: bool = False) -> Array: """Index of minimum value along ``axis``.""" if _BACKEND_INSTANCE is None: - raise _error + raise no_backend_error return _BACKEND_INSTANCE.argmin(x, axis, keepdims) diff --git a/decent_array/interoperability/_iop/reductions.py b/decent_array/interoperability/_iop/reductions.py index 7c81125..20540cc 100644 --- a/decent_array/interoperability/_iop/reductions.py +++ b/decent_array/interoperability/_iop/reductions.py @@ -15,6 +15,7 @@ from typing import TYPE_CHECKING +from decent_array._errors import no_backend_error from decent_array.interoperability._backend_manager import register_backend_listener if TYPE_CHECKING: @@ -22,7 +23,6 @@ from decent_array.interoperability._abstracts import Backend _BACKEND_INSTANCE: Backend | None = None -_error = RuntimeError("No backend active: call 'set_backend' with a supported framework to activate one.") def _update_backend(backend: Backend | None) -> None: @@ -40,7 +40,7 @@ def sum( # noqa: A001 ) -> Array: """Sum elements of ``x`` along ``axis``.""" if _BACKEND_INSTANCE is None: - raise _error + raise no_backend_error return _BACKEND_INSTANCE.sum(x, axis, keepdims) @@ -51,7 +51,7 @@ def mean( ) -> Array: """Mean of ``x`` along ``axis``.""" if _BACKEND_INSTANCE is None: - raise _error + raise no_backend_error return _BACKEND_INSTANCE.mean(x, axis, keepdims) @@ -62,7 +62,7 @@ def min( # noqa: A001 ) -> Array: """Minimum of ``x`` along ``axis``.""" if _BACKEND_INSTANCE is None: - raise _error + raise no_backend_error return _BACKEND_INSTANCE.min(x, axis, keepdims) @@ -73,19 +73,19 @@ def max( # noqa: A001 ) -> Array: """Maximum of ``x`` along ``axis``.""" if _BACKEND_INSTANCE is None: - raise _error + raise no_backend_error return _BACKEND_INSTANCE.max(x, axis, keepdims) def any(x: Array, axis: int | tuple[int, ...] | None = None, keepdims: bool = False) -> bool: # noqa: A001 """Return True if any element of ``x`` is truthy.""" if _BACKEND_INSTANCE is None: - raise _error + raise no_backend_error return _BACKEND_INSTANCE.any(x, axis, keepdims) def all(x: Array, axis: int | tuple[int, ...] | None = None, keepdims: bool = False) -> bool: # noqa: A001 """Return True if all elements of ``x`` are truthy.""" if _BACKEND_INSTANCE is None: - raise _error + raise no_backend_error return _BACKEND_INSTANCE.all(x, axis, keepdims) diff --git a/decent_array/interoperability/_iop/rng.py b/decent_array/interoperability/_iop/rng.py index 3df90ca..7b39029 100644 --- a/decent_array/interoperability/_iop/rng.py +++ b/decent_array/interoperability/_iop/rng.py @@ -20,6 +20,7 @@ import random from typing import TYPE_CHECKING, Any, cast +from decent_array._errors import no_backend_error from decent_array.interoperability._backend_manager import _instantiate, register_backend_listener from decent_array.types import Devices, Frameworks @@ -34,7 +35,6 @@ _NUMPY_STATE_KEY = "__numpy_rng_state__" _PYTHON_RANDOM_KEY = "__python_random_state__" _BACKEND_INSTANCE: Backend | None = None -_error = RuntimeError("No backend active: call 'set_backend' with a supported framework to activate one.") def _update_backend(backend: Backend | None) -> None: @@ -63,7 +63,7 @@ def set_seed(self, seed: int, *, set_global_seed: bool = True) -> None: """ if _BACKEND_INSTANCE is None: - raise _error + raise no_backend_error random.seed(seed) active = _BACKEND_INSTANCE @@ -89,7 +89,7 @@ def get_rng_state(self) -> dict[str, Any]: """ if _BACKEND_INSTANCE is None: - raise _error + raise no_backend_error active = _BACKEND_INSTANCE state = active.get_rng_state() @@ -102,7 +102,7 @@ def get_rng_state(self) -> dict[str, Any]: def set_rng_state(self, state: dict[str, Any]) -> None: """Restore a snapshot produced by :meth:`get_rng_state`.""" if _BACKEND_INSTANCE is None: - raise _error + raise no_backend_error # Copy so we can mutate without surprising the caller. state = dict(state) @@ -188,33 +188,33 @@ def derive_seed() -> int: def normal(mean: float = 0.0, std: float = 1.0, shape: tuple[int, ...] = ()) -> Array: """Draw normally distributed samples on the active backend.""" if _BACKEND_INSTANCE is None: - raise _error + raise no_backend_error return _BACKEND_INSTANCE.normal(mean, std, shape) def uniform(low: float = 0.0, high: float = 1.0, shape: tuple[int, ...] = ()) -> Array: """Draw uniformly distributed samples on the active backend.""" if _BACKEND_INSTANCE is None: - raise _error + raise no_backend_error return _BACKEND_INSTANCE.uniform(low, high, shape) def normal_like(x: Array, mean: float = 0.0, std: float = 1.0) -> Array: """Draw normally distributed samples shaped like ``x`` with same dtype.""" if _BACKEND_INSTANCE is None: - raise _error + raise no_backend_error return _BACKEND_INSTANCE.normal_like(x, mean, std) def uniform_like(x: Array, low: float = 0.0, high: float = 1.0) -> Array: """Draw uniformly distributed samples shaped like ``x`` with same dtype.""" if _BACKEND_INSTANCE is None: - raise _error + raise no_backend_error return _BACKEND_INSTANCE.uniform_like(x, low, high) def choice(x: Array, size: int, replace: bool = True) -> Array: """Sample ``size`` elements from ``x``.""" if _BACKEND_INSTANCE is None: - raise _error + raise no_backend_error return _BACKEND_INSTANCE.choice(x, size, replace) diff --git a/decent_array/interoperability/_iop/utils.py b/decent_array/interoperability/_iop/utils.py index ab69d22..d4014ea 100644 --- a/decent_array/interoperability/_iop/utils.py +++ b/decent_array/interoperability/_iop/utils.py @@ -15,6 +15,7 @@ from typing import TYPE_CHECKING, Any +from decent_array._errors import no_backend_error from decent_array.interoperability._backend_manager import register_backend_listener if TYPE_CHECKING: @@ -23,7 +24,6 @@ from decent_array.types import ArrayKey, Devices _BACKEND_INSTANCE: Backend | None = None -_error = RuntimeError("No backend active: call 'set_backend' with a supported framework to activate one.") def _update_backend(backend: Backend | None) -> None: @@ -37,26 +37,26 @@ def _update_backend(backend: Backend | None) -> None: def device_to_native(device: Devices) -> Any: # noqa: ANN401 """Convert :class:`~decent_array.types.Devices` to the active backend's native device.""" if _BACKEND_INSTANCE is None: - raise _error + raise no_backend_error return _BACKEND_INSTANCE.device_to_native(device) def device_of(x: Array) -> Devices: """Return the :class:`~decent_array.types.Devices` of ``x``.""" if _BACKEND_INSTANCE is None: - raise _error + raise no_backend_error return _BACKEND_INSTANCE.device_of(x) def set_item(x: Array, key: ArrayKey, value: bool | int | float | complex | Array) -> None: """Set ``x[key] = value`` in place.""" if _BACKEND_INSTANCE is None: - raise _error + raise no_backend_error _BACKEND_INSTANCE.set_item(x, key, value) def get_item(x: Array, key: ArrayKey) -> Array: """Return ``x[key]``.""" if _BACKEND_INSTANCE is None: - raise _error + raise no_backend_error return _BACKEND_INSTANCE.get_item(x, key) diff --git a/decent_array/interoperability/_jax/jax_backend.py b/decent_array/interoperability/_jax/jax_backend.py index 74ca145..bdc40eb 100644 --- a/decent_array/interoperability/_jax/jax_backend.py +++ b/decent_array/interoperability/_jax/jax_backend.py @@ -20,7 +20,14 @@ from numpy.typing import NDArray from decent_array import Array -from decent_array._utils import unwrap +from decent_array._errors import ( + MatrixTransposeError, + NDimError, + UnsupportedDeviceError, + UnsupportedDTypeCreationError, + stack_empty_error, +) +from decent_array._utils import is_scalar, unwrap from decent_array.interoperability._abstracts import Backend from decent_array.interoperability._backend_manager import register_backend from decent_array.types import ArrayKey, ArrayTypes, Devices, Frameworks @@ -32,6 +39,8 @@ class JaxBackend(Backend): def __init__(self, device: Devices = Devices.CPU) -> None: super().__init__(device, name=Frameworks.JAX.value) + if device == Devices.MPS: + UnsupportedDeviceError(self.name, device.value) self._native_device: jax.Device = self.device_to_native(device) self._key: jax.Array = jax.random.key(time_ns()) @@ -85,9 +94,21 @@ def from_numpy_like(self, x: NDArray[Any], like: Array) -> Array: def asarray(self, x: bool | int | float | complex) -> Array: return Array(jnp.array(x, device=self._native_device)) + def to_scalar(self, x: Array) -> Any: # noqa: ANN401 + """ + Convert a 0-dim array to a scalar. + + Raises: + TypeError: if ``x`` is not 0-dimensional. + + """ + if not is_scalar(x): + raise TypeError("Only 0-dim arrays can be converted to Python scalars.") + return x.value.item() + def stack(self, arrays: Sequence[Array], axis: int = 0) -> Array: if len(arrays) == 0: - raise ValueError("Cannot stack an empty sequence of arrays.") + raise stack_empty_error return Array(jnp.stack([a.value for a in arrays], axis=axis)) def reshape(self, x: Array, shape: tuple[int, ...]) -> Array: @@ -97,10 +118,9 @@ def transpose(self, x: Array, axis: tuple[int, ...] | None = None) -> Array: return Array(jnp.transpose(x.value, axes=axis)) def matrix_transpose(self, x: Array) -> Array: - v = x.value - if v.ndim < 2: - raise ValueError(f"matrix_transpose requires an array with at least 2 dimensions, got {v.ndim}-D") - return Array(jnp.swapaxes(v, -1, -2)) + if x.ndim < 2: + raise MatrixTransposeError(x.ndim) + return Array(jnp.swapaxes(x.value, -1, -2)) def shape(self, x: Array) -> tuple[int, ...]: return tuple(x.value.shape) @@ -118,18 +138,18 @@ def unsqueeze(self, x: Array, axis: int) -> Array: return Array(jnp.expand_dims(x.value, axis=axis)) def diag(self, x: Array) -> Array: - if x.value.ndim != 1: - raise ValueError(f"diag requires a 1-D array, got {x.value.ndim}-D") + if x.ndim != 1: + raise NDimError(1, x.ndim) return Array(jnp.diag(x.value)) def diagonal(self, x: Array, offset: int = 0) -> Array: - if x.value.ndim != 2: - raise ValueError(f"diagonal requires a 2-D array, got {x.value.ndim}-D") + if x.ndim != 2: + raise NDimError(2, x.ndim) return Array(jnp.diagonal(x.value, offset=offset)) def astype(self, x: Array, dtype: dtype) -> Array: if not dtype.available: - raise ValueError(f"Unsupported dtype '{dtype}' for JAX backend.") + raise UnsupportedDTypeCreationError(dtype, self.name, self.device.value) return Array(jnp.asarray(x.value, dtype=dtype.backend_dtype)) # Linalg diff --git a/decent_array/interoperability/_numpy/numpy_backend.py b/decent_array/interoperability/_numpy/numpy_backend.py index 1cabeaa..9a6577b 100644 --- a/decent_array/interoperability/_numpy/numpy_backend.py +++ b/decent_array/interoperability/_numpy/numpy_backend.py @@ -15,7 +15,14 @@ from numpy.typing import NDArray from decent_array import Array -from decent_array._utils import unwrap +from decent_array._errors import ( + MatrixTransposeError, + NDimError, + UnsupportedDeviceError, + UnsupportedDTypeCreationError, + stack_empty_error, +) +from decent_array._utils import is_scalar, unwrap from decent_array.interoperability._abstracts import Backend from decent_array.interoperability._backend_manager import register_backend from decent_array.types import ArrayKey, ArrayTypes, Devices, Frameworks @@ -26,9 +33,9 @@ class NumpyBackend(Backend): """NumPy implementation of :class:`Backend`.""" def __init__(self, device: Devices = Devices.CPU) -> None: - if device != Devices.CPU: - raise ValueError(f"NumPy backend only supports CPU, got '{device.value}'.") super().__init__(device, name=Frameworks.NUMPY.value) + if device != Devices.CPU: + UnsupportedDeviceError(self.name, device.value) self._rng: np.random.Generator = np.random.default_rng() # Array creation @@ -80,9 +87,21 @@ def from_numpy_like(self, x: NDArray[Any], like: Array) -> Array: def asarray(self, x: bool | int | float | complex) -> Array: return Array(np.array(x)) + def to_scalar(self, x: Array) -> Any: # noqa: ANN401 + """ + Convert a 0-dim array to a scalar. + + Raises: + TypeError: if ``x`` is not 0-dimensional. + + """ + if not is_scalar(x): + raise TypeError("Only 0-dim arrays can be converted to Python scalars.") + return x.value.item() + def stack(self, arrays: Sequence[Array], axis: int = 0) -> Array: if len(arrays) == 0: - raise ValueError("Cannot stack an empty sequence of arrays.") + raise stack_empty_error return Array(np.stack([a.value for a in arrays], axis=axis)) def reshape(self, x: Array, shape: tuple[int, ...]) -> Array: @@ -92,10 +111,9 @@ def transpose(self, x: Array, axis: tuple[int, ...] | None = None) -> Array: return Array(np.transpose(x.value, axes=axis)) def matrix_transpose(self, x: Array) -> Array: - v = x.value - if v.ndim < 2: - raise ValueError(f"matrix_transpose requires an array with at least 2 dimensions, got {v.ndim}-D") - return Array(np.swapaxes(v, -1, -2)) + if x.ndim < 2: + raise MatrixTransposeError(x.ndim) + return Array(np.swapaxes(x.value, -1, -2)) def shape(self, x: Array) -> tuple[int, ...]: return tuple(x.value.shape) @@ -113,18 +131,18 @@ def unsqueeze(self, x: Array, axis: int) -> Array: return Array(np.expand_dims(x.value, axis=axis)) def diag(self, x: Array) -> Array: - if x.value.ndim != 1: - raise ValueError(f"diag requires a 1-D array, got {x.value.ndim}-D") + if x.ndim != 1: + raise NDimError(1, x.ndim) return Array(np.diag(x.value)) def diagonal(self, x: Array, offset: int = 0) -> Array: - if x.value.ndim != 2: - raise ValueError(f"diagonal requires a 2-D array, got {x.value.ndim}-D") + if x.ndim != 2: + raise NDimError(2, x.ndim) return Array(np.diagonal(x.value, offset=offset)) def astype(self, x: Array, dtype: dtype) -> Array: if not dtype.available: - raise ValueError(f"Unsupported dtype '{dtype}' for NumPy backend.") + raise UnsupportedDTypeCreationError(dtype, self.name, self.device.value) return Array(np.asarray(x.value, dtype=dtype.backend_dtype)) # Linalg diff --git a/decent_array/interoperability/_pytorch/pytorch_backend.py b/decent_array/interoperability/_pytorch/pytorch_backend.py index 688e9fd..542a534 100644 --- a/decent_array/interoperability/_pytorch/pytorch_backend.py +++ b/decent_array/interoperability/_pytorch/pytorch_backend.py @@ -15,7 +15,8 @@ from numpy.typing import NDArray from decent_array import Array -from decent_array._utils import unwrap +from decent_array._errors import MatrixTransposeError, NDimError, UnsupportedDTypeCreationError, stack_empty_error +from decent_array._utils import is_scalar, unwrap from decent_array.interoperability._abstracts import Backend from decent_array.interoperability._backend_manager import register_backend from decent_array.types import ArrayKey, ArrayTypes, Devices, Frameworks @@ -90,9 +91,21 @@ def from_numpy_like(self, x: NDArray[Any], like: Array) -> Array: def asarray(self, x: bool | int | float | complex) -> Array: return Array(torch.tensor(x, device=self._native_device)) + def to_scalar(self, x: Array) -> Any: # noqa: ANN401 + """ + Convert a 0-dim array to a scalar. + + Raises: + TypeError: if ``x`` is not 0-dimensional. + + """ + if not is_scalar(x): + raise TypeError("Only 0-dim arrays can be converted to Python scalars.") + return x.value.item() + def stack(self, arrays: Sequence[Array], axis: int = 0) -> Array: if len(arrays) == 0: - raise ValueError("Cannot stack an empty sequence of arrays.") + raise stack_empty_error return Array(torch.stack([a.value for a in arrays], dim=axis)) def reshape(self, x: Array, shape: tuple[int, ...]) -> Array: @@ -104,10 +117,9 @@ def transpose(self, x: Array, axis: tuple[int, ...] | None = None) -> Array: return Array(torch.permute(v, dims=dims)) def matrix_transpose(self, x: Array) -> Array: - v = x.value - if v.ndim < 2: - raise ValueError(f"matrix_transpose requires an array with at least 2 dimensions, got {v.ndim}-D") - return Array(v.mT) + if x.ndim < 2: + raise MatrixTransposeError(x.ndim) + return Array(x.value.mT) def shape(self, x: Array) -> tuple[int, ...]: return tuple(x.value.shape) @@ -128,18 +140,18 @@ def unsqueeze(self, x: Array, axis: int) -> Array: return Array(torch.unsqueeze(x.value, dim=axis)) def diag(self, x: Array) -> Array: - if x.value.ndim != 1: - raise ValueError(f"diag requires a 1-D array, got {x.value.ndim}-D") + if x.ndim != 1: + raise NDimError(1, x.ndim) return Array(torch.diag(x.value)) def diagonal(self, x: Array, offset: int = 0) -> Array: - if x.value.ndim != 2: - raise ValueError(f"diagonal requires a 2-D array, got {x.value.ndim}-D") + if x.ndim != 2: + raise NDimError(2, x.ndim) return Array(torch.diagonal(x.value, offset=offset)) def astype(self, x: Array, dtype: dtype) -> Array: if not dtype.available: - raise ValueError(f"Unsupported dtype '{dtype}' for PyTorch backend.") + raise UnsupportedDTypeCreationError(dtype, self.name, self.device.value) return Array(x.value.to(dtype=dtype.backend_dtype)) # Linalg diff --git a/decent_array/interoperability/_tensorflow/tensorflow_backend.py b/decent_array/interoperability/_tensorflow/tensorflow_backend.py index 7acc1a9..0bd3f51 100644 --- a/decent_array/interoperability/_tensorflow/tensorflow_backend.py +++ b/decent_array/interoperability/_tensorflow/tensorflow_backend.py @@ -18,7 +18,14 @@ from numpy.typing import NDArray from decent_array import Array -from decent_array._utils import unwrap +from decent_array._errors import ( + MatrixTransposeError, + NDimError, + UnsupportedDeviceError, + UnsupportedDTypeCreationError, + stack_empty_error, +) +from decent_array._utils import is_scalar, unwrap from decent_array.interoperability._abstracts import Backend from decent_array.interoperability._backend_manager import register_backend from decent_array.types import ArrayKey, ArrayTypes, Devices, Frameworks @@ -30,6 +37,8 @@ class TensorflowBackend(Backend): def __init__(self, device: Devices = Devices.CPU) -> None: super().__init__(device, name=Frameworks.TENSORFLOW.value) + if device == Devices.MPS: + UnsupportedDeviceError(self.name, device.value) self._native_device: str = self.device_to_native(device) self._generator: tf.random.Generator = tf.random.Generator.from_non_deterministic_state(alg="philox") @@ -95,9 +104,21 @@ def asarray(self, x: bool | int | float | complex) -> Array: # Its not a tf tensor but mypyc doesn't import tf so it complains about unsude type-ignores return Array(tf.convert_to_tensor(cast("tf.Tensor", x))) + def to_scalar(self, x: Array) -> Any: # noqa: ANN401 + """ + Convert a 0-dim array to a scalar. + + Raises: + TypeError: if ``x`` is not 0-dimensional. + + """ + if not is_scalar(x): + raise TypeError("Only 0-dim arrays can be converted to Python scalars.") + return x.value.numpy().item() + def stack(self, arrays: Sequence[Array], axis: int = 0) -> Array: if len(arrays) == 0: - raise ValueError("Cannot stack an empty sequence of arrays.") + raise stack_empty_error return Array(tf.stack([a.value for a in arrays], axis=axis)) def reshape(self, x: Array, shape: tuple[int, ...]) -> Array: @@ -107,11 +128,9 @@ def transpose(self, x: Array, axis: tuple[int, ...] | None = None) -> Array: return Array(tf.transpose(x.value, perm=axis)) def matrix_transpose(self, x: Array) -> Array: - v = x.value - rank = v.shape.ndims - if rank is not None and rank < 2: - raise ValueError(f"matrix_transpose requires an array with at least 2 dimensions, got {rank}-D") - return Array(tf.linalg.matrix_transpose(v)) + if x.ndim < 2: + raise MatrixTransposeError(x.ndim) + return Array(tf.linalg.matrix_transpose(x.value)) def shape(self, x: Array) -> tuple[int, ...]: return cast("tuple[int, ...]", tuple(x.value.shape)) @@ -129,22 +148,18 @@ def unsqueeze(self, x: Array, axis: int) -> Array: return Array(tf.expand_dims(x.value, axis=axis)) def diag(self, x: Array) -> Array: - v = x.value - rank = v.shape.ndims - if rank != 1: - raise ValueError(f"diag requires a 1-D tensor, got rank {rank}") - return Array(tf.linalg.diag(v)) + if x.ndim != 1: + raise NDimError(1, x.ndim) + return Array(tf.linalg.diag(x.value)) def diagonal(self, x: Array, offset: int = 0) -> Array: - v = x.value - rank = v.shape.ndims - if rank != 2: - raise ValueError(f"diagonal requires a 2-D tensor, got rank {rank}") - return Array(tf.linalg.diag_part(v, k=offset)) + if x.ndim != 2: + raise NDimError(2, x.ndim) + return Array(tf.linalg.diag_part(x.value, k=offset)) def astype(self, x: Array, dtype: dtype) -> Array: if not dtype.available: - raise ValueError(f"Unsupported dtype '{dtype}' for TensorFlow backend.") + raise UnsupportedDTypeCreationError(dtype, self.name, self.device.value) return Array(tf.cast(x.value, dtype=dtype.backend_dtype)) # Linalg diff --git a/docs/source/user.rst b/docs/source/user.rst index f31d0b9..4666a57 100644 --- a/docs/source/user.rst +++ b/docs/source/user.rst @@ -10,6 +10,35 @@ Requires `Python 3.13+ `_ pip install decent-array +Supported devices +----------------- + +.. list-table:: device support across frameworks + :header-rows: 1 + :widths: 22 10 10 10 10 + + * - device + - NumPy + - JAX + - PyTorch + - TensorFlow + * - CPU + - ✓ + - ✓ + - ✓ + - ✓ + * - GPU + - + - ✓ + - ✓ + - ✓ + * - MPS + - + - + - ✓ + - + + Constants --------- diff --git a/pyproject.toml b/pyproject.toml index 609a474..e4ffa90 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ classifiers = [ license = "AGPL-3.0-only" dependencies = [ "numpy>=2,<3", + "mypy_extensions>=1.1.0,<2", ] [project.urls] diff --git a/tests/test_array.py b/tests/test_array.py index f2a6cb0..9ca8d3d 100644 --- a/tests/test_array.py +++ b/tests/test_array.py @@ -432,11 +432,34 @@ def test_len(backend: tuple) -> None: assert len(a) == 4 -def test_float_coercion(backend: tuple) -> None: - a = _create_array([2.5]) +def test_float_coercion_float(backend: tuple) -> None: + a = _create_array(2.5) assert float(a) == pytest.approx(2.5) +def test_float_coercion_bool(backend: tuple) -> None: + a = _create_array(1) + assert bool(a) == True + + a = _create_array(0) + assert bool(a) == False + + +def test_float_coercion_int(backend: tuple) -> None: + a = _create_array(10) + assert int(a) == 10 + + +def test_float_coercion_index(backend: tuple) -> None: + a = _create_array(10) + assert int(a) == 10 + + +def test_float_coercion_complex(backend: tuple) -> None: + a = iop.from_numpy(np.array(1 + 2 * 1j, dtype=np.complex64)) + assert complex(a) == 1 + 2 * 1j + + def test_repr(backend: tuple) -> None: a = _create_array([1.0, 2.0]) text = repr(a) @@ -529,3 +552,18 @@ def test_device_property(backend: tuple) -> None: _framework, device = backend a = iop.zeros((3,)) assert a.device == device + + +def test_item_0_dim(backend: tuple) -> None: + a = _create_array(1.0) + assert a.item() == 1.0 + + +def test_item_n_dim(backend: tuple) -> None: + a = _create_array([1.0]) + with pytest.raises(TypeError, match=r"Only 0-dim arrays"): + _ = a.item() + + a = _create_array([1.0, 2.0]) + with pytest.raises(TypeError, match=r"Only 0-dim arrays"): + _ = a.item()