From d9c68e3e46dc85d9a7357a7d48a21278460b8d47 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Thu, 18 Dec 2025 00:03:43 +0000 Subject: [PATCH 1/2] Add test for action returning an NDArray --- tests/test_numpy_type.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/test_numpy_type.py b/tests/test_numpy_type.py index 1f53f6ce..822ecebf 100644 --- a/tests/test_numpy_type.py +++ b/tests/test_numpy_type.py @@ -2,6 +2,7 @@ from pydantic import BaseModel, RootModel import numpy as np +from fastapi.testclient import TestClient from labthings_fastapi.testing import create_thing_without_server from labthings_fastapi.types.numpy import NDArray, DenumpifyingDict @@ -70,6 +71,10 @@ class MyNumpyThing(lt.Thing): def action_with_arrays(self, a: NDArray) -> NDArray: return a * 2 + @lt.action + def read_array(self) -> NDArray: + return np.array([1, 2]) + def test_thing_description(): """Make sure the TD validates when numpy types are used.""" @@ -102,3 +107,14 @@ def test_rootmodel(): m = ArrayModel(root=input) assert isinstance(m.root, np.ndarray) assert (m.model_dump() == [0, 1, 2]).all() + + +def test_numpy_over_http(): + """Read numpy array over http.""" + server = lt.ThingServer({"np_thing": MyNumpyThing}) + with TestClient(server.app) as client: + np_thing_client = lt.ThingClient.from_url("/np_thing/", client=client) + + array = np_thing_client.read_array() + assert isinstance(array, np.ndarray) + assert np.array_equal(array, np.array([1, 2])) From 75bf89d06b36c4ac7eb5e18275bdf299c7c34540 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Thu, 18 Dec 2025 11:38:01 +0000 Subject: [PATCH 2/2] Experimental fix for numpy typing. Works only for returns --- src/labthings_fastapi/client/__init__.py | 21 ++++++++++++++++--- .../thing_description/__init__.py | 5 +++++ src/labthings_fastapi/types/numpy.py | 12 +++++++++++ .../utilities/introspection.py | 6 ++++++ tests/test_numpy_type.py | 8 ++----- 5 files changed, 43 insertions(+), 9 deletions(-) diff --git a/src/labthings_fastapi/client/__init__.py b/src/labthings_fastapi/client/__init__.py index 07fdb74d..1c70b2c1 100644 --- a/src/labthings_fastapi/client/__init__.py +++ b/src/labthings_fastapi/client/__init__.py @@ -13,6 +13,7 @@ import httpx from urllib.parse import urlparse, urljoin +import numpy as np from pydantic import BaseModel from .outputs import ClientBlobOutput @@ -159,7 +160,9 @@ def set_property(self, path: str, value: Any) -> None: r = self.client.put(urljoin(self.path, path), json=value) r.raise_for_status() - def invoke_action(self, path: str, **kwargs: Any) -> Any: + def invoke_action( + self, path: str, labthings_typehint: str | None, **kwargs: Any + ) -> Any: r"""Invoke an action on the Thing. This method will make the initial POST request to invoke an action, @@ -205,7 +208,7 @@ def invoke_action(self, path: str, **kwargs: Any) -> Any: href=invocation["output"]["href"], client=self.client, ) - return invocation["output"] + return _adjust_type(invocation["output"], labthings_typehint) else: raise RuntimeError(f"Action did not complete successfully: {invocation}") @@ -276,6 +279,15 @@ class Client(cls): # type: ignore[valid-type, misc] return Client +def _adjust_type(value: Any, labthings_typehint: str | None) -> Any: + """Adjust the return type based on labthings_typehint.""" + if labthings_typehint is None: + return value + if labthings_typehint == "ndarray": + return np.array(value) + raise ValueError(f"No type of {labthings_typehint} known") + + class PropertyClientDescriptor: """A base class for properties on `.ThingClient` objects.""" @@ -361,9 +373,12 @@ def add_action(cls: type[ThingClient], action_name: str, action: dict) -> None: :param action: a dictionary representing the action, in :ref:`wot_td` format. """ + labthings_typehint = action["output"].get("format", None) def action_method(self: ThingClient, **kwargs: Any) -> Any: - return self.invoke_action(action_name, **kwargs) + return self.invoke_action( + action_name, labthings_typehint=labthings_typehint, **kwargs + ) if "output" in action and "type" in action["output"]: action_method.__annotations__["return"] = action["output"]["type"] diff --git a/src/labthings_fastapi/thing_description/__init__.py b/src/labthings_fastapi/thing_description/__init__.py index 5691c838..6475ec1c 100644 --- a/src/labthings_fastapi/thing_description/__init__.py +++ b/src/labthings_fastapi/thing_description/__init__.py @@ -300,9 +300,12 @@ def type_to_dataschema(t: type, **kwargs: Any) -> DataSchema: :raise ValidationError: if the datatype cannot be represented by a `.DataSchema`. """ + data_format = None if hasattr(t, "model_json_schema"): # The input should be a `BaseModel` subclass, in which case this works: json_schema = t.model_json_schema() + if "_labthings_typehint" in t.__private_attributes__: + data_format = t.__private_attributes__["_labthings_typehint"].default else: # In principle, the below should work for any type, though some # deferred annotations can go wrong. @@ -319,6 +322,8 @@ def type_to_dataschema(t: type, **kwargs: Any) -> DataSchema: if k in schema_dict: del schema_dict[k] schema_dict.update(kwargs) + if data_format is not None: + schema_dict["format"] = data_format try: return DataSchema(**schema_dict) except ValidationError as ve: diff --git a/src/labthings_fastapi/types/numpy.py b/src/labthings_fastapi/types/numpy.py index 2fb27db0..e670d610 100644 --- a/src/labthings_fastapi/types/numpy.py +++ b/src/labthings_fastapi/types/numpy.py @@ -147,3 +147,15 @@ class DenumpifyingDict(RootModel): root: Annotated[Mapping, WrapSerializer(denumpify_serializer)] model_config = ConfigDict(arbitrary_types_allowed=True) + + +class ArrayModel(RootModel): + """A model automatically used by actions as the return type for a numpy array. + + This models is passed to FastAPI as the return model for any action that returns + a numpy array. The private typehint is saved as format information to allow + a ThingClient to reconstruct the array from the list sent over HTTP. + """ + + root: NDArray + _labthings_typehint: str = "ndarray" diff --git a/src/labthings_fastapi/utilities/introspection.py b/src/labthings_fastapi/utilities/introspection.py index daf83d11..902f9415 100644 --- a/src/labthings_fastapi/utilities/introspection.py +++ b/src/labthings_fastapi/utilities/introspection.py @@ -16,6 +16,9 @@ from pydantic import BaseModel, ConfigDict, Field, RootModel from pydantic.main import create_model from fastapi.dependencies.utils import analyze_param, get_typed_signature +import numpy as np + +from ..types.numpy import ArrayModel class EmptyObject(BaseModel): @@ -178,6 +181,9 @@ def return_type(func: Callable) -> Type: else: # We use `get_type_hints` rather than just `sig.return_annotation` # because it resolves forward references, etc. + rtype = get_type_hints(func)["return"] + if isinstance(rtype, type) and issubclass(rtype, np.ndarray): + return ArrayModel type_hints = get_type_hints(func, include_extras=True) return type_hints["return"] diff --git a/tests/test_numpy_type.py b/tests/test_numpy_type.py index 822ecebf..7c82e770 100644 --- a/tests/test_numpy_type.py +++ b/tests/test_numpy_type.py @@ -1,18 +1,14 @@ from __future__ import annotations -from pydantic import BaseModel, RootModel +from pydantic import BaseModel import numpy as np from fastapi.testclient import TestClient from labthings_fastapi.testing import create_thing_without_server -from labthings_fastapi.types.numpy import NDArray, DenumpifyingDict +from labthings_fastapi.types.numpy import NDArray, DenumpifyingDict, ArrayModel import labthings_fastapi as lt -class ArrayModel(RootModel): - root: NDArray - - def check_field_works_with_list(data): class Model(BaseModel): a: NDArray