Skip to content

Commit 48c48b2

Browse files
Merge pull request #282 from labthings/281-configurable-log-levels
Log Level Configuration
2 parents 115cbc9 + 049b6c2 commit 48c48b2

File tree

4 files changed

+95
-8
lines changed

4 files changed

+95
-8
lines changed

src/labthings_fastapi/logs.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -79,11 +79,11 @@ def emit(self, record: logging.LogRecord) -> None:
7979
pass # If there's no destination for a particular log, ignore it.
8080

8181

82-
def configure_thing_logger() -> None:
82+
def configure_thing_logger(level: int | None = None) -> None:
8383
"""Set up the logger for thing instances.
8484
85-
We always set the logger for thing instances to level INFO, as this
86-
is currently used to relay progress to the client.
85+
We always set the logger for thing instances to level INFO by default,
86+
as this is currently used to relay progress to the client.
8787
8888
This function will collect logs on a per-invocation
8989
basis by adding a `.DequeByInvocationIDHandler` to the log. Only one
@@ -93,8 +93,14 @@ def configure_thing_logger() -> None:
9393
a filter to add invocation ID is not possible. Instead, we attach a filter to
9494
the handler, which filters all the records that propagate to it (i.e. anything
9595
that starts with ``labthings_fastapi.things``).
96+
97+
:param level: the logging level to use. If not specified, we use INFO.
9698
"""
97-
THING_LOGGER.setLevel(logging.INFO)
99+
if level is not None:
100+
THING_LOGGER.setLevel(level)
101+
else:
102+
THING_LOGGER.setLevel(logging.INFO)
103+
98104
if not any(
99105
isinstance(h, DequeByInvocationIDHandler) for h in THING_LOGGER.handlers
100106
):

src/labthings_fastapi/server/__init__.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from typing import Any, AsyncGenerator, Optional, TypeVar
1111
from typing_extensions import Self
1212
import os
13+
import logging
1314

