Skip to content

Commit c2e09b8

Browse files
Merge pull request #110 from labthings/persistent-property
Thing Setting syntax and implementation overhaul
2 parents b9d5929 + 2f880a4 commit c2e09b8

23 files changed

+531
-214
lines changed

docs/source/lt_core_concepts.rst

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,21 +10,26 @@ At its core LabThings FastAPI is a server-based framework. To use LabThings Fast
1010

1111
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.
1212

13-
Client Code
14-
-----------
15-
16-
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.
17-
1813
Everything is a Thing
1914
---------------------
2015

21-
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.
16+
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.
2217

2318
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.
2419

2520
_`Thing Description`: wot_core_concepts#thing
2621
_`WoT`: wot_core_concepts
2722

23+
Properties vs Settings
24+
----------------------
25+
26+
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.
27+
28+
Client Code
29+
-----------
30+
31+
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.
32+
2833
ThingClients
2934
------------
3035

docs/source/quickstart/counter.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import time
22
from labthings_fastapi.thing import Thing
33
from labthings_fastapi.decorators import thing_action
4-
from labthings_fastapi.descriptors import PropertyDescriptor
4+
from labthings_fastapi.descriptors import ThingProperty
55

66

77
class TestThing(Thing):
@@ -24,7 +24,7 @@ def slowly_increase_counter(self) -> None:
2424
time.sleep(1)
2525
self.increment_counter()
2626

