diff --git a/docs/source/blobs.rst b/docs/source/blobs.rst new file mode 100644 index 00000000..bc7d9356 --- /dev/null +++ b/docs/source/blobs.rst @@ -0,0 +1,151 @@ +Blob input/output +================= + +: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 :class:`.Blob` object. + +: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 :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 :class:`.Blob` objects +------------------------------------------------ + +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 :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 :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 :class:`.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 :class:`.Blob` objects as inputs +-------------------------------------- + +: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 :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 `.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. + +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`. + + diff --git a/docs/source/concurrency.rst b/docs/source/concurrency.rst new file mode 100644 index 00000000..3eaad64e --- /dev/null +++ b/docs/source/concurrency.rst @@ -0,0 +1,22 @@ +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 :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. + +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. + +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. + +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. + diff --git a/docs/source/conf.py b/docs/source/conf.py index d999369d..551ae884 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -25,8 +25,11 @@ templates_path = ["_templates"] exclude_patterns = [] +default_role = "py:obj" + autodoc2_packages = ["../../src/labthings_fastapi"] autodoc2_render_plugin = "myst" +autodoc2_class_docstring = "both" # autoapi_dirs = ["../../src/labthings_fastapi"] # autoapi_ignore = [] @@ -42,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/index.rst b/docs/source/index.rst index 0b6bb13c..35c3d4c9 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -1,45 +1,50 @@ -.. 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: - core_concepts.rst quickstart/quickstart.rst + wot_core_concepts.rst + lt_core_concepts.rst + tutorial/index.rst dependencies/dependencies.rst + blobs.rst + concurrency.rst + using_things.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 (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) Installation ------------ -``pip install labthings-fastapi`` +``pip install labthings-fastapi[server]`` Indices and tables ================== @@ -48,4 +53,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/lt_core_concepts.rst b/docs/source/lt_core_concepts.rst new file mode 100644 index 00000000..128898ba --- /dev/null +++ b/docs/source/lt_core_concepts.rst @@ -0,0 +1,55 @@ +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` 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 :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. + +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 +------------ + +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 :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 +------------------ + +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. + + + 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..3932187a --- /dev/null +++ b/docs/source/tutorial/index.rst @@ -0,0 +1,18 @@ +LabThings-FastAPI tutorial +========================== + +.. toctree:: + + 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. + +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 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 + diff --git a/docs/source/using_things.rst b/docs/source/using_things.rst new file mode 100644 index 00000000..2b7ca2b9 --- /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. + +.. [#events] Events are not yet implemented. + +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). + +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. + + + 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 --------- diff --git a/src/labthings_fastapi/outputs/blob.py b/src/labthings_fastapi/outputs/blob.py index 51525bcd..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. -To return a file from an action, you should declare its return type as a BlobOutput -subclass, defining the `media_type` attribute. +There is a [dedicated documentation page on blobs](/blobs.rst) that explains how to use +this mechanism. -The output from the class should be an instance of that subclass, with data supplied +To return a file from an action, you should declare its return type as a BlobOutput +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 @@ -52,31 +75,65 @@ @runtime_checkable class BlobData(Protocol): - """A Protocol for a BlobOutput object""" + """The interface for the data store of a Blob. + + [`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 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()` + + [`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 + """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 +189,25 @@ def response(self) -> Response: class Blob(BaseModel): - """An output from LabThings best returned as binary data, not JSON + """A container for binary data that may be retrieved over HTTP + + 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 (see `__init__` method). - 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. + 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 +215,30 @@ class Blob(BaseModel): ) _data: Optional[ServerSideBlobData] = None + """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 [`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 + [`Blob`](#labthings_fastapi.outputs.blob.Blob) that already exists on + this server. + + This validator will only work if the function to resolve URLs to + [`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: return self @@ -170,6 +256,21 @@ 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 [`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 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: blobdata_to_url = blobdata_to_url_ctx.get() @@ -191,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 @@ -271,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( @@ -284,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] @@ -294,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 @@ -322,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 @@ -357,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."""