Skip to content
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

File outputs #54

Merged
merged 14 commits into from
Nov 28, 2023
4 changes: 2 additions & 2 deletions examples/counter.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ class TestThing(Thing):
"""A test thing with a counter property and a couple of actions"""

@thing_action
def increment_counter(self):
def increment_counter(self) -> None:
"""Increment the counter property

This action doesn't do very much - all it does, in fact,
Expand All @@ -19,7 +19,7 @@ def increment_counter(self):
self.counter += 1

@thing_action
def slowly_increase_counter(self):
def slowly_increase_counter(self) -> None:
"""Increment the counter slowly over a minute"""
for i in range(60):
time.sleep(1)
Expand Down
21 changes: 19 additions & 2 deletions src/labthings_fastapi/actions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import FileResponse
from pydantic import BaseModel
from labthings_fastapi.outputs.blob import blob_to_link

from labthings_fastapi.utilities.introspection import EmptyInput
from ..thing_description.model import LinkElement
Expand Down Expand Up @@ -142,7 +143,7 @@ def response(self, request: Optional[Request] = None):
timeCompleted=self._end_time,
timeRequested=self._request_time,
input=self.input,
output=self.output,
output=blob_to_link(self.output, href + "/output"),
links=links,
)

Expand Down Expand Up @@ -325,11 +326,22 @@ def action_invocation(id: uuid.UUID, request: Request):
ACTION_INVOCATIONS_PATH + "/{id}/output",
response_model=Any,
responses={
200: {
"description": "Action invocation output",
"content": {
"*/*": {},
},
},
404: {"description": "Invocation ID not found"},
503: {"description": "No result is available for this invocation"},
},
)
def action_invocation_result(id: uuid.UUID):
def action_invocation_output(id: uuid.UUID):
"""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.
"""
with self._invocations_lock:
try:
invocation: Any = self._invocations[id]
Expand All @@ -343,6 +355,11 @@ def action_invocation_result(id: uuid.UUID):
status_code=503,
detail="No result is available for this invocation",
)
if hasattr(invocation.output, "response") and callable(
invocation.output.response
):
# TODO: honour "accept" header
return invocation.output.response()
return invocation.output

@app.get(
Expand Down
79 changes: 56 additions & 23 deletions src/labthings_fastapi/client/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,15 @@
from __future__ import annotations
import time
from typing import Any, Optional, Union
from typing_extensions import Self # 3.9, 3.10 compatibility
from collections.abc import Mapping
import httpx
from urllib.parse import urlparse, urljoin

from pydantic import BaseModel

from .outputs import ClientBlobOutput


ACTION_RUNNING_KEYWORDS = ["idle", "pending", "running"]

Expand Down Expand Up @@ -52,12 +55,12 @@
so this class will be minimally useful on its own.
"""

def __init__(self, base_url: str):
def __init__(self, base_url: str, client: Optional[httpx.Client] = None):
parsed = urlparse(base_url)
server = f"{parsed.scheme}://{parsed.netloc}"
self.server = server
self.path = parsed.path
self.client = httpx.Client(base_url=server)
self.client = client or httpx.Client(base_url=server)

def get_property(self, path: str) -> Any:
r = self.client.get(urljoin(self.path, path))
Expand All @@ -71,7 +74,21 @@
def invoke_action(self, path: str, **kwargs):
r = self.client.post(urljoin(self.path, path), json=kwargs)
r.raise_for_status()
return poll_task(self.client, r.json())
task = poll_task(self.client, r.json())
if task["status"] == "completed":
if (
isinstance(task["output"], Mapping)
and "href" in task["output"]
and "media_type" in task["output"]
):
return ClientBlobOutput(
media_type=task["output"]["media_type"],
href=task["output"]["href"],
client=self.client,
)
return task["output"]
else:
raise RuntimeError(f"Action did not complete successfully: {task}")

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

View check run for this annotation

Codecov / codecov/patch

src/labthings_fastapi/client/__init__.py#L91

Added line #L91 was not covered by tests

def follow_link(self, response: dict, rel: str) -> httpx.Response:
"""Follow a link in a response object, by its `rel` attribute"""
Expand All @@ -80,6 +97,42 @@
r.raise_for_status()
return r

@classmethod
def from_url(
cls, thing_url: str, client: Optional[httpx.Client] = None, **kwargs
) -> Self:
"""Create a ThingClient from a URL

This will dynamically create a subclass with properties and actions,
and return an instance of that subclass pointing at the Thing URL.

Additional `kwargs` will be passed to the subclass constructor, in
particular you may pass a `client` object (useful for testing).
"""
td_client = client or httpx
r = td_client.get(thing_url)
r.raise_for_status()
subclass = cls.subclass_from_td(r.json())
return subclass(thing_url, client=client, **kwargs)

@classmethod
def subclass_from_td(cls, thing_description: dict) -> type[Self]:
"""Create a ThingClient subclass from a Thing Description"""

