Skip to content

Commit 83b1422

Browse files
committed
Add .Thing.actions
This is added, largely for completeness so it's consistent between actions, properties and settings.
1 parent baf2fdb commit 83b1422

File tree

3 files changed

+122
-2
lines changed

3 files changed

+122
-2
lines changed

src/labthings_fastapi/actions.py

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,11 @@
3939
from fastapi import FastAPI, HTTPException, Request, Body, BackgroundTasks
4040
from pydantic import BaseModel, create_model
4141

42-
from .base_descriptor import BaseDescriptor
42+
from .base_descriptor import (
43+
BaseDescriptor,
44+
BaseDescriptorInfo,
45+
DescriptorInfoCollection,
46+
)
4347
from .logs import add_thing_log_destination
4448
from .utilities import model_to_dict, wrap_plain_types_in_rootmodel
4549
from .invocations import InvocationModel, InvocationStatus, LogRecordModel
@@ -622,6 +626,54 @@ def delete_invocation(id: uuid.UUID) -> None:
622626
OwnerT = TypeVar("OwnerT", bound="Thing")
623627

624628

629+
class ActionInfo(
630+
BaseDescriptorInfo[
631+
"ActionDescriptor", OwnerT, Callable[ActionParams, ActionReturn]
632+
],
633+
Generic[OwnerT, ActionParams, ActionReturn],
634+
):
635+
"""Convenient access to the metadata of an action."""
636+
637+
@property
638+
def response_timeout(self) -> float:
639+
"""The time to wait before replying to the HTTP request initiating an action."""
640+
return self.get_descriptor().response_timeout
641+
642+
@property
643+
def retention_time(self) -> float:
644+
"""How long to retain the action's output for, in seconds."""
645+
return self.get_descriptor().retention_time
646+
647+
@property
648+
def input_model(self) -> type[BaseModel]:
649+
"""A Pydantic model for the input parameters of an Action."""
650+
return self.get_descriptor().input_model
651+
652+
@property
653+
def output_model(self) -> type[BaseModel]:
654+
"""A Pydantic model for the output parameters of an Action."""
655+
return self.get_descriptor().output_model
656+
657+
@property
658+
def invocation_model(self) -> type[BaseModel]:
659+
"""A Pydantic model for an invocation of this action."""
660+
return self.get_descriptor().invocation_model
661+
662+
@property
663+
def func(self) -> Callable[Concatenate[OwnerT, ActionParams], ActionReturn]:
664+
"""The function that runs the action."""
665+
return self.get_descriptor().func
666+
667+
668+
class ActionCollection(
669+
DescriptorInfoCollection[OwnerT, ActionInfo],
670+
Generic[OwnerT],
671+
):
672+
"""Access to the metadata of each Action."""
673+
674+
_descriptorinfo_class = ActionInfo
675+
676+
625677
class ActionDescriptor(
626678
BaseDescriptor[OwnerT, Callable[ActionParams, ActionReturn]],
627679
Generic[ActionParams, ActionReturn, OwnerT],
@@ -914,6 +966,15 @@ def action_affordance(
914966
output=type_to_dataschema(self.output_model, title=f"{self.name}_output"),
915967
)
916968

969+
def descriptor_info(self, owner: OwnerT | None = None) -> ActionInfo:
970+
"""Return an `.ActionInfo` object describing this action.
971+
972+
The returned object will either refer to the class, or be bound to a particular
973+
instance. If it is bound, more properties will be available - e.g. we will be
974+
able to get the bound function.
975+
"""
976+
return self._descriptor_info(ActionInfo, owner)
977+
917978

918979
@overload
919980
def action(

src/labthings_fastapi/thing.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
PropertyCollection,
2828
SettingCollection,
2929
)
30-
from .actions import ActionDescriptor
30+
from .actions import ActionCollection, ActionDescriptor
3131
from .thing_description._model import ThingDescription, NoSecurityScheme
3232
from .utilities import class_attributes
3333
from .thing_description import validation
@@ -258,6 +258,15 @@ def save_settings(self) -> None:
258258
convenient access to metadata of the settings of this `.Thing`\ .
259259
"""
260260

261+
actions: OptionallyBoundDescriptor["Thing", ActionCollection] = (
262+
OptionallyBoundDescriptor(ActionCollection)
263+
)
264+
r"""Access to metadata for the actions of this `.Thing`\ .
265+
266+
`.Thing.actions` is a mapping of names to `.ActionInfo` objects that allows
267+
convenient access to metadata of each action.
268+
"""
269+
261270
_labthings_thing_state: Optional[dict] = None
262271

263272
@property

tests/test_actions.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,32 @@
11
import uuid
22
from fastapi.testclient import TestClient
3+
from pydantic import BaseModel
34
import pytest
45
import functools
56

7+
from labthings_fastapi.actions import ActionInfo
68
from labthings_fastapi.testing import create_thing_without_server
79
from .temp_client import poll_task, get_link
810
from labthings_fastapi.example_things import MyThing
911
import labthings_fastapi as lt
1012

1113

14+
class ActionMan(lt.Thing):
15+
"""A Thing with some actions"""
16+
17+
_direction: str = "centred"
18+
19+
@lt.action(response_timeout=0, retention_time=0)
20+
def move_eyes(self, direction: str) -> None:
21+
"""Take one input and no outputs"""
22+
self._direction = direction
23+
24+
@lt.action
25+
def say_hello(self) -> str:
26+
"""Return a string."""
27+
return "Hello World."
28+
29+
1230
@pytest.fixture
1331
def client():
1432
"""Yield a client connected to a ThingServer"""
@@ -27,6 +45,38 @@ def run(payload=None):
2745
return run
2846

2947

48+
def test_action_info():
49+
"""Test the .actions descriptor works as expected."""
50+
actions = ActionMan.actions
51+
assert len(actions) == 2
52+
assert set(actions) == {"move_eyes", "say_hello"}
53+
assert actions.is_bound is False
54+
55+
move_eyes = ActionMan.actions["move_eyes"]
56+
assert isinstance(move_eyes, ActionInfo)
57+
assert move_eyes.name == "move_eyes"
58+
assert move_eyes.description == "Take one input and no outputs"
59+
assert set(move_eyes.input_model.model_fields) == {"direction"}
60+
assert set(move_eyes.output_model.model_fields) == {"root"} # rootmodel for None
61+
assert issubclass(move_eyes.invocation_model, BaseModel)
62+
assert move_eyes.response_timeout == 0
63+
assert move_eyes.retention_time == 0
64+
assert move_eyes.is_bound is False
65+
assert callable(move_eyes.func)
66+
67+
# Try again with a bound one
68+
action_man = create_thing_without_server(ActionMan)
69+
assert len(action_man.actions) == 2
70+
assert set(action_man.actions) == {"move_eyes", "say_hello"}
71+
assert action_man.actions.is_bound is True
72+
73+
move_eyes = action_man.actions["move_eyes"]
74+
assert isinstance(move_eyes, ActionInfo)
75+
assert move_eyes.name == "move_eyes"
76+
assert move_eyes.description == "Take one input and no outputs"
77+
assert move_eyes.is_bound is True
78+
79+
3080
def test_get_action_invocations(client):
3181
"""Test that running "get" on an action returns a list of invocations."""
3282
# When we start the action has no invocations

0 commit comments

Comments
 (0)