Skip to content

Commit b9d5929

Browse files
Merge pull request #106 from rwb27/more-docs
Documentation improvements
2 parents 7e82c45 + 40ee90b commit b9d5929

File tree

12 files changed

+558
-50
lines changed

12 files changed

+558
-50
lines changed

docs/source/blobs.rst

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
Blob input/output
2+
=================
3+
4+
:class:`.Blob` objects allow binary data to be returned by an Action. This binary data can be passed between Things, or between Things and client code. Using a :class:`.Blob` object allows binary data to be efficiently sent over HTTP if required, and allows the same code to run either on the server (without copying the data) or on a client (where data is transferred over HTTP).
5+
6+
If interactions require only simple data types that can easily be represented in JSON, very little thought needs to be given to data types - strings and numbers will be converted to and from JSON automatically, and your Python code should only ever see native Python datatypes whether it's running on the server or a remote client. However, if you want to transfer larger data objects such as images, large arrays or other binary data, you will need to use a :class:`.Blob` object.
7+
8+
:class:`.Blob` objects are not part of the Web of Things specification, which is most often used with fairly simple data structures in JSON. In LabThings-FastAPI, the :class:`.Blob` mechanism is intended to provide an efficient way to work with arbitrary binary data. If it's used to transfer data between two Things on the same server, the data should not be copied or otherwise iterated over - and when it must be transferred over the network it can be done using a binary transfer, rather than embedding in JSON with base64 encoding.
9+
10+
A :class:`.Blob` consists of some data and a MIME type, which sets how the data should be interpreted. It is best to create a subclass of :class:`.Blob` with the content type set: this makes it clear what kind of data is in the :class:`.Blob`. In the future, it might be possible to add functionality to :class:`.Blob` subclasses, for example to make it simple to obtain an image object from a :class:`.Blob` containing JPEG data. However, this will not currently work across both client and server code.
11+
12+
Creating and using :class:`.Blob` objects
13+
------------------------------------------------
14+
15+
Blobs can be created from binary data that is in memory (a :class:`bytes` object), on disk (a file), or using a URL as a placeholder. The intention is that the code that uses a :class:`.Blob` should not need to know which of these is the case, and should be able to use the same code regardless of how the data is stored.
16+
17+
Blobs offer three ways to access their data:
18+
19+
* A `bytes` object, obtained via the `data` property. For blobs created with a `bytes` object, this simply returns the original data object with no copying. If the data is stored in a file, the file is opened and read when the `data` property is accessed. If the :class:`.Blob` references a URL, it is retrieved and returned when `data` is accessed.
20+
* An `open()` method providing a file-like object. This returns a :class:`~io.BytesIO` wrapper if the :class:`.Blob` was created from a `bytes` object or the file if the data is stored on disk. URLs are retrieved, stored as `bytes` and returned wrapped in a :class:`~io.BytesIO` object.
21+
* A `save` method will either save the data to a file, or copy the existing file on disk. This should be more efficient than loading `data` and writing to a file, if the :class:`.Blob` is pointing to a file rather than data in memory.
22+
23+
The intention here is that :class:`.Blob` objects may be used identically with data in memory or on disk or even at a remote URL, and the code that uses them should not need to know which is the case.
24+
25+
Examples
26+
--------
27+
28+
A camera might want to return an image as a :class:`.Blob` object. The code for the action might look like this:
29+
30+
.. code-block:: python
31+
32+
from labthings_fastapi.blob import Blob
33+
from labthings_fastapi.thing import Thing
34+
from labthings_fastapi.decorators import thing_action
35+
36+
class JPEGBlob(Blob):
37+
content_type = "image/jpeg"
38+
39+
class Camera(Thing):
40+
@thing_action
41+
def capture_image(self) -> JPEGBlob:
42+
# Capture an image and return it as a Blob
43+
image_data = self._capture_image() # This returns a bytes object holding the JPEG data
44+
return JPEGBlob.from_bytes(image_data)
45+
46+
The corresponding client code might look like this:
47+
48+
.. code-block:: python
49+
50+
from PIL import Image
51+
from labthings_fastapi.client import ThingClient
52+
53+
camera = ThingClient.from_url("http://localhost:5000/camera/")
54+
image_blob = camera.capture_image()
55+
image_blob.save("captured_image.jpg") # Save the image to a file
56+
57+
# We can also open the image directly with PIL
58+
with image_blob.open() as f:
59+
img = Image.open(f)
60+
img.show() # This will display the image in a window
61+
62+
We could define a more sophisticated camera that can capture raw images and convert them to JPEG, using two actions:
63+
64+
.. code-block:: python
65+
66+
from labthings_fastapi.blob import Blob
67+
from labthings_fastapi.thing import Thing
68+
from labthings_fastapi.decorators import thing_action
69+
70+
class JPEGBlob(Blob):
71+
content_type = "image/jpeg"
72+
73+
class RAWBlob(Blob):
74+
content_type = "image/x-raw"
75+
76+
class Camera(Thing):
77+
@thing_action
78+
def capture_raw_image(self) -> RAWBlob:
79+
# Capture a raw image and return it as a Blob
80+
raw_data = self._capture_raw_image() # This returns a bytes object holding the raw data
81+
return RAWBlob.from_bytes(raw_data)
82+
83+
@thing_action
84+
def convert_raw_to_jpeg(self, raw_blob: RAWBlob) -> JPEGBlob:
85+
# Convert a raw image Blob to a JPEG Blob
86+
jpeg_data = self._convert_raw_to_jpeg(raw_blob.data) # This returns a bytes object holding the JPEG data
87+
return JPEGBlob.from_bytes(jpeg_data)
88+
89+
@thing_action
90+
def capture_image(self) -> JPEGBlob:
91+
# Capture an image and return it as a Blob
92+
raw_blob = self.capture_raw_image() # Capture the raw image
93+
jpeg_blob = self.convert_raw_to_jpeg(raw_blob) # Convert the raw image to JPEG
94+
return jpeg_blob # Return the JPEG Blob
95+
# NB the `raw_blob` is not retained after this action completes, so it will be garbage collected
96+
97+
On the client, we can use the `capture_image` action directly (as before), or we can capture a raw image and convert it to JPEG:
98+
99+
.. code-block:: python
100+
101+
from PIL import Image
102+
from labthings_fastapi.client import ThingClient
103+
104+
camera = ThingClient.from_url("http://localhost:5000/camera/")
105+
106+
# Capture a JPEG image directly
107+
jpeg_blob = camera.capture_image()
108+
jpeg_blob.save("captured_image.jpg")
109+
110+
# Alternatively, capture a raw image and convert it to JPEG
111+
raw_blob = camera.capture_raw_image() # NB the raw image is not yet downloaded
112+
jpeg_blob = camera.convert_raw_to_jpeg(raw_blob)
113+
jpeg_blob.save("converted_image.jpg")
114+
115+
raw_blob.save("raw_image.raw") # Download and save the raw image to a file
116+
117+
118+
Using :class:`.Blob` objects as inputs
119+
--------------------------------------
120+
121+
:class:`.Blob` objects may be used as either the input or output of an action. There are relatively few good use cases for :class:`.Blob` inputs to actions, but a possible example would be image capture: one action could perform a quick capture of raw data, and another action could convert the raw data into a useful image. The output of the capture action would be a :class:`.Blob` representing the raw data, which could be passed to the conversion action.
122+
123+
Because :class:`.Blob` outputs are represented in JSON as links, they are downloaded with a separate HTTP request if needed. There is currently no way to create a :class:`.Blob` on the server via HTTP, which means remote clients can use :class:`.Blob` objects provided in the output of actions but they cannot yet upload data to be used as input. However, it is possible to pass the URL of a :class:`.Blob` that already exists on the server as input to a subsequent Action. This means, in the example above of raw image capture, a remote client over HTTP can pass the raw :class:`.Blob` to the conversion action, and the raw data need never be sent over the network.
124+
125+
126+
HTTP interface and serialization
127+
--------------------------------
128+
129+
:class:`.Blob` objects are subclasses of `pydantic.BaseModel`, which means they can be serialized to JSON and deserialized from JSON. When this happens, the :class:`.Blob` is represented as a JSON object with `.Blob.url` and `.Blob.content_type` fields. The `.Blob.url` field is a link to the data. The `.Blob.content_type` field is a string representing the MIME type of the data. It is worth noting that models may be nested: this means an action may return many :class:`.Blob` objects in its output, either as a list or as fields in a :class:`pydantic.BaseModel` subclass. Each :class:`.Blob` in the output will be serialized to JSON with its URL and content type, and the client can then download the data from the URL, one download per :class:`.Blob` object.
130+
131+
When a :class:`.Blob` is serialized, the URL is generated with a unique ID to allow it to be downloaded. The URL is not guaranteed to be permanent, and should not be used as a long-term reference to the data. The URL will expire after 5 minutes, and the data will no longer be available for download after that time.
132+
133+
In order to run an action and download the data, currently an HTTP client must:
134+
135+
* Call the action that returns a :class:`.Blob` object, which will return a JSON object representing the invocation.
136+
* Poll the invocation until it is complete, and the :class:`.Blob` is available in its ``output`` property with the URL and content type.
137+
* Download the data from the URL in the :class:`.Blob` object, which will return the binary data.
138+
139+
It may be possible to have actions return binary data directly in the future, but this is not yet implemented.
140+
141+
142+
Memory management and retention
143+
-------------------------------
144+
145+
Management of :class:`.Blob` objects is currently very basic: when a :class:`.Blob` object is returned in the output of an Action that has been called via the HTTP interface, a fixed 5 minute expiry is used. This should be improved in the future to avoid memory management issues.
146+
147+
When a :class:`.Blob` is serialized, a URL is generated with a unique ID to allow it to be downloaded. However, only a weak reference is held to the :class:`.Blob`. Once an Action has finished running, the only strong reference to the :class:`.Blob` should be held by the output property of the action invocation. The :class:`.Blob` should be garbage collected once the output is no longer required, i.e. when the invocation is discarded - currently 5 minutes after the action completes, once the maximum number of invocations has been reached or when it is explicitly deleted by the client.
148+
149+
The behaviour is different when actions are called from other actions. If `action_a` calls `action_b`, and `action_b` returns a :class:`.Blob`, that :class:`.Blob` will be subject to Python's usual garbage collection rules when `action_a` ends - i.e. it will not be retained unless it is included in the output of `action_a`.
150+
151+

