From c2e473d4c69f5decb240daa0766be7f91127f654 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Thu, 26 Jun 2025 09:48:26 +0100 Subject: [PATCH 01/12] Expose key symbols at top level This is a start towards eliminating the large import blocks that pull things from all over LabThings. --- src/labthings_fastapi/__init__.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/labthings_fastapi/__init__.py b/src/labthings_fastapi/__init__.py index e69de29b..cae74521 100644 --- a/src/labthings_fastapi/__init__.py +++ b/src/labthings_fastapi/__init__.py @@ -0,0 +1,5 @@ +from .thing import Thing +from .descriptors import ThingProperty, ThingSetting +from .decorators import ( + thing_property, thing_setting, thing_action, +) From d07599a906313925855bcde77f5b8eb9e71646d9 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Thu, 26 Jun 2025 11:12:41 +0100 Subject: [PATCH 02/12] Add __all__ --- src/labthings_fastapi/__init__.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/labthings_fastapi/__init__.py b/src/labthings_fastapi/__init__.py index cae74521..edfce2d3 100644 --- a/src/labthings_fastapi/__init__.py +++ b/src/labthings_fastapi/__init__.py @@ -1,5 +1,22 @@ from .thing import Thing from .descriptors import ThingProperty, ThingSetting from .decorators import ( - thing_property, thing_setting, thing_action, + thing_property, + thing_setting, + thing_action, ) + +# The symbols in __all__ are part of our public API. +# They are imported when using `import labthings_fastapi as lt`. +# We should check that these symbols stay consistent if modules are rearranged. +# The alternative `from .thing import Thing as Thing` syntax is not used, as +# `mypy` is now happy with the current import style. If other tools prefer the +# re-export style, we may switch in the future. +__all__ = [ + "Thing", + "ThingProperty", + "ThingSetting", + "thing_property", + "thing_setting", + "thing_action", +] From 9de95c6ddbe4c6d0dfd870f6e4d10d60149dc5c1 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Thu, 26 Jun 2025 11:29:22 +0100 Subject: [PATCH 03/12] Add more key symbols to top level --- src/labthings_fastapi/__init__.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/labthings_fastapi/__init__.py b/src/labthings_fastapi/__init__.py index edfce2d3..b50777eb 100644 --- a/src/labthings_fastapi/__init__.py +++ b/src/labthings_fastapi/__init__.py @@ -5,6 +5,13 @@ thing_setting, thing_action, ) +from .dependencies.blocking_portal import BlockingPortal +from .dependencies.invocation import InvocationID, InvocationLogger +from .dependencies.metadata import GetThingStates +from .dependencies.raw_thing import raw_thing_dependency +from .dependencies.thing import direct_thing_client_dependency +from .outputs.mjpeg_stream import MJPEGStream, MJPEGStreamDescriptor +from .outputs.blob import Blob # The symbols in __all__ are part of our public API. # They are imported when using `import labthings_fastapi as lt`. @@ -19,4 +26,13 @@ "thing_property", "thing_setting", "thing_action", + "BlockingPortal", + "InvocationID", + "InvocationLogger", + "GetThingStates", + "raw_thing_dependency", + "direct_thing_client_dependency", + "MJPEGStream", + "MJPEGStreamDescriptor", + "Blob", ] From 1e92ef5cbef5f572aea45856e76df40fc5b56780 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Thu, 26 Jun 2025 11:42:05 +0100 Subject: [PATCH 04/12] Add ThingServer and CancelHook --- src/labthings_fastapi/__init__.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/labthings_fastapi/__init__.py b/src/labthings_fastapi/__init__.py index b50777eb..6e4dda72 100644 --- a/src/labthings_fastapi/__init__.py +++ b/src/labthings_fastapi/__init__.py @@ -6,12 +6,13 @@ thing_action, ) from .dependencies.blocking_portal import BlockingPortal -from .dependencies.invocation import InvocationID, InvocationLogger +from .dependencies.invocation import InvocationID, InvocationLogger, CancelHook from .dependencies.metadata import GetThingStates from .dependencies.raw_thing import raw_thing_dependency from .dependencies.thing import direct_thing_client_dependency from .outputs.mjpeg_stream import MJPEGStream, MJPEGStreamDescriptor from .outputs.blob import Blob +from .server import ThingServer # The symbols in __all__ are part of our public API. # They are imported when using `import labthings_fastapi as lt`. @@ -29,10 +30,12 @@ "BlockingPortal", "InvocationID", "InvocationLogger", + "CancelHook", "GetThingStates", "raw_thing_dependency", "direct_thing_client_dependency", "MJPEGStream", "MJPEGStreamDescriptor", "Blob", + "ThingServer", ] From 48276b42699ba311107ab2c8575e18b10eca75c9 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Thu, 26 Jun 2025 11:42:13 +0100 Subject: [PATCH 05/12] Update example --- docs/source/quickstart/counter.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/docs/source/quickstart/counter.py b/docs/source/quickstart/counter.py index 9416ad72..ea2990c5 100644 --- a/docs/source/quickstart/counter.py +++ b/docs/source/quickstart/counter.py @@ -1,13 +1,11 @@ import time -from labthings_fastapi.thing import Thing -from labthings_fastapi.decorators import thing_action -from labthings_fastapi.descriptors import ThingProperty +import labthings_fastapi as lt -class TestThing(Thing): +class TestThing(lt.Thing): """A test thing with a counter property and a couple of actions""" - @thing_action + @lt.thing_action def increment_counter(self) -> None: """Increment the counter property @@ -17,23 +15,22 @@ def increment_counter(self) -> None: """ self.counter += 1 - @thing_action + @lt.thing_action def slowly_increase_counter(self) -> None: """Increment the counter slowly over a minute""" for i in range(60): time.sleep(1) self.increment_counter() - counter = ThingProperty( + counter = lt.ThingProperty( model=int, initial_value=0, readonly=True, description="A pointless counter" ) if __name__ == "__main__": - from labthings_fastapi.server import ThingServer import uvicorn - server = ThingServer() + server = lt.ThingServer() # The line below creates a TestThing instance and adds it to the server server.add_thing(TestThing(), "/counter/") From 06fe7cf0512c1048735318696d5f0cc47f3eba62 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Thu, 26 Jun 2025 12:06:45 +0100 Subject: [PATCH 06/12] Add fastapi_endpoint decorator --- src/labthings_fastapi/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/labthings_fastapi/__init__.py b/src/labthings_fastapi/__init__.py index 6e4dda72..53385ea9 100644 --- a/src/labthings_fastapi/__init__.py +++ b/src/labthings_fastapi/__init__.py @@ -4,6 +4,7 @@ thing_property, thing_setting, thing_action, + fastapi_endpoint, ) from .dependencies.blocking_portal import BlockingPortal from .dependencies.invocation import InvocationID, InvocationLogger, CancelHook @@ -27,6 +28,7 @@ "thing_property", "thing_setting", "thing_action", + "fastapi_endpoint", "BlockingPortal", "InvocationID", "InvocationLogger", From 5ad1e401507c03cb2106baaf91440b16cc9c786a Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Thu, 26 Jun 2025 12:07:26 +0100 Subject: [PATCH 07/12] Update imports in test suite By using the top level imports in our tests, we make it more likely we'll catch any issues if something changes. --- tests/test_action_cancel.py | 16 ++++------ tests/test_action_logging.py | 13 ++++---- tests/test_action_manager.py | 13 ++++---- tests/test_actions.py | 7 ++--- tests/test_blob_output.py | 32 ++++++++++---------- tests/test_dependencies.py | 2 +- tests/test_dependencies_2.py | 9 +++--- tests/test_dependency_metadata.py | 19 +++++------- tests/test_endpoint_decorator.py | 4 +-- tests/test_numpy_type.py | 7 ++--- tests/test_properties.py | 25 +++++++--------- tests/test_settings.py | 21 ++++++------- tests/test_temp_files.py | 10 +++---- tests/test_thing_dependencies.py | 50 +++++++++++++++---------------- tests/test_thing_lifecycle.py | 10 +++---- tests/test_websocket.py | 4 +-- 16 files changed, 106 insertions(+), 136 deletions(-) diff --git a/tests/test_action_cancel.py b/tests/test_action_cancel.py index 9a9f6e75..fa2e6755 100644 --- a/tests/test_action_cancel.py +++ b/tests/test_action_cancel.py @@ -4,26 +4,22 @@ import uuid from fastapi.testclient import TestClient -from labthings_fastapi.server import ThingServer from temp_client import poll_task, task_href -from labthings_fastapi.thing import Thing -from labthings_fastapi.decorators import thing_action -from labthings_fastapi.descriptors import ThingProperty -from labthings_fastapi.dependencies.invocation import CancelHook +import labthings_fastapi as lt -class ThingOne(Thing): - counter = ThingProperty(int, 0, observable=False) +class ThingOne(lt.Thing): + counter = lt.ThingProperty(int, 0, observable=False) - @thing_action - def count_slowly(self, cancel: CancelHook, n: int = 10): + @lt.thing_action + def count_slowly(self, cancel: lt.CancelHook, n: int = 10): for i in range(n): cancel.sleep(0.1) self.counter += 1 def test_invocation_cancel(): - server = ThingServer() + server = lt.ThingServer() thing_one = ThingOne() server.add_thing(thing_one, "/thing_one") with TestClient(server.app) as client: diff --git a/tests/test_action_logging.py b/tests/test_action_logging.py index 5c4cd528..558094d7 100644 --- a/tests/test_action_logging.py +++ b/tests/test_action_logging.py @@ -4,29 +4,26 @@ import logging from fastapi.testclient import TestClient -from labthings_fastapi.server import ThingServer from temp_client import poll_task -from labthings_fastapi.thing import Thing -from labthings_fastapi.decorators import thing_action -from labthings_fastapi.dependencies.invocation import InvocationLogger +import labthings_fastapi as lt from labthings_fastapi.actions.invocation_model import LogRecordModel -class ThingOne(Thing): +class ThingOne(lt.Thing): LOG_MESSAGES = [ "message 1", "message 2", ] - @thing_action - def action_one(self, logger: InvocationLogger): + @lt.thing_action + def action_one(self, logger: lt.InvocationLogger): for m in self.LOG_MESSAGES: logger.info(m) def test_invocation_logging(caplog): caplog.set_level(logging.INFO) - server = ThingServer() + server = lt.ThingServer() server.add_thing(ThingOne(), "/thing_one") with TestClient(server.app) as client: r = client.post("/thing_one/action_one") diff --git a/tests/test_action_manager.py b/tests/test_action_manager.py index 9c17dbc6..84096086 100644 --- a/tests/test_action_manager.py +++ b/tests/test_action_manager.py @@ -1,27 +1,24 @@ from fastapi.testclient import TestClient import pytest import httpx -from labthings_fastapi.server import ThingServer from temp_client import poll_task import time -from labthings_fastapi.thing import Thing -from labthings_fastapi.decorators import thing_action -from labthings_fastapi.descriptors import ThingProperty +import labthings_fastapi as lt -class TestThing(Thing): - @thing_action(retention_time=0.01) +class TestThing(lt.Thing): + @lt.thing_action(retention_time=0.01) def increment_counter(self): """Increment the counter""" self.counter += 1 - counter = ThingProperty( + counter = lt.ThingProperty( model=int, initial_value=0, readonly=True, description="A pointless counter" ) thing = TestThing() -server = ThingServer() +server = lt.ThingServer() server.add_thing(thing, "/thing") diff --git a/tests/test_actions.py b/tests/test_actions.py index 86ba9825..26d4edb7 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -1,12 +1,11 @@ from fastapi.testclient import TestClient import pytest -from labthings_fastapi.server import ThingServer from temp_client import poll_task, get_link -from labthings_fastapi.decorators import thing_action from labthings_fastapi.example_things import MyThing +import labthings_fastapi as lt thing = MyThing() -server = ThingServer() +server = lt.ThingServer() server.add_thing(thing, "/thing") @@ -49,7 +48,7 @@ def test_varargs(): """Test that we can't use *args in an action""" with pytest.raises(TypeError): - @thing_action + @lt.thing_action def action_with_varargs(self, *args) -> None: """An action that takes *args""" pass diff --git a/tests/test_blob_output.py b/tests/test_blob_output.py index 66d24aac..d8638bdc 100644 --- a/tests/test_blob_output.py +++ b/tests/test_blob_output.py @@ -7,29 +7,27 @@ from fastapi.testclient import TestClient import pytest -from labthings_fastapi.server import ThingServer -from labthings_fastapi.thing import Thing -from labthings_fastapi.decorators import thing_action -from labthings_fastapi.dependencies.thing import direct_thing_client_dependency +import labthings_fastapi as lt from labthings_fastapi.outputs.blob import blob_type from labthings_fastapi.client import ThingClient -TextBlob = blob_type(media_type="text/plain") +class TextBlob(lt.Blob): + media_type: str = "text/plain" -class ThingOne(Thing): +class ThingOne(lt.Thing): ACTION_ONE_RESULT = b"Action one result!" def __init__(self): self._temp_directory = TemporaryDirectory() - @thing_action + @lt.thing_action def action_one(self) -> TextBlob: """An action that makes a blob response from bytes""" return TextBlob.from_bytes(self.ACTION_ONE_RESULT) - @thing_action + @lt.thing_action def action_two(self) -> TextBlob: """An action that makes a blob response from a file and tempdir""" td = TemporaryDirectory() @@ -37,7 +35,7 @@ def action_two(self) -> TextBlob: f.write(self.ACTION_ONE_RESULT) return TextBlob.from_temporary_directory(td, "serverside") - @thing_action + @lt.thing_action def action_three(self) -> TextBlob: """An action that makes a blob response from a file""" fpath = os.path.join(self._temp_directory.name, "serverside") @@ -45,23 +43,23 @@ def action_three(self) -> TextBlob: f.write(self.ACTION_ONE_RESULT) return TextBlob.from_file(fpath) - @thing_action + @lt.thing_action def passthrough_blob(self, blob: TextBlob) -> TextBlob: """An action that passes through a blob response""" return blob -ThingOneDep = direct_thing_client_dependency(ThingOne, "/thing_one/") +ThingOneDep = lt.direct_thing_client_dependency(ThingOne, "/thing_one/") -class ThingTwo(Thing): - @thing_action +class ThingTwo(lt.Thing): + @lt.thing_action def check_both(self, thing_one: ThingOneDep) -> bool: """An action that checks the output of ThingOne""" check_actions(thing_one) return True - @thing_action + @lt.thing_action def check_passthrough(self, thing_one: ThingOneDep) -> bool: """An action that checks the passthrough of ThingOne""" output = thing_one.action_one() @@ -98,7 +96,7 @@ def test_blob_output_client(): This uses the internal thing client mechanism. """ - server = ThingServer() + server = lt.ThingServer() server.add_thing(ThingOne(), "/thing_one") with TestClient(server.app) as client: tc = ThingClient.from_url("/thing_one/", client=client) @@ -113,7 +111,7 @@ def test_blob_output_direct(): def test_blob_output_inserver(): """Test that the blob output works the same when used directly""" - server = ThingServer() + server = lt.ThingServer() server.add_thing(ThingOne(), "/thing_one") server.add_thing(ThingTwo(), "/thing_two") with TestClient(server.app) as client: @@ -143,7 +141,7 @@ def check_actions(thing): def test_blob_input(): """Check that blobs can be used as input.""" - server = ThingServer() + server = lt.ThingServer() server.add_thing(ThingOne(), "/thing_one") server.add_thing(ThingTwo(), "/thing_two") with TestClient(server.app) as client: diff --git a/tests/test_dependencies.py b/tests/test_dependencies.py index 979dabed..7783f955 100644 --- a/tests/test_dependencies.py +++ b/tests/test_dependencies.py @@ -5,7 +5,7 @@ """ from fastapi import Depends, FastAPI, Request -from labthings_fastapi.dependencies.invocation import InvocationID +from labthings_fastapi import InvocationID from labthings_fastapi.file_manager import FileManagerDep from fastapi.testclient import TestClient from module_with_deps import FancyIDDep diff --git a/tests/test_dependencies_2.py b/tests/test_dependencies_2.py index d1999403..62dc254f 100644 --- a/tests/test_dependencies_2.py +++ b/tests/test_dependencies_2.py @@ -18,7 +18,8 @@ from fastapi import Depends, FastAPI from fastapi.testclient import TestClient from module_with_deps import FancyIDDep, FancyID, ClassDependsOnFancyID -from labthings_fastapi.dependencies.invocation import InvocationID, invocation_id +import labthings_fastapi as lt +from labthings_fastapi.dependencies.invocation import invocation_id from labthings_fastapi.file_manager import FileManager from uuid import UUID @@ -122,7 +123,7 @@ def endpoint(id: DepClass = Depends()) -> bool: def test_invocation_id(): - """Add an endpoint that uses a dependency from another file""" + """Add an endpoint that uses a dependency imported from another file""" app = FastAPI() @app.post("/endpoint") @@ -135,11 +136,11 @@ def invoke_fancy(id: Annotated[UUID, Depends(invocation_id)]) -> bool: def test_invocation_id_alias(): - """Add an endpoint that uses a dependency from another file""" + """Add an endpoint that uses a dependency alias from another file""" app = FastAPI() @app.post("/endpoint") - def endpoint(id: InvocationID) -> bool: + def endpoint(id: lt.InvocationID) -> bool: return True with TestClient(app) as client: diff --git a/tests/test_dependency_metadata.py b/tests/test_dependency_metadata.py index 2da4e336..21090b5e 100644 --- a/tests/test_dependency_metadata.py +++ b/tests/test_dependency_metadata.py @@ -4,20 +4,17 @@ from typing import Any, Mapping from fastapi.testclient import TestClient -from labthings_fastapi.server import ThingServer from temp_client import poll_task -from labthings_fastapi.thing import Thing -from labthings_fastapi.decorators import thing_action, thing_property -from labthings_fastapi.dependencies.thing import direct_thing_client_dependency from labthings_fastapi.dependencies.metadata import GetThingStates +import labthings_fastapi as lt -class ThingOne(Thing): +class ThingOne(lt.Thing): def __init__(self): - Thing.__init__(self) + lt.Thing.__init__(self) self._a = 0 - @thing_property + @lt.thing_property def a(self): return self._a @@ -30,17 +27,17 @@ def thing_state(self): return {"a": self.a} -ThingOneDep = direct_thing_client_dependency(ThingOne, "/thing_one/") +ThingOneDep = lt.direct_thing_client_dependency(ThingOne, "/thing_one/") -class ThingTwo(Thing): +class ThingTwo(lt.Thing): A_VALUES = [1, 2, 3] @property def thing_state(self): return {"a": 1} - @thing_action + @lt.thing_action def count_and_watch( self, thing_one: ThingOneDep, get_metadata: GetThingStates ) -> Mapping[str, Mapping[str, Any]]: @@ -52,7 +49,7 @@ def count_and_watch( def test_fresh_metadata(): - server = ThingServer() + server = lt.ThingServer() server.add_thing(ThingOne(), "/thing_one/") server.add_thing(ThingTwo(), "/thing_two/") with TestClient(server.app) as client: diff --git a/tests/test_endpoint_decorator.py b/tests/test_endpoint_decorator.py index f980ba41..0e96322b 100644 --- a/tests/test_endpoint_decorator.py +++ b/tests/test_endpoint_decorator.py @@ -1,7 +1,5 @@ from fastapi.testclient import TestClient -from labthings_fastapi.server import ThingServer -from labthings_fastapi.thing import Thing -from labthings_fastapi.decorators import fastapi_endpoint +from labthings_fastapi import ThingServer, Thing, fastapi_endpoint from pydantic import BaseModel diff --git a/tests/test_numpy_type.py b/tests/test_numpy_type.py index 862096be..a99db29d 100644 --- a/tests/test_numpy_type.py +++ b/tests/test_numpy_type.py @@ -4,8 +4,7 @@ import numpy as np from labthings_fastapi.types.numpy import NDArray, DenumpifyingDict -from labthings_fastapi.thing import Thing -from labthings_fastapi.decorators import thing_action +import labthings_fastapi as lt class ArrayModel(RootModel): @@ -63,8 +62,8 @@ class Model(BaseModel): m.model_dump_json() -class MyNumpyThing(Thing): - @thing_action +class MyNumpyThing(lt.Thing): + @lt.thing_action def action_with_arrays(self, a: NDArray) -> NDArray: return a * 2 diff --git a/tests/test_properties.py b/tests/test_properties.py index 7109a53a..3946c7e1 100644 --- a/tests/test_properties.py +++ b/tests/test_properties.py @@ -4,26 +4,23 @@ from pydantic import BaseModel from fastapi.testclient import TestClient -from labthings_fastapi.descriptors import ThingProperty -from labthings_fastapi.decorators import thing_property, thing_action -from labthings_fastapi.thing import Thing -from labthings_fastapi.server import ThingServer +import labthings_fastapi as lt from labthings_fastapi.exceptions import NotConnectedToServerError -class TestThing(Thing): - boolprop = ThingProperty(bool, False, description="A boolean property") - stringprop = ThingProperty(str, "foo", description="A string property") +class TestThing(lt.Thing): + boolprop = lt.ThingProperty(bool, False, description="A boolean property") + stringprop = lt.ThingProperty(str, "foo", description="A string property") _undoc = None - @thing_property + @lt.thing_property def undoc(self): return self._undoc _float = 1.0 - @thing_property + @lt.thing_property def floatprop(self) -> float: return self._float @@ -31,18 +28,18 @@ def floatprop(self) -> float: def floatprop(self, value: float): self._float = value - @thing_action + @lt.thing_action def toggle_boolprop(self): self.boolprop = not self.boolprop - @thing_action + @lt.thing_action def toggle_boolprop_from_thread(self): t = Thread(target=self.toggle_boolprop) t.start() thing = TestThing() -server = ThingServer() +server = lt.ThingServer() server.add_thing(thing, "/thing") @@ -53,7 +50,7 @@ def test_instantiation_with_type(): To send the data over HTTP LabThings-FastAPI uses Pydantic models to describe data types. """ - prop = ThingProperty(bool, False) + prop = lt.ThingProperty(bool, False) assert issubclass(prop.model, BaseModel) @@ -62,7 +59,7 @@ class MyModel(BaseModel): a: int = 1 b: float = 2.0 - prop = ThingProperty(MyModel, MyModel()) + prop = lt.ThingProperty(MyModel, MyModel()) assert prop.model is MyModel diff --git a/tests/test_settings.py b/tests/test_settings.py index 495f579b..50bb7656 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -7,22 +7,19 @@ from fastapi.testclient import TestClient -from labthings_fastapi.descriptors import ThingSetting -from labthings_fastapi.decorators import thing_setting, thing_action -from labthings_fastapi.thing import Thing -from labthings_fastapi.server import ThingServer +import labthings_fastapi as lt -class TestThing(Thing): - boolsetting = ThingSetting(bool, False, description="A boolean setting") - stringsetting = ThingSetting(str, "foo", description="A string setting") - dictsetting = ThingSetting( +class TestThing(lt.Thing): + boolsetting = lt.ThingSetting(bool, False, description="A boolean setting") + stringsetting = lt.ThingSetting(str, "foo", description="A string setting") + dictsetting = lt.ThingSetting( dict, {"a": 1, "b": 2}, description="A dictionary setting" ) _float = 1.0 - @thing_setting + @lt.thing_setting def floatsetting(self) -> float: return self._float @@ -30,11 +27,11 @@ def floatsetting(self) -> float: def floatsetting(self, value: float): self._float = value - @thing_action + @lt.thing_action def toggle_boolsetting(self): self.boolsetting = not self.boolsetting - @thing_action + @lt.thing_action def toggle_boolsetting_from_thread(self): t = Thread(target=self.toggle_boolsetting) t.start() @@ -72,7 +69,7 @@ def server(): with tempfile.TemporaryDirectory() as tempdir: # Yield server rather than return so that the temp directory isn't cleaned up # until after the test is run - yield ThingServer(settings_folder=tempdir) + yield lt.ThingServer(settings_folder=tempdir) def test_setting_available(thing): diff --git a/tests/test_temp_files.py b/tests/test_temp_files.py index f769afdc..8c8cb795 100644 --- a/tests/test_temp_files.py +++ b/tests/test_temp_files.py @@ -1,13 +1,11 @@ -from labthings_fastapi.thing import Thing -from labthings_fastapi.decorators import thing_action +import labthings_fastapi as lt from labthings_fastapi.file_manager import FileManagerDep from fastapi.testclient import TestClient -from labthings_fastapi.server import ThingServer from temp_client import poll_task, get_link -class FileThing(Thing): - @thing_action +class FileThing(lt.Thing): + @lt.thing_action def write_message_file( self, file_manager: FileManagerDep, @@ -23,7 +21,7 @@ def write_message_file( thing = FileThing() -server = ThingServer() +server = lt.ThingServer() server.add_thing(thing, "/thing") diff --git a/tests/test_thing_dependencies.py b/tests/test_thing_dependencies.py index c0983982..5ba25847 100644 --- a/tests/test_thing_dependencies.py +++ b/tests/test_thing_dependencies.py @@ -6,20 +6,16 @@ from fastapi.testclient import TestClient from fastapi import Request import pytest -from labthings_fastapi.server import ThingServer +import labthings_fastapi as lt from temp_client import poll_task -from labthings_fastapi.thing import Thing -from labthings_fastapi.decorators import thing_action -from labthings_fastapi.dependencies.raw_thing import raw_thing_dependency -from labthings_fastapi.dependencies.thing import direct_thing_client_dependency from labthings_fastapi.client.in_server import direct_thing_client_class from labthings_fastapi.utilities.introspection import fastapi_dependency_params -class ThingOne(Thing): +class ThingOne(lt.Thing): ACTION_ONE_RESULT = "Action one result!" - @thing_action + @lt.thing_action def action_one(self) -> str: """An action that takes no arguments""" return self.action_one_internal() @@ -28,26 +24,26 @@ def action_one_internal(self) -> str: return self.ACTION_ONE_RESULT -ThingOneDep = direct_thing_client_dependency(ThingOne, "/thing_one/") +ThingOneDep = lt.direct_thing_client_dependency(ThingOne, "/thing_one/") -class ThingTwo(Thing): - @thing_action +class ThingTwo(lt.Thing): + @lt.thing_action def action_two(self, thing_one: ThingOneDep) -> str: """An action that needs a ThingOne""" return thing_one.action_one() - @thing_action + @lt.thing_action def action_two_a(self, thing_one: ThingOneDep) -> str: """Another action that needs a ThingOne""" return thing_one.action_one() -ThingTwoDep = direct_thing_client_dependency(ThingTwo, "/thing_two/") +ThingTwoDep = lt.direct_thing_client_dependency(ThingTwo, "/thing_two/") -class ThingThree(Thing): - @thing_action +class ThingThree(lt.Thing): + @lt.thing_action def action_three(self, thing_two: ThingTwoDep) -> str: """An action that needs a ThingTwo""" # Note that we don't have to supply the ThingOne dependency @@ -84,7 +80,7 @@ def test_interthing_dependency(): This uses the internal thing client mechanism. """ - server = ThingServer() + server = lt.ThingServer() server.add_thing(ThingOne(), "/thing_one") server.add_thing(ThingTwo(), "/thing_two") with TestClient(server.app) as client: @@ -100,7 +96,7 @@ def test_interthing_dependency_with_dependencies(): This uses the internal thing client mechanism, and requires dependency injection for the called action """ - server = ThingServer() + server = lt.ThingServer() server.add_thing(ThingOne(), "/thing_one") server.add_thing(ThingTwo(), "/thing_two") server.add_thing(ThingThree(), "/thing_three") @@ -117,15 +113,15 @@ def test_raw_interthing_dependency(): This uses the internal thing client mechanism. """ - ThingOneDep = raw_thing_dependency(ThingOne) + ThingOneDep = lt.raw_thing_dependency(ThingOne) - class ThingTwo(Thing): - @thing_action + class ThingTwo(lt.Thing): + @lt.thing_action def action_two(self, thing_one: ThingOneDep) -> str: """An action that needs a ThingOne""" return thing_one.action_one() - server = ThingServer() + server = lt.ThingServer() server.add_thing(ThingOne(), "/thing_one") server.add_thing(ThingTwo(), "/thing_two") with TestClient(server.app) as client: @@ -143,19 +139,21 @@ def test_conflicting_dependencies(): This also checks that dependencies on the same class but different actions are recognised as "different". """ - ThingTwoDepNoActions = direct_thing_client_dependency(ThingTwo, "/thing_two/", []) + ThingTwoDepNoActions = lt.direct_thing_client_dependency( + ThingTwo, "/thing_two/", [] + ) - class ThingFour(Thing): - @thing_action + class ThingFour(lt.Thing): + @lt.thing_action def action_four(self, thing_two: ThingTwoDepNoActions) -> str: return str(thing_two) - @thing_action + @lt.thing_action def action_five(self, thing_two: ThingTwoDep) -> str: return thing_two.action_two() with pytest.raises(ValueError): - direct_thing_client_dependency(ThingFour, "/thing_four/") + lt.direct_thing_client_dependency(ThingFour, "/thing_four/") def check_request(): @@ -163,7 +161,7 @@ def check_request(): This is mostly just verifying that there's nothing funky in between the Starlette `Request` object and the FastAPI `app`.""" - server = ThingServer() + server = lt.ThingServer() @server.app.get("/check_request_app/") def check_request_app(request: Request) -> bool: diff --git a/tests/test_thing_lifecycle.py b/tests/test_thing_lifecycle.py index 1172e3da..2d7331b9 100644 --- a/tests/test_thing_lifecycle.py +++ b/tests/test_thing_lifecycle.py @@ -1,11 +1,9 @@ -from labthings_fastapi.descriptors import ThingProperty -from labthings_fastapi.thing import Thing +import labthings_fastapi as lt from fastapi.testclient import TestClient -from labthings_fastapi.server import ThingServer -class TestThing(Thing): - alive = ThingProperty(bool, False, description="Is the thing alive?") +class TestThing(lt.Thing): + alive = lt.ThingProperty(bool, False, description="Is the thing alive?") def __enter__(self): print("setting up TestThing from __enter__") @@ -18,7 +16,7 @@ def __exit__(self, *args): thing = TestThing() -server = ThingServer() +server = lt.ThingServer() server.add_thing(thing, "/thing") diff --git a/tests/test_websocket.py b/tests/test_websocket.py index 19b07fd5..deb040b5 100644 --- a/tests/test_websocket.py +++ b/tests/test_websocket.py @@ -1,9 +1,9 @@ +import labthings_fastapi as lt from fastapi.testclient import TestClient -from labthings_fastapi.server import ThingServer from labthings_fastapi.example_things import MyThing my_thing = MyThing() -server = ThingServer() +server = lt.ThingServer() server.add_thing(my_thing, "/my_thing") From bd7475d80dc1ef213f4bbc268b5a073724441c0d Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Sat, 28 Jun 2025 11:08:33 +0100 Subject: [PATCH 08/12] Organise blob, deps, and outputs I've moved dependencies into a convenience module `deps`, so now dependencies, outputs, and blob subclasses are imported as `lt.blob.MyBlob` etc. to avoid cluttering the global namespace too much. `deps` is used rather than `dependencies` to avoid circular imports and allow for future rearrangements, as well as keeping type annotations shorter if it's imported as `lt.deps.Whatever`. Test code is updated to use the new imports. --- src/labthings_fastapi/__init__.py | 23 +++++-------------- src/labthings_fastapi/deps.py | 28 +++++++++++++++++++++++ src/labthings_fastapi/outputs/__init__.py | 10 ++++++++ tests/test_action_cancel.py | 2 +- tests/test_action_logging.py | 2 +- tests/test_blob_output.py | 4 ++-- tests/test_dependencies.py | 2 +- tests/test_dependencies_2.py | 2 +- tests/test_dependency_metadata.py | 2 +- tests/test_thing_dependencies.py | 10 ++++---- 10 files changed, 56 insertions(+), 29 deletions(-) create mode 100644 src/labthings_fastapi/deps.py diff --git a/src/labthings_fastapi/__init__.py b/src/labthings_fastapi/__init__.py index 53385ea9..ff605e72 100644 --- a/src/labthings_fastapi/__init__.py +++ b/src/labthings_fastapi/__init__.py @@ -6,13 +6,9 @@ thing_action, fastapi_endpoint, ) -from .dependencies.blocking_portal import BlockingPortal -from .dependencies.invocation import InvocationID, InvocationLogger, CancelHook -from .dependencies.metadata import GetThingStates -from .dependencies.raw_thing import raw_thing_dependency -from .dependencies.thing import direct_thing_client_dependency -from .outputs.mjpeg_stream import MJPEGStream, MJPEGStreamDescriptor -from .outputs.blob import Blob +from . import deps +from . import outputs +from .outputs import blob from .server import ThingServer # The symbols in __all__ are part of our public API. @@ -29,15 +25,8 @@ "thing_setting", "thing_action", "fastapi_endpoint", - "BlockingPortal", - "InvocationID", - "InvocationLogger", - "CancelHook", - "GetThingStates", - "raw_thing_dependency", - "direct_thing_client_dependency", - "MJPEGStream", - "MJPEGStreamDescriptor", - "Blob", + "deps", + "outputs", + "blob", "ThingServer", ] diff --git a/src/labthings_fastapi/deps.py b/src/labthings_fastapi/deps.py new file mode 100644 index 00000000..cec40edc --- /dev/null +++ b/src/labthings_fastapi/deps.py @@ -0,0 +1,28 @@ +""" +FastAPI dependencies for LabThings. + +The symbols in this module are type annotations that can be used in +the arguments of Action methods (or FastAPI endpoints) to +automatically supply the required dependencies. + +See the documentation on dependencies for more details of how to use +these. +""" + +from .dependencies.blocking_portal import BlockingPortal +from .dependencies.invocation import InvocationID, InvocationLogger, CancelHook +from .dependencies.metadata import GetThingStates +from .dependencies.raw_thing import raw_thing_dependency +from .dependencies.thing import direct_thing_client_dependency + +# The symbols in __all__ are part of our public API. See note +# in src/labthings_fastapi/__init__.py for more details. +__all__ = [ + "BlockingPortal", + "InvocationID", + "InvocationLogger", + "CancelHook", + "GetThingStates", + "raw_thing_dependency", + "direct_thing_client_dependency", +] diff --git a/src/labthings_fastapi/outputs/__init__.py b/src/labthings_fastapi/outputs/__init__.py index e69de29b..e022ed1c 100644 --- a/src/labthings_fastapi/outputs/__init__.py +++ b/src/labthings_fastapi/outputs/__init__.py @@ -0,0 +1,10 @@ +from .mjpeg_stream import MJPEGStream, MJPEGStreamDescriptor + +# __all__ enables convenience imports from this module. +# see the note in src/labthings_fastapi/__init__.py for more details. +# `blob` is intentionally missing: it will likely be promoted out of +# `outputs` in the future. +__all__ = [ + "MJPEGStream", + "MJPEGStreamDescriptor", +] diff --git a/tests/test_action_cancel.py b/tests/test_action_cancel.py index fa2e6755..358fa02f 100644 --- a/tests/test_action_cancel.py +++ b/tests/test_action_cancel.py @@ -12,7 +12,7 @@ class ThingOne(lt.Thing): counter = lt.ThingProperty(int, 0, observable=False) @lt.thing_action - def count_slowly(self, cancel: lt.CancelHook, n: int = 10): + def count_slowly(self, cancel: lt.deps.CancelHook, n: int = 10): for i in range(n): cancel.sleep(0.1) self.counter += 1 diff --git a/tests/test_action_logging.py b/tests/test_action_logging.py index 558094d7..c98c08b0 100644 --- a/tests/test_action_logging.py +++ b/tests/test_action_logging.py @@ -16,7 +16,7 @@ class ThingOne(lt.Thing): ] @lt.thing_action - def action_one(self, logger: lt.InvocationLogger): + def action_one(self, logger: lt.deps.InvocationLogger): for m in self.LOG_MESSAGES: logger.info(m) diff --git a/tests/test_blob_output.py b/tests/test_blob_output.py index d8638bdc..22cda8f7 100644 --- a/tests/test_blob_output.py +++ b/tests/test_blob_output.py @@ -12,7 +12,7 @@ from labthings_fastapi.client import ThingClient -class TextBlob(lt.Blob): +class TextBlob(lt.blob.Blob): media_type: str = "text/plain" @@ -49,7 +49,7 @@ def passthrough_blob(self, blob: TextBlob) -> TextBlob: return blob -ThingOneDep = lt.direct_thing_client_dependency(ThingOne, "/thing_one/") +ThingOneDep = lt.deps.direct_thing_client_dependency(ThingOne, "/thing_one/") class ThingTwo(lt.Thing): diff --git a/tests/test_dependencies.py b/tests/test_dependencies.py index 7783f955..82052840 100644 --- a/tests/test_dependencies.py +++ b/tests/test_dependencies.py @@ -5,7 +5,7 @@ """ from fastapi import Depends, FastAPI, Request -from labthings_fastapi import InvocationID +from labthings_fastapi.deps import InvocationID from labthings_fastapi.file_manager import FileManagerDep from fastapi.testclient import TestClient from module_with_deps import FancyIDDep diff --git a/tests/test_dependencies_2.py b/tests/test_dependencies_2.py index 62dc254f..755cbf77 100644 --- a/tests/test_dependencies_2.py +++ b/tests/test_dependencies_2.py @@ -140,7 +140,7 @@ def test_invocation_id_alias(): app = FastAPI() @app.post("/endpoint") - def endpoint(id: lt.InvocationID) -> bool: + def endpoint(id: lt.deps.InvocationID) -> bool: return True with TestClient(app) as client: diff --git a/tests/test_dependency_metadata.py b/tests/test_dependency_metadata.py index 21090b5e..f22abf15 100644 --- a/tests/test_dependency_metadata.py +++ b/tests/test_dependency_metadata.py @@ -27,7 +27,7 @@ def thing_state(self): return {"a": self.a} -ThingOneDep = lt.direct_thing_client_dependency(ThingOne, "/thing_one/") +ThingOneDep = lt.deps.direct_thing_client_dependency(ThingOne, "/thing_one/") class ThingTwo(lt.Thing): diff --git a/tests/test_thing_dependencies.py b/tests/test_thing_dependencies.py index 5ba25847..729e0f2f 100644 --- a/tests/test_thing_dependencies.py +++ b/tests/test_thing_dependencies.py @@ -24,7 +24,7 @@ def action_one_internal(self) -> str: return self.ACTION_ONE_RESULT -ThingOneDep = lt.direct_thing_client_dependency(ThingOne, "/thing_one/") +ThingOneDep = lt.deps.direct_thing_client_dependency(ThingOne, "/thing_one/") class ThingTwo(lt.Thing): @@ -39,7 +39,7 @@ def action_two_a(self, thing_one: ThingOneDep) -> str: return thing_one.action_one() -ThingTwoDep = lt.direct_thing_client_dependency(ThingTwo, "/thing_two/") +ThingTwoDep = lt.deps.direct_thing_client_dependency(ThingTwo, "/thing_two/") class ThingThree(lt.Thing): @@ -113,7 +113,7 @@ def test_raw_interthing_dependency(): This uses the internal thing client mechanism. """ - ThingOneDep = lt.raw_thing_dependency(ThingOne) + ThingOneDep = lt.deps.raw_thing_dependency(ThingOne) class ThingTwo(lt.Thing): @lt.thing_action @@ -139,7 +139,7 @@ def test_conflicting_dependencies(): This also checks that dependencies on the same class but different actions are recognised as "different". """ - ThingTwoDepNoActions = lt.direct_thing_client_dependency( + ThingTwoDepNoActions = lt.deps.direct_thing_client_dependency( ThingTwo, "/thing_two/", [] ) @@ -153,7 +153,7 @@ def action_five(self, thing_two: ThingTwoDep) -> str: return thing_two.action_two() with pytest.raises(ValueError): - lt.direct_thing_client_dependency(ThingFour, "/thing_four/") + lt.deps.direct_thing_client_dependency(ThingFour, "/thing_four/") def check_request(): From 155b8a134a0159c68fbaf8583cbcb1e50aa77643 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Tue, 1 Jul 2025 11:51:14 +0100 Subject: [PATCH 09/12] Add additional symbols from review Co-authored-by: Julian Stirling --- src/labthings_fastapi/__init__.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/labthings_fastapi/__init__.py b/src/labthings_fastapi/__init__.py index ff605e72..d15b6df4 100644 --- a/src/labthings_fastapi/__init__.py +++ b/src/labthings_fastapi/__init__.py @@ -9,7 +9,9 @@ from . import deps from . import outputs from .outputs import blob -from .server import ThingServer +from .server import ThingServer, cli +from .client import ThingClient +from .utilities import get_blocking_portal # The symbols in __all__ are part of our public API. # They are imported when using `import labthings_fastapi as lt`. @@ -29,4 +31,7 @@ "outputs", "blob", "ThingServer", + "cli", + "ThingClient", + "get_blocking_portal", ] From ee24aa271dad0d2cc8dfda490acfbdc68b5106a9 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Wed, 2 Jul 2025 15:04:52 +0100 Subject: [PATCH 10/12] Update example code and documentation to use new imports --- docs/source/blobs.rst | 30 +-- docs/source/dependencies/example.py | 13 +- docs/source/quickstart/counter_client.py | 2 +- examples/README.md | 11 +- examples/counter.py | 16 +- examples/demo_thing_server.py | 20 +- examples/opencv_camera_server.py | 292 ----------------------- examples/picamera2_camera_server.py | 230 ------------------ 8 files changed, 40 insertions(+), 574 deletions(-) delete mode 100644 examples/opencv_camera_server.py delete mode 100644 examples/picamera2_camera_server.py diff --git a/docs/source/blobs.rst b/docs/source/blobs.rst index bc7d9356..c39e82bf 100644 --- a/docs/source/blobs.rst +++ b/docs/source/blobs.rst @@ -29,15 +29,13 @@ A camera might want to return an image as a :class:`.Blob` object. The code for .. code-block:: python - from labthings_fastapi.blob import Blob - from labthings_fastapi.thing import Thing - from labthings_fastapi.decorators import thing_action + import labthings_fastapi as lt - class JPEGBlob(Blob): + class JPEGBlob(lt.blob.Blob): content_type = "image/jpeg" - class Camera(Thing): - @thing_action + class Camera(lt.Thing): + @lt.thing_action def capture_image(self) -> JPEGBlob: # Capture an image and return it as a Blob image_data = self._capture_image() # This returns a bytes object holding the JPEG data @@ -48,7 +46,7 @@ The corresponding client code might look like this: .. code-block:: python from PIL import Image - from labthings_fastapi.client import ThingClient + from labthings_fastapi import ThingClient camera = ThingClient.from_url("http://localhost:5000/camera/") image_blob = camera.capture_image() @@ -63,30 +61,28 @@ We could define a more sophisticated camera that can capture raw images and conv .. code-block:: python - from labthings_fastapi.blob import Blob - from labthings_fastapi.thing import Thing - from labthings_fastapi.decorators import thing_action + import labthings_fastapi as lt - class JPEGBlob(Blob): + class JPEGBlob(lt.Blob): content_type = "image/jpeg" - class RAWBlob(Blob): + class RAWBlob(lt.Blob): content_type = "image/x-raw" - class Camera(Thing): - @thing_action + class Camera(lt.Thing): + @lt.thing_action def capture_raw_image(self) -> RAWBlob: # Capture a raw image and return it as a Blob raw_data = self._capture_raw_image() # This returns a bytes object holding the raw data return RAWBlob.from_bytes(raw_data) - @thing_action + @lt.thing_action def convert_raw_to_jpeg(self, raw_blob: RAWBlob) -> JPEGBlob: # Convert a raw image Blob to a JPEG Blob jpeg_data = self._convert_raw_to_jpeg(raw_blob.data) # This returns a bytes object holding the JPEG data return JPEGBlob.from_bytes(jpeg_data) - @thing_action + @lt.thing_action def capture_image(self) -> JPEGBlob: # Capture an image and return it as a Blob raw_blob = self.capture_raw_image() # Capture the raw image @@ -99,7 +95,7 @@ On the client, we can use the `capture_image` action directly (as before), or we .. code-block:: python from PIL import Image - from labthings_fastapi.client import ThingClient + from labthings_fastapi import ThingClient camera = ThingClient.from_url("http://localhost:5000/camera/") diff --git a/docs/source/dependencies/example.py b/docs/source/dependencies/example.py index 75f7d267..2edf04a5 100644 --- a/docs/source/dependencies/example.py +++ b/docs/source/dependencies/example.py @@ -1,22 +1,19 @@ -from labthings_fastapi.thing import Thing -from labthings_fastapi.decorators import thing_action -from labthings_fastapi.dependencies.thing import direct_thing_client_dependency +import labthings_fastapi as lt from labthings_fastapi.example_things import MyThing -from labthings_fastapi.server import ThingServer -MyThingDep = direct_thing_client_dependency(MyThing, "/mything/") +MyThingDep = lt.direct_thing_client_dependency(MyThing, "/mything/") -class TestThing(Thing): +class TestThing(lt.Thing): """A test thing with a counter property and a couple of actions""" - @thing_action + @lt.thing_action def increment_counter(self, my_thing: MyThingDep) -> None: """Increment the counter on another thing""" my_thing.increment_counter() -server = ThingServer() +server = lt.ThingServer() server.add_thing(MyThing(), "/mything/") server.add_thing(TestThing(), "/testthing/") diff --git a/docs/source/quickstart/counter_client.py b/docs/source/quickstart/counter_client.py index 346ac3c7..bb24ab13 100644 --- a/docs/source/quickstart/counter_client.py +++ b/docs/source/quickstart/counter_client.py @@ -1,4 +1,4 @@ -from labthings_fastapi.client import ThingClient +from labthings_fastapi import ThingClient counter = ThingClient.from_url("http://localhost:5000/counter/") diff --git a/examples/README.md b/examples/README.md index 8d650e92..f9db97d7 100644 --- a/examples/README.md +++ b/examples/README.md @@ -2,11 +2,10 @@ The files in this folder are example code that was used in development and may be helpful to users. It's not currently tested, so there are no guarantees as to how current each example is. Some of them have been moved into `/tests/` and those ones do get checked: at some point in the future a combined documentation/testing system might usefully deduplicate this. -To run the `demo_thing_server` example, you need to have `labthings_fastapi` installed (we recommend in a virtual environment, see the top-level README), and then do +Two camera-related examples have been removed, there are better `Thing`s already written for handling cameras as part of the [OpenFlexure Microscope] and you can find the relevant [camera Thing code] there. -```shell -cd examples/ -uvicorn demo_thing_server:thing_server.app --reload --reload-dir=.. -``` +To run these examples, it's best to look at the tutorial or quickstart guides on our [readthedocs] site. -The two arguments starting `--reload` will reload the demo if anything changes in the repository, which is useful for development (if you've previously installed the repository as editable) but not necessary if you just want to play with the demo. +[readthedocs]: https://labthings-fastapi.readthedocs.io/ +[OpenFlexure Microscope]: https://openflexure.org/ +[camera Thing code]: https://gitlab.com/openflexure/openflexure-microscope-server/-/tree/v3/src/openflexure_microscope_server/things/camera?ref_type=heads diff --git a/examples/counter.py b/examples/counter.py index 14158aae..95438422 100644 --- a/examples/counter.py +++ b/examples/counter.py @@ -1,14 +1,12 @@ import time -from labthings_fastapi.thing import Thing -from labthings_fastapi.decorators import thing_action -from labthings_fastapi.descriptors import ThingProperty -from labthings_fastapi.server import ThingServer +import labthings_fastapi as lt -class TestThing(Thing): + +class TestThing(lt.Thing): """A test thing with a counter property and a couple of actions""" - @thing_action + @lt.thing_action def increment_counter(self) -> None: """Increment the counter property @@ -18,17 +16,17 @@ def increment_counter(self) -> None: """ self.counter += 1 - @thing_action + @lt.thing_action def slowly_increase_counter(self) -> None: """Increment the counter slowly over a minute""" for i in range(60): time.sleep(1) self.increment_counter() - counter = ThingProperty( + counter = lt.ThingProperty( model=int, initial_value=0, readonly=True, description="A pointless counter" ) -server = ThingServer() +server = lt.ThingServer() server.add_thing(TestThing(), "/test") diff --git a/examples/demo_thing_server.py b/examples/demo_thing_server.py index 5c27f141..17166048 100644 --- a/examples/demo_thing_server.py +++ b/examples/demo_thing_server.py @@ -1,18 +1,16 @@ import logging import time from typing import Optional, Annotated -from labthings_fastapi.thing import Thing -from labthings_fastapi.decorators import thing_action -from labthings_fastapi.server import ThingServer -from labthings_fastapi.descriptors import ThingProperty from pydantic import Field from fastapi.responses import HTMLResponse +import labthings_fastapi as lt + logging.basicConfig(level=logging.INFO) -class MyThing(Thing): - @thing_action +class MyThing(lt.Thing): + @lt.thing_action def anaction( self, repeats: Annotated[ @@ -43,7 +41,7 @@ def anaction( self.increment_counter() return "finished!!" - @thing_action + @lt.thing_action def increment_counter(self): """Increment the counter property @@ -53,25 +51,25 @@ def increment_counter(self): """ self.counter += 1 - @thing_action + @lt.thing_action def slowly_increase_counter(self): """Increment the counter slowly over a minute""" for i in range(60): time.sleep(1) self.increment_counter() - counter = ThingProperty( + counter = lt.ThingProperty( model=int, initial_value=0, readonly=True, description="A pointless counter" ) - foo = ThingProperty( + foo = lt.ThingProperty( model=str, initial_value="Example", description="A pointless string for demo purposes.", ) -thing_server = ThingServer() +thing_server = lt.ThingServer() my_thing = MyThing() td = my_thing.thing_description() my_thing.validate_thing_description() diff --git a/examples/opencv_camera_server.py b/examples/opencv_camera_server.py deleted file mode 100644 index 01bfa4f1..00000000 --- a/examples/opencv_camera_server.py +++ /dev/null @@ -1,292 +0,0 @@ -import logging -import threading - -from fastapi import FastAPI -from fastapi.responses import HTMLResponse, StreamingResponse -from labthings_fastapi.descriptors.property import ThingProperty -from labthings_fastapi.thing import Thing -from labthings_fastapi.decorators import thing_action, thing_property -from labthings_fastapi.server import ThingServer -from labthings_fastapi.file_manager import FileManagerDep -from typing import Optional, AsyncContextManager -from collections.abc import AsyncGenerator -from functools import partial -from dataclasses import dataclass -from datetime import datetime -from contextlib import asynccontextmanager -import anyio -from anyio.from_thread import BlockingPortal -from threading import RLock -import cv2 as cv - -logging.basicConfig(level=logging.INFO) - - -@dataclass -class RingbufferEntry: - """A single entry in a ringbuffer""" - - frame: bytes - timestamp: datetime - index: int - readers: int = 0 - - -class MJPEGStreamResponse(StreamingResponse): - media_type = "multipart/x-mixed-replace; boundary=frame" - - def __init__(self, gen: AsyncGenerator[bytes, None], status_code: int = 200): - """A StreamingResponse that streams an MJPEG stream - - This response is initialised with an async generator that yields `bytes` - objects, each of which is a JPEG file. We add the --frame markers and mime - types that enable it to work in an `img` tag. - - NB the `status_code` argument is used by FastAPI to set the status code of - the response in OpenAPI. - """ - self.frame_async_generator = gen - StreamingResponse.__init__( - self, - self.mjpeg_async_generator(), - media_type=self.media_type, - status_code=status_code, - ) - - async def mjpeg_async_generator(self) -> AsyncGenerator[bytes, None]: - """A generator yielding an MJPEG stream""" - async for frame in self.frame_async_generator: - yield b"--frame\r\nContent-Type: image/jpeg\r\n\r\n" - yield frame - yield b"\r\n" - - -class MJPEGStream: - def __init__(self, ringbuffer_size: int = 10): - self._lock = threading.Lock() - self.condition = anyio.Condition() - self._streaming = False - self.reset(ringbuffer_size=ringbuffer_size) - - def reset(self, ringbuffer_size: Optional[int] = None): - """Reset the stream and optionally change the ringbuffer size""" - with self._lock: - self._streaming = True - n = ringbuffer_size or len(self._ringbuffer) - self._ringbuffer = [ - RingbufferEntry( - frame=b"", - index=-1, - timestamp=datetime.min, - ) - for i in range(n) - ] - self.last_frame_i = -1 - - def stop(self): - """Stop the stream""" - with self._lock: - self._streaming = False - - async def ringbuffer_entry(self, i: int) -> RingbufferEntry: - """Return the `i`th frame acquired by the camera""" - if i < 0: - raise ValueError("i must be >= 0") - if i < self.last_frame_i - len(self._ringbuffer) + 2: - raise ValueError("the ith frame has been overwritten") - if i > self.last_frame_i: - # TODO: await the ith frame - raise ValueError("the ith frame has not yet been acquired") - entry = self._ringbuffer[i % len(self._ringbuffer)] - if entry.index != i: - raise ValueError("the ith frame has been overwritten") - return entry - - @asynccontextmanager - async def buffer_for_reading(self, i: int) -> AsyncContextManager[bytes]: - """Yields the ith frame as a bytes object""" - entry = await self.ringbuffer_entry(i) - try: - entry.readers += 1 - yield entry.frame - finally: - entry.readers -= 1 - - async def next_frame(self) -> int: - """Wait for the next frame, and return its index""" - async with self.condition: - await self.condition.wait() - return self.last_frame_i - - async def frame_async_generator(self) -> AsyncGenerator[bytes, None]: - """A generator that yields frames as bytes""" - while self._streaming: - try: - i = await self.next_frame() - async with self.buffer_for_reading(i) as frame: - yield frame - except Exception as e: - logging.error(f"Error in stream: {e}, stream stopped") - return - - async def mjpeg_stream_response(self) -> MJPEGStreamResponse: - """Return a StreamingResponse that streams an MJPEG stream""" - return MJPEGStreamResponse(self.frame_async_generator()) - - def add_frame(self, frame: bytes, portal: BlockingPortal): - """Return the next buffer in the ringbuffer to write to""" - with self._lock: - entry = self._ringbuffer[(self.last_frame_i + 1) % len(self._ringbuffer)] - if entry.readers > 0: - raise RuntimeError("Cannot write to ringbuffer while it is being read") - entry.timestamp = datetime.now() - entry.frame = frame - entry.index = self.last_frame_i + 1 - portal.start_task_soon(self.notify_new_frame, entry.index) - - async def notify_new_frame(self, i): - """Notify any waiting tasks that a new frame is available""" - async with self.condition: - self.last_frame_i = i - self.condition.notify_all() - - -class MJPEGStreamDescriptor: - """A descriptor that returns a MJPEGStream object when accessed""" - - def __init__(self, **kwargs): - self._kwargs = kwargs - - def __set_name__(self, owner, name): - self.name = name - - def __get__(self, obj, type=None) -> MJPEGStream: - """The value of the property - - If `obj` is none (i.e. we are getting the attribute of the class), - we return the descriptor. - - If no getter is set, we'll return either the initial value, or the value - from the object's __dict__, i.e. we behave like a variable. - - If a getter is set, we will use it, unless the property is observable, at - which point the getter is only ever used once, to set the initial value. - """ - if obj is None: - return self - try: - return obj.__dict__[self.name] - except KeyError: - obj.__dict__[self.name] = MJPEGStream(**self._kwargs) - return obj.__dict__[self.name] - - async def viewer_page(self, url: str) -> HTMLResponse: - return HTMLResponse(f"") - - def add_to_fastapi(self, app: FastAPI, thing: Thing): - """Add the stream to the FastAPI app""" - app.get( - f"{thing.path}{self.name}", - response_class=MJPEGStreamResponse, - )(self.__get__(thing).mjpeg_stream_response) - app.get( - f"{thing.path}{self.name}/viewer", - response_class=HTMLResponse, - )(partial(self.viewer_page, f"{thing.path}{self.name}")) - - -class OpenCVCamera(Thing): - """A Thing that represents an OpenCV camera""" - - def __init__(self, device_index: int = 0): - self.device_index = device_index - self._stream_thread: Optional[threading.Thread] = None - - def __enter__(self): - self._cap = cv.VideoCapture(self.device_index) - self._cap_lock = RLock() - if not self._cap.isOpened(): - raise IOError(f"Cannot open camera with device index {self.device_index}") - self.start_streaming() - return self - - def __exit__(self, exc_type, exc_value, traceback): - self.stop_streaming() - self._cap.release() - del self._cap - del self._cap_lock - - def start_streaming(self): - print("starting stream...") - if self._stream_thread is not None: - raise RuntimeError("Stream thread already running") - self._stream_thread = threading.Thread(target=self._stream_thread_fn) - self._continue_streaming = True - self._stream_thread.start() - print("started") - - def stop_streaming(self): - print("stopping stream...") - if self._stream_thread is None: - raise RuntimeError("Stream thread not running") - self._continue_streaming = False - self.mjpeg_stream.stop() - print("waiting for stream to join") - self._stream_thread.join() - print("stream stopped.") - self._stream_thread = None - - def _stream_thread_fn(self): - while self._continue_streaming: - with self._cap_lock: - ret, frame = self._cap.read() - if not ret: - logging.error("Could not read frame from camera") - continue - success, array = cv.imencode(".jpg", frame) - if success: - self.mjpeg_stream.add_frame( - frame=array.tobytes(), - portal=self._labthings_blocking_portal, - ) - self.last_frame_index = self.mjpeg_stream.last_frame_i - - @thing_action - def snap_image(self, file_manager: FileManagerDep) -> str: - """Acquire one image from the camera. - - This action cannot run if the camera is in use by a background thread, for - example if a preview stream is running. - """ - with self._cap_lock: - ret, frame = self._cap.read() - if not ret: - raise IOError("Could not read image from camera") - fpath = file_manager.path("image.jpg", rel="image") - cv.imwrite(fpath, frame) - return ( - "image.jpg is available from the links property of this Invocation " - "(see ./files)" - ) - - @thing_property - def exposure(self) -> float: - with self._cap_lock: - return self._cap.get(cv.CAP_PROP_EXPOSURE) - - @exposure.setter - def exposure(self, value): - with self._cap_lock: - self._cap.set(cv.CAP_PROP_EXPOSURE, value) - - last_frame_index = ThingProperty(int, initial_value=-1) - - mjpeg_stream = MJPEGStreamDescriptor(ringbuffer_size=10) - - -thing_server = ThingServer() -my_thing = OpenCVCamera() -my_thing.validate_thing_description() -thing_server.add_thing(my_thing, "/camera") - -app = thing_server.app diff --git a/examples/picamera2_camera_server.py b/examples/picamera2_camera_server.py deleted file mode 100644 index 4347f130..00000000 --- a/examples/picamera2_camera_server.py +++ /dev/null @@ -1,230 +0,0 @@ -from __future__ import annotations -import logging -import time - -from pydantic import BaseModel, BeforeValidator - -from labthings_fastapi.descriptors.property import ThingProperty -from labthings_fastapi.thing import Thing -from labthings_fastapi.decorators import thing_action, thing_property -from labthings_fastapi.server import ThingServer -from labthings_fastapi.file_manager import FileManagerDep -from typing import Annotated, Any, Iterator, Optional -from contextlib import contextmanager -from anyio.from_thread import BlockingPortal -from threading import RLock -import picamera2 -from picamera2 import Picamera2 -from picamera2.encoders import MJPEGEncoder, Quality -from picamera2.outputs import Output -from labthings_fastapi.outputs.mjpeg_stream import MJPEGStreamDescriptor, MJPEGStream -from labthings_fastapi.utilities import get_blocking_portal - - -logging.basicConfig(level=logging.INFO) - - -class PicameraControl(ThingProperty): - def __init__( - self, control_name: str, model: type = float, description: Optional[str] = None - ): - """A property descriptor controlling a picamera control""" - ThingProperty.__init__(self, model, observable=False, description=description) - self.control_name = control_name - self._getter - - def _getter(self, obj: StreamingPiCamera2): - print(f"getting {self.control_name} from {obj}") - with obj.picamera() as cam: - ret = cam.capture_metadata()[self.control_name] - print(f"Trying to return camera control {self.control_name} as `{ret}`") - return ret - - def _setter(self, obj: StreamingPiCamera2, value: Any): - with obj.picamera() as cam: - setattr(cam.controls, self.control_name, value) - - -class PicameraStreamOutput(Output): - """An Output class that sends frames to a stream""" - - def __init__(self, stream: MJPEGStream, portal: BlockingPortal): - """Create an output that puts frames in an MJPEGStream - - We need to pass the stream object, and also the blocking portal, because - new frame notifications happen in the anyio event loop and frames are - sent from a thread. The blocking portal enables thread-to-async - communication. - """ - Output.__init__(self) - self.stream = stream - self.portal = portal - - def outputframe(self, frame, _keyframe=True, _timestamp=None): - """Add a frame to the stream's ringbuffer""" - self.stream.add_frame(frame, self.portal) - - -class SensorMode(BaseModel): - unpacked: str - bit_depth: int - size: tuple[int, int] - fps: float - crop_limits: tuple[int, int, int, int] - exposure_limits: tuple[Optional[int], Optional[int], Optional[int]] - format: Annotated[str, BeforeValidator(repr)] - - -class StreamingPiCamera2(Thing): - """A Thing that represents an OpenCV camera""" - - def __init__(self, device_index: int = 0): - self.device_index = device_index - self.camera_configs: dict[str, dict] = {} - - stream_resolution = ThingProperty( - tuple[int, int], - initial_value=(1640, 1232), - description="Resolution to use for the MJPEG stream", - ) - image_resolution = ThingProperty( - tuple[int, int], - initial_value=(3280, 2464), - description="Resolution to use for still images (by default)", - ) - mjpeg_bitrate = ThingProperty( - int, initial_value=0, description="Bitrate for MJPEG stream (best left at 0)" - ) - stream_active = ThingProperty( - bool, - initial_value=False, - description="Whether the MJPEG stream is active", - observable=True, - ) - mjpeg_stream = MJPEGStreamDescriptor() - analogue_gain = PicameraControl("AnalogueGain", float) - colour_gains = PicameraControl("ColourGains", tuple[float, float]) - colour_correction_matrix = PicameraControl( - "ColourCorrectionMatrix", - tuple[float, float, float, float, float, float, float, float, float], - ) - exposure_time = PicameraControl( - "ExposureTime", int, description="The exposure time in microseconds" - ) - exposure_time = PicameraControl( - "ExposureTime", int, description="The exposure time in microseconds" - ) - sensor_modes = ThingProperty(list[SensorMode], readonly=True) - - def __enter__(self): - self._picamera = picamera2.Picamera2(camera_num=self.device_index) - self._picamera_lock = RLock() - self.populate_sensor_modes() - self.start_streaming() - return self - - @contextmanager - def picamera(self) -> Iterator[Picamera2]: - with self._picamera_lock: - yield self._picamera - - def populate_sensor_modes(self): - with self.picamera() as cam: - self.sensor_modes = cam.sensor_modes - - def __exit__(self, exc_type, exc_value, traceback): - self.stop_streaming() - with self.picamera() as cam: - cam.close() - del self._picamera - - def start_streaming(self) -> None: - """ - Start the MJPEG stream - - Sets the camera resolution to the video/stream resolution, and starts recording - if the stream should be active. - """ - with self.picamera() as picam: - # TODO: Filip: can we use the lores output to keep preview stream going - # while recording? According to picamera2 docs 4.2.1.6 this should work - try: - if picam.started: - picam.stop() - if picam.encoder is not None and picam.encoder.running: - picam.encoder.stop() - stream_config = picam.create_video_configuration( - main={"size": self.stream_resolution}, - # colour_space=ColorSpace.Rec709(), - ) - picam.configure(stream_config) - logging.info("Starting picamera MJPEG stream...") - picam.start_recording( - MJPEGEncoder( - self.mjpeg_bitrate if self.mjpeg_bitrate > 0 else None, - ), - PicameraStreamOutput( - self.mjpeg_stream, - get_blocking_portal(self), - ), - Quality.HIGH, # TODO: use provided quality - ) - except Exception as e: - logging.info("Error while starting preview:") - logging.exception(e) - else: - self.stream_active = True - logging.debug( - "Started MJPEG stream at %s on port %s", self.stream_resolution, 1 - ) - - def stop_streaming(self) -> None: - """ - Stop the MJPEG stream - """ - with self.picamera() as picam: - try: - picam.stop_recording() - except Exception as e: - logging.info("Stopping recording failed") - logging.exception(e) - else: - self.stream_active = False - self.mjpeg_stream.stop() - logging.info( - f"Stopped MJPEG stream. Switching to {self.image_resolution}." - ) - - # Increase the resolution for taking an image - time.sleep( - 0.2 - ) # Sprinkled a sleep to prevent camera getting confused by rapid commands - - @thing_action - def snap_image(self, file_manager: FileManagerDep) -> str: - """Acquire one image from the camera. - - This action cannot run if the camera is in use by a background thread, for - example if a preview stream is running. - """ - raise NotImplementedError - - @thing_property - def exposure(self) -> float: - raise NotImplementedError() - - @exposure.setter - def exposure(self, value): - raise NotImplementedError() - - last_frame_index = ThingProperty(int, initial_value=-1) - - mjpeg_stream = MJPEGStreamDescriptor(ringbuffer_size=10) - - -thing_server = ThingServer() -my_thing = StreamingPiCamera2() -my_thing.validate_thing_description() -thing_server.add_thing(my_thing, "/camera") - -app = thing_server.app From fc1ba7c9a390d0bc643f2a03c3cfffad9b6533b1 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Wed, 2 Jul 2025 15:41:18 +0100 Subject: [PATCH 11/12] Tidy up imports in tests, docstrings, and code Imports of `labthings_fastapi` within the package are now consistently changed to relative (.) imports. Tests now use `import labthings_fastapi as lt` wherever possible. Documentation has been updated to use the new import style. --- src/labthings_fastapi/actions/__init__.py | 4 ++-- src/labthings_fastapi/actions/invocation_model.py | 2 +- src/labthings_fastapi/client/in_server.py | 6 +++--- src/labthings_fastapi/dependencies/thing_server.py | 2 +- src/labthings_fastapi/descriptors/endpoint.py | 2 +- src/labthings_fastapi/server/__init__.py | 2 +- src/labthings_fastapi/server/cli.py | 2 +- src/labthings_fastapi/utilities/__init__.py | 2 +- tests/test_blob_output.py | 14 ++++++-------- tests/test_dependencies_2.py | 9 +++++---- tests/test_dependency_metadata.py | 3 +-- tests/test_docs.py | 11 ++++++++++- tests/test_server_cli.py | 3 ++- tests/test_thing.py | 2 +- 14 files changed, 36 insertions(+), 28 deletions(-) diff --git a/src/labthings_fastapi/actions/__init__.py b/src/labthings_fastapi/actions/__init__.py index b6fbaf92..ec75a1c8 100644 --- a/src/labthings_fastapi/actions/__init__.py +++ b/src/labthings_fastapi/actions/__init__.py @@ -11,8 +11,8 @@ from fastapi.responses import FileResponse from pydantic import BaseModel -from labthings_fastapi.utilities import model_to_dict -from labthings_fastapi.utilities.introspection import EmptyInput +from ..utilities import model_to_dict +from ..utilities.introspection import EmptyInput from ..thing_description.model import LinkElement from ..file_manager import FileManager from .invocation_model import InvocationModel, InvocationStatus diff --git a/src/labthings_fastapi/actions/invocation_model.py b/src/labthings_fastapi/actions/invocation_model.py index 65b215d1..6c0c357e 100644 --- a/src/labthings_fastapi/actions/invocation_model.py +++ b/src/labthings_fastapi/actions/invocation_model.py @@ -7,7 +7,7 @@ from pydantic import BaseModel, ConfigDict, model_validator -from labthings_fastapi.thing_description.model import Links +from ..thing_description.model import Links class InvocationStatus(Enum): diff --git a/src/labthings_fastapi/client/in_server.py b/src/labthings_fastapi/client/in_server.py index b6459be1..018e9d5c 100644 --- a/src/labthings_fastapi/client/in_server.py +++ b/src/labthings_fastapi/client/in_server.py @@ -14,10 +14,10 @@ import logging from typing import Any, Mapping, Optional, Union from pydantic import BaseModel -from labthings_fastapi.descriptors.action import ActionDescriptor +from ..descriptors.action import ActionDescriptor -from labthings_fastapi.descriptors.property import ThingProperty -from labthings_fastapi.utilities import attributes +from ..descriptors.property import ThingProperty +from ..utilities import attributes from . import PropertyClientDescriptor from ..thing import Thing from ..dependencies.thing_server import find_thing_server diff --git a/src/labthings_fastapi/dependencies/thing_server.py b/src/labthings_fastapi/dependencies/thing_server.py index 6f0897f4..ab9181f6 100644 --- a/src/labthings_fastapi/dependencies/thing_server.py +++ b/src/labthings_fastapi/dependencies/thing_server.py @@ -12,7 +12,7 @@ from fastapi import FastAPI, Request if TYPE_CHECKING: - from labthings_fastapi.server import ThingServer + from ..server import ThingServer _thing_servers: WeakSet[ThingServer] = WeakSet() diff --git a/src/labthings_fastapi/descriptors/endpoint.py b/src/labthings_fastapi/descriptors/endpoint.py index 273bd94d..4892f259 100644 --- a/src/labthings_fastapi/descriptors/endpoint.py +++ b/src/labthings_fastapi/descriptors/endpoint.py @@ -1,7 +1,7 @@ from __future__ import annotations from functools import partial, wraps -from labthings_fastapi.utilities.introspection import get_docstring, get_summary +from ..utilities.introspection import get_docstring, get_summary from typing import ( Callable, diff --git a/src/labthings_fastapi/server/__init__.py b/src/labthings_fastapi/server/__init__.py index 9fdfbe52..4d7a5290 100644 --- a/src/labthings_fastapi/server/__init__.py +++ b/src/labthings_fastapi/server/__init__.py @@ -10,7 +10,7 @@ from collections.abc import Mapping from types import MappingProxyType -from labthings_fastapi.utilities.object_reference_to_object import ( +from ..utilities.object_reference_to_object import ( object_reference_to_object, ) from ..actions import ActionManager diff --git a/src/labthings_fastapi/server/cli.py b/src/labthings_fastapi/server/cli.py index 759d9484..21e3eab8 100644 --- a/src/labthings_fastapi/server/cli.py +++ b/src/labthings_fastapi/server/cli.py @@ -2,7 +2,7 @@ from typing import Optional import json -from labthings_fastapi.utilities.object_reference_to_object import ( +from ..utilities.object_reference_to_object import ( object_reference_to_object, ) import uvicorn diff --git a/src/labthings_fastapi/utilities/__init__.py b/src/labthings_fastapi/utilities/__init__.py index 73f78224..a9807ffb 100644 --- a/src/labthings_fastapi/utilities/__init__.py +++ b/src/labthings_fastapi/utilities/__init__.py @@ -4,7 +4,7 @@ from pydantic import BaseModel, ConfigDict, Field, RootModel, create_model from pydantic.dataclasses import dataclass from anyio.from_thread import BlockingPortal -from labthings_fastapi.utilities.introspection import EmptyObject +from .introspection import EmptyObject if TYPE_CHECKING: from ..thing import Thing diff --git a/tests/test_blob_output.py b/tests/test_blob_output.py index 22cda8f7..e3bb056b 100644 --- a/tests/test_blob_output.py +++ b/tests/test_blob_output.py @@ -8,8 +8,6 @@ from fastapi.testclient import TestClient import pytest import labthings_fastapi as lt -from labthings_fastapi.outputs.blob import blob_type -from labthings_fastapi.client import ThingClient class TextBlob(lt.blob.Blob): @@ -71,8 +69,8 @@ def check_passthrough(self, thing_one: ThingOneDep) -> bool: def test_blob_type(): """Check we can't put dodgy values into a blob output model""" with pytest.raises(ValueError): - blob_type(media_type="text/plain\\'DROP TABLES") - M = blob_type(media_type="text/plain") + lt.blob.blob_type(media_type="text/plain\\'DROP TABLES") + M = lt.blob.blob_type(media_type="text/plain") assert M.from_bytes(b"").media_type == "text/plain" @@ -99,7 +97,7 @@ def test_blob_output_client(): server = lt.ThingServer() server.add_thing(ThingOne(), "/thing_one") with TestClient(server.app) as client: - tc = ThingClient.from_url("/thing_one/", client=client) + tc = lt.ThingClient.from_url("/thing_one/", client=client) check_actions(tc) @@ -115,7 +113,7 @@ def test_blob_output_inserver(): server.add_thing(ThingOne(), "/thing_one") server.add_thing(ThingTwo(), "/thing_two") with TestClient(server.app) as client: - tc = ThingClient.from_url("/thing_two/", client=client) + tc = lt.ThingClient.from_url("/thing_two/", client=client) output = tc.check_both() assert output is True @@ -145,7 +143,7 @@ def test_blob_input(): server.add_thing(ThingOne(), "/thing_one") server.add_thing(ThingTwo(), "/thing_two") with TestClient(server.app) as client: - tc = ThingClient.from_url("/thing_one/", client=client) + tc = lt.ThingClient.from_url("/thing_one/", client=client) output = tc.action_one() print(f"Output is {output}") assert output is not None @@ -157,7 +155,7 @@ def test_blob_input(): assert passthrough.content == ThingOne.ACTION_ONE_RESULT # Check that the same thing works on the server side - tc2 = ThingClient.from_url("/thing_two/", client=client) + tc2 = lt.ThingClient.from_url("/thing_two/", client=client) assert tc2.check_passthrough() is True diff --git a/tests/test_dependencies_2.py b/tests/test_dependencies_2.py index 755cbf77..50251f9d 100644 --- a/tests/test_dependencies_2.py +++ b/tests/test_dependencies_2.py @@ -2,13 +2,16 @@ Class-based dependencies in modules with `from __future__ import annotations` fail if they have sub-dependencies, because the global namespace is not found by -pydantic. The work-around is to add a line to each class definition: +pydantic. The work-around was to add a line to each class definition: ``` __globals__ = globals() ``` This bakes in the global namespace of the module, and allows FastAPI to correctly traverse the dependency tree. +This is related to https://github.com/fastapi/fastapi/issues/4557 and may have +been fixed upstream in FastAPI. + The tests in this module were written while I was figuring this out: they mostly test things from FastAPI that obviously work, but I will leave them in here as mitigation against something changing in the future. @@ -19,9 +22,7 @@ from fastapi.testclient import TestClient from module_with_deps import FancyIDDep, FancyID, ClassDependsOnFancyID import labthings_fastapi as lt -from labthings_fastapi.dependencies.invocation import invocation_id from labthings_fastapi.file_manager import FileManager -from uuid import UUID def test_dep_from_module(): @@ -127,7 +128,7 @@ def test_invocation_id(): app = FastAPI() @app.post("/endpoint") - def invoke_fancy(id: Annotated[UUID, Depends(invocation_id)]) -> bool: + def invoke_fancy(id: lt.deps.InvocationID) -> bool: return True with TestClient(app) as client: diff --git a/tests/test_dependency_metadata.py b/tests/test_dependency_metadata.py index f22abf15..2536d8f2 100644 --- a/tests/test_dependency_metadata.py +++ b/tests/test_dependency_metadata.py @@ -5,7 +5,6 @@ from typing import Any, Mapping from fastapi.testclient import TestClient from temp_client import poll_task -from labthings_fastapi.dependencies.metadata import GetThingStates import labthings_fastapi as lt @@ -39,7 +38,7 @@ def thing_state(self): @lt.thing_action def count_and_watch( - self, thing_one: ThingOneDep, get_metadata: GetThingStates + self, thing_one: ThingOneDep, get_metadata: lt.deps.GetThingStates ) -> Mapping[str, Mapping[str, Any]]: metadata = {} for a in self.A_VALUES: diff --git a/tests/test_docs.py b/tests/test_docs.py index 8cebbebd..3e9fcdfb 100644 --- a/tests/test_docs.py +++ b/tests/test_docs.py @@ -2,7 +2,7 @@ from runpy import run_path from test_server_cli import MonitoredProcess from fastapi.testclient import TestClient -from labthings_fastapi.client import ThingClient +from labthings_fastapi import ThingClient this_file = Path(__file__) @@ -22,6 +22,15 @@ def test_quickstart_counter(): def test_dependency_example(): + """Check the dependency example creates a server object. + + Running the example with `__name__` set to `__main__` would serve forever, + and start a full-blown HTTP server. Instead, we create the server but do + not run it - effectively we're importing the module into `globals`. + + We then create a TestClient to try out the server without the overhead + of HTTP, which is significantly faster. + """ globals = run_path(docs / "dependencies" / "example.py", run_name="not_main") with TestClient(globals["server"].app) as client: testthing = ThingClient.from_url("/testthing/", client=client) diff --git a/tests/test_server_cli.py b/tests/test_server_cli.py index 49bd5e23..f5e97086 100644 --- a/tests/test_server_cli.py +++ b/tests/test_server_cli.py @@ -5,7 +5,8 @@ from pytest import raises -from labthings_fastapi.server import server_from_config, ThingServer +from labthings_fastapi import ThingServer +from labthings_fastapi.server import server_from_config from labthings_fastapi.server.cli import serve_from_cli diff --git a/tests/test_thing.py b/tests/test_thing.py index ab31fb96..86df025f 100644 --- a/tests/test_thing.py +++ b/tests/test_thing.py @@ -1,6 +1,6 @@ import pytest from labthings_fastapi.example_things import MyThing -from labthings_fastapi.server import ThingServer +from labthings_fastapi import ThingServer def test_td_validates(): From a49f506a7a2b6c0ac0a200fe756f9c63ea57f416 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Wed, 2 Jul 2025 15:42:54 +0100 Subject: [PATCH 12/12] Fix typo in example code --- docs/source/dependencies/example.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/dependencies/example.py b/docs/source/dependencies/example.py index 2edf04a5..89f54d7b 100644 --- a/docs/source/dependencies/example.py +++ b/docs/source/dependencies/example.py @@ -1,7 +1,7 @@ import labthings_fastapi as lt from labthings_fastapi.example_things import MyThing -MyThingDep = lt.direct_thing_client_dependency(MyThing, "/mything/") +MyThingDep = lt.deps.direct_thing_client_dependency(MyThing, "/mything/") class TestThing(lt.Thing):