Skip to content

Commit ba43627

Browse files
authored
Merge pull request #155 from labthings/simplify-property-and-add-type-hints
Simplify property and add type hints This is a big code tidy-up that removes a lot of confusing and unused behaviour, and makes it possible to statically type `Thing` code. See the "properties" tutorial page on readthedocs for examples.
2 parents 278e8db + 45de736 commit ba43627

40 files changed

+3122
-615
lines changed

.codespellrc

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ skip = *.git,
77
./build,
88
./dist,
99
./docs/_build,
10+
./docs/build,
11+
./docs/source/autoapi,
1012
./htmlcov,
1113
./src/openflexure_microscope_server/static,
1214
.venv,

.github/workflows/test.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,9 @@ jobs:
7575

7676
- name: Analyse with MyPy
7777
run: mypy src
78+
79+
- name: Type tests with MyPy
80+
run: mypy --warn-unused-ignores typing_tests
7881

7982
test-with-unpinned-deps:
8083
runs-on: ubuntu-latest

dev-requirements.txt

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,12 @@ click==8.2.1
3434
# uvicorn
3535
codespell==2.4.1
3636
# via labthings-fastapi (pyproject.toml)
37+
colorama==0.4.6
38+
# via
39+
# click
40+
# pytest
41+
# sphinx
42+
# uvicorn
3743
coverage==7.9.2
3844
# via pytest-cov
3945
dnspython==2.7.0
@@ -49,6 +55,10 @@ email-validator==2.2.0
4955
# via
5056
# fastapi
5157
# pydantic
58+
exceptiongroup==1.3.0
59+
# via
60+
# anyio
61+
# pytest
5262
fastapi==0.116.1
5363
# via labthings-fastapi (pyproject.toml)
5464
fastapi-cli==0.0.8
@@ -58,13 +68,10 @@ fastapi-cloud-cli==0.1.4
5868
flake8==7.3.0
5969
# via
6070
# labthings-fastapi (pyproject.toml)
61-
# flake8-docstrings
6271
# flake8-pyproject
6372
# flake8-rst
6473
# flake8-rst-docstrings
6574
# pydoclint
66-
flake8-docstrings==1.7.0
67-
# via labthings-fastapi (pyproject.toml)
6875
flake8-pyproject==1.2.3
6976
# via labthings-fastapi (pyproject.toml)
7077
flake8-rst==0.8.0
@@ -152,8 +159,6 @@ pydantic-settings==2.10.1
152159
# via fastapi
153160
pydoclint==0.6.6
154161
# via labthings-fastapi (pyproject.toml)
155-
pydocstyle==6.3.0
156-
# via flake8-docstrings
157162
pyflakes==3.4.0
158163
# via flake8
159164
pygments==2.19.2
@@ -210,9 +215,7 @@ shellingham==1.5.4
210215
sniffio==1.3.1
211216
# via anyio
212217
snowballstemmer==3.0.1
213-
# via
214-
# pydocstyle
215-
# sphinx
218+
# via sphinx
216219
sphinx==8.1.3
217220
# via
218221
# labthings-fastapi (pyproject.toml)
@@ -239,6 +242,14 @@ sphinxcontrib-serializinghtml==2.0.0
239242
# via sphinx
240243
starlette==0.47.1
241244
# via fastapi
245+
tomli==2.2.1
246+
# via
247+
# coverage
248+
# flake8-pyproject
249+
# mypy
250+
# pydoclint
251+
# pytest
252+
# sphinx
242253
typer==0.16.0
243254
# via
244255
# fastapi-cli
@@ -249,16 +260,20 @@ typing-extensions==4.14.1
249260
# via
250261
# labthings-fastapi (pyproject.toml)
251262
# anyio
263+
# astroid
264+
# exceptiongroup
252265
# fastapi
253266
# mypy
254267
# pydantic
255268
# pydantic-core
256269
# pydantic-extra-types
257270
# referencing
271+
# rich
258272
# rich-toolkit
259273
# starlette
260274
# typer
261275
# typing-inspection
276+
# uvicorn
262277
typing-inspection==0.4.1
263278
# via pydantic-settings
264279
ujson==5.10.0
@@ -272,8 +287,6 @@ uvicorn==0.35.0
272287
# fastapi
273288
# fastapi-cli
274289
# fastapi-cloud-cli
275-
uvloop==0.21.0
276-
# via uvicorn
277290
watchfiles==1.1.0
278291
# via uvicorn
279292
websockets==15.0.1

