Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
431f7ac
Increase strictness of mypy
rwb27 Aug 14, 2025
024847f
Add annotations to property
rwb27 Aug 14, 2025
2978b39
Add type annotations to actions
rwb27 Aug 14, 2025
3173598
Add type annotations to client module.
rwb27 Aug 14, 2025
a097235
Improved type annotations for DirectThingClient
rwb27 Aug 14, 2025
73227f7
Add type annotations to Invocation.
rwb27 Aug 14, 2025
4b7891f
Add more type annotations.
rwb27 Aug 14, 2025
bbed648
Type annotations on Action and Endpoint
rwb27 Aug 14, 2025
7e870ac
Add remaining type annotations.
rwb27 Aug 14, 2025
60cc484
Use paths configured in pyproject.toml in CI
rwb27 Aug 14, 2025
d357705
Use `.value` instead of `str` to convert enum to string.
rwb27 Aug 14, 2025
6e8e279
Remove unnecessary type:ignore comments
rwb27 Aug 14, 2025
46ff8e6
Test the stub BaseProperty.__set__
rwb27 Aug 15, 2025
f6c8976
Add a comment explaining ClientBlobOutput in action inputs.
rwb27 Aug 24, 2025
c539720
Remove an assertion
rwb27 Aug 24, 2025
1490501
Fix endpoint decorator docstring.
rwb27 Aug 25, 2025
12b76dd
Improve test coverage of endpoint decorator
rwb27 Aug 25, 2025
198e3be
Increase ruff strictness
rwb27 Aug 25, 2025
3ac1542
Use only Annotated form of FastAPI Depends()
rwb27 Aug 25, 2025
11faed2
Exempt sphinx config from annotations rules.
rwb27 Aug 25, 2025
b1b5644
Fix docstrings in example code
rwb27 Aug 25, 2025
12e7940
Add return annotations for __init__
rwb27 Aug 25, 2025
b6069f4
Eliminate assert statements from base_descriptor
rwb27 Aug 25, 2025
72b3cf6
Replaced assertions in actions/__init__ with exceptions
rwb27 Aug 25, 2025
6cb1e04
Remove a spurious assertion
rwb27 Aug 25, 2025
cf677fa
Change assertion to exception
rwb27 Aug 25, 2025
cfb18a8
Convert an assert to an exception
rwb27 Aug 25, 2025
bf88684
Convert assertions to exceptions.
rwb27 Aug 25, 2025
57e5b40
Ignore error on eval
rwb27 Aug 25, 2025
a2eec2b
Fix whitespace
rwb27 Aug 25, 2025
cfb0261
Swap assertion for exception.
rwb27 Aug 25, 2025
6122d12
Swap assertions for exceptions, and test them.
rwb27 Aug 25, 2025
ada4d49
Convert assertions to errors, and test.
rwb27 Aug 25, 2025
bba8aee
Delete an unnecessary assertion.
rwb27 Aug 25, 2025
909934a
Replace an assertion with an exception, and add a test.
rwb27 Aug 25, 2025
1460aaa
Replace the last couple of assertions with exceptions.
rwb27 Aug 25, 2025
fe4c515
Enforce no assertions, and a few other linter rules.
rwb27 Aug 25, 2025
15904ee
Delete an unused variable.
rwb27 Aug 25, 2025
d5e07ac
Don't catch blind Exceptions
rwb27 Aug 25, 2025
41bb168
Properly mock `Thing.path` when needed.
rwb27 Aug 25, 2025
07c92e2
Fix failing tests of the server
rwb27 Aug 25, 2025
a19f97b
Add newly-raised exceptions to docstrings
rwb27 Aug 25, 2025
a233270
Avoid raising an exception that's immediately handled
rwb27 Aug 25, 2025
30f4493
Enable flake8-comprehension ruleset
rwb27 Aug 25, 2025
903f9e5
Rename TestThing.
rwb27 Aug 25, 2025
cf815f6
Improve comments in tests.
rwb27 Aug 25, 2025
486c66a
Add tests for errors when retrieving action outputs.
rwb27 Aug 25, 2025
8187a92
Test a couple of unchecked code paths in utilities.
rwb27 Aug 25, 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
7 changes: 2 additions & 5 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,7 @@ jobs:
path: ./coverage.lcov

- name: Analyse with MyPy
run: mypy src

- name: Type tests with MyPy
run: mypy --warn-unused-ignores typing_tests
run: mypy

