Skip to content
Open
178 changes: 119 additions & 59 deletions src/labthings_fastapi/actions.py

Large diffs are not rendered by default.

17 changes: 13 additions & 4 deletions src/labthings_fastapi/client/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,15 +101,24 @@ def poll_invocation(
:param first_interval: sets how long we wait before the first
polling request. Often, it makes sense for this to be a short
interval, in case the action fails (or returns) immediately.

:raises ServerActionError: if an HTTP error is found during polling.
:return: the completed invocation as a dictionary.
"""
first_time = True
while invocation["status"] in ACTION_RUNNING_KEYWORDS:
time.sleep(first_interval if first_time else interval)
r = client.get(invocation_href(invocation))
r.raise_for_status()
invocation = r.json()
response = client.get(invocation_href(invocation))
if response.is_error:
try:
message = response.json()["detail"]
except KeyError:
message = response.text
raise ServerActionError(
f"The server returned error {response.status_code} while polling "
f"action '{invocation['action']}' with id '{invocation['id']}'. "
f"The error message was:\n{message}."
)
invocation = response.json()
first_time = False
return invocation

Expand Down
70 changes: 70 additions & 0 deletions src/labthings_fastapi/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
# An __all__ for this module is less than helpful, unless we have an
# automated check that everything's included.

from collections.abc import Callable


class NotConnectedToServerError(RuntimeError):
"""The Thing is not connected to a server.
Expand Down Expand Up @@ -254,6 +256,74 @@ class NoInvocationContextError(RuntimeError):
"""


class CausedByUserCodeError(Exception):
"""A mixin to allow exceptions to refer to downstream code."""

def _append_to_args(self, message: str) -> None:
"""Add a message to the exception's arguments.

:param message: the message to append.
"""
if len(self.args) == 1:
# If there's only one string, assume it's a message and append
self.args = (self.args[0] + "\n" + message,)
else:
# If there are multiple arguments, add this as a further one
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# If there are multiple arguments, add this as a further one
# If there are multiple or no arguments, add this as a further one

self.args += (message,)

def set_source_function(self, func: Callable) -> None:
"""Add the location of a user-supplied function to the error message.

:param func: the function that caused this error.
"""
code = func.__code__
self._append_to_args(
f"This was likely caused by function '{code.co_name}' "
f"at {code.co_filename}:{code.co_firstlineno}"
)

def set_source_class(self, cls: type, attr: str | None = None) -> None:
"""Add a reference to a class (and optionally attribute).

:param cls: the class that caused this error.
:param attr: the attribute name that caused this error.
"""
self._append_to_args(
f"This was likely caused by '{cls.__module__}.{cls.__qualname__}.{attr}"
if attr
else "'."
)


class InvalidReturnValueError(CausedByUserCodeError, RuntimeError):
r"""The return value from a method cannot be serialised by LabThings.

This error is raised when an action returns a value that can't be serialised.
This usually means that either it doesn't match the declared return type of
the function, or the declared return type permits un-serialisable values.

If an action's return type is missing or `Any`\ , it's possible to return a
value that can't be serialised, which will cause this error.

The solution is usually to ensure that the return type of your action is
either a simple type that can be serialised to JSON, or a Pydantic model.
You should also check that the function's return value matches the declared
type, ideally by regularly running a type checker like `mypy` on your code.
"""


class UnserializableTypeError(CausedByUserCodeError, TypeError):
r"""A type has been specified that can't be serialized to JSON.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
r"""A type has been specified that can't be serialized to JSON.
r"""A type has been specified that can't be serialised to JSON.

Just to be consistent with the docstring for InvalidReturnValueError


This error generally means a property or action has a type that cannot be
serialized to JSON. This might be an instance of a custom class, or another
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
serialized to JSON. This might be an instance of a custom class, or another
serialised to JSON. This might be an instance of a custom class, or another

Just to be consistent with the docstring for InvalidReturnValueError

datatype that doesn't have a ready representation using JSON-compatible types.

This error can often be fixed using `pydantic` annotations, or by using simple
Python types instead of custom ones.
"""


class LogConfigurationError(RuntimeError):
"""There is a problem with logging configuration.

Expand Down
36 changes: 26 additions & 10 deletions src/labthings_fastapi/invocations.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,11 @@
from typing import Optional, Any, Sequence, TypeVar, Generic
import uuid

from pydantic import BaseModel, ConfigDict, model_validator
from pydantic import (
BaseModel,
ConfigDict,
model_validator,
)

from labthings_fastapi.middleware.url_for import URLFor

Expand Down Expand Up @@ -80,11 +84,31 @@ def generate_message(cls, data: Any) -> Any:
return data


class InvocationSummary(BaseModel):
"""A model to represent `.Invocation` objects over HTTP.

This version of the model does not include logs our action outputs, and is intended
for use in endpoints that might list several invocations.

See `GenericInvocationModel` for the full representation, to be used in
endpoints referring to one specific invocation.
"""

status: InvocationStatus
id: uuid.UUID
action: str
href: URLFor
timeStarted: Optional[datetime]
timeRequested: Optional[datetime]
timeCompleted: Optional[datetime]
links: Links = None


InputT = TypeVar("InputT")
OutputT = TypeVar("OutputT")


class GenericInvocationModel(BaseModel, Generic[InputT, OutputT]):
class GenericInvocationModel(InvocationSummary, Generic[InputT, OutputT]):
"""A model to serialise `.Invocation` objects when they are polled over HTTP.

The input and output models are generic parameters, to allow this model to
Expand All @@ -93,17 +117,9 @@ class GenericInvocationModel(BaseModel, Generic[InputT, OutputT]):
are not known in advance.
"""

status: InvocationStatus
id: uuid.UUID
action: str
href: URLFor
timeStarted: Optional[datetime]
timeRequested: Optional[datetime]
timeCompleted: Optional[datetime]
input: InputT
output: OutputT
log: Sequence[LogRecordModel]
links: Links = None


InvocationModel = GenericInvocationModel[Any, Any]
Expand Down
49 changes: 38 additions & 11 deletions src/labthings_fastapi/properties.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ class attribute. Documentation is in strings immediately following the
from typing_extensions import Self, TypedDict
from weakref import WeakSet

from fastapi import Body, FastAPI
from fastapi import Body, FastAPI, Response, HTTPException
from pydantic import (
BaseModel,
ConfigDict,
Expand All @@ -81,9 +81,10 @@ class attribute. Documentation is in strings immediately following the
PropertyOp,
)
from .utilities import (
LabThingsRootModelWrapper,
RootModelWrapper,
labthings_data,
wrap_plain_types_in_rootmodel,
serialize_from_user_code,
validate_from_user_code,
)
from .utilities.introspection import return_type
from .base_descriptor import (
Expand All @@ -93,10 +94,12 @@ class attribute. Documentation is in strings immediately following the
)
from .exceptions import (
FeatureNotAvailableError,
InvalidReturnValueError,
NotConnectedToServerError,
PropertyRedefinitionError,
ReadOnlyPropertyError,
MissingTypeError,
UnserializableTypeError,
UnsupportedConstraintError,
)
from .thing_class_settings import get_validate_properties_on_set
Expand Down Expand Up @@ -464,12 +467,19 @@ def model(self) -> type[BaseModel]:
subclass, this returns it unchanged.

:return: a Pydantic model for the property's type.
:raises UnserializableTypeError: if the property can't be serialized
by `pydantic` to JSON.
"""
if self._model is None:
self._model = wrap_plain_types_in_rootmodel(
self.value_type,
constraints=self.constraints,
)
try:
self._model = RootModelWrapper.wrap_type(
self.value_type,
constraints=self.constraints,
name=f"{self.name.title()}Value",
)
except UnserializableTypeError as e:
e.set_source_class(self.owning_class, self.name)
raise
return self._model

def get_default(self, obj: Owner | None) -> Value:
Expand Down Expand Up @@ -559,8 +569,25 @@ def set_property(body: Any) -> None:
summary=self.title,
description=f"## {self.title}\n\n{self.description or ''}",
)
def get_property() -> Any:
return self.__get__(thing)
def get_property() -> Response:
try:
instance = validate_from_user_code(
model=self.model,
value=self.__get__(thing),
description=f"{thing.name}.{self.name}",
code=(self.owning_class, self.name),
)
return serialize_from_user_code(
model_instance=instance,
description=f"{thing.name}.{self.name}",
code=(self.owning_class, self.name),
)
except InvalidReturnValueError as e:
thing.logger.error(e)
raise HTTPException(
status_code=500,
detail=str(e),
) from e

if self.is_resettable(thing):

Expand Down Expand Up @@ -1270,7 +1297,7 @@ def validate(self, value: Any) -> Value:
with its value type. This should never happen.
"""
try:
if issubclass(self.model, LabThingsRootModelWrapper):
if issubclass(self.model, RootModelWrapper):
# If a plain type has been wrapped in a RootModel, use that to validate
# and then set the property to the root value.
model = self.model.model_validate(value)
Expand All @@ -1283,7 +1310,7 @@ def validate(self, value: Any) -> Value:
return self.value_type.model_validate(value)

# This should be unreachable, because `model` is a
# `LabThingsRootModelWrapper` wrapping the value type, or the value type
# `RootModelWrapper` wrapping the value type, or the value type
# should be a BaseModel.
msg = f"Property {self.name} has an inconsistent model. This is "
msg += f"most likely a LabThings bug. {self.model=}, {self.value_type=}"
Expand Down
16 changes: 15 additions & 1 deletion src/labthings_fastapi/server/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import warnings
from fastapi.testclient import TestClient
from pydantic import ValidationError
from pydantic_core import PydanticSerializationError
from typing import Any, AsyncGenerator, Optional, TypeVar, overload
from fastapi.responses import JSONResponse
from typing_extensions import Self
Expand Down Expand Up @@ -50,6 +51,9 @@
ThingSubclass = TypeVar("ThingSubclass", bound=Thing)


LOGGER = logging.getLogger(__name__)


class ThingServer:
"""Use FastAPI to serve `~lt.Thing` instances.

Expand Down Expand Up @@ -141,7 +145,7 @@ def __init__(
self._config = ThingServerConfig(**kwargs)
if self._config.settings_folder is None:
self._config.settings_folder = "./settings"
self.app = FastAPI(lifespan=self.lifespan)
self.app = FastAPI(lifespan=self.lifespan, separate_input_output_schemas=False)
self._set_cors_middleware()
self._set_url_for_middleware()
self._add_exception_handlers()
Expand Down Expand Up @@ -248,6 +252,16 @@ async def global_lock_exception_handler(
content={"detail": repr(exc)},
)

@self.app.exception_handler(PydanticSerializationError)
async def serialization_error_handler(
request: Request, exc: PydanticSerializationError
) -> JSONResponse:
LOGGER.error(
f"Couldn't serialize response to {request.url} because of error: \n"
f"{exc}"
)
return JSONResponse(status_code=500, content={"detail": str(exc)})

@property
def debug(self) -> bool:
"""Whether the server is in debug mode."""
Expand Down
Loading
Loading