From 719cf932d661b1cc6890f2e891826922ff6064dd Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Thu, 12 Jun 2025 12:07:47 +0100 Subject: [PATCH 01/14] Add more conceptual documentation --- docs/source/client_code.rst | 18 +++++++++ docs/source/concurrency.rst | 15 ++++++++ docs/source/index.rst | 37 ++++++++++++------- ...ore_concepts.rst => wot_core_concepts.rst} | 6 +-- 4 files changed, 59 insertions(+), 17 deletions(-) create mode 100644 docs/source/client_code.rst create mode 100644 docs/source/concurrency.rst rename docs/source/{core_concepts.rst => wot_core_concepts.rst} (85%) diff --git a/docs/source/client_code.rst b/docs/source/client_code.rst new file mode 100644 index 00000000..f4390cef --- /dev/null +++ b/docs/source/client_code.rst @@ -0,0 +1,18 @@ +Client code +=========== + +The interface to a `Thing` is defined by its interaction affordances, which are defined in the Thing Description. The `labthings-fastapi.client` library provides a `ThingClient` class to interact with a `Thing` via HTTP. This is a class with a method for each Action, and a property for each Property of the Thing. The intention is to provide a simple, pythonic interface that plays nicely with IDEs and autocompletion. + +An additional goal is to provide an interface that is consistent between the server and client code: a `DirectThingClient` class is used by the `labthings-fastapi` server to call actions and properties of other `Thing`s, which means code for an action may be developed as an HTTP client, for example in a Jupyter notebook, and then moved to the server with minimal changes. Currently, there are a few differences in behaviour between local and remote `Thing`s, most notably the return types (which are usually Pydantic models on the server, and currently dictionaries generated from JSON on the client). This should be improved in the future. + +Client code generation +---------------------- + +Currently, most clients are created using the class method `ThingClient.from_url`. This returns an instance of a dynamically-created subclass, rather than a `ThingClient` instance directly. The subclass is required in order to add methods and properties corresponding to the Thing Description sent by the server. While this is a solution that should work immediately, it does not work well with code completion or static analysis, and client objects must be introspected on-the-fly. + +In the future, `labthings_fastapi` will generate custom client subclasses. These will have the methods and properties defined in a Python module, including type annotations. This will allow static analysis (e.g. with MyPy) and IDE autocompletion to work. Most packages that provide a `Thing` subclass will want to release a client package that is generated automatically in this way. The intention is to make it possible to add custom Python code to this client, for example to handle specialised return types more gracefully or add convenience methods. + + + + + diff --git a/docs/source/concurrency.rst b/docs/source/concurrency.rst new file mode 100644 index 00000000..996ed0d6 --- /dev/null +++ b/docs/source/concurrency.rst @@ -0,0 +1,15 @@ +Concurrency in `labthings-fastapi` +================================== + +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. + +`labthings-fastapi` instantiates each `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 `Thing` code should protect important variables or resources using locks from the `threading` module, and need not worry about writing asynchronous code. + +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. + +Many of the functions that handle HTTP requests are asynchronous, running in an `anyio` event loop. This enables many HTTP connections to be handled at once with good efficiency. The interface between async and threaded code is provided by a "Blocking Portal" created when the LabThings server is started. A FastAPI Dependency allows the blocking portal to be obtained: while it's very unlikely more than one LabThings server will exist in one Python instance, we avoid referring to the blocking portal globally in an effort to avoid concurrency issues. + +If threaded code needs to call code in the `anyio` event loop, the blocking portal dependency should be used. 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. + +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. + diff --git a/docs/source/index.rst b/docs/source/index.rst index 0b6bb13c..1ca09df7 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -10,36 +10,43 @@ Welcome to labthings-fastapi's documentation! :maxdepth: 2 :caption: Contents: - core_concepts.rst + wot_core_concepts.rst quickstart/quickstart.rst dependencies/dependencies.rst + concurrency.rst + client_code.rst apidocs/index `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 `_. -Features include: +`labthings-fastapi` aims to simplify the process of making laboratory instruments available via an HTTP API. Key features and design aims are below: -* Alignment with the `W3C Web of Things `_ standard (see :doc:`core_concepts`) +* Functionality together in `Thing` subclasses, which represent units of hardware or software (see :doc:`wot_core_concepts`) +* Methods and properties of `Thing` subclasses may be added to the HTTP API and Thing Description using decorators +* Vocabulary and concepts are aligned with the `W3C Web of Things `_ standard (see :doc:`wot_core_concepts`) - Things are classes, with properties and actions defined exactly once - - Various improvements to TD generation and validation with `pydantic` -* Cleaner API + - Thing Descriptions are automatically generated, and validated with `pydantic` + - OpenAPI documentation is automatically generated by FastAPI +* We follow FastAPI_'s lead and try to use standard Python features to minimise unnecessary code - Datatypes of action input/outputs and properties are defined with Python type hints - Actions are defined exactly once, as a method of a `Thing` class - Properties and actions are declared using decorators (or descriptors if that's preferred) - - Dependency injection is used to manage relationships between Things and dependency on the server -* Async HTTP handling - - Starlette (used by FastAPI) can handle requests asynchronously - potential for websockets/events (not used much yet) - - `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 -* Smaller codebase - - FastAPI more or less completely eliminates OpenAPI generation code from our codebase - - Thing Description generation is very much simplified by the new structure (multiple Things instead of one massive Thing with many extensions) + - FastAPI_ "Dependency injection" is used to manage relationships between Things and dependency on the server +* 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. + - Starlette (used by FastAPI) can handle requests asynchronously - this improves performance and enables websockets and other long-lived connections. + - `Thing` code is still, for now, threaded. In the future it may become possible to us other concurrency models in `Thing` code. + +Compared to `python-labthings`_, this framework updates dependencies, shrinks the codebase, and simplifies the API. +* FastAPI more or less completely eliminates OpenAPI generation code from our codebase +* Marshmallow schemas and endpoint classes are replaced with Python type hints, eliminating double- or triple-definition of actions and their inputs/outputs. +* Thing Description generation is very much simplified by the new structure (multiple Things instead of one massive Thing with many extensions) Installation ------------ -``pip install labthings-fastapi`` +``pip install labthings-fastapi[server]`` Indices and tables ================== @@ -48,4 +55,6 @@ Indices and tables * :ref:`modindex` * :ref:`search` -.. _python-labthings: https://github.com/labthings/python-labthings/ \ No newline at end of file +.. _python-labthings: https://github.com/labthings/python-labthings/ +.. _FastAPI: https://fastapi.tiangolo.com/ +.. _pydantic: https://pydantic-docs.helpmanual.io/ \ No newline at end of file diff --git a/docs/source/core_concepts.rst b/docs/source/wot_core_concepts.rst similarity index 85% rename from docs/source/core_concepts.rst rename to docs/source/wot_core_concepts.rst index c36afc5e..b7e47635 100644 --- a/docs/source/core_concepts.rst +++ b/docs/source/wot_core_concepts.rst @@ -1,7 +1,7 @@ -Core Concepts -============= +Web of Things Core Concepts +=========================== -LabThings is rooted in the `W3C Web of Things standards `_. Using IP networking in labs is not itself new, though perhaps under-used. However lack of proper standardisation has stiffled widespread adoption. LabThings, rather than try to introduce new competing standards, uses the architecture and terminology introduced by the W3C Web of Things. A full description of the core architecture can be found in the `Web of Things (WoT) Architecture `_ document. However, a brief outline of the concepts relevant to `labthings-fastapi` is given below. +LabThings is rooted in the `W3C Web of Things standards `_. Using IP networking in labs is not new, though perhaps under-used. However lack of proper standardisation has stiffled widespread adoption. LabThings, rather than try to introduce new competing standards, uses the architecture and terminology introduced by the W3C Web of Things. A full description of the core architecture can be found in the `Web of Things (WoT) Architecture `_ document. However, a brief outline of the concepts relevant to `labthings-fastapi` is given below. Thing --------- From c1d7b0a479c62775cabb72fa25a7545bcffdc91d Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Thu, 12 Jun 2025 12:07:47 +0100 Subject: [PATCH 02/14] Basic tutorial on how to install and run --- docs/source/index.rst | 10 ++---- docs/source/quickstart/.gitignore | 1 + docs/source/tutorial/index.rst | 13 ++++++++ docs/source/tutorial/installing_labthings.rst | 28 ++++++++++++++++ docs/source/tutorial/running_labthings.rst | 33 +++++++++++++++++++ 5 files changed, 78 insertions(+), 7 deletions(-) create mode 100644 docs/source/quickstart/.gitignore create mode 100644 docs/source/tutorial/index.rst create mode 100644 docs/source/tutorial/installing_labthings.rst create mode 100644 docs/source/tutorial/running_labthings.rst diff --git a/docs/source/index.rst b/docs/source/index.rst index 1ca09df7..bef03d03 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -1,17 +1,13 @@ -.. labthings-fastapi documentation master file, created by - sphinx-quickstart on Wed May 15 16:34:51 2024. - You can adapt this file completely to your liking, but it should at least - contain the root `toctree` directive. - -Welcome to labthings-fastapi's documentation! +Documentation for LabThings-FastAPI ============================================= .. toctree:: :maxdepth: 2 :caption: Contents: - wot_core_concepts.rst quickstart/quickstart.rst + wot_core_concepts.rst + tutorial/index.rst dependencies/dependencies.rst concurrency.rst client_code.rst diff --git a/docs/source/quickstart/.gitignore b/docs/source/quickstart/.gitignore new file mode 100644 index 00000000..93250682 --- /dev/null +++ b/docs/source/quickstart/.gitignore @@ -0,0 +1 @@ +/settings/ \ No newline at end of file diff --git a/docs/source/tutorial/index.rst b/docs/source/tutorial/index.rst new file mode 100644 index 00000000..c3676a8b --- /dev/null +++ b/docs/source/tutorial/index.rst @@ -0,0 +1,13 @@ +LabThings-FastAPI tutorial +========================== + +.. toctree: + + installing_labthings.rst + running_labthings.rst + writing_a_thing.rst + client_code.rst + blobs.rst + thing_dependencies.rst + +In this tutorial, we'll cover how to start up and interact with a LabThings-FastAPI server, how to write a Thing, and a few more advanced topics. It is intended as an introduction for someone using LabThings-FastAPI and/or writing Thing code to implement a new instrument. It doesn't detail all the internal workings of the library, but we hope this isn't needed for most people. \ No newline at end of file diff --git a/docs/source/tutorial/installing_labthings.rst b/docs/source/tutorial/installing_labthings.rst new file mode 100644 index 00000000..fc82302f --- /dev/null +++ b/docs/source/tutorial/installing_labthings.rst @@ -0,0 +1,28 @@ +Installing LabThings-FastAPI +============================ + +LabThings-FastAPI is a Python package, which is published to PyPI. You can install `labthings-fastapi` using `pip`. To see compatible versions of Python, please check PyPI_. + +It is common practice to use virtual environments in Python: this isolates projects from each other, and makes sure that installing packages for one project doesn't break other work you are doing. There are many ways of managing virtual environments in Python: if you are using a distribution like Anaconda, you may prefer to manage environments using the `conda` command or Anaconda interface. This tutorial uses the built-in `venv` module to create a virtual environment, but you can use whatever tool you are happy with. + +The commands below are all intended to be run in a terminal. We tend to use PowerShell on Windows, Terminal on a mac or your preferred terminal utility if you are on Linux. Note that most of our automated testing runs on Linux, and one or two commands are different on Windows. This is indicated with a comment (some text after a ``#`` character). + +It's always a good idea to check your Python version before you start, by running ``python --version``. This should print out something like ``Python 3.12.3``, although the exact version is not particularly important so long as it's up to date enough for the package to install. If this doesn't work, you likely need to install Python, which this tutorial doesn't cover. The Python website has instructions for most common operating systems. + +To create a virtual environment, run the following command: + +.. literalinclude:: ../quickstart/quickstart_example.sh + :language: bash + :start-after: BEGIN venv + :end-before: END venv + +then install labthings with: + +.. literalinclude:: ../quickstart/quickstart_example.sh + :language: bash + :start-after: BEGIN install + :end-before: END install + +It is also possible to install LabThings from source, by cloning the GitHub repository and running ``pip install -e .[dev]``, but this is only recommended if you intend to alter the LabThings-FastAPI library; it is best to use the published package unless you have a good reason not to. + +.. _PyPI: https://pypi.org/project/labthings-fastapi/ \ No newline at end of file diff --git a/docs/source/tutorial/running_labthings.rst b/docs/source/tutorial/running_labthings.rst new file mode 100644 index 00000000..5a42bf1a --- /dev/null +++ b/docs/source/tutorial/running_labthings.rst @@ -0,0 +1,33 @@ +Running LabThings-FastAPI +========================= + +Each time you want to use LabThings-FastAPI, you will need to open a terminal and activate your virtual environment. If you created a virtual environment using the command on the :doc:`installing_labthings` page, you will need to change directory to the folder where you created your virtual environment (using `cd`) and then activate the virtual environment with `source .venv/bin/activate` or `.venv/Scripts/activate` (on Windows). + +Once you have activated the virtual environment, you should be able to run an example server with the command: + +.. code-block:: bash + + labthings-server --json '{"things":{"/mything":"labthings_fastapi.example_things:MyThing"}}' + +This command will start a LabThings server, and will print the root URL for your server (by default, ``http://127.0.0.1:5000``). The ``127.0.0.1`` part means the server is only accessible from your computer, so you don't need to worry about other computers on your network accessing it. + +Now that your server is running, you should be able to view the interactive documentation in your web browser. There is an OpenAPI documentation page at ``http://127.0.0.1:5000/docs/``. This shows all the requests that the server supports, and even allows you to try them out in the web browser. + +Another important document is the Thing Description: this is a higher-level description of all the capabilities of each Thing in the server. For our example server, we have just one Thing, which is at ``http://127.0.0.1:5000/mything/``. This is a JSON document, but if you view it in Firefox there is a convenient tree view that makes it easier to navigate. Currently the Thing Description is not as interactive as the OpenAPI documentation, but it is rather neater as it's a higher-level description: rather than describing every possible request, it describes the capabilities of your Thing in a way that should correspond nicely to the code you might write using a Python client object, or a client in some other language. + +It is worth unpicking the command you ran to start the server: it has one argument, which is a JSON string. This is fine if you are starting up a test server for one Thing, but it gets unwieldy very quickly. Most of the time, you will want to start the server with a configuration file. This is a JSON file that contains the same information as the JSON string you passed to the command above, but in a more convenient format. To do this, create a file called `example_things.json` in the same directory as your virtual environment, and put the following content in it: + +.. code-block:: json + + { + "things": { + "/mything": "labthings_fastapi.example_things:MyThing" + } + } + +You can then start the server using the command: + +.. code-block:: bash + + labthings-server --config example_things.json + From 0e3fae699e29d97e2969be059325cf667dac26dc Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Thu, 12 Jun 2025 12:07:47 +0100 Subject: [PATCH 03/14] more readable description of client code --- docs/source/client_code.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/client_code.rst b/docs/source/client_code.rst index f4390cef..c1508911 100644 --- a/docs/source/client_code.rst +++ b/docs/source/client_code.rst @@ -1,7 +1,7 @@ Client code =========== -The interface to a `Thing` is defined by its interaction affordances, which are defined in the Thing Description. The `labthings-fastapi.client` library provides a `ThingClient` class to interact with a `Thing` via HTTP. This is a class with a method for each Action, and a property for each Property of the Thing. The intention is to provide a simple, pythonic interface that plays nicely with IDEs and autocompletion. +The interface to a `Thing` is defined by its actions, properties and events. Usually, Python code interacts with a `Thing` through a `ThingClient` subclass, where each action is a method and each property is a property of the class. The intention is to provide a simple, pythonic interface that plays nicely with IDEs and autocompletion. `ThingClient` subclasses can be generated dynamicall from a URL. Currently, this creates an object with the right methods and properties, but type hints are usually missing and autocompletion does not work well. In the future, `labthings-fastapi` will generate custom client subclasses that include type hints and autocompletion. An additional goal is to provide an interface that is consistent between the server and client code: a `DirectThingClient` class is used by the `labthings-fastapi` server to call actions and properties of other `Thing`s, which means code for an action may be developed as an HTTP client, for example in a Jupyter notebook, and then moved to the server with minimal changes. Currently, there are a few differences in behaviour between local and remote `Thing`s, most notably the return types (which are usually Pydantic models on the server, and currently dictionaries generated from JSON on the client). This should be improved in the future. From 08cf0550d25224e68e5a2cbdc33145a34cf2fc33 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Thu, 12 Jun 2025 12:07:47 +0100 Subject: [PATCH 04/14] Page on blobs --- docs/source/blobs.rst | 135 +++++++++++++++++++++++++++++++++ docs/source/index.rst | 1 + docs/source/tutorial/index.rst | 7 +- 3 files changed, 142 insertions(+), 1 deletion(-) create mode 100644 docs/source/blobs.rst diff --git a/docs/source/blobs.rst b/docs/source/blobs.rst new file mode 100644 index 00000000..786575fe --- /dev/null +++ b/docs/source/blobs.rst @@ -0,0 +1,135 @@ +Blob input/output +================= + +`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 `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). + +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 `Blob` object. + +`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 `Blob` mechanism is intended to provide an efficient way to work with arbitrary binary data. If it's used to transfer data between two `Thing`s 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. + +A `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 `Blob` with the content type set: this makes it clear what kind of data is in the `Blob`. In the future, it might be possible to add functionality to `Blob` subclasses, for example to make it simple to obtain a `PIL` `Image` object from a `Blob` containing JPEG data. However, this will not yet work across both client and server code. + +Creating and using `Blob` objects +------------------------------------------------ + +Blobs can be created from binary data that is in memory (a `bytes` object), on disk (a file), or using a URL as a placeholder. The intention is that the code that uses a `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. + +Blobs offer three ways to access their data: + +* 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 `Blob` references a URL, it is retrieved and returned when `data` is accessed. +* An `open()` method providing a file-like object. This returns a `BytesIO` wrapper if the `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 `BytesIO` object. +* 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 `Blob` is pointing to a file rather than data in memory. + +The intention here is that `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. + +Examples +-------- + +A camera might want to return an image as a `Blob` object. The code for the action might look like this: + +.. code-block:: python + + from labthings_fastapi.blob import Blob + from labthings_fastapi.thing import Thing + from labthings_fastapi.decorators import thing_action + + class JPEGBlob(Blob): + content_type = "image/jpeg" + + class Camera(Thing): + @thing_action + def capture_image(self) -> JPEGBlob: + # Capture an image and return it as a Blob + image_data = self._capture_image() # This returns a bytes object holding the JPEG data + return JPEGBlob.from_bytes(image_data) + +The corresponding client code might look like this: + +.. code-block:: python + + from PIL import Image + from labthings_fastapi.client import ThingClient + + camera = ThingClient.from_url("http://localhost:5000/camera/") + image_blob = camera.capture_image() + image_blob.save("captured_image.jpg") # Save the image to a file + + # We can also open the image directly with PIL + with image_blob.open() as f: + img = Image.open(f) + img.show() # This will display the image in a window + +We could define a more sophisticated camera that can capture raw images and convert them to JPEG, using two actions: + +.. code-block:: python + + from labthings_fastapi.blob import Blob + from labthings_fastapi.thing import Thing + from labthings_fastapi.decorators import thing_action + + class JPEGBlob(Blob): + content_type = "image/jpeg" + + class RAWBlob(Blob): + content_type = "image/x-raw" + + class Camera(Thing): + @thing_action + def capture_raw_image(self) -> RAWBlob: + # Capture a raw image and return it as a Blob + raw_data = self._capture_raw_image() # This returns a bytes object holding the raw data + return RAWBlob.from_bytes(raw_data) + + @thing_action + def convert_raw_to_jpeg(self, raw_blob: RAWBlob) -> JPEGBlob: + # Convert a raw image Blob to a JPEG Blob + jpeg_data = self._convert_raw_to_jpeg(raw_blob.data) # This returns a bytes object holding the JPEG data + return JPEGBlob.from_bytes(jpeg_data) + + @thing_action + def capture_image(self) -> JPEGBlob: + # Capture an image and return it as a Blob + raw_blob = self.capture_raw_image() # Capture the raw image + jpeg_blob = self.convert_raw_to_jpeg(raw_blob) # Convert the raw image to JPEG + return jpeg_blob # Return the JPEG Blob + # NB the `raw_blob` is not retained after this action completes, so it will be garbage collected + +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: + +.. code-block:: python + + from PIL import Image + from labthings_fastapi.client import ThingClient + + camera = ThingClient.from_url("http://localhost:5000/camera/") + + # Capture a JPEG image directly + jpeg_blob = camera.capture_image() + jpeg_blob.save("captured_image.jpg") + + # Alternatively, capture a raw image and convert it to JPEG + raw_blob = camera.capture_raw_image() # NB the raw image is not yet downloaded + jpeg_blob = camera.convert_raw_to_jpeg(raw_blob) + jpeg_blob.save("converted_image.jpg") + + raw_blob.save("raw_image.raw") # Download and save the raw image to a file + + +Using `Blob` objects as inputs +------------------------------ + +`Blob` objects may be used as either the input or output of an action. There are relatively few good use cases for `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 `Blob` representing the raw data, which could be passed to the conversion action. + +Because `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 `Blob` on the server via HTTP, which means remote clients can use `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 `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 `Blob` to the conversion action, and the raw data need never be sent over the network. + +Memory management and retention +------------------------------- + +Management of `Blob` objects is currently very basic: when a `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. + +The behaviour is different when actions are called from other actions. If `action_a` calls `action_b`, and `action_b` returns a `Blob`, that `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`. + +HTTP interface and serialization +----------------------- + +`Blob` objects are subclasses of `pydantic.BaseModel`, which means they can be serialized to JSON and deserialized from JSON. When this happens, the `Blob` is represented as a JSON object with two fields: `url` and `content_type`. The `url` field is a link to the data. The `content_type` field is a string representing the MIME type of the data. When a `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 `Blob`. Once an Action has finished running, the only strong reference to the `Blob` should be held by the output property of the action invocation. The `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. \ No newline at end of file diff --git a/docs/source/index.rst b/docs/source/index.rst index bef03d03..4fc39964 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -9,6 +9,7 @@ Documentation for LabThings-FastAPI wot_core_concepts.rst tutorial/index.rst dependencies/dependencies.rst + blobs.rst concurrency.rst client_code.rst diff --git a/docs/source/tutorial/index.rst b/docs/source/tutorial/index.rst index c3676a8b..67007e5c 100644 --- a/docs/source/tutorial/index.rst +++ b/docs/source/tutorial/index.rst @@ -5,9 +5,14 @@ LabThings-FastAPI tutorial installing_labthings.rst running_labthings.rst + +.. + In due course, these pages should exist... writing_a_thing.rst client_code.rst blobs.rst thing_dependencies.rst -In this tutorial, we'll cover how to start up and interact with a LabThings-FastAPI server, how to write a Thing, and a few more advanced topics. It is intended as an introduction for someone using LabThings-FastAPI and/or writing Thing code to implement a new instrument. It doesn't detail all the internal workings of the library, but we hope this isn't needed for most people. \ No newline at end of file +In this tutorial, we'll cover how to start up and interact with a LabThings-FastAPI server. + +In the future, it should include how to write a Thing, and a few more advanced topics. It is intended as an introduction for someone using LabThings-FastAPI and/or writing Thing code to implement a new instrument. \ No newline at end of file From 07191f9d602726d453c1d17d7f7c5b21dba9f65d Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Thu, 12 Jun 2025 12:35:31 +0100 Subject: [PATCH 05/14] Fix toctree and cross-references --- docs/source/blobs.rst | 38 +++++++++++++++++----------------- docs/source/tutorial/index.rst | 4 ++-- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/docs/source/blobs.rst b/docs/source/blobs.rst index 786575fe..1bb819e1 100644 --- a/docs/source/blobs.rst +++ b/docs/source/blobs.rst @@ -1,31 +1,31 @@ Blob input/output ================= -`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 `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). +: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). -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 `Blob` object. +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. -`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 `Blob` mechanism is intended to provide an efficient way to work with arbitrary binary data. If it's used to transfer data between two `Thing`s 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. +: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. -A `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 `Blob` with the content type set: this makes it clear what kind of data is in the `Blob`. In the future, it might be possible to add functionality to `Blob` subclasses, for example to make it simple to obtain a `PIL` `Image` object from a `Blob` containing JPEG data. However, this will not yet work across both client and server code. +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. -Creating and using `Blob` objects +Creating and using :class:`.Blob` objects ------------------------------------------------ -Blobs can be created from binary data that is in memory (a `bytes` object), on disk (a file), or using a URL as a placeholder. The intention is that the code that uses a `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. +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. Blobs offer three ways to access their data: -* 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 `Blob` references a URL, it is retrieved and returned when `data` is accessed. -* An `open()` method providing a file-like object. This returns a `BytesIO` wrapper if the `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 `BytesIO` object. -* 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 `Blob` is pointing to a file rather than data in memory. +* 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. +* 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. +* 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. -The intention here is that `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. +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. Examples -------- -A camera might want to return an image as a `Blob` object. The code for the action might look like this: +A camera might want to return an image as a :class:`.Blob` object. The code for the action might look like this: .. code-block:: python @@ -115,21 +115,21 @@ On the client, we can use the `capture_image` action directly (as before), or we raw_blob.save("raw_image.raw") # Download and save the raw image to a file -Using `Blob` objects as inputs ------------------------------- +Using :class:`.Blob` objects as inputs +-------------------------------------- -`Blob` objects may be used as either the input or output of an action. There are relatively few good use cases for `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 `Blob` representing the raw data, which could be passed to the conversion action. +: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. -Because `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 `Blob` on the server via HTTP, which means remote clients can use `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 `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 `Blob` to the conversion action, and the raw data need never be sent over the network. +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. Memory management and retention ------------------------------- -Management of `Blob` objects is currently very basic: when a `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. +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. -The behaviour is different when actions are called from other actions. If `action_a` calls `action_b`, and `action_b` returns a `Blob`, that `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`. +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`. HTTP interface and serialization ------------------------ +-------------------------------- -`Blob` objects are subclasses of `pydantic.BaseModel`, which means they can be serialized to JSON and deserialized from JSON. When this happens, the `Blob` is represented as a JSON object with two fields: `url` and `content_type`. The `url` field is a link to the data. The `content_type` field is a string representing the MIME type of the data. When a `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 `Blob`. Once an Action has finished running, the only strong reference to the `Blob` should be held by the output property of the action invocation. The `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. \ No newline at end of file +: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 two fields: `url` and `content_type`. The `url` field is a link to the data. The `content_type` field is a string representing the MIME type of the data. 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. \ No newline at end of file diff --git a/docs/source/tutorial/index.rst b/docs/source/tutorial/index.rst index 67007e5c..3932187a 100644 --- a/docs/source/tutorial/index.rst +++ b/docs/source/tutorial/index.rst @@ -1,8 +1,8 @@ LabThings-FastAPI tutorial ========================== -.. toctree: - +.. toctree:: + installing_labthings.rst running_labthings.rst From e2c6865036362e7520592bd92d09b4d5b4ee1d29 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Fri, 13 Jun 2025 00:18:59 +0100 Subject: [PATCH 06/14] Add some big picture docs based on my understanding --- docs/source/index.rst | 3 +- docs/source/lt_core_concepts.rst | 52 ++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 docs/source/lt_core_concepts.rst diff --git a/docs/source/index.rst b/docs/source/index.rst index 4fc39964..3dd0fc1f 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -7,6 +7,7 @@ Documentation for LabThings-FastAPI quickstart/quickstart.rst wot_core_concepts.rst + lt_core_concepts.rst tutorial/index.rst dependencies/dependencies.rst blobs.rst @@ -34,7 +35,7 @@ Documentation for LabThings-FastAPI - Starlette (used by FastAPI) can handle requests asynchronously - this improves performance and enables websockets and other long-lived connections. - `Thing` code is still, for now, threaded. In the future it may become possible to us other concurrency models in `Thing` code. -Compared to `python-labthings`_, this framework updates dependencies, shrinks the codebase, and simplifies the API. +Compared to `python-labthings`_, this framework updates dependencies, shrinks the codebase, and simplifies the API (see :doc:`lt_core_concepts`). * FastAPI more or less completely eliminates OpenAPI generation code from our codebase * Marshmallow schemas and endpoint classes are replaced with Python type hints, eliminating double- or triple-definition of actions and their inputs/outputs. * Thing Description generation is very much simplified by the new structure (multiple Things instead of one massive Thing with many extensions) diff --git a/docs/source/lt_core_concepts.rst b/docs/source/lt_core_concepts.rst new file mode 100644 index 00000000..8e802d9e --- /dev/null +++ b/docs/source/lt_core_concepts.rst @@ -0,0 +1,52 @@ +LabThings Core Concepts +============= + +LabThings FastAPI is a ground-up rewrite of LabThings using FastAPI. Many of the core concepts from FastAPI such as dependency injection are used heavily + +The LabThings Server +-------------------- + +At its core LabThings FastAPI is a server-based framework. To use LabThings FastAPI a LabThings Server is created, and `Thing`s are added to the the server to provide functionality. + +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 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. + +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 `Thing`s. As such the full API is composed of the actions and properties (and perhaps eventually events) defined in each `Thing`. + +ThingClients +------------ + +When writing client code in Python it would be possible to formulate every interaction as an HTTP request. This has two major downsides: + +1. The code must establish a new connection to the server for each request. +2. Each request is formulated as a string pointing to the endpoint and `json` headers for sending any data. This leads to very messy code. + +Ideally the client would be able to run the `Thing` object's actions and read its properties in native python code. However, as the client code is running in a different process, and probably in a different python environment (or even on a different machine entirely!) there is no way to directly import the Python objectfor the `Thing`. + +To mitigate this client code can ask the server for a description of all of a `Thing`'s properties and actions, this is known as a `ThingDescription`. From this `ThingDescription` the client code can dynamically generate a new object with methods matching each `ThingAction` and properties matching each `ThingProperty`. **This dynamically generated object is called a ThingClient**. + +The `ThingClient` also handle supplying certain arguments to ThingActions without them needing to be explicitly passed each time the method is called. More detail on this is provided in the :doc:`dependencies` page. + +DirectThingClients +------------------ + +When writing code to run on the server one Thing will need to call another Thing. Ideally this code should be identical to code written in a client. This way the code can be prototyped in a client notebook before being ported to the server. + +It would be possible to directly call the Thing object, however in this case the Python API would not be the same as for client code, because the dependencies would not automatically be supplied. +**RICHARD, Are there other reasons too?** + +To provide the same interface in server code as is provided in client code LabThings FastAPI can dynamically create a new object with the same (or at least very similar) API as the `ThingClient`, this is called a **DirectThingClient**. + +The key difference between a `ThingClient` and a `DirectThingClient` is that the `ThingClient` calls the `Thing` over HTTP from client code, whereas the `DirectThingClient` calls directly through the Python API from within the Server. + + + From 8b44a93a39d66ce51b6258c5c0106f439a7f583d Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Mon, 16 Jun 2025 17:15:37 +0100 Subject: [PATCH 07/14] Improved docstrings on blob --- src/labthings_fastapi/outputs/blob.py | 86 ++++++++++++++++++++++----- 1 file changed, 72 insertions(+), 14 deletions(-) diff --git a/src/labthings_fastapi/outputs/blob.py b/src/labthings_fastapi/outputs/blob.py index 51525bcd..836026a8 100644 --- a/src/labthings_fastapi/outputs/blob.py +++ b/src/labthings_fastapi/outputs/blob.py @@ -7,7 +7,7 @@ To return a file from an action, you should declare its return type as a BlobOutput subclass, defining the `media_type` attribute. -The output from the class should be an instance of that subclass, with data supplied +The action should then return an instance of that subclass, with data supplied either as a `bytes` object or a file on disk. If files are used, it's your responsibility to ensure the file is deleted after the `BlobOutput` object is garbage-collected. Constructing it using the class methods `from_bytes` or @@ -52,31 +52,54 @@ @runtime_checkable class BlobData(Protocol): - """A Protocol for a BlobOutput object""" + """The interface for the data store of a Blob. + + :class:`.Blob` objects can represent their data in various ways. Each of + those options must provide three ways to access the data, which are the + `content` property, the `save()` method, and the `open()` method. + """ @property def media_type(self) -> str: + """The MIME type of the data, e.g. 'image/png' or 'application/json'""" pass @property def content(self) -> bytes: + """The data as a `bytes` object""" pass - def save(self, filename: str) -> None: ... + def save(self, filename: str) -> None: + """Save the data to a file""" + ... - def open(self) -> io.IOBase: ... + def open(self) -> io.IOBase: + """Return a file-like object that may be read from.""" + ... class ServerSideBlobData(BlobData, Protocol): - """A BlobOutput protocol for server-side use, i.e. including `response()`""" + """A BlobData protocol for server-side use, i.e. including `response()` + + :class:`Blob` objects returned by actions must use :class:`.BlobData` objects + that can be downloaded. This protocol extends the :class:`.BlobData` protocol to + include a :meth:`~.ServerSideBlobData.response()` method that returns a FastAPI response object. + """ id: Optional[uuid.UUID] = None + """A unique identifier for this BlobData object. + + The ID is set when the BlobData object is added to the BlobDataManager. + It is used to retrieve the BlobData object from the manager. + """ - def response(self) -> Response: ... + def response(self) -> Response: + """A :class:`fastapi.Response` object that sends binary data.""" + ... class BlobBytes: - """A BlobOutput that holds its data in memory as a `bytes` object""" + """A BlobOutput that holds its data in memory as a :class:`bytes` object""" id: Optional[uuid.UUID] = None @@ -132,17 +155,26 @@ def response(self) -> Response: class Blob(BaseModel): - """An output from LabThings best returned as binary data, not JSON - - This may be instantiated either using the class methods `from_bytes` or - `from_temporary_directory`, which will use a `bytes` object to store the - output, or return a file on disk in a temporary directory. In the latter - case, the temporary directory will be deleted when the object is garbage - collected. + """A container for binary data that may be retrieved over HTTP + + See :doc:`blobs` for more information on how to use this class. + + A :class:`.Blob` may be created to hold data using the class methods + `from_bytes` or `from_temporary_directory`. The constructor will + attempt to deserialise a Blob from a URL, and may only be used within + a `blob_serialisation_context_manager`. This is made available when + actions are invoked, or when their output is returned. + + You are strongly advised to subclass this class and specify the + `media_type` attribute, as this will propagate to the auto-generated + documentation. """ href: str + """The URL where the data may be retrieved. This will be `blob://local` + if the data is stored locally.""" media_type: str = "*/*" + """The MIME type of the data. This should be overridden in subclasses.""" rel: Literal["output"] = "output" description: str = ( "The output from this action is not serialised to JSON, so it must be " @@ -150,9 +182,22 @@ class Blob(BaseModel): ) _data: Optional[ServerSideBlobData] = None + """This object holds the data, either in memory or as a file.""" @model_validator(mode="after") def retrieve_data(self): + """Retrieve the data from the URL + + When a :class:`.Blob` is created using its constructor, :mod:`pydantic` + will attempt to deserialise it by retrieving the data from the URL + specified in `href`. Currently, this must be a URL pointing to a + :class:`.Blob` that already exists on this server. + + This validator will only work if the function to resolve URLs to + :class:`.BlobData` objects has been set in the context variable. This + is done when actions are being invoked over HTTP, or when + their outputs are being returned. + """ if self.href == "blob://local": if self._data: return self @@ -170,6 +215,19 @@ def retrieve_data(self): @model_serializer(mode="plain", when_used="always") def to_dict(self) -> Mapping[str, str]: + """Serialise the Blob to a dictionary and make it downloadable + + When :mod:`pydantic` serialises this object, it will call this method + to convert it to a dictionary. There is a significant side-effect, which + is that we will add the blob to the :class:`.BlobDataManager` so it + can be downloaded. + + This serialiser will only work if the function to resolve URLs to + :class:`.BlobData` objects has been set in the context variable. This + is done when the outputs of actions are being returned. + + Note that the + """ if self.href == "blob://local": try: blobdata_to_url = blobdata_to_url_ctx.get() From fdd49432191ef2f12a188a0fa4fd93136a2bb8b7 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Mon, 16 Jun 2025 17:15:37 +0100 Subject: [PATCH 08/14] Improvements to client documentation in response to MR comments --- docs/source/client_code.rst | 18 ------------------ docs/source/using_things.rst | 37 ++++++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 18 deletions(-) delete mode 100644 docs/source/client_code.rst create mode 100644 docs/source/using_things.rst diff --git a/docs/source/client_code.rst b/docs/source/client_code.rst deleted file mode 100644 index c1508911..00000000 --- a/docs/source/client_code.rst +++ /dev/null @@ -1,18 +0,0 @@ -Client code -=========== - -The interface to a `Thing` is defined by its actions, properties and events. Usually, Python code interacts with a `Thing` through a `ThingClient` subclass, where each action is a method and each property is a property of the class. The intention is to provide a simple, pythonic interface that plays nicely with IDEs and autocompletion. `ThingClient` subclasses can be generated dynamicall from a URL. Currently, this creates an object with the right methods and properties, but type hints are usually missing and autocompletion does not work well. In the future, `labthings-fastapi` will generate custom client subclasses that include type hints and autocompletion. - -An additional goal is to provide an interface that is consistent between the server and client code: a `DirectThingClient` class is used by the `labthings-fastapi` server to call actions and properties of other `Thing`s, which means code for an action may be developed as an HTTP client, for example in a Jupyter notebook, and then moved to the server with minimal changes. Currently, there are a few differences in behaviour between local and remote `Thing`s, most notably the return types (which are usually Pydantic models on the server, and currently dictionaries generated from JSON on the client). This should be improved in the future. - -Client code generation ----------------------- - -Currently, most clients are created using the class method `ThingClient.from_url`. This returns an instance of a dynamically-created subclass, rather than a `ThingClient` instance directly. The subclass is required in order to add methods and properties corresponding to the Thing Description sent by the server. While this is a solution that should work immediately, it does not work well with code completion or static analysis, and client objects must be introspected on-the-fly. - -In the future, `labthings_fastapi` will generate custom client subclasses. These will have the methods and properties defined in a Python module, including type annotations. This will allow static analysis (e.g. with MyPy) and IDE autocompletion to work. Most packages that provide a `Thing` subclass will want to release a client package that is generated automatically in this way. The intention is to make it possible to add custom Python code to this client, for example to handle specialised return types more gracefully or add convenience methods. - - - - - diff --git a/docs/source/using_things.rst b/docs/source/using_things.rst new file mode 100644 index 00000000..687748da --- /dev/null +++ b/docs/source/using_things.rst @@ -0,0 +1,37 @@ +Using Things +============ + +The interface to a `Thing` is defined by its actions, properties and events [#events]_. These can all be accessed remotely via HTTP from any language, but a more convenient interface in Python is a :class:`.ThingClient` subclass. This provides a simple, pythonic interface to the :class:`.Thing`, allowing you to call actions and access properties as if they were methods and attributes of a Python object. + +:class:`.ThingClient` subclasses can be generated dynamically from a URL using :meth:`.ThingClient.from_url`. This creates an object with the right methods, properties and docstrings, though type hints are often missing. The client can be "introspected" to explore its methods and properties using tools that work at run-time (e.g. autocompletion in a Jupyter notebook), but "static" analysis tools will not yet work. + +Using Things from other languages +---------------------------------- + +LabThings exposes all the Actions and Properties of each Thing over HTTP, meaning they may be called from nearly every programming language, or interactively using tools such as `curl` or `swagger`. Each Thing is described using both a Thing Description document and an OpenAPI description. Thing Descriptions are a high-level description, standardised by W3C, that can be used to create intuitive client code. There are currently a few tools that work with Thing Description, but the Web of Things standard is still growing and developing. The OpenAPI description is a lower-level description of the HTTP API, which can be used to generate client code in many languages. The OpenAPI description is also used to `render the interactive documentation`_ using Swagger or Redocly, which is available at the `/docs` URL of the server (e.g. `http://localhost:5000/docs` when running a local server). + +_`render the interactive documentation`: https://fastapi.tiangolo.com/#interactive-api-docs + +Dynamic class generation +------------------------- + +The object returned by :meth:`.ThingClient.from_url` is an instance of a dynamically-created subclass of :class:`.ThingClient`. Dynamically creating the class is needed because we don't know what the methods and properties should be until we have downloaded the Thing Description. However, this means most code autocompletion tools, type checkers, and linters will not work well with these classes. In the future, LabThings-FastAPI will generate custom client subclasses that can be shared in client modules, which should fix these problems (see below). + +.. [#events] Events are not yet implemented. + +Using Things from other Things +------------------------------ + +One goal of LabThings-FastAPI is to make code portable between a client (e.g. a Jupyter notebook, or a Python script on another computer) and server-side code (i.e. code inside an action of a :class:`.Thing`). This is done using a :class:`.DirectThingClient` class, which is a subclass of :class:`.ThingClient`. + +A :class:`.DirectThingClient` class will call actions and properties of other :class:`.Thing` subclasses using the same interface that would be used by a remote client, which means code for an action may be developed as an HTTP client, for example in a Jupyter notebook, and then moved to the server with minimal changes. Currently, there are a few differences in behaviour between working locally or remotely, most notably the return types (which are usually Pydantic models on the server, and currently dictionaries on the client). This should be improved in the future. + +Planned future development: static code generation +-------------------------------------------------- + +In the future, `labthings_fastapi` will generate custom client subclasses. These will have the methods and properties defined in a Python module, including type annotations. This will allow static analysis (e.g. with MyPy) and IDE autocompletion to work. Most packages that provide a `Thing` subclass will want to release a client package that is generated automatically in this way. The intention is to make it possible to add custom Python code to this client, for example to handle specialised return types more gracefully or add convenience methods. Generated client code does mean there will be more packages to install on the client in order to use a particular Thing. However, the significant benefits of having a properly defined interface should make this worthwhile. + +Return types are also currently not consistent between client and server code: currently, the HTTP implementation of :class:`.ThingClient` deserialises the JSON response and returns it directly, meaning that :class:`pydantic.BaseModel` subclasses become dictionaries. This behaviour should change in the future to be consistent between client and server. Most liekly, this will mean Pydantic models are used in both cases. + + + From dd6095c0b833c4822a5e5734d912964d753ee48a Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Mon, 16 Jun 2025 17:15:37 +0100 Subject: [PATCH 09/14] Improved docstrings and docs for Blob --- docs/source/blobs.rst | 22 +++- docs/source/conf.py | 1 + docs/source/index.rst | 2 +- src/labthings_fastapi/outputs/blob.py | 177 ++++++++++++++++++++------ 4 files changed, 156 insertions(+), 46 deletions(-) diff --git a/docs/source/blobs.rst b/docs/source/blobs.rst index 1bb819e1..8ed97329 100644 --- a/docs/source/blobs.rst +++ b/docs/source/blobs.rst @@ -122,14 +122,30 @@ Using :class:`.Blob` objects as inputs 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. + +HTTP interface and serialization +-------------------------------- + +: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 :prop:`.Blob.url` and :prop:`.Blob.content_type` fields. The :prop:`.Blob.url` field is a link to the data. The :prop:`.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. + +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. + +In order to run an action and download the data, currently an HTTP client must: + +* Call the action that returns a :class:`.Blob` object, which will return a JSON object representing the invocation. +* Poll the invocation until it is complete, and the :class:`.Blob` is available in its ``output`` property with the URL and content type. +* Download the data from the URL in the :class:`.Blob` object, which will return the binary data. + +It may be possible to have actions return binary data directly in the future, but this is not yet implemented. + + Memory management and retention ------------------------------- 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. +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. + 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`. -HTTP interface and serialization --------------------------------- -: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 two fields: `url` and `content_type`. The `url` field is a link to the data. The `content_type` field is a string representing the MIME type of the data. 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. \ No newline at end of file diff --git a/docs/source/conf.py b/docs/source/conf.py index d999369d..63ad1b01 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -27,6 +27,7 @@ autodoc2_packages = ["../../src/labthings_fastapi"] autodoc2_render_plugin = "myst" +autodoc2_class_docstring = "both" # autoapi_dirs = ["../../src/labthings_fastapi"] # autoapi_ignore = [] diff --git a/docs/source/index.rst b/docs/source/index.rst index 3dd0fc1f..35c3d4c9 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -12,7 +12,7 @@ Documentation for LabThings-FastAPI dependencies/dependencies.rst blobs.rst concurrency.rst - client_code.rst + using_things.rst apidocs/index diff --git a/src/labthings_fastapi/outputs/blob.py b/src/labthings_fastapi/outputs/blob.py index 836026a8..23ff1f8f 100644 --- a/src/labthings_fastapi/outputs/blob.py +++ b/src/labthings_fastapi/outputs/blob.py @@ -1,22 +1,45 @@ -"""BLOB Output Module +""" +# BLOB Output Module The BlobOutput class is used when you need to return something file-like that can't easily (or efficiently) be converted to JSON. This is useful for returning large objects like images, especially where an existing file-type is the obvious way to handle it. +There is a [dedicated documentation page on blobs](/blobs.rst) that explains how to use +this mechanism. + To return a file from an action, you should declare its return type as a BlobOutput -subclass, defining the `media_type` attribute. +subclass, defining the +[`media_type`](#labthings_fastapi.outputs.blob.Blob.media_type) attribute. + +```python +class MyImageBlob(Blob): + media_type = "image/png" + +class MyThing(Thing): + @thing_action + def get_image(self) -> MyImageBlob: + # Do something to get the image data + data = self._get_image_data() + return MyImageBlob.from_bytes(data) +``` The action should then return an instance of that subclass, with data supplied either as a `bytes` object or a file on disk. If files are used, it's your -responsibility to ensure the file is deleted after the `BlobOutput` object is -garbage-collected. Constructing it using the class methods `from_bytes` or -`from_temporary_directory` will ensure this is done for you. +responsibility to ensure the file is deleted after the +[`Blob`](#labthings_fastapi.outputs.blob.Blob) object is +garbage-collected. Constructing it using the class methods +[`from_bytes`](#labthings_fastapi.outputs.blob.Blob.from_bytes) or +[`from_temporary_directory`](#labthings_fastapi.outputs.blob.Blob.from_temporary_directory) +will ensure this is done for you. Bear in mind a `tempfile` object only holds a file descriptor and is not safe for -concurrent use: action outputs may be retrieved multiple times after the action has -completed. Creating a temp folder and making a file inside it is the safest way to -deal with this. +concurrent use, which does not work well with the HTTP API: +action outputs may be retrieved multiple times after the action has +completed, possibly concurrently. Creating a temp folder and making a file inside it +with +[`from_temporary_directory`](#labthings_fastapi.outputs.blob.Blob.from_temporary_directory) +is the safest way to deal with this. """ from __future__ import annotations @@ -53,10 +76,17 @@ @runtime_checkable class BlobData(Protocol): """The interface for the data store of a Blob. - - :class:`.Blob` objects can represent their data in various ways. Each of + + [`Blob`](#labthings_fastapi.outputs.blob.Blob) objects can represent their data in various ways. Each of those options must provide three ways to access the data, which are the `content` property, the `save()` method, and the `open()` method. + + This protocol defines the interface needed by any data store used by a + [`Blob`](#labthings_fastapi.outputs.blob.Blob). + + Objects that are used on the server will additionally need to implement the + [`ServerSideBlobData`](#labthings_fastapi.outputs.blob.ServerSideBlobData) protocol, + which adds a `response()` method and `id` property. """ @property @@ -80,10 +110,14 @@ def open(self) -> io.IOBase: class ServerSideBlobData(BlobData, Protocol): """A BlobData protocol for server-side use, i.e. including `response()` - - :class:`Blob` objects returned by actions must use :class:`.BlobData` objects - that can be downloaded. This protocol extends the :class:`.BlobData` protocol to - include a :meth:`~.ServerSideBlobData.response()` method that returns a FastAPI response object. + + [`Blob`](#labthings_fastapi.outputs.blob.Blob) objects returned by actions must use + [`BlobData`](#labthings_fastapi.outputs.blob.BlobData) objects + that can be downloaded. This protocol extends that protocol to + include a [`response()`](#labthings_fastapi.outputs.blob.ServerSideBlobData.response) method that returns a FastAPI response object. + + See [`BlobBytes`](#labthings_fastapi.outputs.blob.BlobBytes) or + [`BlobFile`](#labthings_fastapi.outputs.blob.BlobFile) for concrete implementations. """ id: Optional[uuid.UUID] = None @@ -157,13 +191,12 @@ def response(self) -> Response: class Blob(BaseModel): """A container for binary data that may be retrieved over HTTP - See :doc:`blobs` for more information on how to use this class. - - A :class:`.Blob` may be created to hold data using the class methods + See the [documentation on blobs](/blobs.rst) for more information on how to use this class. + + A [`Blob`](#labthings_fastapi.outputs.blob.Blob) may be created + to hold data using the class methods `from_bytes` or `from_temporary_directory`. The constructor will - attempt to deserialise a Blob from a URL, and may only be used within - a `blob_serialisation_context_manager`. This is made available when - actions are invoked, or when their output is returned. + attempt to deserialise a Blob from a URL (see `__init__` method). You are strongly advised to subclass this class and specify the `media_type` attribute, as this will propagate to the auto-generated @@ -182,21 +215,29 @@ class Blob(BaseModel): ) _data: Optional[ServerSideBlobData] = None - """This object holds the data, either in memory or as a file.""" + """This object holds the data, either in memory or as a file. + + If `_data` is `None`, then the Blob has not been deserialised yet, and the + `href` should point to a valid address where the data may be downloaded. + """ @model_validator(mode="after") def retrieve_data(self): """Retrieve the data from the URL - - When a :class:`.Blob` is created using its constructor, :mod:`pydantic` + + When a [`Blob`](#labthings_fastapi.outputs.blob.Blob) is created + using its constructor, [`pydantic`](https://docs.pydantic.dev/latest/) will attempt to deserialise it by retrieving the data from the URL - specified in `href`. Currently, this must be a URL pointing to a - :class:`.Blob` that already exists on this server. + specified in `href`. Currently, this must be a URL pointing to a + [`Blob`](#labthings_fastapi.outputs.blob.Blob) that already exists on + this server. This validator will only work if the function to resolve URLs to - :class:`.BlobData` objects has been set in the context variable. This - is done when actions are being invoked over HTTP, or when - their outputs are being returned. + [`BlobData`](#labthings_fastapi.outputs.blob.BlobData) objects + has been set in the context variable + [`url_to_blobdata_ctx`](#labthings_fastapi.outputs.blob.url_to_blobdata_ctx). + This is done when actions are being invoked over HTTP by the + [`BlobIOContextDep`](#labthings_fastapi.outputs.blob.BlobIOContextDep) dependency. """ if self.href == "blob://local": if self._data: @@ -216,17 +257,19 @@ def retrieve_data(self): @model_serializer(mode="plain", when_used="always") def to_dict(self) -> Mapping[str, str]: """Serialise the Blob to a dictionary and make it downloadable - - When :mod:`pydantic` serialises this object, it will call this method - to convert it to a dictionary. There is a significant side-effect, which - is that we will add the blob to the :class:`.BlobDataManager` so it + + When [`pydantic`](https://docs.pydantic.dev/latest/) serialises this object, + it will call this method to convert it to a dictionary. There is a + significant side-effect, which is that we will add the blob to the + [`BlobDataManager`](#labthings_fastapi.outputs.blob.BlobDataManager) so it can be downloaded. - - This serialiser will only work if the function to resolve URLs to - :class:`.BlobData` objects has been set in the context variable. This - is done when the outputs of actions are being returned. - Note that the + This serialiser will only work if the function to assign URLs to + [`BlobData`](#labthings_fastapi.outputs.blob.BlobData) objects + has been set in the context variable + [`blobdata_to_url_ctx`](#labthings_fastapi.outputs.blob.blobdata_to_url_ctx). + This is done when actions are being returned over HTTP by the + [`BlobIOContextDep`](#labthings_fastapi.outputs.blob.BlobIOContextDep) dependency. """ if self.href == "blob://local": try: @@ -249,10 +292,30 @@ def to_dict(self) -> Mapping[str, str]: @classmethod def default_media_type(cls) -> str: + """The default media type. + + `Blob` should generally be subclassed to define the default media type, + as this forms part of the auto-generated documentation. Using the + `Blob` class directly will result in a media type of `*/*`, which makes + it unclear what format the output is in. + """ return cls.model_fields["media_type"].get_default() @property def data(self) -> ServerSideBlobData: + """The data store for this Blob + + `Blob` objects may hold their data in various ways, defined by the + [`ServerSideBlobData`](#labthings_fastapi.outputs.blob.ServerSideBlobData) + protocol. This property returns the data store for this `Blob`. + + If the `Blob` has not yet been downloaded, there may be no data + held locally, in which case this function will raise a `ValueError`. + + It is recommended to use the `content` property or `save()` or `open()` + methods rather than accessing this property directly. Those methods will + download data if required, rather than raising an error. + """ if self._data is None: raise ValueError("This Blob has no data.") return self._data @@ -329,7 +392,16 @@ def response(self): def blob_type(media_type: str) -> type[Blob]: - """Create a BlobOutput subclass for a given media type""" + """Create a BlobOutput subclass for a given media type + + This convenience function may confuse static type checkers, so it is usually + clearer to make a subclass instead, e.g.: + + ```python + class MyImageBlob(Blob): + media_type = "image/png" + ``` + """ if "'" in media_type or "\\" in media_type: raise ValueError("media_type must not contain single quotes or backslashes") return create_model( @@ -342,9 +414,12 @@ def blob_type(media_type: str) -> type[Blob]: class BlobDataManager: """A class to manage BlobData objects - The BlobManager is responsible for serving `Blob` objects to clients. It + The `BlobManager` is responsible for serving `Blob` objects to clients. It holds weak references: it will not retain `Blob`s that are no longer in use. - Most `Blob`s will be retained""" + Most `Blob`s will be retained by the output of an action: this holds a strong + reference, and will be expired by the + [`ActionManager`](#labthings_fastapi.actions.ActionManager). + """ _blobs: WeakValueDictionary[uuid.UUID, ServerSideBlobData] @@ -352,7 +427,7 @@ def __init__(self): self._blobs = WeakValueDictionary() def add_blob(self, blob: ServerSideBlobData) -> uuid.UUID: - """Add a BlobOutput to the manager""" + """Add a BlobOutput to the manager, generating a unique ID""" if hasattr(blob, "id") and blob.id is not None: if blob.id in self._blobs: return blob.id @@ -380,12 +455,29 @@ def attach_to_app(self, app: FastAPI): blobdata_to_url_ctx = ContextVar[Callable[[ServerSideBlobData], str]]("blobdata_to_url") +"""This context variable gives access to a function that makes BlobData objects +downloadable, by assigning a URL and adding them to the +[`BlobDataManager`](#labthings_fastapi.outputs.blob.BlobDataManager). + +It is only available within a +[`blob_serialisation_context_manager`](#labthings_fastapi.outputs.blob.blob_serialisation_context_manager) +because it requires access to the `BlobDataManager` and the `url_for` function +from the FastAPI app. +""" url_to_blobdata_ctx = ContextVar[Callable[[str], BlobData]]("url_to_blobdata") +"""This context variable gives access to a function that makes BlobData objects +from a URL, by retrieving them from the +[`BlobDataManager`](#labthings_fastapi.outputs.blob.BlobDataManager). + +It is only available within a +[`blob_serialisation_context_manager`](#labthings_fastapi.outputs.blob.blob_serialisation_context_manager) +because it requires access to the `BlobDataManager`. +""" async def blob_serialisation_context_manager(request: Request): - """Set context variables to allow blobs to be serialised""" + """Set context variables to allow blobs to be [de]serialised""" thing_server = find_thing_server(request.app) blob_manager: BlobDataManager = thing_server.blob_data_manager url_for = request.url_for @@ -415,3 +507,4 @@ def url_to_blobdata(url: str) -> BlobData: BlobIOContextDep: TypeAlias = Annotated[ BlobDataManager, Depends(blob_serialisation_context_manager) ] +"""A dependency that enables `Blob`s to be serialised and deserialised.""" From 3e6920465ff8557aac6e0c1fc6f0fa2b9f088447 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Mon, 16 Jun 2025 17:29:05 +0100 Subject: [PATCH 10/14] Fix links and link to anyio --- docs/source/blobs.rst | 2 +- docs/source/concurrency.rst | 12 ++++++------ docs/source/conf.py | 4 ++++ docs/source/lt_core_concepts.rst | 17 ++++++++++------- 4 files changed, 21 insertions(+), 14 deletions(-) diff --git a/docs/source/blobs.rst b/docs/source/blobs.rst index 8ed97329..bc7d9356 100644 --- a/docs/source/blobs.rst +++ b/docs/source/blobs.rst @@ -126,7 +126,7 @@ Because :class:`.Blob` outputs are represented in JSON as links, they are downlo HTTP interface and serialization -------------------------------- -: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 :prop:`.Blob.url` and :prop:`.Blob.content_type` fields. The :prop:`.Blob.url` field is a link to the data. The :prop:`.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. +: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. 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. diff --git a/docs/source/concurrency.rst b/docs/source/concurrency.rst index 996ed0d6..41add040 100644 --- a/docs/source/concurrency.rst +++ b/docs/source/concurrency.rst @@ -1,15 +1,15 @@ -Concurrency in `labthings-fastapi` +Concurrency in LabThings-FastAPI ================================== 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. -`labthings-fastapi` instantiates each `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 `Thing` code should protect important variables or resources using locks from the `threading` module, and need not worry about writing asynchronous code. +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. -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. +In the case of properties, the HTTP response is only returned once the :class:`.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. -Many of the functions that handle HTTP requests are asynchronous, running in an `anyio` event loop. This enables many HTTP connections to be handled at once with good efficiency. The interface between async and threaded code is provided by a "Blocking Portal" created when the LabThings server is started. A FastAPI Dependency allows the blocking portal to be obtained: while it's very unlikely more than one LabThings server will exist in one Python instance, we avoid referring to the blocking portal globally in an effort to avoid concurrency issues. +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 interface between async and threaded code is provided by a :class:`anyio.BlockingPortal`, created when the LabThings server is started. A FastAPI Dependency allows the blocking portal to be obtained: while it's very unlikely more than one LabThings server will exist in one Python instance, we avoid referring to the blocking portal globally in an effort to avoid concurrency issues. -If threaded code needs to call code in the `anyio` event loop, the blocking portal dependency should be used. 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. +If threaded code needs to call code in the `anyio` event loop, the :class:`~.dependencies.blocking_portal.BlockingPortal` dependency should be used. There are relatively few occasions when :class:`.Thing` code will need to consider this explicitly: more usually the blocking portal will be obtained by a LabThings function, for example the :class:`.MJPEGStream` class. -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. +When one `Thing` calls the actions or properties of another :class:`.Thing`, either directly or via a :class:`.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 :class:`.ThingClient`, which blocks until the action or property is complete. diff --git a/docs/source/conf.py b/docs/source/conf.py index 63ad1b01..551ae884 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -25,6 +25,8 @@ templates_path = ["_templates"] exclude_patterns = [] +default_role = "py:obj" + autodoc2_packages = ["../../src/labthings_fastapi"] autodoc2_render_plugin = "myst" autodoc2_class_docstring = "both" @@ -43,6 +45,8 @@ intersphinx_mapping = { "python": ("https://docs.python.org/3", None), "fastapi": ("https://fastapi.tiangolo.com", None), + "anyio": ("https://anyio.readthedocs.io/en/stable/", None), + "pydantic": ("https://docs.pydantic.dev/latest/", None), } myst_enable_extensions = ["fieldlist"] diff --git a/docs/source/lt_core_concepts.rst b/docs/source/lt_core_concepts.rst index 8e802d9e..128898ba 100644 --- a/docs/source/lt_core_concepts.rst +++ b/docs/source/lt_core_concepts.rst @@ -1,26 +1,29 @@ LabThings Core Concepts -============= +======================= LabThings FastAPI is a ground-up rewrite of LabThings using FastAPI. Many of the core concepts from FastAPI such as dependency injection are used heavily The LabThings Server -------------------- -At its core LabThings FastAPI is a server-based framework. To use LabThings FastAPI a LabThings Server is created, and `Thing`s are added to the the server to provide functionality. +At its core LabThings FastAPI is a server-based framework. To use LabThings FastAPI a LabThings Server is created, and `.Thing` objects are added to the the server to provide functionality. 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 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. +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, Action, or Event. 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 `Thing`s. As such the full API is composed of the actions and properties (and perhaps eventually events) defined in each `Thing`. +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 ThingClients ------------ @@ -28,13 +31,13 @@ ThingClients When writing client code in Python it would be possible to formulate every interaction as an HTTP request. This has two major downsides: 1. The code must establish a new connection to the server for each request. -2. Each request is formulated as a string pointing to the endpoint and `json` headers for sending any data. This leads to very messy code. +2. Each request is formulated as a string pointing to the endpoint and ``json`` headers for sending any data. This leads to very messy code. Ideally the client would be able to run the `Thing` object's actions and read its properties in native python code. However, as the client code is running in a different process, and probably in a different python environment (or even on a different machine entirely!) there is no way to directly import the Python objectfor the `Thing`. To mitigate this client code can ask the server for a description of all of a `Thing`'s properties and actions, this is known as a `ThingDescription`. From this `ThingDescription` the client code can dynamically generate a new object with methods matching each `ThingAction` and properties matching each `ThingProperty`. **This dynamically generated object is called a ThingClient**. -The `ThingClient` also handle supplying certain arguments to ThingActions without them needing to be explicitly passed each time the method is called. More detail on this is provided in the :doc:`dependencies` page. +The :class:`.ThingClient` also handle supplying certain arguments to ThingActions without them needing to be explicitly passed each time the method is called. More detail on this is provided in the :doc:`dependencies/dependencies` page. DirectThingClients ------------------ From 72a3a878db20573a33aad40768cc45d9380305b5 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Mon, 16 Jun 2025 17:42:38 +0100 Subject: [PATCH 11/14] Fix links in concurrency page --- docs/source/concurrency.rst | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/docs/source/concurrency.rst b/docs/source/concurrency.rst index 41add040..3eaad64e 100644 --- a/docs/source/concurrency.rst +++ b/docs/source/concurrency.rst @@ -5,11 +5,18 @@ One of the major challenges when controlling hardware, particularly from web fra 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. -In the case of properties, the HTTP response is only returned once the :class:`.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. +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. -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 interface between async and threaded code is provided by a :class:`anyio.BlockingPortal`, created when the LabThings server is started. A FastAPI Dependency allows the blocking portal to be obtained: while it's very unlikely more than one LabThings server will exist in one Python instance, we avoid referring to the blocking portal globally in an effort to avoid concurrency issues. +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. -If threaded code needs to call code in the `anyio` event loop, the :class:`~.dependencies.blocking_portal.BlockingPortal` dependency should be used. There are relatively few occasions when :class:`.Thing` code will need to consider this explicitly: more usually the blocking portal will be obtained by a LabThings function, for example the :class:`.MJPEGStream` class. +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. -When one `Thing` calls the actions or properties of another :class:`.Thing`, either directly or via a :class:`.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 :class:`.ThingClient`, which blocks until the action or property is complete. +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. + +.. _`anyio documentation`: https://anyio.readthedocs.io/en/stable/threads.html + +Calling Things from other Things +-------------------------------- + +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. From 1fb12f2550214d9e92cf0317d1117046897ca525 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Thu, 19 Jun 2025 14:11:20 +0100 Subject: [PATCH 12/14] Build docs in CI, without sphinx-action --- .github/workflows/docs.yml | 44 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 .github/workflows/docs.yml diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 00000000..28372aa2 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,44 @@ +# This file was modified from https://github.com/ammaraskar/sphinx-action-test/blob/master/.github/workflows/pull_request.yml + +name: Pull Request Docs Check + +on: [pull_request] + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.13' + cache: 'pip' # caching pip dependencies + # Standard drop-in approach that should work for most people. + #- uses: ammaraskar/sphinx-action@7.3.7 + # with: + # docs-folder: "docs/" + # Example of using a custom build-command. + #- uses: ammaraskar/sphinx-action@master + # with: + # build-command: "sphinx-build -b html . _build" + # docs-folder: "docs2/" + ## Grabbing custom dependencies and building as a pdf. + #- uses: ammaraskar/sphinx-action@master + # with: + # pre-build-command: "apt-get update -y && apt-get install -y latexmk texlive-latex-recommended texlive-latex-extra texlive-fonts-recommended" + # build-command: "make latexpdf" + # docs-folder: "docs2/" + # Great extra actions to compose with: + # Create an artifact of the html output. + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r docs/requirements.txt + - name: Build documentation + run: cd docs && make html + - uses: actions/upload-artifact@v4 + with: + name: DocumentationHTML + path: docs/build/html/ \ No newline at end of file From dd8c0a22e1c34aaff3f9f3617cb9e433035beb95 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Thu, 19 Jun 2025 14:23:22 +0100 Subject: [PATCH 13/14] Don't build docs on github This should be taken care of by reathedocs.io --- .github/workflows/docs.yml | 44 -------------------------------------- 1 file changed, 44 deletions(-) delete mode 100644 .github/workflows/docs.yml diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml deleted file mode 100644 index 28372aa2..00000000 --- a/.github/workflows/docs.yml +++ /dev/null @@ -1,44 +0,0 @@ -# This file was modified from https://github.com/ammaraskar/sphinx-action-test/blob/master/.github/workflows/pull_request.yml - -name: Pull Request Docs Check - -on: [pull_request] - -jobs: - build: - - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: '3.13' - cache: 'pip' # caching pip dependencies - # Standard drop-in approach that should work for most people. - #- uses: ammaraskar/sphinx-action@7.3.7 - # with: - # docs-folder: "docs/" - # Example of using a custom build-command. - #- uses: ammaraskar/sphinx-action@master - # with: - # build-command: "sphinx-build -b html . _build" - # docs-folder: "docs2/" - ## Grabbing custom dependencies and building as a pdf. - #- uses: ammaraskar/sphinx-action@master - # with: - # pre-build-command: "apt-get update -y && apt-get install -y latexmk texlive-latex-recommended texlive-latex-extra texlive-fonts-recommended" - # build-command: "make latexpdf" - # docs-folder: "docs2/" - # Great extra actions to compose with: - # Create an artifact of the html output. - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r docs/requirements.txt - - name: Build documentation - run: cd docs && make html - - uses: actions/upload-artifact@v4 - with: - name: DocumentationHTML - path: docs/build/html/ \ No newline at end of file From 40ee90b4a6b4964454e7e546e15d86ca2dac89d4 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Sun, 22 Jun 2025 04:47:52 -0400 Subject: [PATCH 14/14] Moving events footnote to correct subsection --- docs/source/using_things.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/source/using_things.rst b/docs/source/using_things.rst index 687748da..2b7ca2b9 100644 --- a/docs/source/using_things.rst +++ b/docs/source/using_things.rst @@ -5,6 +5,8 @@ The interface to a `Thing` is defined by its actions, properties and events [#ev :class:`.ThingClient` subclasses can be generated dynamically from a URL using :meth:`.ThingClient.from_url`. This creates an object with the right methods, properties and docstrings, though type hints are often missing. The client can be "introspected" to explore its methods and properties using tools that work at run-time (e.g. autocompletion in a Jupyter notebook), but "static" analysis tools will not yet work. +.. [#events] Events are not yet implemented. + Using Things from other languages ---------------------------------- @@ -17,8 +19,6 @@ Dynamic class generation The object returned by :meth:`.ThingClient.from_url` is an instance of a dynamically-created subclass of :class:`.ThingClient`. Dynamically creating the class is needed because we don't know what the methods and properties should be until we have downloaded the Thing Description. However, this means most code autocompletion tools, type checkers, and linters will not work well with these classes. In the future, LabThings-FastAPI will generate custom client subclasses that can be shared in client modules, which should fix these problems (see below). -.. [#events] Events are not yet implemented. - Using Things from other Things ------------------------------