docs/source/concurrency.rst

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
Concurrency in LabThings-FastAPI
2+
==================================
3+
4+
One of the major challenges when controlling hardware, particularly from web frameworks, is concurrency. Most web frameworks assume resources (database connections, object storage, etc.) may be instantiated multiple times, and often initialise or destroy objects as required. In contrast, hardware can usually only be controlled from one process, and usually is initialised and shut down only once.
5+
6+
LabThings-FastAPI instantiates each :class:`.Thing` only once, and runs all code in a thread. More specifically, each time an action is invoked via HTTP, a new thread is created to run the action. Similarly, each time a property is read or written, a new thread is created to run the property method. This means that :class:`.Thing` code should protect important variables or resources using locks from the `threading` module, and need not worry about writing asynchronous code.
7+
8+
In the case of properties, the HTTP response is only returned once the `.Thing` code is complete. Actions currently return a response immediately, and must be polled to determine when they have completed. This behaviour may change in the future, most likely with the introduction of a timeout to allow the client to choose between waiting for a response or polling.
9+
10+
Many of the functions that handle HTTP requests are asynchronous, running in an :mod:`anyio` event loop. This enables many HTTP connections to be handled at once with good efficiency. The `anyio documentation`_ describes the functions that link between async and threaded code. When the LabThings server is started, we create an :class:`anyio.from_thread.BlockingPortal`, which allows threaded code to run code asynchronously in the event loop.
11+
12+
An action can obtain the blocking portal using the `~labthings_fastapi.dependencies.blocking_portal.BlockingPortal` dependency, i.e. by declaring an argument of that type. This avoids referring to the blocking portal through a global variable, which could lead to confusion if there are multiple event loops, e.g. during testing.
13+
14+
There are relatively few occasions when `.Thing` code will need to consider this explicitly: more usually the blocking portal will be obtained by a LabThings function, for example the `.MJPEGStream` class.
15+
16+
.. _`anyio documentation`: https://anyio.readthedocs.io/en/stable/threads.html
17+
18+
Calling Things from other Things
19+
--------------------------------
20+
21+
When one `Thing` calls the actions or properties of another `.Thing`, either directly or via a `.DirectThingClient`, no new threads are spawned: the action or property is run in the same thread as the caller. This mirrors the behaviour of the `.ThingClient`, which blocks until the action or property is complete. See :doc:`using_things` for more details on how to call actions and properties of other Things.
22+

