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..89f54d7b 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.deps.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.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/")
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
diff --git a/src/labthings_fastapi/__init__.py b/src/labthings_fastapi/__init__.py
index e69de29b..d15b6df4 100644
--- a/src/labthings_fastapi/__init__.py
+++ b/src/labthings_fastapi/__init__.py
@@ -0,0 +1,37 @@
+from .thing import Thing
+from .descriptors import ThingProperty, ThingSetting
+from .decorators import (
+ thing_property,
+ thing_setting,
+ thing_action,
+ fastapi_endpoint,
+)
+from . import deps
+from . import outputs
+from .outputs import blob
+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`.
+# 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",
+ "fastapi_endpoint",
+ "deps",
+ "outputs",
+ "blob",
+ "ThingServer",
+ "cli",
+ "ThingClient",
+ "get_blocking_portal",
+]
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/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/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/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/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_action_cancel.py b/tests/test_action_cancel.py
index 9a9f6e75..358fa02f 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.deps.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..c98c08b0 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.deps.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..e3bb056b 100644
--- a/tests/test_blob_output.py
+++ b/tests/test_blob_output.py
@@ -7,29 +7,25 @@
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
-from labthings_fastapi.outputs.blob import blob_type
-from labthings_fastapi.client import ThingClient
+import labthings_fastapi as lt
-TextBlob = blob_type(media_type="text/plain")
+class TextBlob(lt.blob.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 +33,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 +41,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.deps.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()
@@ -73,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"
@@ -98,10 +94,10 @@ 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)
+ tc = lt.ThingClient.from_url("/thing_one/", client=client)
check_actions(tc)
@@ -113,11 +109,11 @@ 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:
- 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
@@ -143,11 +139,11 @@ 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:
- 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
@@ -159,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.py b/tests/test_dependencies.py
index 979dabed..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.dependencies.invocation 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 d1999403..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.
@@ -18,9 +21,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.file_manager import FileManager
-from uuid import UUID
def test_dep_from_module():
@@ -122,11 +124,11 @@ 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")
- 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:
@@ -135,11 +137,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.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 2da4e336..2536d8f2 100644
--- a/tests/test_dependency_metadata.py
+++ b/tests/test_dependency_metadata.py
@@ -4,20 +4,16 @@
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,19 +26,19 @@ def thing_state(self):
return {"a": self.a}
-ThingOneDep = direct_thing_client_dependency(ThingOne, "/thing_one/")
+ThingOneDep = lt.deps.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
+ self, thing_one: ThingOneDep, get_metadata: lt.deps.GetThingStates
) -> Mapping[str, Mapping[str, Any]]:
metadata = {}
for a in self.A_VALUES:
@@ -52,7 +48,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_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_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_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_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.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():
diff --git a/tests/test_thing_dependencies.py b/tests/test_thing_dependencies.py
index c0983982..729e0f2f 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.deps.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.deps.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.deps.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.deps.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.deps.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")