Skip to content

Commit ea14198

Browse files
committed
✨ Added LoggerConfig
xtl.logging.config:LoggerConfig - New pydantic model for storing logging configuration - The configuration can be directly applied to a logger with the .configure() method - Multiple handlers with independent formatters can be configured
1 parent fa4e43c commit ea14198

File tree

3 files changed

+377
-0
lines changed

3 files changed

+377
-0
lines changed

src/xtl/logging/config.py

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import logging
2+
import sys
3+
from typing import Any
4+
5+
from xtl.common.options import Option, Options
6+
7+
8+
class LoggingFormat(Options):
9+
"""
10+
Configuration for the logging format.
11+
"""
12+
format: str = Option(default='[%(asctime)s] - %(name)s - %(levelname)s - %(message)s',
13+
desc='Format string for log messages')
14+
datefmt: str = Option(default='%H:%M:%S',
15+
desc='Date format string for log messages')
16+
17+
def get_formatter(self) -> logging.Formatter:
18+
"""
19+
Create a logging formatter based on the current configuration.
20+
21+
:return: A logging.Formatter instance configured with the specified format
22+
"""
23+
return logging.Formatter(fmt=self.format, datefmt=self.datefmt)
24+
25+
26+
class HandlerConfig(Options):
27+
"""
28+
Base configuration for a logging handler.
29+
"""
30+
handler: type(logging.Handler) = Option(default=logging.Handler,
31+
desc='Handler class for logging')
32+
format: LoggingFormat = Option(default=LoggingFormat(),
33+
desc='Format configuration for the handler')
34+
options: dict = Option(default=dict(),
35+
desc='Additional keyword arguments to pass to the '
36+
'handler constructor')
37+
38+
def get_handler(self) -> logging.Handler:
39+
"""
40+
Create a logging handler based on the current configuration.
41+
42+
:return: A logging.Handler instance configured with the specified class,
43+
format, and options
44+
"""
45+
handler = getattr(self, 'handler', logging.Handler)(**self.options)
46+
handler.setFormatter(self.format.get_formatter())
47+
return handler
48+
49+
50+
class StreamHandlerConfig(HandlerConfig):
51+
"""
52+
Configuration for a stream handler.
53+
"""
54+
handler: type(logging.Handler) = Option(default=logging.StreamHandler,
55+
desc='Handler class for the stream')
56+
stream: Any = Option(default=sys.stdout,
57+
desc='Stream to which log messages are sent')
58+
59+
def get_handler(self) -> logging.Handler:
60+
"""
61+
Create a stream handler based on the current configuration.
62+
63+
:return: A logging.StreamHandler instance configured with the specified stream,
64+
format, and options
65+
"""
66+
handler = getattr(self, 'handler', logging.StreamHandler)(stream=self.stream)
67+
handler.setFormatter(self.format.get_formatter())
68+
return handler
69+
70+
71+
class FileHandlerConfig(HandlerConfig):
72+
"""
73+
Configuration for a file handler.
74+
"""
75+
handler: type(logging.Handler) = Option(default=logging.FileHandler,
76+
desc='Handler class for the file')
77+
filename: str = Option(default='job.log', desc='File to which log messages are '
78+
'written')
79+
mode: str = Option(default='a', desc='File mode for the log file (e.g., "a" for '
80+
'append)')
81+
encoding: str = Option(default='utf-8', desc='Encoding for the log file')
82+
83+
def get_handler(self) -> logging.Handler:
84+
"""
85+
Create a file handler based on the current configuration.
86+
87+
:return: A logging.FileHandler instance configured with the specified filename,
88+
mode, encoding, format, and options
89+
"""
90+
handler = getattr(self, 'handler', logging.FileHandler)(filename=self.filename,
91+
mode=self.mode,
92+
encoding=self.encoding)
93+
handler.setFormatter(self.format.get_formatter())
94+
return handler
95+
96+
97+
class LoggerConfig(Options):
98+
"""
99+
Configuration for a Job logger.
100+
"""
101+
level: int = Option(default=logging.INFO, desc='Logging level for the job logger',
102+
ge=0, le=50) # logging.UNSET to logging.CRITICAL
103+
propagate: bool = Option(default=False, desc='Whether to propagate log messages to '
104+
'parent loggers')
105+
handlers: list[HandlerConfig] = Option(default_factory=\
106+
lambda: [StreamHandlerConfig()],
107+
desc='List of handlers for the job logger')
108+
109+
def configure(self, logger: logging.Logger) -> None:
110+
"""
111+
Configure the given logger based on this configuration.
112+
113+
:param logger: The logger to configure
114+
"""
115+
logger.setLevel(self.level)
116+
logger.propagate = self.propagate
117+
118+
for handler_config in self.handlers:
119+
handler = handler_config.get_handler()
120+
logger.addHandler(handler)
121+
122+
# Ensure at least a NullHandler is present
123+
if not logger.hasHandlers():
124+
logger.addHandler(logging.NullHandler())

