Skip to content

Commit ea6b00a

Browse files
authored
Merge pull request #215 from labthings/action_typing
Fix typing for actions.
2 parents beccf83 + 50c1580 commit ea6b00a

File tree

5 files changed

+235
-86
lines changed

5 files changed

+235
-86
lines changed

src/labthings_fastapi/actions.py

Lines changed: 59 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
import datetime
1919
import logging
2020
from collections import deque
21-
from functools import partial, wraps
21+
from functools import partial
2222
import inspect
2323
from threading import Thread, Lock
2424
import uuid
@@ -27,20 +27,21 @@
2727
Annotated,
2828
Any,
2929
Callable,
30+
Concatenate,
31+
Generic,
3032
Optional,
31-
Literal,
32-
Union,
33+
ParamSpec,
34+
TypeVar,
3335
overload,
3436
)
3537
from weakref import WeakSet
3638
import weakref
3739
from fastapi import FastAPI, HTTPException, Request, Body, BackgroundTasks
3840
from pydantic import BaseModel, create_model
3941

40-
from labthings_fastapi.logs import add_thing_log_destination
41-
42+
from .base_descriptor import BaseDescriptor
43+
from .logs import add_thing_log_destination
4244
from .utilities import model_to_dict
43-
from .thing_description._model import LinkElement
4445
from .invocations import InvocationModel, InvocationStatus, LogRecordModel
4546
from .dependencies.invocation import NonWarningInvocationID
4647
from .exceptions import (
@@ -55,13 +56,11 @@
5556
EmptyInput,
5657
StrictEmptyInput,
5758
fastapi_dependency_params,
58-
get_docstring,
59-
get_summary,
6059
input_model_from_signature,
6160
return_type,
6261
)
6362
from .thing_description import type_to_dataschema
64-
from .thing_description._model import ActionAffordance, ActionOp, Form
63+
from .thing_description._model import ActionAffordance, ActionOp, Form, LinkElement
6564
from .utilities import labthings_data
6665

6766

@@ -622,7 +621,15 @@ def delete_invocation(id: uuid.UUID) -> None:
622621
"""
623622

624623

625-
class ActionDescriptor:
624+
ActionParams = ParamSpec("ActionParams")
625+
ActionReturn = TypeVar("ActionReturn")
626+
OwnerT = TypeVar("OwnerT", bound="Thing")
627+
628+
629+
class ActionDescriptor(
630+
BaseDescriptor[Callable[ActionParams, ActionReturn]],
631+
Generic[ActionParams, ActionReturn, OwnerT],
632+
):
626633
"""Wrap actions to enable them to be run over HTTP.
627634
628635
This class is responsible for generating the action description for
@@ -640,7 +647,7 @@ class ActionDescriptor:
640647

