Skip to content

Thing Setting syntax and implementation overhaul #110

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 17 commits into from
Jun 24, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 11 additions & 6 deletions docs/source/lt_core_concepts.rst
Original file line number Diff line number Diff line change
Expand Up @@ -10,21 +10,26 @@ At its core LabThings FastAPI is a server-based framework. To use LabThings Fast

The server API is accessed over an HTTP requests, allowing client code (see below) to be written in any language that can send an HTTP request.

Client Code
-----------

Clients or client code (Not to be confused with a :class:`.ThingClient`, see below) is the terminology used to describe any software that uses HTTP requests to access the LabThing Server. Clients can be written in any language that supports an HTTP request. However, LabThings FastAPI provides additional functionality that makes writing client code in Python easier.

Everything is a Thing
---------------------

As described in :doc:`wot_core_concepts`, a Thing represents a piece of hardware or software. LabThings-FastAPI automatically generates a `Thing Description`_ to describe each Thing. Each function offered by the Thing is either a Property, Action, or Event. These are termed "interaction affordances" in WoT_ terminology.
As described in :doc:`wot_core_concepts`, a Thing represents a piece of hardware or software. LabThings-FastAPI automatically generates a `Thing Description`_ to describe each Thing. Each function offered by the Thing is either a Property or Action (LabThings-FastAPI does not yet support Events). These are termed "interaction affordances" in WoT_ terminology.

Code on the LabThings FastAPI Server is composed of Things, however these can call generic Python functions/classes. The entire HTTP API served by the server is defined by :class:`.Thing` objects. As such the full API is composed of the actions and properties (and perhaps eventually events) defined in each Thing.

_`Thing Description`: wot_core_concepts#thing
_`WoT`: wot_core_concepts

Properties vs Settings
----------------------

A Thing in LabThings-FastAPI can have Settings as well as Properties. "Setting" is LabThings-FastAPI terminology for a "Property" with a value that persists after the server is restarted. All Settings are Properties, and -- except for persisting after a server restart -- Settings are identical to any other Properties.

Client Code
-----------

Clients or client code (Not to be confused with a :class:`.ThingClient`, see below) is the terminology used to describe any software that uses HTTP requests to access the LabThing Server. Clients can be written in any language that supports an HTTP request. However, LabThings FastAPI provides additional functionality that makes writing client code in Python easier.

ThingClients
------------

Expand Down
4 changes: 2 additions & 2 deletions docs/source/quickstart/counter.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import time
from labthings_fastapi.thing import Thing
from labthings_fastapi.decorators import thing_action
from labthings_fastapi.descriptors import PropertyDescriptor
from labthings_fastapi.descriptors import ThingProperty


class TestThing(Thing):
Expand All @@ -24,7 +24,7 @@ def slowly_increase_counter(self) -> None:
time.sleep(1)
self.increment_counter()

