diff --git a/src/labthings_fastapi/actions.py b/src/labthings_fastapi/actions.py index 5a4b5f2..edce639 100644 --- a/src/labthings_fastapi/actions.py +++ b/src/labthings_fastapi/actions.py @@ -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, @@ -154,7 +154,7 @@ def output(self) -> Any: return self._return_value @property - def log(self) -> list[LogRecordModel]: + def log(self) -> list[logging.LogRecord]: """A list of log items generated by the Action.""" with self._status_lock: return list(self._log) diff --git a/src/labthings_fastapi/thing.py b/src/labthings_fastapi/thing.py index 7698199..e3f2e88 100644 --- a/src/labthings_fastapi/thing.py +++ b/src/labthings_fastapi/thing.py @@ -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 @@ -383,3 +383,26 @@ def observe_action(self, action_name: str, stream: ObjectSendStream) -> None: 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 diff --git a/tests/test_logs.py b/tests/test_logs.py index 0e00f6c..095bfb5 100644 --- a/tests/test_logs.py +++ b/tests/test_logs.py @@ -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, @@ -19,6 +20,8 @@ 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 @@ -26,6 +29,20 @@ 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.""" @@ -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