1415
from fastapi import FastAPI, Request
1516
from fastapi.middleware.cors import CORSMiddleware
@@ -65,6 +66,7 @@ def __init__(
6566
things: ThingsConfig,
6667
settings_folder: Optional[str] = None,
6768
application_config: Optional[Mapping[str, Any]] = None,
69+
debug: bool = False,
6870
) -> None:
6971
r"""Initialise a LabThings server.
7072
@@ -85,9 +87,12 @@ def __init__(
8587
application. This is not processed by LabThings. Each `.Thing` can access
8688
application. This is not processed by LabThings. Each `.Thing` can access
8789
this via the Thing-Server interface.
90+
:param debug: If ``True``, set the log level for `.Thing` instances to
91+
DEBUG.
8892
"""
8993
self.startup_failure: dict | None = None
90-
configure_thing_logger() # Note: this is safe to call multiple times.
94+
# Note: this is safe to call multiple times.
95+
configure_thing_logger(logging.DEBUG if debug else None)
9196
self._config = ThingServerConfig(
9297
things=things,
9398
settings_folder=settings_folder,
@@ -110,15 +115,17 @@ def __init__(
110115
self._attach_things_to_server()
111116

112117
@classmethod
113-
def from_config(cls, config: ThingServerConfig) -> Self:
118+
def from_config(cls, config: ThingServerConfig, debug: bool = False) -> Self:
114119
r"""Create a ThingServer from a configuration model.
115120
116121
This is equivalent to ``ThingServer(**dict(config))``\ .
117122
118123
:param config: The configuration parameters for the server.
124+
:param debug: If ``True``, set the log level for `.Thing` instances to
125+
DEBUG.
119126
:return: A `.ThingServer` configured as per the model.
120127
"""
121-
return cls(**dict(config))
128+
return cls(**dict(config), debug=debug)
122129

123130
def _set_cors_middleware(self) -> None:
124131
"""Configure the server to allow requests from other origins.

src/labthings_fastapi/server/cli.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,11 @@ def get_default_parser() -> ArgumentParser:
5656
default=5000,
5757
help="Bind socket to this port. If 0, an available port will be picked.",
5858
)
59+
parser.add_argument(
60+
"--debug",
61+
action="store_true",
62+
help="Enable debug logging.",
63+
)
5964
return parser
6065

6166

@@ -149,10 +154,11 @@ def serve_from_cli(
149154
option is not specified.
150155
"""
151156
args = parse_args(argv)
157+
152158
try:
153159
config, server = None, None
154160
config = config_from_args(args)
155-
server = ThingServer.from_config(config)
161+
server = ThingServer.from_config(config, True if args.debug else False)
156162
if dry_run:
157163
return server
158164
uvicorn.run(server.app, host=args.host, port=args.port)

tests/test_logs.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"""
77

88
from collections import deque
9+
import json
910
import logging
1011
from types import EllipsisType
1112
import pytest
@@ -20,6 +21,7 @@
2021
import labthings_fastapi as lt
2122
from labthings_fastapi.exceptions import LogConfigurationError
2223
from labthings_fastapi.testing import create_thing_without_server
24+
from labthings_fastapi.server.cli import serve_from_cli
2325

2426
from .temp_client import poll_task
2527

@@ -45,6 +47,12 @@ def log_and_capture(self, msg: str) -> str:
4547
return logging_str
4648

4749

50+
class ThingWithDebugInit(lt.Thing):
51+
def __init__(self, *args, **kwargs):
52+
super().__init__(*args, **kwargs)
53+
self.logger.debug("Debug message during __init__")
54+
55+
4856
def reset_thing_logger():
4957
"""Remove all handlers from the THING_LOGGER to reset it."""
5058
logger = logs.THING_LOGGER
@@ -169,6 +177,66 @@ def test_configure_thing_logger():
169177
assert dest[0].msg == "Test"
170178

171179

180+
def test_cli_debug_flag():
181+
"""
182+
Test that using the --debug flag sets the logger level to DEBUG,
183+
and that not using it leaves the logger level at INFO.
184+
"""
185+
# Reset logger level to INFO
186+
reset_thing_logger()
187+
188+
# Then configure it
189+
logs.configure_thing_logger()
190+
191+
# Run without --debug
192+
# We use dry_run=True to avoid starting uvicorn
193+
# We need a dummy config
194+
dummy_json = '{"things": {}}'
195+
serve_from_cli(["--json", dummy_json], dry_run=True)
196+
197+
assert logs.THING_LOGGER.level == logging.INFO
198+
199+
# Run with --debug
200+
serve_from_cli(["--json", dummy_json, "--debug"], dry_run=True)
201+
202+
assert logs.THING_LOGGER.level == logging.DEBUG
203+
204+
# Reset back to INFO
205+
reset_thing_logger()
206+
207+
208+
def test_cli_debug_flag_with_thing(caplog):
209+
"""
210+
Test that using the --debug flag allows capturing debug logs during __init__.
211+
"""
212+
213+
# Reset logger level to INFO
214+
reset_thing_logger()
215+
216+
# Define a config that uses ThingWithDebugInit
217+
# We use the full path to the class so it can be imported by LabThings
218+
config_json = json.dumps(
219+
{"things": {"my_thing": {"cls": "tests.test_logs.ThingWithDebugInit"}}}
220+
)
221+
222+
# Run without --debug and capture logs
223+
with caplog.at_level(logging.DEBUG, logger="labthings_fastapi.things"):
224+
serve_from_cli(["--json", config_json], dry_run=True)
225+
226+
# There are no logs
227+
assert len(caplog.messages) == 0
228+
229+
# Run with --debug and capture logs
230+
with caplog.at_level(logging.DEBUG, logger="labthings_fastapi.things"):
231+
serve_from_cli(["--json", config_json, "--debug"], dry_run=True)
232+
233+
# Check that the debug message was captured
234+
assert "Debug message during __init__" in caplog.text
235+
236+
# Reset back to INFO
237+
reset_thing_logger()
238+
239+
172240
def test_add_thing_log_destination():
173241
"""Check the module-level function to add an invocation log destination."""
174242
reset_thing_logger()

0 commit comments

Comments
 (0)