-
Notifications
You must be signed in to change notification settings - Fork 4
Documentation improvements #106
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 6 commits
Commits
Show all changes
14 commits
Select commit
Hold shift + click to select a range
719cf93
Add more conceptual documentation
rwb27 c1d7b0a
Basic tutorial on how to install and run
rwb27 0e3fae6
more readable description of client code
rwb27 08cf055
Page on blobs
rwb27 07191f9
Fix toctree and cross-references
rwb27 e2c6865
Add some big picture docs based on my understanding
julianstirling 8b44a93
Improved docstrings on blob
rwb27 fdd4943
Improvements to client documentation in response to MR comments
rwb27 dd6095c
Improved docstrings and docs for Blob
rwb27 3e69204
Fix links and link to anyio
rwb27 72a3a87
Fix links in concurrency page
rwb27 1fb12f2
Build docs in CI, without sphinx-action
rwb27 dd8c0a2
Don't build docs on github
rwb27 40ee90b
Moving events footnote to correct subsection
julianstirling File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,135 @@ | ||
| 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 | ||
|
rwb27 marked this conversation as resolved.
|
||
|
|
||
| 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. | ||
|
|
||
| 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. | ||
|
|
||
| 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. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| Client code | ||
|
rwb27 marked this conversation as resolved.
Outdated
|
||
| =========== | ||
|
|
||
| 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. | ||
|
rwb27 marked this conversation as resolved.
Outdated
|
||
|
|
||
|
|
||
|
|
||
|
|
||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. | ||
|
rwb27 marked this conversation as resolved.
Outdated
|
||
|
|
||
| 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. | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.