Skip to content

Commit c80cd1e

Browse files
committed
Add device-neutral array geometry
1 parent fcaf113 commit c80cd1e

6 files changed

Lines changed: 120 additions & 3 deletions

File tree

docs/source/api_reference.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ Public imports
1212
``detect_memory_type`` and ``convert_memory``
1313
Detection and explicit conversion.
1414

15+
``ArrayGeometry``
16+
Framework-neutral shape inspection for arrays and nominal ``ArrayPayload``
17+
values. Geometry inspection reads declared shape metadata and does not move
18+
device data to host memory.
19+
1520
``memory_types``, ``numpy``, ``cupy``, ``torch``, ``tensorflow``, ``jax``, ``pyclesperanto``
1621
Callable memory declarations and wrappers.
1722

docs/source/conf.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
project = 'arraybridge'
2323
copyright = '2025, Tristan Simas'
2424
author = 'Tristan Simas'
25-
release = '0.3.0'
25+
release = '0.3.1'
2626

2727
# -- General configuration ---------------------------------------------------
2828
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ build-backend = "hatchling.build"
55

66
[project]
77
name = "arraybridge"
8-
version = "0.3.0"
8+
version = "0.3.1"
99
description = "Unified API for NumPy, CuPy, PyTorch, TensorFlow, JAX, and pyclesperanto with automatic memory type conversion"
1010
authors = [{name = "Tristan Simas", email = "tristan.simas@mail.mcgill.ca"}]
1111
license = {text = "MIT"}

src/arraybridge/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@
55
and unified utilities for working with multiple array/tensor frameworks.
66
"""
77

8-
__version__ = "0.3.0"
8+
__version__ = "0.3.1"
99

1010
from . import decorators as _decorators
11+
from .array_geometry import ArrayGeometry
1112
from .array_payload import ArrayPayload
1213
from .converters import convert_memory, detect_memory_type
1314
from .dtype_scaling import SCALING_FUNCTIONS
@@ -39,6 +40,7 @@
3940
"MemoryType",
4041
"MemoryContractAttribute",
4142
"ArrayPayload",
43+
"ArrayGeometry",
4244
"CPU_MEMORY_TYPES",
4345
"GPU_MEMORY_TYPES",
4446
"SUPPORTED_MEMORY_TYPES",

src/arraybridge/array_geometry.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
"""Framework-neutral array geometry inspection."""
2+
3+
from __future__ import annotations
4+
5+
from dataclasses import dataclass
6+
from typing import Any
7+
8+
import numpy as np
9+
10+
from arraybridge.array_payload import ArrayPayload
11+
12+
13+
@dataclass(frozen=True, slots=True)
14+
class ArrayGeometry:
15+
"""Concrete shape metadata without moving array data between frameworks."""
16+
17+
shape: tuple[int, ...]
18+
19+
@property
20+
def ndim(self) -> int:
21+
"""Return the rank derived from the canonical shape."""
22+
23+
return len(self.shape)
24+
25+
@classmethod
26+
def from_value(cls, value: Any) -> ArrayGeometry | None:
27+
"""Inspect an array or nominal payload without forcing host conversion."""
28+
29+
data = value.array_payload_data() if isinstance(value, ArrayPayload) else value
30+
declared_shape = getattr(data, "shape", None)
31+
if declared_shape is not None:
32+
try:
33+
return cls(tuple(int(axis_size) for axis_size in declared_shape))
34+
except (TypeError, ValueError):
35+
return None
36+
37+
try:
38+
array = np.asarray(data)
39+
except (TypeError, ValueError):
40+
return None
41+
return cls(tuple(int(axis_size) for axis_size in array.shape))
42+
43+
@classmethod
44+
def require_from_value(
45+
cls,
46+
value: Any,
47+
*,
48+
value_name: str = "Value",
49+
) -> ArrayGeometry:
50+
"""Return concrete geometry or reject a value without an array shape."""
51+
52+
geometry = cls.from_value(value)
53+
if geometry is None:
54+
raise TypeError(
55+
f"{value_name} requires concrete array geometry, got " f"{type(value).__name__}."
56+
)
57+
return geometry

tests/test_array_geometry.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
from __future__ import annotations
2+
3+
import numpy as np
4+
import pytest
5+
6+
from arraybridge import ArrayGeometry, ArrayPayload
7+
8+
9+
class DeviceArray:
10+
"""Array-shaped value that rejects implicit host conversion."""
11+
12+
shape = (2, 3, 4)
13+
14+
def __array__(self, dtype=None):
15+
del dtype
16+
raise TypeError("implicit host conversion is forbidden")
17+
18+
19+
class DevicePayload(ArrayPayload):
20+
def __init__(self, data):
21+
self.data = data
22+
23+
def array_payload_data(self):
24+
return self.data
25+
26+
def with_data(self, data):
27+
return type(self)(data)
28+
29+
30+
def test_array_geometry_reads_declared_device_shape_without_host_conversion() -> None:
31+
assert ArrayGeometry.from_value(DeviceArray()) == ArrayGeometry((2, 3, 4))
32+
33+
34+
def test_array_geometry_unwraps_nominal_array_payload() -> None:
35+
assert ArrayGeometry.from_value(DevicePayload(DeviceArray())) == ArrayGeometry((2, 3, 4))
36+
37+
38+
def test_array_geometry_falls_back_for_python_array_inputs() -> None:
39+
assert ArrayGeometry.from_value([[1, 2], [3, 4]]) == ArrayGeometry((2, 2))
40+
assert ArrayGeometry.from_value(np.zeros((4, 5))) == ArrayGeometry((4, 5))
41+
42+
43+
def test_array_geometry_requires_concrete_shape() -> None:
44+
class UnshapedValue:
45+
def __array__(self, dtype=None):
46+
del dtype
47+
raise TypeError("not an array")
48+
49+
with pytest.raises(TypeError, match="Runtime output requires concrete array geometry"):
50+
ArrayGeometry.require_from_value(
51+
UnshapedValue(),
52+
value_name="Runtime output",
53+
)

0 commit comments

Comments
 (0)