Skip to content
Merged
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
3 changes: 3 additions & 0 deletions docs/source/public_api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,9 @@ This page summarises the parts of the LabThings API that should be most frequent
.. autoattribute:: labthings_fastapi.server.config_model.ThingServerConfig.enable_global_lock
:no-index:

.. autoattribute:: labthings_fastapi.server.config_model.ThingServerConfig.global_lock_log_level
:no-index:

.. autoattribute:: labthings_fastapi.server.config_model.ThingServerConfig.application_config
:no-index:

Expand Down
9 changes: 7 additions & 2 deletions src/labthings_fastapi/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -389,8 +389,13 @@
and self._status == InvocationStatus.PENDING
):
# The global lock timed out before the function started.
# In this case, don't print a traceback.
logger.warning(f"Global lock was busy: didn't run {action.name}.")
# In this case, don't print a traceback, and log at the level.
# specified by the server.
server = thing._thing_server_interface._get_server()
logger.log(
server.global_lock_log_level,
f"Global lock was busy: didn't run {action.name}.",
)
else:
# Other exceptions show up in the log with a traceback
logger.exception(e)
Expand Down Expand Up @@ -590,8 +595,8 @@
with self._invocations_lock:
try:
invocation: Any = self._invocations[id]
except KeyError as e:
raise HTTPException(

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

View workflow job for this annotation

GitHub Actions / coverage

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

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

View workflow job for this annotation

GitHub Actions / coverage

612 line is not covered with tests
try:
return serialise_from_user_code(
model_instance=invocation.output_model_instance,
Expand Down Expand Up @@ -640,8 +645,8 @@
with self._invocations_lock:
try:
invocation: Any = self._invocations[id]
except KeyError as e:
raise HTTPException(

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

View workflow job for this annotation

GitHub Actions / coverage

648-649 lines are not covered with tests
status_code=404,
detail="No action invocation found with ID {id}",
) from e
Expand Down Expand Up @@ -836,7 +841,7 @@
"""
super().__set_name__(owner, name)
if self.name != self.func.__name__:
raise ValueError(

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

View workflow job for this annotation

GitHub Actions / coverage

844 line is not covered with tests
f"Action name '{self.name}' does not match function name "
f"'{self.func.__name__}'",
)
Expand Down Expand Up @@ -947,9 +952,9 @@
status_code=201,
code=self.func,
)
except InvalidReturnValueError as e:
thing.logger.error(e)
raise HTTPException(status_code=500, detail=str(e)) from e

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

View workflow job for this annotation

GitHub Actions / coverage

955-957 lines are not covered with tests

if issubclass(self.input_model, EmptyInput):
annotation = Body(default_factory=StrictEmptyInput)
Expand Down Expand Up @@ -980,14 +985,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 989 in src/labthings_fastapi/actions.py

View workflow job for this annotation

GitHub Actions / coverage

988-989 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 992 in src/labthings_fastapi/actions.py

View workflow job for this annotation

GitHub Actions / coverage

992 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 995 in src/labthings_fastapi/actions.py

View workflow job for this annotation

GitHub Actions / coverage

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

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

View workflow job for this annotation

GitHub Actions / coverage

1043 line is not covered with tests
forms = [
Form[ActionOp](href=path + self.name, op=[ActionOp.invokeaction]),
]
Expand Down
8 changes: 5 additions & 3 deletions src/labthings_fastapi/logs.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ class DequeByInvocationIDHandler(logging.Handler):

def __init__(
self,
level: int = logging.INFO,
level: int = logging.NOTSET,
) -> None:
"""Set up a log handler that appends messages to a deque.

Expand All @@ -48,8 +48,10 @@ def __init__(
the list. It's best to use a `deque` with a finite capacity
to avoid memory leaks.

:param level: sets the level of the logger. For most invocations,
a log level of `logging.INFO` is appropriate.
:param level: sets the level of the handler. Usually
a log level of `logging.NOTSET` is appropriate. This does not
do any extra filtering, and so will use the log level of the
logger to which it is attached.
"""
super().__init__()
self.setLevel(level)
Expand Down
8 changes: 8 additions & 0 deletions src/labthings_fastapi/server/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,7 @@
async def serialisation_error_handler(
request: Request, exc: PydanticSerializationError
) -> JSONResponse:
LOGGER.error(

Check warning on line 261 in src/labthings_fastapi/server/__init__.py

View workflow job for this annotation

GitHub Actions / coverage

261 line is not covered with tests
f"Couldn't serialise response to {request.url} because of error: \n"
f"{exc}"
)
Expand Down Expand Up @@ -307,6 +307,14 @@
"""
return self._config.api_prefix

@property
def global_lock_log_level(self) -> int:
"""The level at which to log when the global lock is busy."""
# Note that the config entry below is validated by the config
# model, to be one of DEBUG, INFO, WARNING or ERROR.
levelname = self._config.global_lock_log_level
return getattr(logging, levelname)

ThingInstance = TypeVar("ThingInstance", bound=Thing)

def things_by_class(self, cls: type[ThingInstance]) -> Sequence[ThingInstance]:
Expand Down
10 changes: 9 additions & 1 deletion src/labthings_fastapi/server/config_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"""

from collections.abc import Iterable, Mapping, Sequence
from typing import Annotated, Any, TypeAlias
from typing import Annotated, Any, Literal, TypeAlias

from pydantic import (
AfterValidator,
Expand Down Expand Up @@ -236,6 +236,14 @@ def thing_configs(self) -> Mapping[ThingName, ThingConfig]:
),
)

global_lock_log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = Field(
default="INFO",
description=(
"The log level to use when an action can't start due to the global lock "
"being busy."
),
)

application_config: dict[str, Any] | None = Field(
default=None,
description=(
Expand Down
41 changes: 32 additions & 9 deletions tests/test_global_lock.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Test code for the global lock."""

import logging
from collections.abc import Iterator
from collections.abc import Generator
from contextlib import contextmanager
from threading import Event, Thread

Expand Down Expand Up @@ -183,7 +183,7 @@ def assert_changes(thing: ConcurrencyChecker):


@contextmanager
def assert_fails(thing: ConcurrencyChecker) -> Iterator[None]:
def assert_fails(thing: ConcurrencyChecker) -> Generator[None]:
"""Assert that the code in a with block fails with an error.

Currently, this will look for several exceptions, so that it works on both client
Expand All @@ -199,7 +199,7 @@ def assert_fails(thing: ConcurrencyChecker) -> Iterator[None]:


@contextmanager
def monitor_for_changes(thing: ConcurrencyChecker, hold_lock: bool) -> Iterator[None]:
def monitor_for_changes(thing: ConcurrencyChecker, hold_lock: bool) -> Generator[None]:
"""Monitor for changes in a background thread"""
# Start the background action that checks for changes.
monitor_thread = Thread(
Expand Down Expand Up @@ -481,10 +481,30 @@ def test_reuse_of_action_callables():
func()


def test_global_lock_log(caplog):
"""Test that we get sensible errors when the lock is busy."""
@pytest.mark.parametrize("loglevel", ["DEBUG", "INFO", "WARNING", "ERROR"])
@pytest.mark.parametrize("debug", [True, False])
def test_global_lock_log(caplog, debug, loglevel):
"""Test that we get sensible errors when the lock is busy.

This performs tests with and without DEBUG mode - if the lock is set to
log at DEBUG level and the server isn't configured to propagate DEBUG
logs, we don't expect anything to show up.

In that case, we do still want to check that the client raises the right
error: the lock error should be reported to the client even if it's not
logged.

We check that:
1. The client is informed that the global lock was busy (and thus it
raises a `GlobalLockBusyError`).
2. The lock error is recorded in the log, at the specified level, if
appropriate (i.e. DEBUG logs shouldn't show up if debug is False).
"""
server = lt.ThingServer.from_things(
{"checker": ConcurrencyChecker}, enable_global_lock=True
{"checker": ConcurrencyChecker},
enable_global_lock=True,
global_lock_log_level=loglevel,
debug=debug,
)
with server.test_client() as client:
checker = lt.ThingClient.from_url("/checker/", client=client)
Expand All @@ -500,9 +520,12 @@ def test_global_lock_log(caplog):
):
checker.increment_fprop2()
matches = [r for r in caplog.records if "Global lock was busy" in r.message]
assert len(matches) == 1
assert matches[0].levelno == logging.WARNING
assert "Traceback" not in caplog.text
if loglevel == "DEBUG" and debug is False:
assert len(matches) == 0
else:
assert len(matches) == 1
assert matches[0].levelno == getattr(logging, loglevel)
assert "Traceback" not in caplog.text
Comment thread
rwb27 marked this conversation as resolved.

# Next, try the same thing with an action that does
# not hold the global lock, but calls a property that
Expand Down
2 changes: 1 addition & 1 deletion tests/test_logs.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ def test_inject_invocation_id_withcontext():
def test_dequebyinvocationidhandler():
"""Check the custom log handler works as expected."""
handler = logs.DequeByInvocationIDHandler()
assert handler.level == logging.INFO
assert handler.level == logging.NOTSET

destinations = {
uuid4(): deque(),
Expand Down
Loading