docs/source/documentation.rst

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
.. _gen_docs:
2+
3+
Generated documentation
4+
=======================
5+
6+
LabThings describes its HTTP API in two ways: with a :ref:`wot_td` and with an OpenAPI_ document.
7+
8+
.. _openapi:
9+
10+
OpenAPI
11+
-------
12+
13+
OpenAPI_ is a standard way to describe an HTTP interface. It lists all of the possible HTTP requests that may be made, along with a description of each one, and a description of the possible responses.
14+
15+
.. _gen_td:
16+
17+
Thing Description
18+
-----------------
19+
20+
Each :ref:`wot_thing` is documented by a Thing Description, which is a JSON document describing all of the ways to interact with that Thing (:ref:`wot_affordances`\ ). The WoT_ standard defines the `Thing Description`_ and includes a JSON Schema against which it may be validated.
21+
22+
Thing Description documents are higher-level than OpenAPI_ and focus on the capabilities of the Thing. For example, they include a list of properties, where each action is described only once. LabThings treats the Thing Description as your public API, and as a general rule anything not described in the Thing Description is not available over HTTP or to a `.DirectThingClient`\ .
23+
24+
Comparison of Thing Description and OpenAPI
25+
-------------------------------------------
26+
27+
Thing Description aims to be a neat way to describe the capabilities of a Thing, while OpenAPI focuses on detailed documentation of every possible interaction with the server. Thing Description is a newer and less well adopted standard that's specific to the Web of Things, while OpenAPI has been around for a while and is widely used and understood as a general-purpose API description.
28+
29+
OpenAPI describes each HTTP endpoint individually. There are usually more HTTP endpoints than there are :ref:`wot_affordances` because a Property may have two endpoints, one to read its value and one to write it. Actions usually correspond to several endpoints; one to invoke the action, one to check the action's status, one to cancel it, and another to retrieve its output once it has finished. In principle, client code based on a Thing Description should be more meaningful because related endpoints can be grouped together into properties or actions that correspond to structures in the programming language (like methods and properties in Python). However, OpenAPI is a much more widely adopted standard and so both forms of documentation are generated by LabThings-FastAPI.
30+
31+
.. _WoT: https://www.w3.org/WoT/
32+
.. _Thing Description: https://www.w3.org/TR/wot-thing-description/
33+
.. _OpenAPI: https://www.openapis.org/

docs/source/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ Documentation for LabThings-FastAPI
1515
blobs.rst
1616
concurrency.rst
1717
using_things.rst
18+
see_also.rst
1819

1920
autoapi/index
2021

docs/source/quickstart/counter.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,8 @@ def slowly_increase_counter(self) -> None:
2222
time.sleep(1)
2323
self.increment_counter()
2424

25-
counter = lt.ThingProperty(
26-
model=int, initial_value=0, readonly=True, description="A pointless counter"
27-
)
25+
counter: int = lt.property(default=0, readonly=True)
26+
"A pointless counter"
2827

2928

3029
if __name__ == "__main__":

docs/source/see_also.rst

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
See Also
2+
========
3+
4+
LabThings-FastAPI makes quite heavy use of a few key concepts from external libraries, including `fastapi`, `pydantic`, and of course Python's core library. This page attempts to summarise these, and also acts as a useful place for docstrings to link to, so we can avoid repetition.
5+
6+
.. _descriptors:
7+
8+
Descriptors
9+
-----------
10+
11+
Descriptors are a way to intercept attribute access on an object. By default, attributes of an object are just variables - so an object called ``foo`` might have an attribute called ``bar``, and you may read its value with ``foo.bar``, write its value with ``foo.bar = "baz"``, and delete the attribute with ``del foo.bar``. If ``foo`` is a descriptor, Python will call the ``__get__`` method of that descriptor when it's read and the ``__set__`` method when it's written to. You have quite probably used a descriptor already, because the built-in `~builtins.property` creates a descriptor object: that's what runs your getter method when the property is accessed. The descriptor protocol is described with plenty of examples in the `Descriptor Guide`_ in the Python documentation.
12+
13+
In LabThings-FastAPI, descriptors are used to implement :ref:`wot_actions` and :ref:`wot_properties` on `.Thing` subclasses. The intention is that these will function like standard Python methods and properties, but will also be available over HTTP, along with automatic documentation in the :ref:`wot_td` and OpenAPI documents.
14+
15+
There are a few useful notes that relate to many of the descriptors in LabThings-FastAPI:
16+
17+
* Descriptor objects **may have more than one owner**. As a rule, a descriptor object
18+
(e.g. an instance of `.DataProperty`) is assigned to an attribute of one `.Thing` subclass. There may, however, be multiple *instances* of that class, so it is not safe to assume that the descriptor object corresponds to only one `.Thing`. This is why the `.Thing` is passed to the ``__get__`` method: we should ensure that any values being remembered are keyed to the owning `.Thing` and are not simply stored in the descriptor. Usually, this is done using `.WeakKeyDictionary` objects, which allow us to look up values based on the `.Thing`, without interfering with garbage collection.
19+
20+
The example below shows how this can go wrong.
21+
22+
.. code-block:: python
23+
24+
class BadProperty:
25+
"An example of a descriptor that has unwanted behaviour."
26+
def __init__(self):
27+
self._value = None
28+
29+
def __get__(self, obj):
30+
return self._value
31+
32+
def __set__(self, obj, val):
33+
self._value = val
34+
35+
class BrokenExample:
36+
myprop = BadProperty()
37+
38+
a = BrokenExample()
39+
b = BrokenExample()
40+
41+
assert a.myprop is None
42+
b.myprop = True
43+
assert a.myprop is None # FAILS because `myprop` shares values between a and b
44+
45+
* Descriptor objects **may know their name**. Python calls ``__set_name__`` on a descriptor if it is available. This allows the descriptor to know the name of the attribute to which it is assigned. LabThings-FastAPI uses the name in the URL and in the Thing Description. When ``__set_name__`` is called, the descriptor **is also passed the class that owns it**. This allows us to check for type hints and docstrings that are part of the class, rather than part of the descriptor.
46+
* There is a convention that descriptors return their value when accessed as an instance attribute, but return themselves when accessed as a class attribute (as done by `builtins.property`). LabThings adheres to that convention.
47+
48+
.. _`Descriptor Guide`: https://docs.python.org/3/howto/descriptor.html