docs/source/conf.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,11 @@
2525
templates_path = ["_templates"]
2626
exclude_patterns = []
2727

28+
default_role = "py:obj"
29+
2830
autodoc2_packages = ["../../src/labthings_fastapi"]
2931
autodoc2_render_plugin = "myst"
32+
autodoc2_class_docstring = "both"
3033

3134
# autoapi_dirs = ["../../src/labthings_fastapi"]
3235
# autoapi_ignore = []
@@ -42,6 +45,8 @@
4245
intersphinx_mapping = {
4346
"python": ("https://docs.python.org/3", None),
4447
"fastapi": ("https://fastapi.tiangolo.com", None),
48+
"anyio": ("https://anyio.readthedocs.io/en/stable/", None),
49+
"pydantic": ("https://docs.pydantic.dev/latest/", None),
4550
}
4651

4752
myst_enable_extensions = ["fieldlist"]

docs/source/index.rst

Lines changed: 27 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,50 @@
1-
.. labthings-fastapi documentation master file, created by
2-
sphinx-quickstart on Wed May 15 16:34:51 2024.
3-
You can adapt this file completely to your liking, but it should at least
4-
contain the root `toctree` directive.
5-
6-
Welcome to labthings-fastapi's documentation!
1+
Documentation for LabThings-FastAPI
72
=============================================
83

