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
22 changes: 8 additions & 14 deletions src/labthings_fastapi/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,8 +159,8 @@
"""
try:
blobdata_to_url_ctx.get()
except LookupError as e:
raise NoBlobManagerError(

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

View workflow job for this annotation

GitHub Actions / coverage

162-163 lines are not covered with tests
"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."
Expand Down Expand Up @@ -247,10 +247,10 @@
]
# 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]
return self.action.invocation_model( # type: ignore[attr-defined]
status=self.status,
id=self.id,
action=self.thing.path + self.action.name, # type: ignore[call-overload]
action=self.thing.path + self.action.name, # type: ignore[attr-defined]
href=href,
timeStarted=self._start_time,
timeCompleted=self._end_time,
Expand Down Expand Up @@ -290,7 +290,7 @@
"""
# self.action evaluates to an ActionDescriptor. This confuses mypy,
# which thinks we are calling ActionDescriptor.__get__.
action: ActionDescriptor = self.action # type: ignore[call-overload]
action: ActionDescriptor = self.action # type: ignore[assignment]
logger = self.thing.logger
# The line below saves records matching our ID to ``self._log``
add_thing_log_destination(self.id, self._log)
Expand Down Expand Up @@ -411,8 +411,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 415 in src/labthings_fastapi/actions.py

View workflow job for this annotation

GitHub Actions / coverage

414-415 lines are not covered with tests

def list_invocations(
self,
Expand Down Expand Up @@ -445,10 +445,7 @@
i.response(request=request)
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]
# i.action evaluates to an ActionDescriptor, which confuses mypy - it
# thinks we are calling ActionDescriptor.__get__ but this isn't ever
# called.
if action is None or i.action == action
]

def expire_invocations(self) -> None:
Expand Down Expand Up @@ -539,8 +536,8 @@
with self._invocations_lock:
try:
invocation: Any = self._invocations[id]
except KeyError as e:
raise HTTPException(

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

View workflow job for this annotation

GitHub Actions / coverage

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

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

View workflow job for this annotation

GitHub Actions / coverage

553 line is not covered with tests
return invocation.output

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

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

View workflow job for this annotation

GitHub Actions / coverage

578-579 lines are not covered with tests
status_code=404,
detail="No action invocation found with ID {id}",
) from e
Expand Down Expand Up @@ -626,7 +623,7 @@


class ActionDescriptor(
BaseDescriptor[Callable[ActionParams, ActionReturn]],
BaseDescriptor[OwnerT, Callable[ActionParams, ActionReturn]],
Generic[ActionParams, ActionReturn, OwnerT],
):
"""Wrap actions to enable them to be run over HTTP.
Expand Down Expand Up @@ -691,7 +688,7 @@
)
self.invocation_model.__name__ = f"{name}_invocation"

def __set_name__(self, owner: type[Thing], name: str) -> None:
def __set_name__(self, owner: type[OwnerT], name: str) -> None:
"""Ensure the action name matches the function name.

It's assumed in a few places that the function name and the
Expand All @@ -704,12 +701,12 @@
"""
super().__set_name__(owner, name)
if self.name != self.func.__name__:
raise ValueError(

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

View workflow job for this annotation

GitHub Actions / coverage

704 line is not covered with tests
f"Action name '{self.name}' does not match function name "
f"'{self.func.__name__}'",
)

def instance_get(self, obj: Thing) -> Callable[ActionParams, ActionReturn]:
def instance_get(self, obj: OwnerT) -> Callable[ActionParams, ActionReturn]:
"""Return the function, bound to an object as for a normal method.

This currently doesn't validate the arguments, though it may do so
Expand All @@ -721,10 +718,7 @@
descriptor.
:return: the action function, bound to ``obj``.
"""
# `obj` should be of type `OwnerT`, but `BaseDescriptor` currently
# isn't generic in the type of the owning Thing, so we can't express
# that here.
return partial(self.func, obj) # type: ignore[arg-type]
return partial(self.func, obj)

def _observers_set(self, obj: Thing) -> WeakSet:
"""Return a set used to notify changes.
Expand Down Expand Up @@ -853,14 +847,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 851 in src/labthings_fastapi/actions.py

View workflow job for this annotation

GitHub Actions / coverage

850-851 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 854 in src/labthings_fastapi/actions.py

View workflow job for this annotation

