Source code for httk.core.vectors.vector_numpy_view

"""
A view presenting any vector backend as a numpy ndarray, with a selectable dtype.

This module subclasses :class:`numpy.ndarray`, which happens at class-definition time, so it
cannot be imported at all unless numpy is installed. The package ``__init__`` guards its import
with ``try/except ImportError`` accordingly.
"""

import copy
import copyreg
from typing import Any, Self, cast

import numpy

from httk.core.views import unwrap

from .leaf_codecs import apply_leaf_codec, leaf_codec_for_name
from .vector_api import Fractions
from .vector_backend import VectorBackend
from .vector_like import VectorLike
from .vector_native_backend import VectorNativeBackend
from .vector_numpy_backend import VectorNumpyBackend
from .vector_view import VectorView

_BACKEND_STATE_TAG = "httk-vector-backend"


def _to_floats(node: Fractions) -> Any:
    if isinstance(node, tuple):
        return [_to_floats(e) for e in node]
    return float(node)


def _shed(node: Any) -> Any:
    """Recursively replace VectorNumpyView instances with base-class ndarrays in args/kwargs."""
    if isinstance(node, VectorNumpyView):
        return node.view(numpy.ndarray)
    if isinstance(node, tuple):
        return tuple(_shed(e) for e in node)
    if isinstance(node, list):
        return [_shed(e) for e in node]
    if isinstance(node, dict):
        return {k: _shed(v) for k, v in node.items()}
    return node


def _rebuild_backend(backend_cls: type, value: Any) -> Any:
    return backend_cls(value)


def _reduce_backend(backend: VectorBackend) -> Any:
    return _rebuild_backend, (type(backend), unwrap(backend))


for _backend_cls in (VectorNumpyBackend, VectorNativeBackend):
    copyreg.pickle(_backend_cls, _reduce_backend)


