Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
c84a950
Add documentation pages on OpenAPI/TD and Descriptors
rwb27 Jul 24, 2025
a914ac2
First pass at splitting up ThingProperty
rwb27 Jul 24, 2025
da345fb
Update the rest of the library to use the new names
rwb27 Jul 24, 2025
54b19f0
Update examples and test suite
rwb27 Jul 24, 2025
7ad0755
Fix test_property tests
rwb27 Jul 25, 2025
a4e44d4
Fix last remaining mypy error
rwb27 Jul 25, 2025
95f44ab
Fix a typo in example code
rwb27 Jul 28, 2025
2212e66
Restore functional settings
rwb27 Jul 29, 2025
2809992
Added a tutorial page on properties and things
rwb27 Jul 29, 2025
90d5de8
Add typing tests for thing definition code.
rwb27 Jul 29, 2025
847c192
Tighten up type checking of properties
rwb27 Jul 29, 2025
25dbdfb
Make `default` a keyword-only argument for `setting` and `property`
rwb27 Jul 29, 2025
a64bc24
Re-lock dependencies under Windows, Python 3.10
rwb27 Jul 29, 2025
1b91ae3
Fix codespell config and some spelling errors
rwb27 Jul 29, 2025
3c02865
Fixed flake8 errors
rwb27 Jul 29, 2025
587829f
Update tests and docs to use a named `default` arg to `lt.property`
rwb27 Jul 29, 2025
029343f
More helpful error if a non-callable value is passed as the positiona…
rwb27 Jul 29, 2025
14d3dd9
Add to external linter codes to stop ruff warning
rwb27 Jul 29, 2025
551e308
Make CI compatible with Python 3.10 and Linux
rwb27 Jul 30, 2025
1d37e80
Implement, and test, docstring-after-the-property syntax.
rwb27 Jul 30, 2025
6927ce3
Documentation improvements from review and codespell
rwb27 Jul 30, 2025
315725b
Fix test of BaseDescriptor and docs
rwb27 Jul 30, 2025
efc330e
Fix typing tests foder in CI
rwb27 Jul 30, 2025
531e87e
Comments on modified settings test
rwb27 Jul 30, 2025
ab54ce7
Add a comment, and test both object and class attributes
rwb27 Jul 30, 2025
c92500f
Improve test_properties.py
rwb27 Jul 30, 2025
c6b24e7
Add comments/docstrings about cacheing in get_class_attribute_docstrings
rwb27 Jul 30, 2025
b3a8c6e
Add a docstring for the example class.
rwb27 Jul 30, 2025
b023023
Add docstrings to the example Thing.
rwb27 Jul 30, 2025
70ab0ee
Fix spelling error
rwb27 Jul 31, 2025
4659d59
Improve tests of base_descriptor
rwb27 Jul 31, 2025
77f154c
Add a full stop
rwb27 Jul 31, 2025
c5f98d6
Apply suggestions from code review
rwb27 Jul 31, 2025
9c1de79
Apply suggestions from code review
rwb27 Jul 31, 2025
8b18d75
Better error if default_factory is not callable.
rwb27 Aug 1, 2025
43e436f
Remove deleter
rwb27 Aug 1, 2025
f757a95
Apply suggestions from code review
rwb27 Aug 1, 2025
ab7a631
Improve docstrings
rwb27 Aug 1, 2025
089b98e
Add tests of `property` and `setting`
rwb27 Aug 2, 2025
416e9b0
Test what happens if getter and setter have different names.
rwb27 Aug 2, 2025
354cdfb
Guard against BaseDescriptor being used twice.
rwb27 Aug 4, 2025
0473952
WIP: Add an exemption to allow properties with differently named setters
rwb27 Aug 4, 2025
25a42e5
Better handling of differently named setters.
rwb27 Aug 4, 2025
55dcb00
Eliminate DocstringToMessage in errors
rwb27 Aug 4, 2025
df8c5a9
Better documentation of type test.
rwb27 Aug 4, 2025
b2f597e
Remove unused typevar
rwb27 Aug 4, 2025
f976ac1
Better comments/docstrings
rwb27 Aug 4, 2025
9ed747a
Add more detailed tests of settings
rwb27 Aug 4, 2025
309aa8a
Add a test for dictsetting
rwb27 Aug 4, 2025
f30f425
Add comment on cache
rwb27 Aug 4, 2025
f3963df
Fix docstring format
rwb27 Aug 4, 2025
4cd9873
Fix spelling errors
rwb27 Aug 5, 2025
7f4e8dd
Fix test imports
rwb27 Aug 5, 2025
f988566
Fix tests of basedescriptor errors on old Python versions.
rwb27 Aug 5, 2025
c092f9c
Use raises_or_is_caused_by in test_properties
rwb27 Aug 5, 2025
124d963
Added a test for redefinition errors when getter/setter names match.
rwb27 Aug 8, 2025
316a775
Get rid of unnecessary #noqa
rwb27 Aug 8, 2025
8ce08ce
Re-lock dependencies without flake8-docstrings
rwb27 Aug 9, 2025
45de736
Rename `thing_property` to `properties`
rwb27 Aug 12, 2025
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
2 changes: 2 additions & 0 deletions .codespellrc
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ skip = *.git,
./build,
./dist,
./docs/_build,
./docs/build,
./docs/source/autoapi,
./htmlcov,
./src/openflexure_microscope_server/static,
.venv,
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,9 @@ jobs:

