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
4 changes: 2 additions & 2 deletions src/labthings_fastapi/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
from .base_descriptor import BaseDescriptor
from .logs import add_thing_log_destination
from .utilities import model_to_dict, wrap_plain_types_in_rootmodel
from .invocations import InvocationModel, InvocationStatus, LogRecordModel
from .invocations import InvocationModel, InvocationStatus
from .dependencies.invocation import NonWarningInvocationID
from .exceptions import (
InvocationCancelledError,
Expand Down Expand Up @@ -154,7 +154,7 @@
return self._return_value

@property
def log(self) -> list[LogRecordModel]:
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Note: The type hint here was not correct, behaviour has not been changed

def log(self) -> list[logging.LogRecord]:
"""A list of log items generated by the Action."""
with self._status_lock:
return list(self._log)
Expand Down Expand Up @@ -505,8 +505,8 @@
with self._invocations_lock:
try:
invocation: Any = self._invocations[id]
except KeyError as e:
raise HTTPException(

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

View workflow job for this annotation

GitHub Actions / coverage

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

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

View workflow job for this annotation

GitHub Actions / coverage

522 line is not covered with tests
return invocation.output

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

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

View workflow job for this annotation

GitHub Actions / coverage

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

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

View workflow job for this annotation

GitHub Actions / coverage

673 line is not covered with tests
f"Action name '{self.name}' does not match function name "
f"'{self.func.__name__}'",
)
Expand Down Expand Up @@ -814,14 +814,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 818 in src/labthings_fastapi/actions.py

View workflow job for this annotation

GitHub Actions / coverage

817-818 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 821 in src/labthings_fastapi/actions.py

View workflow job for this annotation

GitHub Actions / coverage

821 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 824 in src/labthings_fastapi/actions.py

View workflow job for this annotation

GitHub Actions / coverage

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

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

View workflow job for this annotation

GitHub Actions / coverage

872 line is not covered with tests
forms = [
Form[ActionOp](href=path + self.name, op=[ActionOp.invokeaction]),
]
Expand Down
25 changes: 24 additions & 1 deletion src/labthings_fastapi/thing.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
from .websockets import websocket_endpoint
from .exceptions import PropertyNotObservableError
from .thing_server_interface import ThingServerInterface

from .invocation_contexts import get_invocation_id

if TYPE_CHECKING:
from .server import ThingServer
Expand Down Expand Up @@ -249,13 +249,13 @@
the settings file every time.
"""
if self._disable_saving_settings:
return

Check warning on line 252 in src/labthings_fastapi/thing.py

View workflow job for this annotation

GitHub Actions / coverage

252 line is not covered with tests
if self._settings is not None:
setting_dict = {}
for name in self._settings.keys():
value = getattr(self, name)
if isinstance(value, BaseModel):
value = value.model_dump()

Check warning on line 258 in src/labthings_fastapi/thing.py

View workflow job for this annotation

GitHub Actions / coverage

258 line is not covered with tests
setting_dict[name] = value
# Dumpy to string before writing so if this fails the file isn't overwritten
setting_json = json.dumps(setting_dict, indent=4)
Expand Down Expand Up @@ -383,3 +383,26 @@
raise KeyError(f"{action_name} is not an LabThings Action")
observers = action._observers_set(self)
observers.add(stream)

def get_logs(self) -> list[logging.LogRecord]:
"""Get the log records for an on going action.

This is useful if an action wishes to save its logs alongside any data.

Note that only the last 1000 logs are returned so for long running tasks that
log frequently this may want to be read periodically.

This will error if it is called outside an action invocation.

:return: a list of all logs from this action.

:raises RuntimeError: If the server cannot be retrieved. This should never
happen.
"""
inv_id = get_invocation_id()
server = self._thing_server_interface._server()
if server is None:
raise RuntimeError("Could not get server from thing_server_interface")
action_manager = server.action_manager
this_invocation = action_manager.get_invocation(inv_id)
return this_invocation.log
29 changes: 29 additions & 0 deletions tests/test_logs.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from types import EllipsisType
import pytest
from uuid import UUID, uuid4
from fastapi.testclient import TestClient
from labthings_fastapi import logs
from labthings_fastapi.invocation_contexts import (
fake_invocation_context,
Expand All @@ -19,13 +20,29 @@
from labthings_fastapi.exceptions import LogConfigurationError
from labthings_fastapi.testing import create_thing_without_server

from .temp_client import poll_task


class ThingThatLogs(lt.Thing):
@lt.action
def log_a_message(self, msg: str):
"""Log a message to the thing's logger."""
self.logger.info(msg)

@lt.action
def log_and_capture(self, msg: str) -> str:
"""Log a message to the thing's logger."""
self.logger.info(msg)
self.logger.warning(msg)
self.logger.error(msg)
logs = self.get_logs()
logging_str = ""
for record in logs:
level = record.levelname
msg = record.getMessage()
logging_str += f"[{level}] {msg}\n"
return logging_str


def reset_thing_logger():
"""Remove all handlers from the THING_LOGGER to reset it."""
Expand Down Expand Up @@ -176,3 +193,15 @@ def test_add_thing_log_destination():
thing.log_a_message("Test Message.")
assert len(dest) == 1
assert dest[0].getMessage() == "Test Message."


def test_action_can_get_logs():
"""Check that an action can get a copy of its own logs."""
server = lt.ThingServer({"logging_thing": ThingThatLogs})
with TestClient(server.app) as client:
response = client.post("/logging_thing/log_and_capture", json={"msg": "foobar"})
response.raise_for_status()
invocation = poll_task(client, response.json())
assert invocation["status"] == "completed"
expected_message = "[INFO] foobar\n[WARNING] foobar\n[ERROR] foobar\n"
assert invocation["output"] == expected_message