[docs] class VectorNumpyView(VectorView, numpy.ndarray): r""" A view presenting an underlying vector backend as a :class:`numpy.ndarray`. This view is a genuine ndarray, so it can be passed anywhere a numpy array is accepted. Construction has two paths: - **Adoption (O(1), zero-copy).** A raw base-class :class:`numpy.ndarray` of numeric dtype (integer, float, or complex — not bool/object) is adopted directly when ``dtype=`` is omitted, ``None``, or equal to the array's dtype: the view shares the array's memory and preserves its dtype, no element is scanned or converted, and both ``unwrap()`` and ``unview()`` recover the original array object. Values that cannot enter the exact Fraction hub (non-finite floats, complex numbers) fail only if and when an exact conversion is actually requested (e.g. ``.fractions``). The adopted array is not copied, so the httk no-mutation rule applies: do not mutate it while the view is in use. - **Conversion.** Any other input — a non-ndarray source, or an explicit ``dtype=`` change — is built from the backend's exact ``fractions`` interchange: ``float64`` by default (lossy-by-design: exact rationals become their nearest binary value), and for an **integer** dtype each element is first converted through the ``"int"`` leaf codec's default (nearest, ties to even) so a value such as ``1/2`` becomes ``0`` by half-even rounding rather than being silently truncated by numpy. The original backend remains recoverable via ``unwrap()``. Common numpy operations **shed the view**: operators, ufuncs, NumPy-dispatched functions, reductions, and slicing return base-class ndarrays, so hot numeric loops carry no wrapper overhead past the first operation. A residual path that still produces a ``VectorNumpyView`` (e.g. ``.reshape()``/``.T``) yields a *backend-less* view whose own array data is authoritative; it never falsely unwraps to the source backend and is normalized with :func:`~httk.core.unview`. numpy is an optional dependency (``httk-core[numpy]``). :param obj: The source value to present numerically. :param \**hints: Backend-selection hints and an optional ``dtype`` conversion. """ _backend: VectorBackend # Route copy.copy through __reduce_ex__ so the backend survives. __copy__ = None # type: ignore[assignment] # pyright: ignore[reportIncompatibleVariableOverride] def __new__(cls, obj: VectorLike, **hints: Any) -> Self: dtype = hints.pop("dtype", None) if isinstance(obj, cls): if dtype is None or numpy.dtype(dtype) == obj.dtype: return obj if getattr(obj, "_backend", None) is None: # A backend-less derived view converting to another dtype: its own data is # authoritative, so convert from the base array, not a source backend. obj = obj.view(numpy.ndarray) if ( type(obj) is numpy.ndarray and obj.dtype.kind in "iufc" and (dtype is None or numpy.dtype(dtype) == obj.dtype) and hints.get("kind", "numpy") == "numpy" ): # Zero-copy adoption: share the array's memory and preserve its dtype. instance = obj.view(cls) instance._backend = VectorNumpyBackend(obj) return instance backend = cls._prepare_backend(obj, hints) np_dtype = numpy.dtype(numpy.float64 if dtype is None else dtype) if numpy.issubdtype(np_dtype, numpy.integer): # Round through the int codec via the exact Fraction hub BEFORE array construction, so # numpy casts already-integral Python ints (no silent truncation of fractional values). data = apply_leaf_codec(leaf_codec_for_name("int"), backend.fractions) else: data = _to_floats(backend.fractions) arr = numpy.asarray(data, dtype=np_dtype) instance = arr.view(cls) instance._backend = backend return instance def __array_ufunc__(self, ufunc: Any, method: str, *inputs: Any, **kwargs: Any) -> Any: # Shed the view: operate on and return base-class ndarrays. shed_inputs = tuple(_shed(x) for x in inputs) kwargs = {key: _shed(value) for key, value in kwargs.items()} return getattr(ufunc, method)(*shed_inputs, **kwargs) def __array_function__(self, func: Any, types: Any, args: Any, kwargs: Any) -> Any: # Shed the view for NumPy-dispatched functions (numpy.concatenate, numpy.linalg.norm, ...). return func(*_shed(tuple(args)), **_shed(dict(kwargs))) def __array_wrap__(self, obj: Any, context: Any = None, return_scalar: bool = False) -> Any: # Residual numpy paths that wrap results into the input's class: return base instead. arr = numpy.asarray(obj) return arr[()] if return_scalar else arr def __getitem__(self, key: Any) -> Any: # Slicing/indexing results are presentation output: base-class ndarrays (or numpy scalars). return self.view(numpy.ndarray)[key] def __reduce__(self) -> Any: reduced = cast(tuple[Any, tuple[Any, ...], Any], super().__reduce__()) reconstruct, args, state = reduced return reconstruct, args, state + ((_BACKEND_STATE_TAG, getattr(self, "_backend", None)),) def __setstate__(self, state: Any) -> None: backend = None if state and isinstance(state[-1], tuple) and len(state[-1]) == 2 and state[-1][0] == _BACKEND_STATE_TAG: backend = state[-1][1] state = state[:-1] super().__setstate__(state) self.__dict__.pop("_backend", None) if backend is not None: self._backend = backend def __deepcopy__(self, memo: dict[int, Any] | None) -> Self: result = super().__deepcopy__(memo) backend = getattr(self, "_backend", None) if backend is not None: result._backend = copy.deepcopy(backend, memo) return result
[docs] def unwrap(self) -> Any: """Return the original backend value, or this view when it has no backend.""" backend = getattr(self, "_backend", None) if backend is None: return self return unwrap(backend)
[docs] def unview(self) -> Any: """Return a base-class numpy array containing this view's data.""" # Instances built by __new__ sit directly on their presentation array: the adopted raw # array or the converted base array, either way `self.base`. Derived (backend-less) # instances have some other base; their own data is authoritative. base = self.base if type(base) is numpy.ndarray and base.shape == self.shape and base.dtype == self.dtype: return base return self.view(numpy.ndarray)