- name: Analyse with MyPy
run: mypy src

- name: Type tests with MyPy
run: mypy --warn-unused-ignores typing_tests

test-with-unpinned-deps:
runs-on: ubuntu-latest
Expand Down
33 changes: 23 additions & 10 deletions dev-requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ click==8.2.1
# uvicorn
codespell==2.4.1
# via labthings-fastapi (pyproject.toml)
colorama==0.4.6
# via
# click
# pytest
# sphinx
# uvicorn
coverage==7.9.2
# via pytest-cov
dnspython==2.7.0
Expand All @@ -49,6 +55,10 @@ email-validator==2.2.0
# via
# fastapi
# pydantic
exceptiongroup==1.3.0
# via
# anyio
# pytest
fastapi==0.116.1
# via labthings-fastapi (pyproject.toml)
fastapi-cli==0.0.8
Expand All @@ -58,13 +68,10 @@ fastapi-cloud-cli==0.1.4
flake8==7.3.0
# via
# labthings-fastapi (pyproject.toml)
# flake8-docstrings
# flake8-pyproject
# flake8-rst
# flake8-rst-docstrings
# pydoclint
flake8-docstrings==1.7.0
# via labthings-fastapi (pyproject.toml)
flake8-pyproject==1.2.3
# via labthings-fastapi (pyproject.toml)
flake8-rst==0.8.0
Expand Down Expand Up @@ -152,8 +159,6 @@ pydantic-settings==2.10.1
# via fastapi
pydoclint==0.6.6
# via labthings-fastapi (pyproject.toml)
pydocstyle==6.3.0
# via flake8-docstrings
pyflakes==3.4.0
# via flake8
pygments==2.19.2
Expand Down Expand Up @@ -210,9 +215,7 @@ shellingham==1.5.4
sniffio==1.3.1
# via anyio
snowballstemmer==3.0.1
# via
# pydocstyle
# sphinx
# via sphinx
sphinx==8.1.3
# via
# labthings-fastapi (pyproject.toml)
Expand All @@ -239,6 +242,14 @@ sphinxcontrib-serializinghtml==2.0.0
# via sphinx
starlette==0.47.1
# via fastapi
tomli==2.2.1
# via
# coverage
# flake8-pyproject
# mypy
# pydoclint
# pytest
# sphinx
typer==0.16.0
# via
# fastapi-cli
Expand All @@ -249,16 +260,20 @@ typing-extensions==4.14.1
# via
# labthings-fastapi (pyproject.toml)
# anyio
# astroid
# exceptiongroup
# fastapi
# mypy
# pydantic
# pydantic-core
# pydantic-extra-types
# referencing
# rich
# rich-toolkit
# starlette
# typer
# typing-inspection
# uvicorn
typing-inspection==0.4.1
# via pydantic-settings
ujson==5.10.0
Expand All @@ -272,8 +287,6 @@ uvicorn==0.35.0
# fastapi
# fastapi-cli
# fastapi-cloud-cli
uvloop==0.21.0
# via uvicorn
watchfiles==1.1.0
# via uvicorn
websockets==15.0.1
Expand Down
33 changes: 33 additions & 0 deletions docs/source/documentation.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
.. _gen_docs:

Generated documentation
=======================

LabThings describes its HTTP API in two ways: with a :ref:`wot_td` and with an OpenAPI_ document.

.. _openapi:

OpenAPI
-------

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.

.. _gen_td:

