Skip to content

Commit 7ac72fa

Browse files
authored
Merge pull request #83 from labthings/blob_input
Blob input
2 parents c198dd7 + ede8d5e commit 7ac72fa

File tree

15 files changed

+483
-181
lines changed

15 files changed

+483
-181
lines changed

src/labthings_fastapi/actions/__init__.py

Lines changed: 28 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@
1010
from fastapi import FastAPI, HTTPException, Request
1111
from fastapi.responses import FileResponse
1212
from pydantic import BaseModel
13-
from labthings_fastapi.outputs.blob import blob_to_link
1413

14+
from labthings_fastapi.utilities import model_to_dict
1515
from labthings_fastapi.utilities.introspection import EmptyInput
1616
from ..thing_description.model import LinkElement
1717
from ..file_manager import FileManager
@@ -21,6 +21,7 @@
2121
InvocationCancelledError,
2222
invocation_logger,
2323
)
24+
from ..outputs.blob import BlobIOContextDep
2425

2526
if TYPE_CHECKING:
2627
# We only need these imports for type hints, so this avoids circular imports.
@@ -158,32 +159,32 @@ def response(self, request: Optional[Request] = None):
158159
timeCompleted=self._end_time,
159160
timeRequested=self._request_time,
160161
input=self.input,
161-
output=blob_to_link(self.output, href + "/output"),
162+
output=self.output,
162163
links=links,
163164
log=self.log,
164165
)
165166

166167
def run(self):
167168
"""Overrides default threading.Thread run() method"""
168-
self.action.emit_changed_event(self.thing, self._status)
169+
try:
170+
self.action.emit_changed_event(self.thing, self._status)
169171

170-
# Capture just this thread's log messages
171-
handler = DequeLogHandler(dest=self._log)
172-
logger = invocation_logger(self.id)
173-
logger.addHandler(handler)
172+
# Capture just this thread's log messages
173+
handler = DequeLogHandler(dest=self._log)
174+
logger = invocation_logger(self.id)
175+
logger.addHandler(handler)
174176

175-
action = self.action
176-
thing = self.thing
177-
kwargs = self.input.model_dump() or {}
178-
assert action is not None
179-
assert thing is not None
177+
action = self.action
178+
thing = self.thing
179+
kwargs = model_to_dict(self.input)
180+
assert action is not None
181+
assert thing is not None
180182

181-
with self._status_lock:
182-
self._status = InvocationStatus.RUNNING
183-
self._start_time = datetime.datetime.now()
184-
self.action.emit_changed_event(self.thing, self._status)
183+
with self._status_lock:
184+
self._status = InvocationStatus.RUNNING
185+
self._start_time = datetime.datetime.now()
186+
self.action.emit_changed_event(self.thing, self._status)
185187

186-
try:
187188
# The next line actually runs the action.
188189
ret = action.__get__(thing)(**kwargs, **self.dependencies)
189190