docs/source/tutorial/index.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ LabThings-FastAPI tutorial
77

88
installing_labthings.rst
99
running_labthings.rst
10+
writing_a_thing.rst
11+
properties.rst
1012

1113
..
1214
In due course, these pages should exist...
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
.. _tutorial_properties:
2+
3+
Properties
4+
=========================
5+
6+
7+
8+
Properties are values that can be read from and written to a Thing. They are used to represent the state of the Thing, such as its current temperature, brightness, or status. :ref:`wot_properties` are a key concept in the Web of Things standard.
9+
10+
LabThings implements properties in a very similar way to the built-in Python `~builtins.property`. The key difference is that defining an attribute as a `.property` means that the property will be listed in the :ref:`gen_td` and exposed over HTTP. This is important for two reasons:
11+
12+
* Only properties declared using `.property` (usually imported as ``lt.property``) can be accessed over HTTP. Regular attributes or properties using `builtins.property` are only available to your `.Thing` internally, except in some special cases.
13+
* Communication between `.Thing`\ s within a LabThings server should be done using a `.DirectThingClient` class. The purpose of `.DirectThingClient` is to provide the same interface as a `.ThingClient` over HTTP, so it will also only expose functionality described in the Thing Description.
14+
15+
You can add properties to a `.Thing` by using `.property` (usually imported as ``lt.property``).
16+
17+
Data properties
18+
-------------------------
19+
20+
Data properties behave like variables: they simply store a value that is used by other code on the `.Thing`. They are defined similarly to fields in `dataclasses` or `pydantic` models:
21+
22+
.. code-block:: python
23+
24+
import labthings_fastapi as lt
25+
26+
class MyThing(lt.Thing):
27+
my_property: int = lt.property(default=42)
28+
29+
The example above defines a property called `my_property` that has a default value of `42`. Note the type hint `int` which indicates that the property should hold an integer value. This is important, as the type will be enforced when the property is written to via HTTP, and it will appear in :ref:`gen_docs`. By default, this property may be read or written to by HTTP requests. If you want to make it read-only, you can set the `readonly` parameter to `True`:
30+
31+
.. code-block:: python
32+
33+
class MyThing(lt.Thing):
34+
my_property: int = lt.property(default=42, readonly=True)
35+
36+
Note that the ``readonly`` parameter only affects *client* code, i.e. it may not be written to via HTTP requests or `.DirectThingClient` instances. However, the property can still be modified by the Thing's code, e.g. in response to an action or another property change as ``self.my_property = 100``.
37+
38+
It is a good idea to make sure there is a docstring for your property. This will be used in the :ref:`gen_docs`, and it will help users understand what the property is for. You can add a docstring to the property by placing a string immediately after the property definition:
39+
40+
.. code-block:: python
41+
42+
class MyThing(lt.Thing):
43+
my_property: int = lt.property(default=42, readonly=True)
44+
"""A property that holds an integer value."""
45+
46+
You don't need to include the type in the docstring, as it will be inferred from the type hint. However, you can include additional information about the property, such as its units or any constraints on its value.
47+
48+
Data properties may be *observed*, which means notifications will be sent when the property is written to (see below).
49+
50+
Functional properties
51+
-------------------------
52+
53+
It is also possible to have properties that run code when they are read or written to. These are called functional properties, and they are defined using the `lt.FunctionalProperty` class. They might communicate with hardware (for example to read or write a setting on an instrument), or they might perform some computation based on other properties. They are defined with a decorator, very similarly to the built-in `property` function:
54+
55+
.. code-block:: python
56+
57+
import labthings_fastapi as lt
58+
59+
class MyThing(lt.Thing):
60+
my_property: int = lt.property(default=42)
61+
"""A property that holds an integer value."""
62+
63+
@lt.property
64+
def twice_my_property(self) -> int:
65+
"""Twice the value of my_property."""
66+
return self.my_property * 2
67+
68+
The example above defines a functional property called `twice_my_property` that returns twice the value of `my_property`. The type hint `-> int` indicates that the property should return an integer value. When this property is read via HTTP, the code in the method will be executed, and the result will be returned to the client. As with `property`, the docstring of the property is taken from the method's docstring, so you can include additional information about the property there.
69+
70+
Functional properties may also have a "setter" method, which is called when the property is written to via HTTP. This allows you to perform some action when the property is set, such as updating a hardware setting or performing some computation. The setter method should take a single argument, which is the new value of the property:
71+
72+
.. code-block:: python
73+
74+
import labthings_fastapi as lt
75+
76+
class MyThing(lt.Thing):
77+
my_property: int = lt.property(default=42)
78+
"""A property that holds an integer value."""
79+
80+
@lt.property
81+
def twice_my_property(self) -> int:
82+
"""Twice the value of my_property."""
83+
return self.my_property * 2
84+
85+
@twice_my_property.setter
86+
def twice_my_property(self, value: int):
87+
"""Set the value of twice_my_property."""
88+
self.my_property = value // 2
89+
90+
Adding a setter makes the property read-write (if only a getter is present, it must be read-only).
91+
92+
It is possible to make a property read-only for clients by setting its ``readonly`` attribute: this has the same behaviour as for data properties.
93+
94+
.. code-block:: python
95+
96+
import labthings_fastapi as lt
97+
98+
class MyThing(lt.Thing):
99+
my_property: int = lt.property(default=42)
100+
"""A property that holds an integer value."""
101+
102+
@lt.property
103+
def twice_my_property(self) -> int:
104+
"""Twice the value of my_property."""
105+
return self.my_property * 2
106+
107+
@twice_my_property.setter
108+
def twice_my_property(self, value: int):
109+
"""Set the value of twice_my_property."""
110+
self.my_property = value // 2
111+
112+
# Make the property read-only for clients
113+
twice_my_property.readonly = True
114+
115+
In the example above, ``twice_my_property`` may be set by code within ``MyThing`` but cannot be written to via HTTP requests or `.DirectThingClient` instances.
116+
117+
Functional properties may not be observed, as they are not backed by a simple value. If you need to notify clients when the value changes, you can use a data property that is updated by the functional property. In the example above, ``my_property`` may be observed, while ``twice_my_property`` cannot be observed. It would be possible to observe changes in ``my_property`` and then query ``twice_my_property`` for its new value.
118+
119+
HTTP interface
120+
--------------
121+
122+
LabThings is primarily controlled using HTTP. Mozilla have a good `Overview of HTTP`_ that is worth a read if you are unfamiliar with the concept of requests, or what ``GET`` and ``PUT`` mean.
123+
124+
Each property in LabThings will be assigned a URL, which allows it to be read and (optionally) written to. The easiest way to explore this is in the interactive OpenAPI documentation, served by your LabThings server at ``/docs``\ . Properties can be read using a ``GET`` request and written using a ``PUT`` request.
125+
126+
LabThings follows the `HTTP Protocol Binding`_ from the Web of Things standard. That's quite a detailed document: for a gentle introduction to HTTP and what a request means, see
127+
128+
.. _`Overview of HTTP`: https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Overview
129+
.. _`HTTP Protocol Binding`: https://w3c.github.io/wot-binding-templates/bindings/protocols/http/index.html
130+
131+
Observable properties
132+
-------------------------
133+
134+
Properties can be made observable, which means that clients can subscribe to changes in the property's value. This is useful for properties that change frequently, such as sensor readings or instrument settings. In order for a property to be observable, LabThings must know whenever it changes. Currently, this means only data properties can be observed, as functional properties do not have a simple value that can be tracked.
135+
136+
Properties are currently only observable via websockets: in the future, it may be possible to observe them from other `.Thing` instances or from other parts of the code.

0 commit comments

Comments
 (0)