test-with-unpinned-deps:
runs-on: ubuntu-latest
Expand Down Expand Up @@ -105,7 +102,7 @@ jobs:

- name: Analyse with MyPy
if: success() || failure()
run: mypy src
run: mypy

- name: Test with pytest
if: success() || failure()
Expand Down
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
34 changes: 30 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 All @@ -108,7 +129,12 @@ property-decorators = [
]

[tool.mypy]
files = ["src/", "typing_tests/"]
plugins = ["pydantic.mypy"]
disallow_untyped_defs = true
check_untyped_defs = true
disallow_untyped_decorators = true
warn_unused_ignores = true


[tool.flake8]
Expand Down
76 changes: 53 additions & 23 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 @@ -168,17 +168,31 @@
return self._status

@property
def action(self):
"""The `.ActionDescriptor` object running in this thread."""
def action(self) -> ActionDescriptor:
"""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 @@ -210,10 +224,12 @@
LinkElement(rel="self", href=href),
LinkElement(rel="output", href=href + "/output"),
]
return self.action.invocation_model(
# 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]
status=self.status,
id=self.id,
action=self.thing.path + self.action.name,
action=self.thing.path + self.action.name, # type: ignore[call-overload]
href=href,
timeStarted=self._start_time,
timeCompleted=self._end_time,
Expand Down Expand Up @@ -248,38 +264,45 @@
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__.
action: ActionDescriptor = self.action # type: ignore[call-overload]
try:
self.action.emit_changed_event(self.thing, self._status)
action.emit_changed_event(self.thing, self._status.value)

# Capture just this thread's log messages
handler = DequeLogHandler(dest=self._log)
logger = invocation_logger(self.id)
logger.addHandler(handler)

action = self.action
thing = self.thing
kwargs = model_to_dict(self.input)
assert action is not None
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
self._start_time = datetime.datetime.now()
self.action.emit_changed_event(self.thing, self._status)
action.emit_changed_event(self.thing, self._status.value)

# The next line actually runs the action.
ret = action.__get__(thing)(**kwargs, **self.dependencies)

with self._status_lock:
self._return_value = ret
self._status = InvocationStatus.COMPLETED
self.action.emit_changed_event(self.thing, self._status)
action.emit_changed_event(self.thing, self._status.value)
except InvocationCancelledError:
logger.info(f"Invocation {self.id} was cancelled.")
with self._status_lock:
self._status = InvocationStatus.CANCELLED
self.action.emit_changed_event(self.thing, self._status)
action.emit_changed_event(self.thing, self._status.value)
except Exception as e: # skipcq: PYL-W0703
# First log
if isinstance(e, InvocationError):
Expand All @@ -291,7 +314,7 @@
with self._status_lock:
self._status = InvocationStatus.ERROR
self._exception = e
self.action.emit_changed_event(self.thing, self._status)
action.emit_changed_event(self.thing, self._status.value)
finally:
with self._status_lock:
self._end_time = datetime.datetime.now()
Expand All @@ -309,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 @@ -341,9 +364,9 @@
class ActionManager:
"""A class to manage a collection of actions."""

def __init__(self):
def __init__(self) -> None:
"""Set up an `.ActionManager`."""
self._invocations = {}
self._invocations: dict[uuid.UUID, Invocation] = {}
self._invocations_lock = Lock()

@property
Expand Down Expand Up @@ -409,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 @@ -443,10 +466,13 @@
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
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.
]

def expire_invocations(self):
def expire_invocations(self) -> None:
"""Delete invocations that have passed their expiry time."""
to_delete = []
with self._invocations_lock:
Expand All @@ -465,7 +491,9 @@
"""

@app.get(ACTION_INVOCATIONS_PATH, response_model=list[InvocationModel])
def list_all_invocations(request: Request, _blob_manager: BlobIOContextDep):
def list_all_invocations(
request: Request, _blob_manager: BlobIOContextDep
) -> list[InvocationModel]:
return self.list_invocations(request=request)

@app.get(
Expand Down Expand Up @@ -512,7 +540,9 @@
503: {"description": "No result is available for this invocation"},
},
)
def action_invocation_output(id: uuid.UUID, _blob_manager: BlobIOContextDep):
def action_invocation_output(
id: uuid.UUID, _blob_manager: BlobIOContextDep
) -> Any:
"""Get the output of an action invocation.

This returns just the "output" component of the action invocation. If the
Expand All @@ -531,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 @@ -545,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 @@ -570,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
Loading