Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
15e5114
Remove an assertion
rwb27 Sep 10, 2025
7c58e6d
Fix endpoint decorator docstring.
rwb27 Sep 10, 2025
ca7377a
Improve test coverage of endpoint decorator
rwb27 Sep 10, 2025
effae8f
Increase ruff strictness
rwb27 Sep 10, 2025
78dac2d
Use only Annotated form of FastAPI Depends()
rwb27 Sep 10, 2025
152192b
Exempt sphinx config from annotations rules.
rwb27 Sep 10, 2025
97c56c3
Fix docstrings in example code
rwb27 Sep 10, 2025
f65afcb
Add return annotations for __init__
rwb27 Sep 10, 2025
2a0451f
Eliminate assert statements from base_descriptor
rwb27 Sep 10, 2025
6220c7d
Replaced assertions in actions/__init__ with exceptions
rwb27 Sep 10, 2025
802c5cb
Remove a spurious assertion
rwb27 Sep 10, 2025
f983601
Change assertion to exception
rwb27 Sep 10, 2025
03368d5
Convert an assert to an exception
rwb27 Sep 10, 2025
9fb00df
Convert assertions to exceptions.
rwb27 Sep 10, 2025
d26a21e
Ignore error on eval
rwb27 Sep 10, 2025
3e3fadb
Fix whitespace
rwb27 Sep 10, 2025
e30db95
Swap assertion for exception.
rwb27 Sep 10, 2025
e11405b
Swap assertions for exceptions, and test them.
rwb27 Sep 10, 2025
9d9d561
Convert assertions to errors, and test.
rwb27 Sep 10, 2025
505551c
Delete an unnecessary assertion.
rwb27 Sep 10, 2025
9fe7640
Replace an assertion with an exception, and add a test.
rwb27 Sep 10, 2025
0f95ee9
Replace the last couple of assertions with exceptions.
rwb27 Sep 10, 2025
0c92caf
Enforce no assertions, and a few other linter rules.
rwb27 Sep 10, 2025
95655f1
Delete an unused variable.
rwb27 Sep 10, 2025
0b9746d
Don't catch blind Exceptions
rwb27 Sep 10, 2025
7249896
Properly mock `Thing.path` when needed.
rwb27 Sep 10, 2025
0a12162
Fix failing tests of the server
rwb27 Sep 10, 2025
2a6a9a5
Add newly-raised exceptions to docstrings
rwb27 Sep 10, 2025
02a933a
Avoid raising an exception that's immediately handled
rwb27 Sep 10, 2025
18fc750
Enable flake8-comprehension ruleset
rwb27 Sep 10, 2025
2716215
Rename TestThing.
rwb27 Sep 10, 2025
44e29f7
Improve comments in tests.
rwb27 Sep 10, 2025
697e1d2
Add tests for errors when retrieving action outputs.
rwb27 Sep 10, 2025
18b35c0
Test a couple of unchecked code paths in utilities.
rwb27 Sep 10, 2025
eca23fd
Get rid of unnecessary #noqa
rwb27 Sep 10, 2025
e6d0253
Improve a comment and remove a confusing `return`
rwb27 Sep 10, 2025
42465fa
Add tests for validation of thing context URLs.
rwb27 Sep 10, 2025
0442991
Mock path properly in test code for wrapped action.
rwb27 Sep 10, 2025
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
19 changes: 18 additions & 1 deletion dev-requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ email-validator==2.2.0
# via
# fastapi
# pydantic
exceptiongroup==1.3.0
# via
# anyio
# pytest
fastapi==0.116.1
# via labthings-fastapi (pyproject.toml)
fastapi-cli==0.0.8
Expand Down Expand Up @@ -160,9 +164,10 @@ pyflakes==3.4.0
pygments==2.19.2
# via
# flake8-rst-docstrings
# pytest
# rich
# sphinx
pytest==7.4.4
pytest==8.4.1
# via
# labthings-fastapi (pyproject.toml)
# pytest-cov
Expand Down Expand Up @@ -241,6 +246,14 @@ sphinxcontrib-serializinghtml==2.0.0
# via sphinx
starlette==0.47.1
# via fastapi
tomli==2.2.1
# via
# coverage
# flake8-pyproject
# mypy
# pydoclint
# pytest
# sphinx
typer==0.16.0
# via
# fastapi-cli
Expand All @@ -251,16 +264,20 @@ typing-extensions==4.14.1
# via
# labthings-fastapi (pyproject.toml)
# anyio
# astroid
# exceptiongroup
# fastapi
# mypy
# pydantic
# pydantic-core
# pydantic-extra-types
# referencing
# rich
# rich-toolkit
# starlette
# typer
# typing-inspection
# uvicorn
typing-inspection==0.4.1
# via pydantic-settings
ujson==5.10.0
Expand Down
10 changes: 6 additions & 4 deletions docs/source/quickstart/counter.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
"""An example Thing that implements a counter."""