@@ -283,6 +284,11 @@ def invoke_action(
283284
thread.start()
284285
return thread
285286

287+
def get_invocation(self, id: uuid.UUID) -> Invocation:
288+
"""Retrieve an invocation by ID"""
289+
with self._invocations_lock:
290+
return self._invocations[id]
291+
286292
def list_invocations(
287293
self,
288294
action: Optional[ActionDescriptor] = None,
@@ -314,15 +320,17 @@ def attach_to_app(self, app: FastAPI):
314320
"""Add /action_invocations and /action_invocation/{id} endpoints to FastAPI"""
315321

316322
@app.get(ACTION_INVOCATIONS_PATH, response_model=list[InvocationModel])
317-
def list_all_invocations(request: Request):
323+
def list_all_invocations(request: Request, _blob_manager: BlobIOContextDep):
318324
return self.list_invocations(as_responses=True, request=request)
319325

320326
@app.get(
321327
ACTION_INVOCATIONS_PATH + "/{id}",
322328
response_model=InvocationModel,
323329
responses={404: {"description": "Invocation ID not found"}},
324330
)
325-
def action_invocation(id: uuid.UUID, request: Request):
331+
def action_invocation(
332+
id: uuid.UUID, request: Request, _blob_manager: BlobIOContextDep
333+
):
326334
try:
327335
with self._invocations_lock:
328336
return self._invocations[id].response(request=request)
@@ -346,7 +354,7 @@ def action_invocation(id: uuid.UUID, request: Request):
346354
503: {"description": "No result is available for this invocation"},
347355
},
348356
)
349-
def action_invocation_output(id: uuid.UUID):
357+
def action_invocation_output(id: uuid.UUID, _blob_manager: BlobIOContextDep):
350358
"""Get the output of an action invocation
351359
352360
This returns just the "output" component of the action invocation. If the

src/labthings_fastapi/client/__init__.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,10 @@ def set_property(self, path: str, value: Any):
7272
r.raise_for_status()
7373

7474
def invoke_action(self, path: str, **kwargs):
75+
"Invoke an action on the Thing"
76+
for k in kwargs.keys():
77+
if isinstance(kwargs[k], ClientBlobOutput):
78+
kwargs[k] = {"href": kwargs[k].href, "media_type": kwargs[k].media_type}
7579
r = self.client.post(urljoin(self.path, path), json=kwargs)
7680
r.raise_for_status()
7781
task = poll_task(self.client, r.json())
@@ -118,14 +122,15 @@ def from_url(
118122
@classmethod
119123
def subclass_from_td(cls, thing_description: dict) -> type[Self]:
120124
"""Create a ThingClient subclass from a Thing Description"""
125+
my_thing_description = thing_description
121126

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

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

src/labthings_fastapi/client/in_server.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
from labthings_fastapi.utilities import attributes
2121
from . import PropertyClientDescriptor
2222
from ..thing import Thing
23-
from ..server import find_thing_server
23+
from ..dependencies.thing_server import find_thing_server
2424
from fastapi import Request
2525

2626

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
"""
2+
Context Var access to the Action Manager
3+
4+
This module provides a context var to access the Action Manager instance.
5+
While LabThings tries pretty hard to conform to FastAPI's excellent convention
6+
that everything should be passed as a parameter, there are some cases where
7+
that's hard. In particular, generating URLs when responses are serialised is
8+
difficult, because `pydantic` doesn't have a way to pass in extra context.
9+
10+
If an endpoint uses the `ActionManagerDep` dependency, then the Action Manager
11+
is available using `ActionManagerContext.get()`.
12+
"""
13+
14+
from __future__ import annotations
15+
16+
from contextvars import ContextVar
17+
18+
from typing import Annotated
19+
from typing_extensions import TypeAlias
20+
from fastapi import Depends, Request
21+
from ..dependencies.thing_server import find_thing_server
22+
from ..actions import ActionManager
23+
24+
25+
def action_manager_from_thing_server(request: Request) -> ActionManager:
26+
"""Retrieve the Action Manager from the Thing Server
27+
28+
This is for use as a FastAPI dependency, so the thing server is
29+
retrieved from the request object.
30+
"""
31+
action_manager = find_thing_server(request.app).action_manager
32+
if action_manager is None:
33+
raise RuntimeError("Could not get the blocking portal from the server.")
34+
return action_manager
35+
36+
37+
ActionManagerDep = Annotated[ActionManager, Depends(action_manager_from_thing_server)]
38+
"""
39+
A ready-made dependency type for the `ActionManager` object.
40+
"""
41+
42+
43+
ActionManagerContext = ContextVar[ActionManager]("ActionManagerContext")
44+
45+
46+
async def make_action_manager_context_available(action_manager: ActionManagerDep):
47+
"""Make the Action Manager available in the context
48+
49+
The action manager may be accessed using `ActionManagerContext.get()`.
50+
"""
51+
ActionManagerContext.set(action_manager)
52+
yield action_manager
53+
54+
55+
ActionManagerContextDep: TypeAlias = Annotated[
56+
ActionManager, Depends(make_action_manager_context_available)
57+
]

src/labthings_fastapi/dependencies/blocking_portal.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,14 @@
88
from typing import Annotated
99
from fastapi import Depends, Request
1010
from anyio.from_thread import BlockingPortal as RealBlockingPortal
11-
from ..server import find_thing_server
11+
from .thing_server import find_thing_server
1212

1313

1414
def blocking_portal_from_thing_server(request: Request) -> RealBlockingPortal:
15-
"""Return a UUID for an action invocation
15+
"""Return the blocking portal from our ThingServer
1616
17-
This is for use as a FastAPI dependency, to allow other dependencies to
18-
access the invocation ID. Useful for e.g. file management.
17+
This is for use as a FastAPI dependency, to allow threaded code to call
18+
async code.
1919
"""
2020
portal = find_thing_server(request.app).blocking_portal
2121
if portal is None:

src/labthings_fastapi/dependencies/metadata.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
from fastapi import Depends, Request
66

7-
from ..server import find_thing_server
7+
from .thing_server import find_thing_server
88

99

1010
def thing_states_getter(request: Request) -> Callable[[], Mapping[str, Any]]:

src/labthings_fastapi/dependencies/raw_thing.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from fastapi import Depends, Request
55

66
from ..thing import Thing
7-
from ..server import find_thing_server
7+
from .thing_server import find_thing_server
88

99