641648
def __init__(
642649
self,
643-
func: Callable,
650+
func: Callable[Concatenate[OwnerT, ActionParams], ActionReturn],
644651
response_timeout: float = 1,
645652
retention_time: float = 300,
646653
) -> None:
@@ -662,7 +669,12 @@ def __init__(
662669
:param retention_time: how long, in seconds, the action should be kept
663670
for after it has completed.
664671
"""
672+
super().__init__()
665673
self.func = func
674+
if func.__doc__ is not None:
675+
# Use the docstring from the function, if there is one.
676+
self.__doc__ = func.__doc__
677+
name = func.__name__ # this is checked in __set_name__
666678
self.response_timeout = response_timeout
667679
self.retention_time = retention_time
668680
self.dependency_params = fastapi_dependency_params(func)
@@ -673,63 +685,47 @@ def __init__(
673685
)
674686
self.output_model = return_type(func)
675687
self.invocation_model = create_model(
676-
f"{self.name}_invocation",
688+
f"{name}_invocation",
677689
__base__=InvocationModel,
678690
input=(self.input_model, ...),
679691
output=(Optional[self.output_model], None),
680692
)
681-
self.invocation_model.__name__ = f"{self.name}_invocation"
693+
self.invocation_model.__name__ = f"{name}_invocation"
682694

683-
@overload
684-
def __get__(self, obj: Literal[None], type: type[Thing]) -> ActionDescriptor: # noqa: D105
685-
...
695+
def __set_name__(self, owner: type[Thing], name: str) -> None:
696+
"""Ensure the action name matches the function name.
686697
687-
@overload
688-
def __get__(self, obj: Thing, type: type[Thing] | None = None) -> Callable: # noqa: D105
689-
...
698+
It's assumed in a few places that the function name and the
699+
descriptor's name are the same. This should always be the case
700+
if it's used as a decorator.
701+
702+
:param owner: The class owning the descriptor.
703+
:param name: The name of the descriptor in the class.
704+
:raises ValueError: if the action name does not match the function name.
705+
"""
706+
super().__set_name__(owner, name)
707+
if self.name != self.func.__name__:
708+
raise ValueError(
709+
f"Action name '{self.name}' does not match function name "
710+
f"'{self.func.__name__}'",
711+
)
690712

691-
def __get__(
692-
self, obj: Optional[Thing], type: Optional[type[Thing]] = None
693-
) -> Union[ActionDescriptor, Callable]:
713+
def instance_get(self, obj: Thing) -> Callable[ActionParams, ActionReturn]:
694714
"""Return the function, bound to an object as for a normal method.
695715
696716
This currently doesn't validate the arguments, though it may do so
697717
in future. In its present form, this is equivalent to a regular
698718
Python method, i.e. all we do is supply the first argument, `self`.
699719
700-
If `obj` is None, the descriptor is returned, so we can get
701-
the descriptor conveniently as an attribute of the class.
702-
703720
:param obj: the `.Thing` to which we are attached. This will be
704721
the first argument supplied to the function wrapped by this
705722
descriptor.
706-
:param type: the class of the `.Thing` to which we are attached.
707-
If the descriptor is accessed via the class it is returned
708-
directly.
709-
:return: the action function, bound to ``obj`` (when accessed
710-
via an instance), or the descriptor (accessed via the class).
723+
:return: the action function, bound to ``obj``.
711724
"""
712-
if obj is None:
713-
return self
714-
# TODO: do we attempt dependency injection here? I think not.
715-
# If we want dependency injection, we should be calling the action
716-
# via some sort of client object.
717-
return partial(self.func, obj)
718-
719-
@property
720-
def name(self) -> str:
721-
"""The name of the wrapped function."""
722-
return self.func.__name__
723-
724-
@property
725-
def title(self) -> str:
726-
"""A human-readable title."""
727-
return get_summary(self.func) or self.name
728-
729-
@property
730-
def description(self) -> str | None:
731-
"""A description of the action."""
732-
return get_docstring(self.func, remove_summary=True)
725+
# `obj` should be of type `OwnerT`, but `BaseDescriptor` currently
726+
# isn't generic in the type of the owning Thing, so we can't express
727+
# that here.
728+
return partial(self.func, obj) # type: ignore[arg-type]
733729

734730
def _observers_set(self, obj: Thing) -> WeakSet:
735731
"""Return a set used to notify changes.
@@ -926,50 +922,33 @@ def action_affordance(
926922
)
927923

928924

929-
def mark_action(func: Callable, **kwargs: Any) -> ActionDescriptor:
930-
r"""Mark a method of a Thing as an Action.
931-
932-
We replace the function with a descriptor that's a
933-
subclass of `.ActionDescriptor`
934-
935-
:param func: The function to be decorated.
936-
:param \**kwargs: Additional keyword arguments are passed to the constructor
937-
of `.ActionDescriptor`.
938-
939-
:return: An `.ActionDescriptor` wrapping the method.
940-
"""
941-
942-
class ActionDescriptorSubclass(ActionDescriptor):
943-
pass
944-
945-
return ActionDescriptorSubclass(func, **kwargs)
946-
947-
948925
@overload
949-
def action(func: Callable, **kwargs: Any) -> ActionDescriptor: ...
926+
def action(
927+
func: Callable[Concatenate[OwnerT, ActionParams], ActionReturn], **kwargs: Any
928+
) -> ActionDescriptor[ActionParams, ActionReturn, OwnerT]: ...
950929

951930

952931
@overload
953932
def action(
954933
**kwargs: Any,
955934
) -> Callable[
956935
[
957-
Callable,
936+
Callable[Concatenate[OwnerT, ActionParams], ActionReturn],
958937
],
959-
ActionDescriptor,
938+
ActionDescriptor[ActionParams, ActionReturn, OwnerT],
960939
]: ...
961940

962941

963-
@wraps(mark_action)
964942
def action(
965-
func: Optional[Callable] = None, **kwargs: Any
943+
func: Callable[Concatenate[OwnerT, ActionParams], ActionReturn] | None = None,
944+
**kwargs: Any,
966945
) -> (
967-
ActionDescriptor
946+
ActionDescriptor[ActionParams, ActionReturn, OwnerT]
968947
| Callable[
969948
[
970-
Callable,
949+
Callable[Concatenate[OwnerT, ActionParams], ActionReturn],
971950
],
972-
ActionDescriptor,
951+
ActionDescriptor[ActionParams, ActionReturn, OwnerT],
973952
]
974953
):
975954
r"""Mark a method of a `.Thing` as a LabThings Action.
@@ -996,6 +975,6 @@ def action(
996975
# return a partial object, which then calls the
997976
# wrapped function once.
998977
if func is not None:
999-
return mark_action(func, **kwargs)
978+
return ActionDescriptor(func, **kwargs)
1000979
else:
1001-
return partial(mark_action, **kwargs)
980+
return partial(ActionDescriptor, **kwargs)

tests/test_actions.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,3 +206,83 @@ def decorated(
206206
# Check we can make the thing and it has a valid TD
207207
example = create_thing_without_server(Example)
208208
example.validate_thing_description()
209+
210+
211+
def test_action_docs():
212+
"""Check that action documentation is included in the Thing Description.
213+
214+
This test was added to check that the generated documentation is correct,
215+
after some refactoring of `lt.action`.
216+
217+
`name`, `title` and `description` attributes are now handled by `BaseDescriptor`
218+
and are tested more extensively there - but it seemed worthwhile to have some
219+
tests of them in the context of actions.
220+
"""
221+
222+
class DocThing(lt.Thing):
223+
@lt.action
224+
def documented_action(self) -> None:
225+
"""This is the action docstring."""
226+
pass
227+
228+
@lt.action
229+
def convert_type(self, a: int) -> float:
230+
"""Convert an integer to a float."""
231+
return float(a)
232+
233+
@lt.action
234+
def no_doc_action(self) -> None:
235+
pass
236+
237+
@lt.action
238+
def long_docstring(self) -> None:
239+
"""Do something with a very long docstring.
240+
241+
It has multiple paragraphs.
242+
243+
Here is the second paragraph.
244+
245+
And here is the third.
246+
"""
247+
pass
248+
249+
# Create a Thing, and generate the Thing Description. This uses `BaseDescriptor`
250+
# functionality to extract the name, title, and description.
251+
thing = create_thing_without_server(DocThing)
252+
td = thing.thing_description()
253+
actions = td.actions
254+
# The various `assert <whatever> is not None` statements are mostly for type
255+
# checking/autocompletion while writing the tests.
256+
assert actions is not None
257+
258+
# The function docstring should propagate through as the description.
259+
assert actions["documented_action"].description == "This is the action docstring."
260+
261+
# It's important that we check more than one action, to ensure there is no
262+
# "leakage" between instances. Previous implementations always subclassed
263+
# `ActionDescriptor` to avoid leakage when methods were manipulated at runtime.
264+
# This is no longer done, so we can instantiate `ActionDescriptor` directly - but
265+
# it's good to make sure we can have multiple actions with different docstrings.
266+
assert actions["convert_type"].description == "Convert an integer to a float."
267+
268+
# convert_type also allows us to check that the action inputs and outputs are
269+
# correctly represented in the thing description.
270+
input = actions["convert_type"].input
271+
assert input is not None
272+
input_properties = input.properties
273+
assert input_properties is not None
274+
assert input_properties["a"].type.value == "integer"
275+
output = actions["convert_type"].output
276+
assert output is not None
277+
assert output.type.value == "number"
278+
279+
# An action with no docstring should have no description, and a default title.
280+
assert actions["no_doc_action"].description is None
281+
assert actions["no_doc_action"].title == "no_doc_action"
282+
283+
# An action with a long docstring should have the docstring body as description,
284+
# and the first line as title.
285+
assert actions["long_docstring"].title == "Do something with a very long docstring."
286+
assert actions["long_docstring"].description.startswith(
287+
"It has multiple paragraphs."
288+
)

typing_tests/README.md

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,7 @@
11
# Typing tests: check `labthings_fastapi` plays nicely with `mypy`.
22

3-
The codebase is type-checked with `mypy src/` and tested with `pytest`, however neither of these explicitly check that `mypy` can infer the correct types for `Thing` attributes like properties and actions. The Python files in this folder are intended to be checked using:
3+
The codebase is type-checked with `mypy` and tested with `pytest`, however neither of these explicitly check that `mypy` can infer the correct types for `Thing` attributes like properties and actions. The Python files in this folder are checked when we run `mypy`, and are intended to test that `Thing` code may be statically type-checked.
44

5-
```terminal
6-
mypy --warn-unused-ignores typing_tests
7-
```
8-
9-
The files include valid code that's accompanied by `assert_type` statements (which check the inferred types are what we expect them to be) as well as invalid code where the expected `mypy` errors are ignored. This tests for expected errors - if an expected error is not thrown, it will cause an `unused-ignore` error.
5+
The files include valid code that's accompanied by `assert_type` statements (which check the inferred types are what we expect them to be) as well as invalid code where the expected `mypy` errors are ignored. This tests for expected errors - if an expected error is not thrown, it will cause an `unused-ignore` error as per the configuration in `pyproject.toml`.
106

117
There are more elaborate type testing solutions available, but all of them add dependencies and require us to learn a new tool. This folder of "tests" feels like a reasonable way to test that the package plays well with static type checking, without too much added complication.

0 commit comments

Comments
 (0)