94
.. toctree::
105
:maxdepth: 2
116
:caption: Contents:
127

13-
core_concepts.rst
148
quickstart/quickstart.rst
9+
wot_core_concepts.rst
10+
lt_core_concepts.rst
11+
tutorial/index.rst
1512
dependencies/dependencies.rst
13+
blobs.rst
14+
concurrency.rst
15+
using_things.rst
1616

1717
apidocs/index
1818

1919
`labthings-fastapi` implements a Web of Things interface for laboratory hardware using Python. This is a ground-up rewrite of python-labthings_, replacing Flask 1 and Marshmallow with FastAPI and Pydantic. It is the underlying framework for v3 of the `OpenFlexure Microscope software <https://gitlab.com/openflexure/openflexure-microscope-server/>`_.
2020

21-
Features include:
21+
`labthings-fastapi` aims to simplify the process of making laboratory instruments available via an HTTP API. Key features and design aims are below:
2222

23-
* Alignment with the `W3C Web of Things <https://www.w3.org/WoT/>`_ standard (see :doc:`core_concepts`)
23+
* Functionality together in `Thing` subclasses, which represent units of hardware or software (see :doc:`wot_core_concepts`)
24+
* Methods and properties of `Thing` subclasses may be added to the HTTP API and Thing Description using decorators
25+
* Vocabulary and concepts are aligned with the `W3C Web of Things <https://www.w3.org/WoT/>`_ standard (see :doc:`wot_core_concepts`)
2426
- Things are classes, with properties and actions defined exactly once
25-
- Various improvements to TD generation and validation with `pydantic`
26-
* Cleaner API
27+
- Thing Descriptions are automatically generated, and validated with `pydantic`
28+
- OpenAPI documentation is automatically generated by FastAPI
29+
* We follow FastAPI_'s lead and try to use standard Python features to minimise unnecessary code
2730
- Datatypes of action input/outputs and properties are defined with Python type hints
2831
- Actions are defined exactly once, as a method of a `Thing` class
2932
- Properties and actions are declared using decorators (or descriptors if that's preferred)
30-
- Dependency injection is used to manage relationships between Things and dependency on the server
31-
* Async HTTP handling
32-
- Starlette (used by FastAPI) can handle requests asynchronously - potential for websockets/events (not used much yet)
33-
- `Thing` code is still, for now, threaded. I intend to make it possible to write async things in the future, but don't intend it to become mandatory
34-
* Smaller codebase
35-
- FastAPI more or less completely eliminates OpenAPI generation code from our codebase
36-
- Thing Description generation is very much simplified by the new structure (multiple Things instead of one massive Thing with many extensions)
33+
- FastAPI_ "Dependency injection" is used to manage relationships between Things and dependency on the server
34+
* Lifecycle and concurrency are appropriate for hardware: `Thing` code is always run in a thread, and each `Thing` is instantiated and shut down only once.
35+
- Starlette (used by FastAPI) can handle requests asynchronously - this improves performance and enables websockets and other long-lived connections.
36+
- `Thing` code is still, for now, threaded. In the future it may become possible to us other concurrency models in `Thing` code.
37+
38+
Compared to `python-labthings`_, this framework updates dependencies, shrinks the codebase, and simplifies the API (see :doc:`lt_core_concepts`).
39+
* FastAPI more or less completely eliminates OpenAPI generation code from our codebase
40+
* Marshmallow schemas and endpoint classes are replaced with Python type hints, eliminating double- or triple-definition of actions and their inputs/outputs.
41+
* Thing Description generation is very much simplified by the new structure (multiple Things instead of one massive Thing with many extensions)
3742

3843

3944
Installation
4045
------------
4146

42-
``pip install labthings-fastapi``
47+
``pip install labthings-fastapi[server]``
4348

4449
Indices and tables
4550
==================
@@ -48,4 +53,6 @@ Indices and tables
4853
* :ref:`modindex`
4954
* :ref:`search`
5055

51-
.. _python-labthings: https://github.com/labthings/python-labthings/
56+
.. _python-labthings: https://github.com/labthings/python-labthings/
57+
.. _FastAPI: https://fastapi.tiangolo.com/
58+
.. _pydantic: https://pydantic-docs.helpmanual.io/

0 commit comments

Comments
 (0)