1010
ThingInstance = TypeVar("ThingInstance", bound=Thing)
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
"""
2+
Retrieve the ThingServer object
3+
4+
This module provides a function that will retrieve the ThingServer
5+
based on the `Request` object. It may be used as a dependency with:
6+
`Annotated[ThingServer, Depends(thing_server_from_request)]`.
7+
"""
8+
9+
from __future__ import annotations
10+
from weakref import WeakSet
11+
from typing import TYPE_CHECKING
12+
from fastapi import FastAPI, Request
13+
14+
if TYPE_CHECKING:
15+
from labthings_fastapi.server import ThingServer
16+
17+
_thing_servers: WeakSet[ThingServer] = WeakSet()
18+
19+
20+
def find_thing_server(app: FastAPI) -> ThingServer:
21+
"""Find the ThingServer associated with an app"""
22+
for server in _thing_servers:
23+
if server.app == app:
24+
return server
25+
raise RuntimeError("No ThingServer found for this app")
26+
27+
28+
def thing_server_from_request(request: Request) -> ThingServer:
29+
"""Retrieve the Action Manager from the Thing Server
30+
31+
This is for use as a FastAPI dependency, so the thing server is
32+
retrieved from the request object.
33+
"""
34+
return find_thing_server(request.app)

src/labthings_fastapi/descriptors/action.py

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from pydantic import create_model
1111
from ..actions import InvocationModel
1212
from ..dependencies.invocation import CancelHook, InvocationID
13+
from ..dependencies.action_manager import ActionManagerContextDep
1314
from ..utilities.introspection import (
1415
EmptyInput,
1516
StrictEmptyInput,
@@ -19,7 +20,7 @@
1920
input_model_from_signature,
2021
return_type,
2122
)
22-
from ..outputs.blob import blob_to_model, get_model_media_type
23+
from ..outputs.blob import BlobIOContextDep
2324
from ..thing_description import type_to_dataschema
2425
from ..thing_description.model import ActionAffordance, ActionOp, Form, Union
2526

@@ -69,7 +70,7 @@ def __init__(
6970
remove_first_positional_arg=True,
7071
ignore=[p.name for p in self.dependency_params],
7172
)
72-
self.output_model = blob_to_model(return_type(func))
73+
self.output_model = return_type(func)
7374
self.invocation_model = create_model(
7475
f"{self.name}_invocation",
7576
__base__=InvocationModel,
@@ -163,6 +164,8 @@ def add_to_fastapi(self, app: FastAPI, thing: Thing):
163164
# The solution below is to manually add the annotation, before passing
164165
# the function to the decorator.
165166
def start_action(
167+
action_manager: ActionManagerContextDep,
168+
_blob_manager: BlobIOContextDep,
166169
request: Request,
167170
body,
168171
id: InvocationID,
@@ -171,15 +174,15 @@ def start_action(
171174
**dependencies,
172175
):
173176
try:
174-
action = thing.action_manager.invoke_action(
177+
action = action_manager.invoke_action(
175178
action=self,
176179
thing=thing,
177180
input=body,
178181
dependencies=dependencies,
179182
id=id,
180183
cancel_hook=cancel_hook,
181184
)
182-
background_tasks.add_task(thing.action_manager.expire_invocations)
185+
background_tasks.add_task(action_manager.expire_invocations)
183186
return action.response(request=request)
184187
finally:
185188
try:
@@ -219,8 +222,8 @@ def start_action(
219222
except AttributeError:
220223
print(f"Failed to generate response model for action {self.name}")
221224
# Add an additional media type if we may return a file
222-
if get_model_media_type(self.output_model):
223-
responses[200]["content"][get_model_media_type(self.output_model)] = {}
225+
if hasattr(self.output_model, "media_type"):
226+
responses[200]["content"][self.output_model.media_type] = {}
224227
# Now we can add the endpoint to the app.
225228
app.post(
226229
thing.path + self.name,
@@ -243,8 +246,8 @@ def start_action(
243246
),
244247
summary=f"All invocations of {self.name}.",
245248
)
246-
def list_invocations():
247-
return thing.action_manager.list_invocations(self, thing, as_responses=True)
249+
def list_invocations(action_manager: ActionManagerContextDep):
250+
return action_manager.list_invocations(self, thing, as_responses=True)
248251

249252
def action_affordance(
250253
self, thing: Thing, path: Optional[str] = None

src/labthings_fastapi/file_manager.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
"""Manage files created by Actions
22
3+
**This module is deprecated in favour of `labthings_fastapi.outputs.blob`**
4+
35
Simple actions return everything you need to know about them in their return value,
46
which can be serialised to JSON. More complicated actions might need to return
57
more complicated responses, for example files.
@@ -12,6 +14,7 @@
1214
from __future__ import annotations
1315
from tempfile import TemporaryDirectory
1416
from typing import Annotated, Sequence, Optional
17+
from warnings import warn
1518

1619
from fastapi import Depends, Request
1720

@@ -27,6 +30,10 @@ class FileManager:
2730
__globals__ = globals() # "bake in" globals so dependency injection works
2831

2932
def __init__(self, invocation_id: InvocationID, request: Request):
33+
warn(
34+
"FileManager is deprecated in favour of labthings_fastapi.outputs.blob",
35+
DeprecationWarning,
36+
)
3037
self.invocation_id = invocation_id
3138
self._links: set[tuple[str, str]] = set()
3239
self._dir = TemporaryDirectory(prefix=f"labthings-{self.invocation_id}-")

0 commit comments

Comments
 (0)