tests/logging/__init__.py

Whitespace-only changes.

tests/logging/test_config.py

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
import io
2+
import logging
3+
import os
4+
import pytest
5+
import sys
6+
import tempfile
7+
from pathlib import Path
8+
9+
from xtl.logging.config import (
10+
LoggingFormat, HandlerConfig, StreamHandlerConfig,
11+
FileHandlerConfig, LoggerConfig
12+
)
13+
14+
15+
class TestLoggingFormat:
16+
"""Tests for the LoggingFormat class."""
17+
18+
def test_get_formatter(self):
19+
"""Test that a formatter is created with the correct format."""
20+
fmt = LoggingFormat(format='%(levelname)s: %(message)s', datefmt='%H:%M')
21+
formatter = fmt.get_formatter()
22+
assert isinstance(formatter, logging.Formatter)
23+
assert formatter._fmt == '%(levelname)s: %(message)s'
24+
assert formatter.datefmt == '%H:%M'
25+
26+
27+
class TestHandlerConfig:
28+
"""Tests for the HandlerConfig class."""
29+
30+
def test_get_handler(self):
31+
"""Test that a handler is created with the correct configuration."""
32+
config = HandlerConfig(handler=logging.NullHandler)
33+
handler = config.get_handler()
34+
assert isinstance(handler, logging.NullHandler)
35+
assert isinstance(handler.formatter, logging.Formatter)
36+
37+
38+
class TestStreamHandlerConfig:
39+
"""Tests for the StreamHandlerConfig class."""
40+
41+
def test_get_handler(self):
42+
"""Test that a stream handler is created with the correct configuration."""
43+
stream = io.StringIO()
44+
config = StreamHandlerConfig(stream=stream)
45+
handler = config.get_handler()
46+
assert isinstance(handler, logging.StreamHandler)
47+
assert handler.stream is stream
48+
49+
def test_logging_to_stream(self):
50+
"""Test that logging works correctly with a stream handler."""
51+
stream = io.StringIO()
52+
config = StreamHandlerConfig(
53+
stream=stream,
54+
format=LoggingFormat(format='%(message)s')
55+
)
56+
handler = config.get_handler()
57+
58+
logger = logging.getLogger('test_stream')
59+
logger.setLevel(logging.INFO)
60+
logger.addHandler(handler)
61+
logger.propagate = False
62+
63+
# Clear existing handlers
64+
for h in logger.handlers[:]:
65+
if h is not handler:
66+
logger.removeHandler(h)
67+
68+
logger.info('Test message')
69+
assert stream.getvalue() == 'Test message\n'
70+
71+
72+
class TestFileHandlerConfig:
73+
"""Tests for the FileHandlerConfig class."""
74+
75+
def test_get_handler(self):
76+
"""Test that a file handler is created with the correct configuration."""
77+
with tempfile.TemporaryDirectory() as tmpdir:
78+
log_path = Path(tmpdir) / 'test.log'
79+
config = FileHandlerConfig(filename=str(log_path))
80+
handler = config.get_handler()
81+
assert isinstance(handler, logging.FileHandler)
82+
assert handler.baseFilename == str(log_path)
83+
handler.close() # release the file handler so that tempfile can be deleted
84+
85+
def test_logging_to_file(self):
86+
"""Test that logging works correctly with a file handler."""
87+
with tempfile.TemporaryDirectory() as tmpdir:
88+
log_path = Path(tmpdir) / 'test.log'
89+
config = FileHandlerConfig(
90+
filename=str(log_path),
91+
format=LoggingFormat(format='%(message)s')
92+
)
93+
handler = config.get_handler()
94+
95+
logger = logging.getLogger('test_file')
96+
logger.setLevel(logging.INFO)
97+
logger.addHandler(handler)
98+
logger.propagate = False
99+
100+
# Clear existing handlers
101+
for h in logger.handlers[:]:
102+
if h is not handler:
103+
logger.removeHandler(h)
104+
105+
logger.info('Test message')
106+
handler.close() # Ensure file is written
107+
108+
with open(log_path, 'r') as f:
109+
content = f.read()
110+
assert content == 'Test message\n'
111+
112+
113+
class TestLoggerConfig:
114+
"""Tests for the LoggerConfig class."""
115+
116+
def test_default_logger_config(self):
117+
"""Test that the default logger config is created correctly."""
118+
config = LoggerConfig()
119+
assert config.level == logging.INFO
120+
assert config.propagate is False
121+
assert len(config.handlers) == 1
122+
assert isinstance(config.handlers[0], StreamHandlerConfig)
123+
124+
def test_custom_logger_config(self):
125+
"""Test that a custom logger config can be created."""
126+
stream_handler = StreamHandlerConfig(stream=io.StringIO())
127+
file_handler = FileHandlerConfig(filename='custom.log')
128+
config = LoggerConfig(
129+
level=logging.DEBUG,
130+
propagate=True,
131+
handlers=[stream_handler, file_handler]
132+
)
133+
assert config.level == logging.DEBUG
134+
assert config.propagate is True
135+
assert len(config.handlers) == 2
136+
assert config.handlers[0] is stream_handler
137+
assert config.handlers[1] is file_handler
138+
139+
def test_configure_logger(self):
140+
"""Test that a logger is configured correctly."""
141+
stream = io.StringIO()
142+
config = LoggerConfig(
143+
level=logging.DEBUG,
144+
handlers=[
145+
StreamHandlerConfig(
146+
stream=stream,
147+
format=LoggingFormat(format='%(levelname)s: %(message)s')
148+
)
149+
]
150+
)
151+
152+
logger = logging.getLogger('test_configure')
153+
154+
# Clear existing handlers
155+
for h in logger.handlers[:]:
156+
logger.removeHandler(h)
157+
158+
config.configure(logger)
159+
160+
assert logger.level == logging.DEBUG
161+
assert logger.propagate is False
162+
assert len(logger.handlers) == 1
163+
164+
logger.debug('Debug message')
165+
assert stream.getvalue() == 'DEBUG: Debug message\n'
166+
167+
def test_configure_with_no_handlers(self):
168+
"""Test that a NullHandler is added if no handlers are present."""
169+
config = LoggerConfig(handlers=[])
170+
171+
logger = logging.getLogger('test_null_handler')
172+
173+
# Clear existing handlers
174+
for h in logger.handlers[:]:
175+
logger.removeHandler(h)
176+
177+
config.configure(logger)
178+
179+
assert len(logger.handlers) == 1
180+
assert isinstance(logger.handlers[0], logging.NullHandler)
181+
182+
183+
@pytest.fixture
184+
def cleanup_logging():
185+
"""Fixture to clean up logging configuration after tests."""
186+
yield
187+
# Reset the root logger
188+
root = logging.getLogger()
189+
for handler in root.handlers[:]:
190+
root.removeHandler(handler)
191+
root.setLevel(logging.WARNING)
192+
193+
194+
@pytest.mark.usefixtures('cleanup_logging')
195+
class TestIntegration:
196+
"""Integration tests for the logging configuration."""
197+
198+
def test_full_logging_setup(self):
199+
"""Test a complete logging setup with multiple handlers."""
200+
with tempfile.TemporaryDirectory() as tmpdir:
201+
log_path = Path(tmpdir) / 'integration.log'
202+
stream = io.StringIO()
203+
204+
# Create a complex logger configuration
205+
config = LoggerConfig(
206+
level=logging.DEBUG,
207+
propagate=False,
208+
handlers=[
209+
StreamHandlerConfig(
210+
stream=stream,
211+
format=LoggingFormat(
212+
format='STREAM: %(levelname)s - %(message)s'
213+
)
214+
),
215+
FileHandlerConfig(
216+
filename=str(log_path),
217+
format=LoggingFormat(
218+
format='FILE: %(levelname)s - %(message)s'
219+
)
220+
)
221+
]
222+
)
223+
224+
# Configure a logger with this setup
225+
logger = logging.getLogger('integration_test')
226+
227+
# Clear existing handlers
228+
for h in logger.handlers[:]:
229+
logger.removeHandler(h)
230+
231+
config.configure(logger)
232+
233+
# Log messages at different levels
234+
logger.debug('Debug message')
235+
logger.info('Info message')
236+
logger.warning('Warning message')
237+
238+
# Close handlers to ensure file is written
239+
for handler in logger.handlers:
240+
handler.close()
241+
242+
# Check stream output
243+
stream_output = stream.getvalue()
244+
assert 'STREAM: DEBUG - Debug message' in stream_output
245+
assert 'STREAM: INFO - Info message' in stream_output
246+
assert 'STREAM: WARNING - Warning message' in stream_output
247+
248+
# Check file output
249+
with open(log_path, 'r') as f:
250+
file_output = f.read()
251+
assert 'FILE: DEBUG - Debug message' in file_output
252+
assert 'FILE: INFO - Info message' in file_output
253+
assert 'FILE: WARNING - Warning message' in file_output

0 commit comments

Comments
 (0)