GitHub Actions / coverage

854 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 857 in src/labthings_fastapi/actions.py

View workflow job for this annotation

GitHub Actions / coverage

857 line is not covered with tests
"Can't add the endpoint without thing.path!"
)
app.post(
Expand Down Expand Up @@ -908,7 +902,7 @@
"""
path = path or thing.path
if path is None:
raise NotConnectedToServerError("Can't generate forms without a path!")

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

View workflow job for this annotation

GitHub Actions / coverage

905 line is not covered with tests
forms = [
Form[ActionOp](href=path + self.name, op=[ActionOp.invokeaction]),
]
Expand Down
22 changes: 14 additions & 8 deletions src/labthings_fastapi/base_descriptor.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@
Value = TypeVar("Value")
"""The value returned by the descriptor, when called on an instance."""

Owner = TypeVar("Owner", bound="Thing")
"""A Thing subclass that owns a descriptor."""

Descriptor = TypeVar("Descriptor", bound="BaseDescriptor")
"""The type of a descriptor that's referred to by a `BaseDescriptorInfo` object."""


class DescriptorNotAddedToClassError(RuntimeError):
"""Descriptor has not yet been added to a class.
Expand Down Expand Up @@ -138,7 +144,7 @@ def _set_prop4(self, val):
"""


class BaseDescriptor(Generic[Value]):
class BaseDescriptor(Generic[Owner, Value]):
r"""A base class for descriptors in LabThings-FastAPI.

This class implements several behaviours common to descriptors in LabThings:
Expand Down Expand Up @@ -184,7 +190,7 @@ def __init__(self) -> None:
self._set_name_called: bool = False
self._owner_name: str = ""

def __set_name__(self, owner: type[Thing], name: str) -> None:
def __set_name__(self, owner: type[Owner], name: str) -> None:
r"""Take note of the name to which the descriptor is assigned.

This is called when the descriptor is assigned to an attribute of a class.
Expand Down Expand Up @@ -306,12 +312,12 @@ def description(self) -> str | None:
# I have ignored D105 (missing docstrings) on the overloads - these should not
# exist on @overload definitions.
@overload
def __get__(self, obj: Thing, type: type | None = None) -> Value: ...
def __get__(self, obj: Owner, type: type | None = None) -> Value: ...

@overload
def __get__(self, obj: None, type: type) -> Self: ...

def __get__(self, obj: Thing | None, type: type | None = None) -> Value | Self:
def __get__(self, obj: Owner | None, type: type | None = None) -> Value | Self:
"""Return the value or the descriptor, as per `property`.

If ``obj`` is ``None`` (i.e. the descriptor is accessed as a class attribute),
Expand All @@ -331,7 +337,7 @@ def __get__(self, obj: Thing | None, type: type | None = None) -> Value | Self:
return self.instance_get(obj)
return self

def instance_get(self, obj: Thing) -> Value:
def instance_get(self, obj: Owner) -> Value:
"""Return the value of the descriptor.

This method is called from ``__get__`` if the descriptor is accessed as an
Expand All @@ -357,7 +363,7 @@ def instance_get(self, obj: Thing) -> Value:
)


class FieldTypedBaseDescriptor(Generic[Value], BaseDescriptor[Value]):
class FieldTypedBaseDescriptor(Generic[Owner, Value], BaseDescriptor[Owner, Value]):
"""A BaseDescriptor that determines its type like a dataclass field."""

def __init__(self) -> None:
Expand All @@ -379,7 +385,7 @@ def __init__(self) -> None:
# the object on which they are defined, to provide the context for the
# evaluation.

def __set_name__(self, owner: type[Thing], name: str) -> None:
def __set_name__(self, owner: type[Owner], name: str) -> None:
r"""Take note of the name and type.

This function is where we determine the type of the property. It may
Expand Down Expand Up @@ -431,7 +437,7 @@ class MyThing(Thing):
# __orig_class__ is set on generic classes when they are instantiated
# with a subscripted type. It is not available during __init__, which
# is why we check for it here.
self._type = typing.get_args(self.__orig_class__)[0]
self._type = typing.get_args(self.__orig_class__)[1]
if isinstance(self._type, typing.ForwardRef):
raise MissingTypeError(
f"{owner}.{name} is a subscripted descriptor, where the "
Expand Down
Loading