Thing Description
-----------------

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.

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`\ .

Comparison of Thing Description and OpenAPI
-------------------------------------------

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.

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.

.. _WoT: https://www.w3.org/WoT/
.. _Thing Description: https://www.w3.org/TR/wot-thing-description/
.. _OpenAPI: https://www.openapis.org/
1 change: 1 addition & 0 deletions docs/source/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ Documentation for LabThings-FastAPI
blobs.rst
concurrency.rst
using_things.rst
see_also.rst

autoapi/index

Expand Down
5 changes: 2 additions & 3 deletions docs/source/quickstart/counter.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,8 @@ def slowly_increase_counter(self) -> None:
time.sleep(1)
self.increment_counter()

counter = lt.ThingProperty(
model=int, initial_value=0, readonly=True, description="A pointless counter"
)
counter: int = lt.property(default=0, readonly=True)
"A pointless counter"


if __name__ == "__main__":
Expand Down
48 changes: 48 additions & 0 deletions docs/source/see_also.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
See Also
========

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.

.. _descriptors:

Descriptors
-----------

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.

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.

There are a few useful notes that relate to many of the descriptors in LabThings-FastAPI:

* Descriptor objects **may have more than one owner**. As a rule, a descriptor object
(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.

The example below shows how this can go wrong.

.. code-block:: python

class BadProperty:
"An example of a descriptor that has unwanted behaviour."
def __init__(self):
self._value = None

def __get__(self, obj):
return self._value

def __set__(self, obj, val):
self._value = val

class BrokenExample:
myprop = BadProperty()

a = BrokenExample()
b = BrokenExample()

assert a.myprop is None
b.myprop = True
assert a.myprop is None # FAILS because `myprop` shares values between a and b

* 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.
* 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.

.. _`Descriptor Guide`: https://docs.python.org/3/howto/descriptor.html
2 changes: 2 additions & 0 deletions docs/source/tutorial/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ LabThings-FastAPI tutorial

installing_labthings.rst
running_labthings.rst
writing_a_thing.rst
properties.rst

..
In due course, these pages should exist...
Expand Down
136 changes: 136 additions & 0 deletions docs/source/tutorial/properties.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
.. _tutorial_properties:

Properties
=========================



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.

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:

* 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.
* 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.

You can add properties to a `.Thing` by using `.property` (usually imported as ``lt.property``).

Data properties
-------------------------

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:

.. code-block:: python

import labthings_fastapi as lt

class MyThing(lt.Thing):
my_property: int = lt.property(default=42)

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`:

.. code-block:: python

class MyThing(lt.Thing):
my_property: int = lt.property(default=42, readonly=True)

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``.

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:

.. code-block:: python

class MyThing(lt.Thing):
my_property: int = lt.property(default=42, readonly=True)
"""A property that holds an integer value."""

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.

Data properties may be *observed*, which means notifications will be sent when the property is written to (see below).

Functional properties
-------------------------

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:

.. code-block:: python

import labthings_fastapi as lt

class MyThing(lt.Thing):
my_property: int = lt.property(default=42)
"""A property that holds an integer value."""

@lt.property
def twice_my_property(self) -> int:
"""Twice the value of my_property."""
return self.my_property * 2

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.

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:

.. code-block:: python

import labthings_fastapi as lt

class MyThing(lt.Thing):
my_property: int = lt.property(default=42)
"""A property that holds an integer value."""

@lt.property
def twice_my_property(self) -> int:
"""Twice the value of my_property."""
return self.my_property * 2

@twice_my_property.setter
def twice_my_property(self, value: int):
"""Set the value of twice_my_property."""
self.my_property = value // 2

Adding a setter makes the property read-write (if only a getter is present, it must be read-only).

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.

.. code-block:: python

import labthings_fastapi as lt

class MyThing(lt.Thing):
my_property: int = lt.property(default=42)
"""A property that holds an integer value."""

@lt.property
def twice_my_property(self) -> int:
"""Twice the value of my_property."""
return self.my_property * 2

@twice_my_property.setter
def twice_my_property(self, value: int):
"""Set the value of twice_my_property."""
self.my_property = value // 2

# Make the property read-only for clients
twice_my_property.readonly = True

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.

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.

HTTP interface
--------------

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.

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.

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

.. _`Overview of HTTP`: https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Overview
.. _`HTTP Protocol Binding`: https://w3c.github.io/wot-binding-templates/bindings/protocols/http/index.html

Observable properties
-------------------------

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.

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.
Loading