Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions dev-requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,7 @@ tabulate==0.9.0
# via sphinx-toolbox
tomli==2.2.1
# via
# labthings-fastapi (pyproject.toml)
# coverage
# flake8-pyproject
# mypy
Expand Down
10 changes: 5 additions & 5 deletions docs/source/blobs.rst
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,14 @@ Blob input/output

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 doesn't give much consideration to returning large or complicated datatypes. 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 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.
`.Blob` objects are not part of the Web of Things specification, which doesn't give much consideration to returning large or complicated datatypes. In LabThings-FastAPI, the `.Blob` mechanism is intended to provide an efficient way to work with arbitrary binary data. If a `.Blob` is passed between two Things on the same server, the data will not be copied - 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 an image object from a `.Blob` containing JPEG data. However, this will not currently work across both client and server code.
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 ``media_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 an image object from a `.Blob` containing JPEG data. However, this will not currently 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) with `.Blob.from_bytes`, on disk (with `.Blob.from_temporary_directory` or `.Blob.from_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 `bytes` object) with `.Blob.from_bytes`, on disk (with `.Blob.from_temporary_directory` or `.Blob.from_file`). A `.Blob` may also point to remote data (see `.Blob.from_url`). Code that uses a `.Blob` should not need to know how the data is stored, as the interface is the same in each case.

Blobs offer three ways to access their data:

Expand Down Expand Up @@ -122,7 +122,7 @@ On the client, we can use the `capture_image` action directly (as before), or we
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 `.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 `.Blob` objects in its output, either as a list or as fields in a `pydantic.BaseModel` subclass. Each `.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 `.Blob` object.
`.Blob` objects can be serialized to JSON and deserialized from JSON. When this happens, the `.Blob` is represented as a JSON object with ``href`` and ``content_type`` fields. The ``href`` field is a link to the data. The ``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 `.Blob` objects in its output, either as a list or as fields in a `pydantic.BaseModel` subclass. Each `.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 `.Blob` object.

When a `.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. For `.Blob` objects that are part of the output of an action, the URL will expire after 5 minutes (or the retention time set for the action), and the data will no longer be available for download after that time.

Expand All @@ -136,7 +136,7 @@ It may be possible to have actions return binary data directly in the future, bu

.. note::

Serialising or deserialising `.Blob` objects requires access to the `.BlobDataManager`\ . As there is no way to pass this in to the relevant methods at serialisation/deserialisation time, we use context variables to access them. This means that a `.blob_serialisation_context_manager` should be used to set (and then clear) those context variables. This is done by the `.BlobIOContextDep` dependency on the relevant endpoints (currently any endpoint that may return the output of an action).
Serialising or deserialising `.Blob` objects generates URLs, which are specific to the HTTP request. This means that `.Blob` objects cannot be serialised or deserialised outside the context of an HTTP request handler, so if code in an Action or Property attempts to turn a `.Blob` into JSON, it is likely to raise exceptions. For more detail on this mechanism, see `.middleware.url_for`\ .


Memory management and retention
Expand Down
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ dev = [
"sphinx>=7.2",
"sphinx-autoapi",
"sphinx-toolbox",
"tomli; python_version < '3.11'",
"codespell",
]

Expand Down Expand Up @@ -171,5 +172,8 @@ check-return-types = false
check-class-attributes = false # prefer docstrings on the attributes
check-yield-types = false # use type annotations instead

[tool.codespell]
ignore-words-list = ["ser"]

[project.scripts]
labthings-server = "labthings_fastapi.server.cli:serve_from_cli"
65 changes: 16 additions & 49 deletions src/labthings_fastapi/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@
from fastapi import FastAPI, HTTPException, Request, Body, BackgroundTasks
from pydantic import BaseModel, create_model

from labthings_fastapi.middleware.url_for import URLFor

from .base_descriptor import BaseDescriptor
from .logs import add_thing_log_destination
from .utilities import model_to_dict, wrap_plain_types_in_rootmodel
Expand All @@ -47,10 +49,8 @@
from .exceptions import (
InvocationCancelledError,
InvocationError,
NoBlobManagerError,
NotConnectedToServerError,
)
from .outputs.blob import BlobIOContextDep, blobdata_to_url_ctx
from . import invocation_contexts
from .utilities.introspection import (
EmptyInput,
Expand Down Expand Up @@ -149,23 +149,7 @@

@property
def output(self) -> Any:
"""Return value of the Action. If the Action is still running, returns None.

:raise NoBlobManagerError: If this is called in a context where the blob
manager context variables are not available. This stops errors being raised
later once the blob is returned and tries to serialise. If the errors
happen during serialisation the stack-trace will not clearly identify
the route with the missing dependency.
"""
try:
blobdata_to_url_ctx.get()
except LookupError as e:
raise NoBlobManagerError(
"An invocation output has been requested from a api route that "
"doesn't have a BlobIOContextDep dependency. This dependency is needed "
" for blobs to identify their url."
) from e

"""Return value of the Action. If the Action is still running, returns None."""
with self._status_lock:
return self._return_value

Expand Down Expand Up @@ -225,33 +209,28 @@
"""
self.cancel_hook.set()

def response(self, request: Optional[Request] = None) -> InvocationModel:
def response(self) -> InvocationModel:
"""Generate a representation of the invocation suitable for HTTP.

When an invocation is polled, we return a JSON object that includes
its status, any log entries, a return value (if completed), and a link
to poll for updates.

:param request: is used to generate the ``href`` in the response, which
should retrieve an updated version of this response.

:return: an `.InvocationModel` representing this `.Invocation`.
"""
if request:
href = str(request.url_for("action_invocation", id=self.id))
else:
href = f"{ACTION_INVOCATIONS_PATH}/{self.id}"
links = [
LinkElement(rel="self", href=href),
LinkElement(rel="output", href=href + "/output"),
LinkElement(rel="self", href=URLFor("action_invocation", id=self.id)),
LinkElement(
rel="output", href=URLFor("action_invocation_output", id=self.id)
),
]
# The line below confuses MyPy because self.action **evaluates to** a Descriptor
# object (i.e. we don't call __get__ on the descriptor).
return self.action.invocation_model( # type: ignore[call-overload]
status=self.status,
id=self.id,
action=self.thing.path + self.action.name, # type: ignore[call-overload]
href=href,
href=URLFor("action_invocation", id=self.id),
timeStarted=self._start_time,
timeCompleted=self._end_time,
timeRequested=self._request_time,
Expand Down Expand Up @@ -411,8 +390,8 @@
:param id: the unique ID of the action to retrieve.
:return: the `.Invocation` object.
"""
with self._invocations_lock:
return self._invocations[id]

Check warning on line 394 in src/labthings_fastapi/actions.py

View workflow job for this annotation

GitHub Actions / coverage

393-394 lines are not covered with tests

def list_invocations(
self,
Expand Down Expand Up @@ -442,7 +421,7 @@
:return: A list of invocations, optionally filtered by Thing and/or Action.
"""
return [
i.response(request=request)
i.response()
for i in self.invocations
if thing is None or i.thing == thing
if action is None or i.action == action # type: ignore[call-overload]
Expand Down Expand Up @@ -470,25 +449,19 @@
"""

@app.get(ACTION_INVOCATIONS_PATH, response_model=list[InvocationModel])
def list_all_invocations(
request: Request, _blob_manager: BlobIOContextDep
) -> list[InvocationModel]:
def list_all_invocations(request: Request) -> list[InvocationModel]:
return self.list_invocations(request=request)

@app.get(
ACTION_INVOCATIONS_PATH + "/{id}",
responses={404: {"description": "Invocation ID not found"}},
)
def action_invocation(
id: uuid.UUID, request: Request, _blob_manager: BlobIOContextDep
) -> InvocationModel:
def action_invocation(id: uuid.UUID, request: Request) -> InvocationModel:
"""Return a description of a specific action.

:param id: The action's ID (from the path).
:param request: FastAPI dependency for the request object, used to
find URLs via ``url_for``.
:param _blob_manager: FastAPI dependency that enables `.Blob` objects
to be serialised.

:return: Details of the invocation.

Expand All @@ -497,7 +470,7 @@
"""
try:
with self._invocations_lock:
return self._invocations[id].response(request=request)
return self._invocations[id].response()
except KeyError as e:
raise HTTPException(
status_code=404,
Expand All @@ -518,17 +491,13 @@
503: {"description": "No result is available for this invocation"},
},
)
def action_invocation_output(
id: uuid.UUID, _blob_manager: BlobIOContextDep
) -> Any:
def action_invocation_output(id: uuid.UUID) -> Any:
"""Get the output of an action invocation.

This returns just the "output" component of the action invocation. If the
output is a file, it will return the file.

:param id: The action's ID (from the path).
:param _blob_manager: FastAPI dependency that enables `.Blob` objects
to be serialised.

:return: The output of the invocation, as a `pydantic.BaseModel`
instance. If this is a `.Blob`, it may be returned directly.
Expand All @@ -539,8 +508,8 @@
with self._invocations_lock:
try:
invocation: Any = self._invocations[id]
except KeyError as e:
raise HTTPException(

Check warning on line 512 in src/labthings_fastapi/actions.py

View workflow job for this annotation

GitHub Actions / coverage

511-512 lines are not covered with tests
status_code=404,
detail="No action invocation found with ID {id}",
) from e
Expand All @@ -553,7 +522,7 @@
invocation.output.response
):
# TODO: honour "accept" header
return invocation.output.response()

Check warning on line 525 in src/labthings_fastapi/actions.py

View workflow job for this annotation

GitHub Actions / coverage

525 line is not covered with tests
return invocation.output

@app.delete(
Expand All @@ -578,8 +547,8 @@
with self._invocations_lock:
try:
invocation: Any = self._invocations[id]
except KeyError as e:
raise HTTPException(

Check warning on line 551 in src/labthings_fastapi/actions.py

View workflow job for this annotation

GitHub Actions / coverage

550-551 lines are not covered with tests
status_code=404,
detail="No action invocation found with ID {id}",
) from e
Expand Down Expand Up @@ -704,7 +673,7 @@
"""
super().__set_name__(owner, name)
if self.name != self.func.__name__:
raise ValueError(

Check warning on line 676 in src/labthings_fastapi/actions.py

View workflow job for this annotation

GitHub Actions / coverage

676 line is not covered with tests
f"Action name '{self.name}' does not match function name "
f"'{self.func.__name__}'",
)
Expand Down Expand Up @@ -806,8 +775,6 @@
# The solution below is to manually add the annotation, before passing
# the function to the decorator.
def start_action(
_blob_manager: BlobIOContextDep,
request: Request,
body: Any, # This annotation will be overwritten below.
id: NonWarningInvocationID,
background_tasks: BackgroundTasks,
Expand All @@ -822,7 +789,7 @@
id=id,
)
background_tasks.add_task(action_manager.expire_invocations)
return action.response(request=request)
return action.response()

if issubclass(self.input_model, EmptyInput):
annotation = Body(default_factory=StrictEmptyInput)
Expand Down Expand Up @@ -853,14 +820,14 @@
try:
responses[200]["model"] = self.output_model
pass
except AttributeError:
print(f"Failed to generate response model for action {self.name}")

Check warning on line 824 in src/labthings_fastapi/actions.py

View workflow job for this annotation

GitHub Actions / coverage

823-824 lines are not covered with tests
# Add an additional media type if we may return a file
if hasattr(self.output_model, "media_type"):
responses[200]["content"][self.output_model.media_type] = {}

Check warning on line 827 in src/labthings_fastapi/actions.py

View workflow job for this annotation

GitHub Actions / coverage

827 line is not covered with tests
# Now we can add the endpoint to the app.
if thing.path is None:
raise NotConnectedToServerError(

Check warning on line 830 in src/labthings_fastapi/actions.py

View workflow job for this annotation

GitHub Actions / coverage

830 line is not covered with tests
"Can't add the endpoint without thing.path!"
)
app.post(
Expand All @@ -884,7 +851,7 @@
),
summary=f"All invocations of {self.name}.",
)
def list_invocations(_blob_manager: BlobIOContextDep) -> list[InvocationModel]:
def list_invocations() -> list[InvocationModel]:
action_manager = thing._thing_server_interface._action_manager
return action_manager.list_invocations(self, thing)

Expand All @@ -908,7 +875,7 @@
"""
path = path or thing.path
if path is None:
raise NotConnectedToServerError("Can't generate forms without a path!")

Check warning on line 878 in src/labthings_fastapi/actions.py

View workflow job for this annotation

GitHub Actions / coverage

878 line is not covered with tests
forms = [
Form[ActionOp](href=path + self.name, op=[ActionOp.invokeaction]),
]
Expand Down
26 changes: 13 additions & 13 deletions src/labthings_fastapi/client/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@
import httpx
from urllib.parse import urlparse, urljoin

from pydantic import BaseModel
from pydantic import BaseModel, TypeAdapter

from .outputs import ClientBlobOutput
from ..outputs.blob import Blob, RemoteBlobData
from ..exceptions import (
FailedToInvokeActionError,
ServerActionError,
Expand Down Expand Up @@ -59,7 +59,7 @@
:raise KeyError: if there is no link with the specified ``rel`` value.
"""
if "links" not in obj:
raise ObjectHasNoLinksError(f"Can't find any links on {obj}.")

Check warning on line 62 in src/labthings_fastapi/client/__init__.py

View workflow job for this annotation

GitHub Actions / coverage

62 line is not covered with tests
try:
return next(link for link in obj["links"] if link["rel"] == rel)
except StopIteration as e:
Expand Down Expand Up @@ -206,16 +206,14 @@
"""
for k in kwargs.keys():
value = kwargs[k]
if isinstance(value, ClientBlobOutput):
# ClientBlobOutput objects may be used as input to a subsequent
# action. When this is done, they should be serialised to a dict
# with `href` and `media_type` keys, as done below.
# Ideally this should be replaced with `Blob` and the use of
# `pydantic` models to serialise action inputs.
if isinstance(value, Blob):
# Blob objects may be used as input to a subsequent
# action. When this is done, they should be serialised by
# pydantic, to a dictionary that includes href and media_type.
#
# Note that the blob will not be uploaded: we rely on the blob
# still existing on the server.
kwargs[k] = {"href": value.href, "media_type": value.media_type}
kwargs[k] = TypeAdapter(Blob).dump_python(value)
response = self.client.post(urljoin(self.path, path), json=kwargs)
if response.is_error:
message = _construct_failed_to_invoke_message(path, response)
Expand All @@ -228,10 +226,12 @@
and "href" in invocation["output"]
and "media_type" in invocation["output"]
):
return ClientBlobOutput(
media_type=invocation["output"]["media_type"],
href=invocation["output"]["href"],
client=self.client,
return Blob(
RemoteBlobData(
media_type=invocation["output"]["media_type"],
href=invocation["output"]["href"],
client=self.client,
)
)
return invocation["output"]
message = _construct_invocation_error_message(invocation)
Expand Down
77 changes: 0 additions & 77 deletions src/labthings_fastapi/client/outputs.py

This file was deleted.

4 changes: 3 additions & 1 deletion src/labthings_fastapi/invocations.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@

from pydantic import BaseModel, ConfigDict, model_validator

from labthings_fastapi.middleware.url_for import URLFor

from .thing_description._model import Links


Expand Down Expand Up @@ -91,7 +93,7 @@ class GenericInvocationModel(BaseModel, Generic[InputT, OutputT]):
status: InvocationStatus
id: uuid.UUID
action: str
href: str
href: URLFor
timeStarted: Optional[datetime]
timeRequested: Optional[datetime]
timeCompleted: Optional[datetime]
Expand Down
Loading