import time
import labthings_fastapi as lt


class TestThing(lt.Thing):
"""A test thing with a counter property and a couple of actions"""
"""A test thing with a counter property and a couple of actions."""

@lt.thing_action
def increment_counter(self) -> None:
"""Increment the counter property
"""Increment the counter property.

This action doesn't do very much - all it does, in fact,
is increment the counter (which may be read using the
Expand All @@ -17,13 +19,13 @@ def increment_counter(self) -> None:

@lt.thing_action
def slowly_increase_counter(self) -> None:
"""Increment the counter slowly over a minute"""
"""Increment the counter slowly over a minute."""
for _i in range(60):
time.sleep(1)
self.increment_counter()

counter: int = lt.property(default=0, readonly=True)
"A pointless counter"
"A pointless counter."


if __name__ == "__main__":
Expand Down
2 changes: 2 additions & 0 deletions docs/source/quickstart/counter_client.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"""Client code that interacts with the counter Thing over HTTP."""

from labthings_fastapi import ThingClient

counter = ThingClient.from_url("http://localhost:5000/counter/")
Expand Down
29 changes: 25 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ dependencies = [

[project.optional-dependencies]
dev = [
"pytest>=7.4.0, <8",
"pytest>=8.4.0",
"pytest-cov",
"pytest-mock",
"mypy>=1.6.1, <2",
Expand Down Expand Up @@ -77,7 +77,20 @@ docstring-code-format = true

[tool.ruff.lint]
external = ["DOC401", "F824", "DOC101", "DOC103"] # used via flake8/pydoclint
select = ["B", "E4", "E7", "E9", "F", "D", "DOC"]
select = [
"ANN", # type annotations (ensuring they are present, complements mypy)
"ASYNC", # flake8 async rules
"B", # flake8-bugbear
"BLE", # don't catch Exception
"C4", # flake8-comprehension rules
"E", # flake8
"F", # flake8
"FAST", # FastAPI rules (mostly around dependencies)
"D", # pydoclint (recommended by the developers over flake8 plugin)
"DOC", # pydocstyle (limited support for sphinx style: see ignores)
"FAST", # FastAPI rules
"S", # flake8-bandit - notably, this disallows `assert`
]
ignore = [
"D203", # incompatible with D204
"D213", # incompatible with D212
Expand All @@ -88,16 +101,24 @@ ignore = [
"B008", # This disallows function calls in default values.
# FastAPI Depends() breaks this rule, and FastAPI's response is "disable it".
# see https://github.com/fastapi/fastapi/issues/1522
"ANN401", # ANN401 disallows Any. There are quite a few places where Any is
# needed for dynamically typed code.
]
preview = true

[tool.ruff.lint.per-file-ignores]
# Tests are currently not fully docstring-ed, we'll ignore this for now.
"tests/*" = ["D", "DOC"]
"tests/*" = [
"D", # Tests are not yet fully covered by docstrings
"DOC", # Tests are not yet fully covered by docstrings
"ANN", # We don't require type hints in test code.
"S101", # S101 disallows assert. That's not appropriate for tests.
]
# Typing tests do have docstrings, but it's not helpful to insist on imperative
# mood etc.
"typing_tests/*" = ["D404", "D401"]
"docs/*" = ["D", "DOC"]
# The docs config file is a python file, but doesn't conform to the usual rules.
"docs/source/conf.py" = ["D", "DOC", "ANN"]

[tool.ruff.lint.pydocstyle]
# This lets the D401 checker understand that decorated thing properties and thing
Expand Down
34 changes: 27 additions & 7 deletions src/labthings_fastapi/actions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@
dependencies: Optional[dict[str, Any]] = None,
log_len: int = 1000,
cancel_hook: Optional[CancelHook] = None,
):
) -> None:
"""Create a thread to run an action and track its outputs.

:param action: provides the function that we run, as well as metadata
Expand Down Expand Up @@ -142,8 +142,8 @@
"""
try:
blobdata_to_url_ctx.get()
except LookupError as e:
raise NoBlobManagerError(

Check warning on line 146 in src/labthings_fastapi/actions/__init__.py

View workflow job for this annotation

GitHub Actions / coverage

145-146 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 All @@ -169,16 +169,30 @@

@property
def action(self) -> ActionDescriptor:
"""The `.ActionDescriptor` object running in this thread."""
"""The `.ActionDescriptor` object running in this thread.

:raises RuntimeError: if the action descriptor has been deleted.
This should never happen, as the descriptor is a property of
a class, which won't be deleted.
"""
action = self.action_ref()
assert action is not None, "The action for an `Invocation` has been deleted!"
if action is None: # pragma: no cover
# Action descriptors should only be deleted after the server has
# stopped, so this error should never occur.
raise RuntimeError("The action for an `Invocation` has been deleted!")
return action

@property
def thing(self) -> Thing:
"""The `.Thing` to which the action is bound, i.e. this is ``self``."""
"""The `.Thing` to which the action is bound, i.e. this is ``self``.

:raises RuntimeError: if the Thing no longer exists.
"""
thing = self.thing_ref()
assert thing is not None, "The `Thing` on which an action was run is missing!"
if thing is None: # pragma: no cover
# this error block is primarily for mypy: the Thing will exist as
# long as the server is running, so we should never hit this error.
raise RuntimeError("The `Thing` on which an action was run is missing!")
return thing

def cancel(self) -> None:
Expand Down Expand Up @@ -250,6 +264,8 @@
stored. The status is then set to ERROR and the thread terminates.

See `.Invocation.status` for status values.

:raises RuntimeError: if there is no Thing associated with the invocation.
"""
# self.action evaluates to an ActionDescriptor. This confuses mypy,
# which thinks we are calling ActionDescriptor.__get__.
Expand All @@ -264,7 +280,11 @@

thing = self.thing
kwargs = model_to_dict(self.input)
assert thing is not None
if thing is None: # pragma: no cover
# The Thing is stored as a weakref, but it will always exist
# while the server is running - this error should never
# occur.
raise RuntimeError("Cannot start an invocation without a Thing.")

with self._status_lock:
self._status = InvocationStatus.RUNNING
Expand Down Expand Up @@ -312,7 +332,7 @@
self,
dest: MutableSequence,
level: int = logging.INFO,
):
) -> None:
"""Set up a log handler that appends messages to a deque.

.. warning::
Expand Down Expand Up @@ -412,8 +432,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 436 in src/labthings_fastapi/actions/__init__.py

View workflow job for this annotation

GitHub Actions / coverage

435-436 lines are not covered with tests

def list_invocations(
self,
Expand Down Expand Up @@ -541,8 +561,8 @@
with self._invocations_lock:
try:
invocation: Any = self._invocations[id]
except KeyError as e:
raise HTTPException(

Check warning on line 565 in src/labthings_fastapi/actions/__init__.py

View workflow job for this annotation

GitHub Actions / coverage

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

Check warning on line 578 in src/labthings_fastapi/actions/__init__.py

View workflow job for this annotation

GitHub Actions / coverage

578 line is not covered with tests
return invocation.output

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

Check warning on line 604 in src/labthings_fastapi/actions/__init__.py

View workflow job for this annotation

GitHub Actions / coverage

603-604 lines are not covered with tests
status_code=404,
detail="No action invocation found with ID {id}",
) from e
Expand Down
16 changes: 10 additions & 6 deletions src/labthings_fastapi/base_descriptor.py
Original file line number Diff line number Diff line change
Expand Up @@ -244,11 +244,16 @@ def name(self) -> str:

The ``name`` of :ref:`wot_affordances` is used in their URL and in
the :ref:`gen_docs` served by LabThings.

:raises DescriptorNotAddedToClassError: if ``__set_name__`` has not yet
been called.
"""
self.assert_set_name_called()
assert self._name is not None
# The assert statement is mostly for typing: if assert_set_name_called
# doesn't raise an error, self._name has been set.
if self._name is None: # pragma: no cover
raise DescriptorNotAddedToClassError("`_name` is not set.")
# The exception is mostly for typing: if `assert_set_name_called``
# doesn't raise an error, `BaseDescriptor.__set_name__` has been
# called and thus `self._name`` has been set.
return self._name

@property
Expand Down Expand Up @@ -297,10 +302,10 @@ 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: ... # noqa: D105
def __get__(self, obj: Thing, type: type | None = None) -> Value: ...

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

def __get__(self, obj: Thing | None, type: type | None = None) -> Value | Self:
"""Return the value or the descriptor, as per `property`.
Expand Down Expand Up @@ -429,7 +434,6 @@ class Example:
return {}
# The line below parses the class to get a syntax tree.
module_ast = ast.parse(textwrap.dedent(src))
assert isinstance(module_ast, ast.Module)
class_def = module_ast.body[0]
if not isinstance(class_def, ast.ClassDef):
raise TypeError("The object supplied was not a class.")
Expand Down
2 changes: 1 addition & 1 deletion src/labthings_fastapi/client/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,11 @@
:raise KeyError: if there is no link with the specified ``rel`` value.
"""
if "links" not in obj:
raise ObjectHasNoLinksError(f"Can't find any links on {obj}.")

Check warning on line 57 in src/labthings_fastapi/client/__init__.py

View workflow job for this annotation

GitHub Actions / coverage

57 line is not covered with tests
try:
return next(link for link in obj["links"] if link["rel"] == rel)
except StopIteration as e:
raise KeyError(f"No link was found with rel='{rel}' on {obj}.") from e

Check warning on line 61 in src/labthings_fastapi/client/__init__.py

View workflow job for this annotation

GitHub Actions / coverage

60-61 lines are not covered with tests


def invocation_href(invocation: dict) -> str:
Expand Down Expand Up @@ -121,7 +121,7 @@
creates a subclass with the right attributes.
"""

def __init__(self, base_url: str, client: Optional[httpx.Client] = None):
def __init__(self, base_url: str, client: Optional[httpx.Client] = None) -> None:
"""Create a ThingClient connected to a remote Thing.

:param base_url: the base URL of the Thing. This should be the URL
Expand All @@ -144,9 +144,9 @@

:return: the property's value, as deserialised from JSON.
"""
r = self.client.get(urljoin(self.path, path))
r.raise_for_status()
return r.json()

Check warning on line 149 in src/labthings_fastapi/client/__init__.py

View workflow job for this annotation

GitHub Actions / coverage

147-149 lines are not covered with tests

def set_property(self, path: str, value: Any) -> None:
"""Make a PUT request to set the value of a property.
Expand All @@ -156,8 +156,8 @@
:param value: the property's value. Currently this must be
serialisable to JSON.
"""
r = self.client.put(urljoin(self.path, path), json=value)
r.raise_for_status()

Check warning on line 160 in src/labthings_fastapi/client/__init__.py

View workflow job for this annotation

GitHub Actions / coverage

159-160 lines are not covered with tests

def invoke_action(self, path: str, **kwargs: Any) -> Any:
r"""Invoke an action on the Thing.
Expand Down Expand Up @@ -207,7 +207,7 @@
)
return invocation["output"]
else:
raise RuntimeError(f"Action did not complete successfully: {invocation}")

Check warning on line 210 in src/labthings_fastapi/client/__init__.py

View workflow job for this annotation

GitHub Actions / coverage

210 line is not covered with tests

def follow_link(self, response: dict, rel: str) -> httpx.Response:
"""Follow a link in a response object, by its `rel` attribute.
Expand Down
4 changes: 2 additions & 2 deletions src/labthings_fastapi/client/in_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ class DirectThingClient:
thing_path: str
"""The path to the Thing on the server. Relative to the server's base URL."""

def __init__(self, request: Request, **dependencies: Mapping[str, Any]):
def __init__(self, request: Request, **dependencies: Mapping[str, Any]) -> None:
r"""Wrap a `.Thing` so it works like a `.ThingClient`.

This class is designed to be used as a FastAPI dependency, and will
Expand Down Expand Up @@ -157,7 +157,7 @@ class DependencyNameClashError(KeyError):
exception is raised.
"""

def __init__(self, name: str, existing: type, new: type):
def __init__(self, name: str, existing: type, new: type) -> None:
"""Create a DependencyNameClashError.

See class docstring for an explanation of the error.
Expand Down
2 changes: 1 addition & 1 deletion src/labthings_fastapi/client/outputs.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ class ClientBlobOutput:

def __init__(
self, media_type: str, href: str, client: Optional[httpx.Client] = None
):
) -> None:
"""Create a ClientBlobOutput to wrap a link to a downloadable file.

:param media_type: the MIME type of the remote file.
Expand Down
10 changes: 4 additions & 6 deletions src/labthings_fastapi/dependencies/action_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,19 +27,17 @@


def action_manager_from_thing_server(request: Request) -> ActionManager:
"""Retrieve the Action Manager from the Thing Server.
r"""Retrieve the Action Manager from the Thing Server.

This is for use as a FastAPI dependency. We use the ``request`` to
access the `.ThingServer` and thus access the `.ActionManager`.
access the `.ThingServer` and thus access the `.ActionManager`\ .

:param request: the FastAPI request object. This will be supplied by
FastAPI when this function is used as a dependency.

:return: the `.ActionManager` object associated with our `.ThingServer`.
:return: the `.ActionManager` object associated with our `.ThingServer`\ .
"""
action_manager = find_thing_server(request.app).action_manager
assert action_manager is not None
return action_manager
return find_thing_server(request.app).action_manager


ActionManagerDep = Annotated[ActionManager, Depends(action_manager_from_thing_server)]
Expand Down
18 changes: 13 additions & 5 deletions src/labthings_fastapi/dependencies/blocking_portal.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
from fastapi import Depends, Request
from anyio.from_thread import BlockingPortal as RealBlockingPortal
from .thing_server import find_thing_server
from ..exceptions import ServerNotRunningError


def blocking_portal_from_thing_server(request: Request) -> RealBlockingPortal:
Expand All @@ -41,13 +42,20 @@ def blocking_portal_from_thing_server(request: Request) -> RealBlockingPortal:

:return: the `anyio.from_thread.BlockingPortal` allowing access to the
`.ThingServer`\ 's event loop.

:raises ServerNotRunningError: if the server does not have an available
blocking portal. This should not normally happen, as dependencies
are only evaluated while the server is running.
"""
portal = find_thing_server(request.app).blocking_portal
assert portal is not None, RuntimeError(
"Could not get the blocking portal from the server."
# This should never happen, as the blocking portal is added
# and removed in `.ThingServer.lifecycle`.
)
if portal is None: # pragma: no cover
raise ServerNotRunningError(
"Could not get the blocking portal from the server."
# This should never happen, as the blocking portal is added
# and removed in `.ThingServer.lifecycle`.
# As dependencies are only evaluated while the server is running,
# this error should never be raised.
)
return portal


Expand Down
2 changes: 1 addition & 1 deletion src/labthings_fastapi/dependencies/invocation.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ class CancelEvent(threading.Event):
usually by a ``DELETE`` request to the invocation's URL.
"""

def __init__(self, id: InvocationID):
def __init__(self, id: InvocationID) -> None:
"""Initialise the cancellation event.

:param id: The invocation ID, annotated as a dependency so it is
Expand Down
Loading