counter = PropertyDescriptor(
counter = ThingProperty(
model=int, initial_value=0, readonly=True, description="A pointless counter"
)

Expand Down
4 changes: 2 additions & 2 deletions examples/counter.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import time
from labthings_fastapi.thing import Thing
from labthings_fastapi.decorators import thing_action
from labthings_fastapi.descriptors import PropertyDescriptor
from labthings_fastapi.descriptors import ThingProperty
from labthings_fastapi.server import ThingServer


Expand All @@ -25,7 +25,7 @@ def slowly_increase_counter(self) -> None:
time.sleep(1)
self.increment_counter()

counter = PropertyDescriptor(
counter = ThingProperty(
model=int, initial_value=0, readonly=True, description="A pointless counter"
)

Expand Down
6 changes: 3 additions & 3 deletions examples/demo_thing_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from labthings_fastapi.thing import Thing
from labthings_fastapi.decorators import thing_action
from labthings_fastapi.server import ThingServer
from labthings_fastapi.descriptors import PropertyDescriptor
from labthings_fastapi.descriptors import ThingProperty
from pydantic import Field
from fastapi.responses import HTMLResponse

Expand Down Expand Up @@ -60,11 +60,11 @@ def slowly_increase_counter(self):
time.sleep(1)
self.increment_counter()

counter = PropertyDescriptor(
counter = ThingProperty(
model=int, initial_value=0, readonly=True, description="A pointless counter"
)

foo = PropertyDescriptor(
foo = ThingProperty(
model=str,
initial_value="Example",
description="A pointless string for demo purposes.",
Expand Down
4 changes: 2 additions & 2 deletions examples/opencv_camera_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

from fastapi import FastAPI
from fastapi.responses import HTMLResponse, StreamingResponse
from labthings_fastapi.descriptors.property import PropertyDescriptor
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
Expand Down Expand Up @@ -279,7 +279,7 @@ def exposure(self, value):
with self._cap_lock:
self._cap.set(cv.CAP_PROP_EXPOSURE, value)

last_frame_index = PropertyDescriptor(int, initial_value=-1)
last_frame_index = ThingProperty(int, initial_value=-1)

mjpeg_stream = MJPEGStreamDescriptor(ringbuffer_size=10)

Expand Down
20 changes: 9 additions & 11 deletions examples/picamera2_camera_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

from pydantic import BaseModel, BeforeValidator

from labthings_fastapi.descriptors.property import PropertyDescriptor
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
Expand All @@ -24,14 +24,12 @@
logging.basicConfig(level=logging.INFO)


class PicameraControl(PropertyDescriptor):
class PicameraControl(ThingProperty):
def __init__(
self, control_name: str, model: type = float, description: Optional[str] = None
):
"""A property descriptor controlling a picamera control"""
PropertyDescriptor.__init__(
self, model, observable=False, description=description
)
ThingProperty.__init__(self, model, observable=False, description=description)
self.control_name = control_name
self._getter

Expand Down Expand Up @@ -84,20 +82,20 @@ def __init__(self, device_index: int = 0):
self.device_index = device_index
self.camera_configs: dict[str, dict] = {}

stream_resolution = PropertyDescriptor(
stream_resolution = ThingProperty(
tuple[int, int],
initial_value=(1640, 1232),
description="Resolution to use for the MJPEG stream",
)
image_resolution = PropertyDescriptor(
image_resolution = ThingProperty(
tuple[int, int],
initial_value=(3280, 2464),
description="Resolution to use for still images (by default)",
)
mjpeg_bitrate = PropertyDescriptor(
mjpeg_bitrate = ThingProperty(
int, initial_value=0, description="Bitrate for MJPEG stream (best left at 0)"
)
stream_active = PropertyDescriptor(
stream_active = ThingProperty(
bool,
initial_value=False,
description="Whether the MJPEG stream is active",
Expand All @@ -116,7 +114,7 @@ def __init__(self, device_index: int = 0):
exposure_time = PicameraControl(
"ExposureTime", int, description="The exposure time in microseconds"
)
sensor_modes = PropertyDescriptor(list[SensorMode], readonly=True)
sensor_modes = ThingProperty(list[SensorMode], readonly=True)

def __enter__(self):
self._picamera = picamera2.Picamera2(camera_num=self.device_index)
Expand Down Expand Up @@ -219,7 +217,7 @@ def exposure(self) -> float:
def exposure(self, value):
raise NotImplementedError()

last_frame_index = PropertyDescriptor(int, initial_value=-1)
last_frame_index = ThingProperty(int, initial_value=-1)

mjpeg_stream = MJPEGStreamDescriptor(ringbuffer_size=10)

Expand Down
9 changes: 9 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,15 @@ artifacts = ["src/*.json"]
[tool.hatch.build.targets.wheel]
artifacts = ["src/*.json"]

[tool.pytest.ini_options]
addopts = [
"--cov=labthings_fastapi",
"--cov-report=term",
"--cov-report=xml:coverage.xml",
"--cov-report=html:htmlcov",
"--cov-report=lcov",
]

[tool.ruff]
target-version = "py310"

Expand Down
9 changes: 4 additions & 5 deletions src/labthings_fastapi/client/in_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
from pydantic import BaseModel
from labthings_fastapi.descriptors.action import ActionDescriptor

from labthings_fastapi.descriptors.property import PropertyDescriptor
from labthings_fastapi.descriptors.property import ThingProperty
from labthings_fastapi.utilities import attributes
from . import PropertyClientDescriptor
from ..thing import Thing
Expand Down Expand Up @@ -66,9 +66,9 @@
obj: Optional[DirectThingClient] = None,
_objtype: Optional[type[DirectThingClient]] = None,
):
if obj is None:
return self
return getattr(obj._wrapped_thing, self.name)

Check warning on line 71 in src/labthings_fastapi/client/in_server.py

View workflow job for this annotation

GitHub Actions / coverage

69-71 lines are not covered with tests

def __set__(self, obj: DirectThingClient, value: Any):
setattr(obj._wrapped_thing, self.name, value)
Expand Down Expand Up @@ -123,15 +123,15 @@


def add_property(
attrs: dict[str, Any], property_name: str, property: PropertyDescriptor
attrs: dict[str, Any], property_name: str, property: ThingProperty
) -> None:
"""Add a property to a DirectThingClient subclass"""
attrs[property_name] = property_descriptor(
property_name,
property.model,
description=property.description,
writeable=not property.readonly,
readable=True, # TODO: make this configurable in PropertyDescriptor
readable=True, # TODO: make this configurable in ThingProperty
)


Expand Down Expand Up @@ -163,8 +163,7 @@
}
dependencies: list[inspect.Parameter] = []
for name, item in attributes(thing_class):
if isinstance(item, PropertyDescriptor):
# TODO: What about properties that don't use descriptors? Fall back to http?
if isinstance(item, ThingProperty):
add_property(client_attrs, name, item)
elif isinstance(item, ActionDescriptor):
if actions is None or name in actions:
Expand All @@ -174,7 +173,7 @@
else:
for affordance in ["property", "action", "event"]:
if hasattr(item, f"{affordance}_affordance"):
logging.warning(

Check warning on line 176 in src/labthings_fastapi/client/in_server.py

View workflow job for this annotation

GitHub Actions / coverage

176 line is not covered with tests
f"DirectThingClient doesn't support custom affordances, "
f"ignoring {name}"
)
Expand Down
55 changes: 45 additions & 10 deletions src/labthings_fastapi/decorators/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@
from typing import Optional, Callable
from ..descriptors import (
ActionDescriptor,
PropertyDescriptor,
ThingProperty,
ThingSetting,
EndpointDescriptor,
HTTPMethod,
)
Expand Down Expand Up @@ -71,20 +72,54 @@ def thing_action(func: Optional[Callable] = None, **kwargs):
return partial(mark_thing_action, **kwargs)


def thing_property(func: Callable) -> PropertyDescriptor:
"""Mark a method of a Thing as a Property
def thing_property(func: Callable) -> ThingProperty:
"""Mark a method of a Thing as a LabThings Property

We replace the function with a `Descriptor` that's a
subclass of `PropertyDescriptor`
This should be used as a decorator with a getter and a setter
just like a standard python property decorator. If extra functionality
is not required in the decorator, then using the ThingProperty class
directly may allow for clearer code

TODO: try https://stackoverflow.com/questions/54413434/type-hinting-with-descriptors
As properties are accessed over the HTTP API they need to be JSON serialisable
only return standard python types, or Pydantic BaseModels
"""
# Replace the function with a `Descriptor` that's a `ThingProperty`
return ThingProperty(
return_type(func),
readonly=True,
observable=False,
getter=func,
)


class PropertyDescriptorSubclass(PropertyDescriptor):
def __get__(self, obj, objtype=None):
return super().__get__(obj, objtype)
def thing_setting(func: Callable) -> ThingSetting:
"""Mark a method of a Thing as a LabThings Setting.

return PropertyDescriptorSubclass(
A setting is a property that persists between runs.

This should be used as a decorator with a getter and a setter
just like a standard python property decorator. If extra functionality
is not required in the decorator, then using the ThingSetting class
directly may allow for clearer code where the property works like a normal variable.

When creating a Setting using this decorator you must always create a setter
as it is used to load the value from disk.

As settings are accessed over the HTTP API and saved to disk they need to be
JSON serialisable only return standard python types, or Pydantic BaseModels.

If the type is a pydantic BaseModel, then the setter must also be able to accept
the dictionary representation of this BaseModel as this is what will be used to
set the Setting when loading from disk on starting the server.

Note: If a setting is mutated rather than set, this will not trigger saving.
For example: if a Thing has a setting called `dictsetting` holding the dictionary
`{"a": 1, "b": 2}` then `self.dictsetting = {"a": 2, "b": 2}` would trigger saving
but `self.dictsetting[a] = 2` would not, as the setter for `dictsetting` is never
called.
"""
# Replace the function with a `Descriptor` that's a `ThingSetting`
return ThingSetting(
return_type(func),
readonly=True,
observable=False,
Expand Down
3 changes: 2 additions & 1 deletion src/labthings_fastapi/descriptors/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from .action import ActionDescriptor as ActionDescriptor
from .property import PropertyDescriptor as PropertyDescriptor
from .property import ThingProperty as ThingProperty
from .property import ThingSetting as ThingSetting
from .endpoint import EndpointDescriptor as EndpointDescriptor
from .endpoint import HTTPMethod as HTTPMethod
21 changes: 17 additions & 4 deletions src/labthings_fastapi/descriptors/action.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@
from functools import partial
import inspect
from typing import TYPE_CHECKING, Annotated, Any, Callable, Optional, Literal, overload
from weakref import WeakSet

from fastapi import Body, FastAPI, Request, BackgroundTasks
from pydantic import create_model

from ..actions import InvocationModel
from ..dependencies.invocation import CancelHook, InvocationID
from ..dependencies.action_manager import ActionManagerContextDep
Expand All @@ -23,12 +26,11 @@
from ..outputs.blob import BlobIOContextDep
from ..thing_description import type_to_dataschema
from ..thing_description.model import ActionAffordance, ActionOp, Form, Union

from weakref import WeakSet
from ..utilities import labthings_data, get_blocking_portal
from ..exceptions import NotConnectedToServerError

if TYPE_CHECKING:
from ..thing import Thing

Check warning on line 33 in src/labthings_fastapi/descriptors/action.py

View workflow job for this annotation

GitHub Actions / coverage

33 line is not covered with tests


ACTION_POST_NOTICE = """
Expand Down Expand Up @@ -129,12 +131,23 @@
def emit_changed_event(self, obj, status):
"""Notify subscribers that the action status has changed

NB this function **must** be run from a thread, not the event loop.
This function is run from within the `Invocation` thread that
is created when an action is called. It must be run from this thread
as it is communicating with the event loop via an `asyncio` blocking
portal.

:raises NotConnectedToServerError: if the Thing calling the action is not
connected to a server with a running event loop.
"""
try:
runner = get_blocking_portal(obj)
if not runner:
raise RuntimeError("Can't emit without a blocking portal")
thing_name = obj.__class__.__name__
msg = (
f"Cannot emit action changed event. Is {thing_name} connected to "
"a running server?"
)
raise NotConnectedToServerError(msg)
runner.start_task_soon(
self.emit_changed_event_async,
obj,
Expand Down Expand Up @@ -219,11 +232,11 @@
try:
responses[200]["model"] = self.output_model
pass
except AttributeError:
print(f"Failed to generate response model for action {self.name}")

Check warning on line 236 in src/labthings_fastapi/descriptors/action.py

View workflow job for this annotation

GitHub Actions / coverage

235-236 lines are not covered with tests
# Add an additional media type if we may return a file
if hasattr(self.output_model, "media_type"):
responses[200]["content"][self.output_model.media_type] = {}

Check warning on line 239 in src/labthings_fastapi/descriptors/action.py

View workflow job for this annotation

GitHub Actions / coverage

239 line is not covered with tests
# Now we can add the endpoint to the app.
app.post(
thing.path + self.name,
Expand All @@ -247,7 +260,7 @@
summary=f"All invocations of {self.name}.",
)
def list_invocations(action_manager: ActionManagerContextDep):
return action_manager.list_invocations(self, thing, as_responses=True)

Check warning on line 263 in src/labthings_fastapi/descriptors/action.py

View workflow job for this annotation

GitHub Actions / coverage

263 line is not covered with tests

def action_affordance(
self, thing: Thing, path: Optional[str] = None
Expand Down
Loading