27-
counter = PropertyDescriptor(
27+
counter = ThingProperty(
2828
model=int, initial_value=0, readonly=True, description="A pointless counter"
2929
)
3030

examples/counter.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import time
22
from labthings_fastapi.thing import Thing
33
from labthings_fastapi.decorators import thing_action
4-
from labthings_fastapi.descriptors import PropertyDescriptor
4+
from labthings_fastapi.descriptors import ThingProperty
55
from labthings_fastapi.server import ThingServer
66

77

@@ -25,7 +25,7 @@ def slowly_increase_counter(self) -> None:
2525
time.sleep(1)
2626
self.increment_counter()
2727

28-
counter = PropertyDescriptor(
28+
counter = ThingProperty(
2929
model=int, initial_value=0, readonly=True, description="A pointless counter"
3030
)
3131

examples/demo_thing_server.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from labthings_fastapi.thing import Thing
55
from labthings_fastapi.decorators import thing_action
66
from labthings_fastapi.server import ThingServer
7-
from labthings_fastapi.descriptors import PropertyDescriptor
7+
from labthings_fastapi.descriptors import ThingProperty
88
from pydantic import Field
99
from fastapi.responses import HTMLResponse
1010

@@ -60,11 +60,11 @@ def slowly_increase_counter(self):
6060
time.sleep(1)
6161
self.increment_counter()
6262

63-
counter = PropertyDescriptor(
63+
counter = ThingProperty(
6464
model=int, initial_value=0, readonly=True, description="A pointless counter"
6565
)
6666

67-
foo = PropertyDescriptor(
67+
foo = ThingProperty(
6868
model=str,
6969
initial_value="Example",
7070
description="A pointless string for demo purposes.",

examples/opencv_camera_server.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
from fastapi import FastAPI
55
from fastapi.responses import HTMLResponse, StreamingResponse
6-
from labthings_fastapi.descriptors.property import PropertyDescriptor
6+
from labthings_fastapi.descriptors.property import ThingProperty
77
from labthings_fastapi.thing import Thing
88
from labthings_fastapi.decorators import thing_action, thing_property
99
from labthings_fastapi.server import ThingServer
@@ -279,7 +279,7 @@ def exposure(self, value):
279279
with self._cap_lock:
280280
self._cap.set(cv.CAP_PROP_EXPOSURE, value)
281281

282-
last_frame_index = PropertyDescriptor(int, initial_value=-1)
282+
last_frame_index = ThingProperty(int, initial_value=-1)
283283

284284
mjpeg_stream = MJPEGStreamDescriptor(ringbuffer_size=10)
285285

examples/picamera2_camera_server.py

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
from pydantic import BaseModel, BeforeValidator
66

7-
from labthings_fastapi.descriptors.property import PropertyDescriptor
7+
from labthings_fastapi.descriptors.property import ThingProperty
88
from labthings_fastapi.thing import Thing
99
from labthings_fastapi.decorators import thing_action, thing_property
1010
from labthings_fastapi.server import ThingServer
@@ -24,14 +24,12 @@
2424
logging.basicConfig(level=logging.INFO)
2525

2626

27-
class PicameraControl(PropertyDescriptor):
27+
class PicameraControl(ThingProperty):
2828
def __init__(
2929
self, control_name: str, model: type = float, description: Optional[str] = None
3030
):
3131
"""A property descriptor controlling a picamera control"""
32-
PropertyDescriptor.__init__(
33-
self, model, observable=False, description=description
34-
)
32+
ThingProperty.__init__(self, model, observable=False, description=description)
3533
self.control_name = control_name
3634
self._getter
3735

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

87-
stream_resolution = PropertyDescriptor(
85+
stream_resolution = ThingProperty(
8886
tuple[int, int],
8987
initial_value=(1640, 1232),
9088
description="Resolution to use for the MJPEG stream",
9189
)
92-
image_resolution = PropertyDescriptor(
90+
image_resolution = ThingProperty(
9391
tuple[int, int],
9492
initial_value=(3280, 2464),
9593
description="Resolution to use for still images (by default)",
9694
)
97-
mjpeg_bitrate = PropertyDescriptor(
95+
mjpeg_bitrate = ThingProperty(
9896
int, initial_value=0, description="Bitrate for MJPEG stream (best left at 0)"
9997
)
100-
stream_active = PropertyDescriptor(
98+
stream_active = ThingProperty(
10199
bool,
102100
initial_value=False,
103101
description="Whether the MJPEG stream is active",
@@ -116,7 +114,7 @@ def __init__(self, device_index: int = 0):
116114
exposure_time = PicameraControl(
117115
"ExposureTime", int, description="The exposure time in microseconds"
118116
)
119-
sensor_modes = PropertyDescriptor(list[SensorMode], readonly=True)
117+
sensor_modes = ThingProperty(list[SensorMode], readonly=True)
120118

121119
def __enter__(self):
122120
self._picamera = picamera2.Picamera2(camera_num=self.device_index)
@@ -219,7 +217,7 @@ def exposure(self) -> float:
219217
def exposure(self, value):
220218
raise NotImplementedError()
221219

222-
last_frame_index = PropertyDescriptor(int, initial_value=-1)
220+
last_frame_index = ThingProperty(int, initial_value=-1)
223221

224222
mjpeg_stream = MJPEGStreamDescriptor(ringbuffer_size=10)
225223

pyproject.toml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,15 @@ artifacts = ["src/*.json"]
5151
[tool.hatch.build.targets.wheel]
5252
artifacts = ["src/*.json"]
5353

54+
[tool.pytest.ini_options]
55+
addopts = [
56+
"--cov=labthings_fastapi",
57+
"--cov-report=term",
58+
"--cov-report=xml:coverage.xml",
59+
"--cov-report=html:htmlcov",
60+
"--cov-report=lcov",
61+
]
62+
5463
[tool.ruff]
5564
target-version = "py310"
5665

src/labthings_fastapi/client/in_server.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from pydantic import BaseModel
1717
from labthings_fastapi.descriptors.action import ActionDescriptor
1818

19-
from labthings_fastapi.descriptors.property import PropertyDescriptor
19+
from labthings_fastapi.descriptors.property import ThingProperty
2020
from labthings_fastapi.utilities import attributes
2121
from . import PropertyClientDescriptor
2222
from ..thing import Thing
@@ -123,15 +123,15 @@ def action_method(self, **kwargs):
123123

124124

125125
def add_property(
126-
attrs: dict[str, Any], property_name: str, property: PropertyDescriptor
126+
attrs: dict[str, Any], property_name: str, property: ThingProperty
127127
) -> None:
128128
"""Add a property to a DirectThingClient subclass"""
129129
attrs[property_name] = property_descriptor(
130130
property_name,
131131
property.model,
132132
description=property.description,
133133
writeable=not property.readonly,
134-
readable=True, # TODO: make this configurable in PropertyDescriptor
134+
readable=True, # TODO: make this configurable in ThingProperty
135135
)
136136

137137

@@ -163,8 +163,7 @@ def init_proxy(self, request: Request, **dependencies: Mapping[str, Any]):
163163
}
164164
dependencies: list[inspect.Parameter] = []
165165
for name, item in attributes(thing_class):
166-
if isinstance(item, PropertyDescriptor):
167-
# TODO: What about properties that don't use descriptors? Fall back to http?
166+
if isinstance(item, ThingProperty):
168167
add_property(client_attrs, name, item)
169168
elif isinstance(item, ActionDescriptor):
170169
if actions is None or name in actions:

src/labthings_fastapi/decorators/__init__.py

Lines changed: 45 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@
3636
from typing import Optional, Callable
3737
from ..descriptors import (
3838
ActionDescriptor,
39-
PropertyDescriptor,
39+
ThingProperty,
40+
ThingSetting,
4041
EndpointDescriptor,
4142
HTTPMethod,
4243
)
@@ -71,20 +72,54 @@ def thing_action(func: Optional[Callable] = None, **kwargs):
7172
return partial(mark_thing_action, **kwargs)
7273

7374

74-
def thing_property(func: Callable) -> PropertyDescriptor:
75-
"""Mark a method of a Thing as a Property
75+
def thing_property(func: Callable) -> ThingProperty:
76+
"""Mark a method of a Thing as a LabThings Property
7677
77-
We replace the function with a `Descriptor` that's a
78-
subclass of `PropertyDescriptor`
78+
This should be used as a decorator with a getter and a setter
79+
just like a standard python property decorator. If extra functionality
80+
is not required in the decorator, then using the ThingProperty class
81+
directly may allow for clearer code
7982
80-
TODO: try https://stackoverflow.com/questions/54413434/type-hinting-with-descriptors
83+
As properties are accessed over the HTTP API they need to be JSON serialisable
84+
only return standard python types, or Pydantic BaseModels
8185
"""
86+
# Replace the function with a `Descriptor` that's a `ThingProperty`
87+
return ThingProperty(
88+
return_type(func),
89+
readonly=True,
90+
observable=False,
91+
getter=func,
92+
)
93+
8294

83-
class PropertyDescriptorSubclass(PropertyDescriptor):
84-
def __get__(self, obj, objtype=None):
85-
return super().__get__(obj, objtype)
95+
def thing_setting(func: Callable) -> ThingSetting:
96+
"""Mark a method of a Thing as a LabThings Setting.
8697
87-
return PropertyDescriptorSubclass(
98+
A setting is a property that persists between runs.
99+
100+
This should be used as a decorator with a getter and a setter
101+
just like a standard python property decorator. If extra functionality
102+
is not required in the decorator, then using the ThingSetting class
103+
directly may allow for clearer code where the property works like a normal variable.
104+
105+
When creating a Setting using this decorator you must always create a setter
106+
as it is used to load the value from disk.
107+
108+
As settings are accessed over the HTTP API and saved to disk they need to be
109+
JSON serialisable only return standard python types, or Pydantic BaseModels.
110+
111+
If the type is a pydantic BaseModel, then the setter must also be able to accept
112+
the dictionary representation of this BaseModel as this is what will be used to
113+
set the Setting when loading from disk on starting the server.
114+
115+
Note: If a setting is mutated rather than set, this will not trigger saving.
116+
For example: if a Thing has a setting called `dictsetting` holding the dictionary
117+
`{"a": 1, "b": 2}` then `self.dictsetting = {"a": 2, "b": 2}` would trigger saving
118+
but `self.dictsetting[a] = 2` would not, as the setter for `dictsetting` is never
119+
called.
120+
"""
121+
# Replace the function with a `Descriptor` that's a `ThingSetting`
122+
return ThingSetting(
88123
return_type(func),
89124
readonly=True,
90125
observable=False,
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from .action import ActionDescriptor as ActionDescriptor
2-
from .property import PropertyDescriptor as PropertyDescriptor
2+
from .property import ThingProperty as ThingProperty
3+
from .property import ThingSetting as ThingSetting
34
from .endpoint import EndpointDescriptor as EndpointDescriptor
45
from .endpoint import HTTPMethod as HTTPMethod

0 commit comments

Comments
 (0)