class Client(cls): # type: ignore[valid-type, misc]
# mypy wants the superclass to be statically type-able, but
# this isn't possible (for now) if we are to be able to
# use this class method on `ThingClient` subclasses, i.e.
# to provide customisation but also add methods from a
# Thing Description.
pass

for name, p in thing_description["properties"].items():
add_property(Client, name, p)

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

View check run for this annotation

Codecov / codecov/patch

src/labthings_fastapi/client/__init__.py#L131

Added line #L131 was not covered by tests
for name, a in thing_description["actions"].items():
add_action(Client, name, a)
return Client


class PropertyClientDescriptor:
pass
Expand Down Expand Up @@ -151,23 +204,3 @@
readable=not property.get("writeOnly", False),
),
)


def thing_client_class(thing_description: dict):
"""Create a ThingClient from a Thing Description"""

class Client(ThingClient):
pass

for name, p in thing_description["properties"].items():
add_property(Client, name, p)
for name, a in thing_description["actions"].items():
add_action(Client, name, a)
return Client


def thing_client_from_url(thing_url: str) -> ThingClient:
"""Create a ThingClient from a URL"""
r = httpx.get(thing_url)
r.raise_for_status()
return thing_client_class(r.json())(thing_url)
40 changes: 40 additions & 0 deletions src/labthings_fastapi/client/outputs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import io
from typing import Optional
import httpx


class ClientBlobOutput:
"""An output from LabThings best returned as a file

This object is returned by a client when the output is not serialised to JSON.
It may be either retrieved to memory using `.content`, or saved to a file using
`.save()`.
"""

media_type: str
download_url: str

def __init__(
self, media_type: str, href: str, client: Optional[httpx.Client] = None
):
self.media_type = media_type
self.href = href
self.client = client or httpx.Client()

@property
def content(self) -> bytes:
"""Return the the output as a `bytes` object"""
return self.client.get(self.href).content

def save(self, filepath: str) -> None:
"""Save the output to a file.

This may remove the need to hold the output in memory, though for now it
simply retrieves the output into memory, then writes it to a file.
"""
with open(filepath, "wb") as f:
f.write(self.content)

def open(self) -> io.IOBase:
"""Open the output as a binary file-like object."""
return io.BytesIO(self.content)
32 changes: 23 additions & 9 deletions src/labthings_fastapi/descriptors/action.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from __future__ import annotations
from functools import partial
import inspect
from typing import TYPE_CHECKING, Annotated, Callable, Optional, Literal, overload
from typing import TYPE_CHECKING, Annotated, Any, Callable, Optional, Literal, overload
from fastapi import Body, FastAPI, Request, BackgroundTasks
from pydantic import create_model
from ..actions import InvocationModel
Expand All @@ -18,6 +18,7 @@
input_model_from_signature,
return_type,
)
from ..outputs.blob import blob_to_model, get_model_media_type
from ..thing_description import type_to_dataschema
from ..thing_description.model import ActionAffordance, ActionOp, Form, Union

Expand Down Expand Up @@ -64,7 +65,7 @@
remove_first_positional_arg=True,
ignore=[p.name for p in self.dependency_params],
)
self.output_model = return_type(func)
self.output_model = blob_to_model(return_type(func))
self.invocation_model = create_model(
f"{self.name}_invocation",
__base__=InvocationModel,
Expand Down Expand Up @@ -139,7 +140,7 @@
id=id,
)
background_tasks.add_task(thing.action_manager.expire_invocations)
return action.response()
return action.response(request=request)
finally:
try:
action._file_manager = request.state.file_manager
Expand All @@ -162,6 +163,24 @@
start_action.__signature__ = sig.replace( # type: ignore[attr-defined]
parameters=params
)
# We construct a responses dictionary that allows us to specify the model or
# the media type of the returned file. Not yet actually used.
responses: dict[int | str, dict[str, Any]] = {
200: { # TODO: This does not currently get used
"description": "Action completed.",
"content": {
"application/json": {},
},
},
}
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 180 in src/labthings_fastapi/descriptors/action.py

View check run for this annotation

Codecov / codecov/patch

src/labthings_fastapi/descriptors/action.py#L179-L180

Added lines #L179 - L180 were not covered by tests
# Add an additional media type if we may return a file
if get_model_media_type(self.output_model):
responses[200]["content"][get_model_media_type(self.output_model)] = {}
# Now we can add the endpoint to the app.
app.post(
thing.path + self.name,
Expand All @@ -170,12 +189,7 @@
response_description="Action has been invoked (and may still be running).",
description=f"## {self.title}\n\n {self.description} {ACTION_POST_NOTICE}",
summary=self.title,
responses={
200: {
"description": "Action completed.",
"model": self.invocation_model,
},
},
responses=responses,
)(start_action)

@app.get(
Expand Down
Loading