Skip to content

Commit a2f26c5

Browse files
committed
refactor(LOGGING): extract base class
1 parent eebbd7e commit a2f26c5

20 files changed

+315
-153
lines changed
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
11
"""Configuration Modules for the pi_portal cli."""
22

33
from . import state, user_config
4-
from .logger import LoggingConfiguration

pi_portal/modules/configuration/logger.py

-60
This file was deleted.

pi_portal/modules/configuration/logging/__init__.py

Whitespace-only changes.

pi_portal/modules/configuration/logging/bases/__init__.py

Whitespace-only changes.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
"""LoggerConfigurationBase class."""
2+
3+
import abc
4+
import logging
5+
from typing import Optional
6+
7+
from pi_portal.modules.configuration import state
8+
9+
10+
class LoggerConfigurationBase(abc.ABC):
11+
"""Pi Portal base logging configuration."""
12+
13+
format_str: str
14+
formatter: logging.Formatter
15+
running_state: state.State
16+
17+
def __init__(self) -> None:
18+
self.running_state = state.State()
19+
self.level = self.running_state.log_level
20+
21+
def configure(
22+
self,
23+
log: logging.Logger,
24+
file_name: Optional[str] = None,
25+
) -> None:
26+
"""Configure application logging.
27+
28+
:param log: The logger instance to configure.
29+
:param file_name: The path to write logs to, none for stdout.
30+
"""
31+
32+
log.setLevel(self.level)
33+
self.configure_formatter()
34+
self.configure_handler(log, file_name)
35+
36+
def configure_handler(
37+
self, log: logging.Logger, file_name: Optional[str]
38+
) -> None:
39+
"""Configure the logger's handler.
40+
41+
:param log: The logger instance to configure.
42+
:param file_name: The path to write logs to, none for stdout.
43+
"""
44+
45+
log.handlers = []
46+
47+
if file_name is None:
48+
handler = logging.StreamHandler()
49+
else:
50+
handler = logging.FileHandler(file_name, delay=True)
51+
52+
handler.setFormatter(self.formatter)
53+
log.addHandler(handler)
54+
55+
@abc.abstractmethod
56+
def configure_formatter(self) -> None:
57+
"""Configure the logger's formatter."""
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
"""Shared fixtures for the logging modules tests."""
2+
# pylint: disable=redefined-outer-name
3+
4+
from io import StringIO
5+
6+
import pytest
7+
8+
9+
@pytest.fixture
10+
def mocked_logger_name() -> str:
11+
"""Return a mock logger name."""
12+
return "mockLogger"
13+
14+
15+
@pytest.fixture
16+
def mocked_stream() -> StringIO:
17+
"""Return a mock logging stream."""
18+
return StringIO()

pi_portal/modules/configuration/logging/formatters/__init__.py

Whitespace-only changes.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
"""JsonFormatter class."""
2+
3+
import logging
4+
from typing import Any, Dict, Optional
5+
6+
from pythonjsonlogger import jsonlogger
7+
8+
9+
class JsonFormatter(jsonlogger.JsonFormatter):
10+
"""JSON log formatter.
11+
12+
:param trace_id: The unique uuid for this process instance.
13+
"""
14+
15+
def __init__(self, trace_id: str, *args: Any, **kwargs: Any) -> None:
16+
super().__init__(*args, **kwargs)
17+
self.trace_id = trace_id
18+
19+
def add_fields(
20+
self,
21+
log_record: Dict[str, Any],
22+
record: logging.LogRecord,
23+
message_dict: Dict[str, Optional[str]],
24+
) -> None:
25+
"""Add custom fields to the base JsonFormatter.
26+
27+
:param log_record: The Python object that will be converted to JSON.
28+
:param record: The Python LogRecord object generated.
29+
:param message_dict: The existing message fields configuration.
30+
"""
31+
32+
super().add_fields(log_record, record, message_dict)
33+
log_record['trace_id'] = self.trace_id

pi_portal/modules/configuration/logging/formatters/tests/__init__.py

Whitespace-only changes.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
"""Shared fixtures for the formatters modules."""
2+
# pylint: disable=redefined-outer-name
3+
4+
import logging
5+
from io import StringIO
6+
7+
import pytest
8+
from ..json import JsonFormatter
9+
10+
11+
@pytest.fixture
12+
def mocked_trace_id() -> str:
13+
return "mockTraceId"
14+
15+
16+
@pytest.fixture
17+
def json_formatted_logger_instance(
18+
mocked_logger_name: str,
19+
mocked_stream: StringIO,
20+
mocked_trace_id: str,
21+
) -> logging.Logger:
22+
log = logging.getLogger(mocked_logger_name)
23+
handler = logging.StreamHandler(stream=mocked_stream)
24+
handler.setFormatter(
25+
JsonFormatter(mocked_trace_id, '%(message)%(levelname)%(name)'),
26+
)
27+
log.addHandler(handler)
28+
return log
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
"""Test the JsonFormatter class."""
2+
3+
import json
4+
import logging
5+
from io import StringIO
6+
7+
8+
class TestJsonFormatter:
9+
"""Test the JsonFormatter class."""
10+
11+
def test__no_fields__error_message(
12+
self,
13+
json_formatted_logger_instance: logging.Logger,
14+
mocked_logger_name: str,
15+
mocked_trace_id: str,
16+
mocked_stream: StringIO,
17+
) -> None:
18+
test_message = "test logging message"
19+
20+
json_formatted_logger_instance.error(test_message)
21+
22+
assert json.loads(mocked_stream.getvalue()) == {
23+
"message": test_message,
24+
"levelname": "ERROR",
25+
"name": mocked_logger_name,
26+
"trace_id": mocked_trace_id,
27+
}
28+
29+
def test__extra_field__error_message(
30+
self,
31+
json_formatted_logger_instance: logging.Logger,
32+
mocked_logger_name: str,
33+
mocked_trace_id: str,
34+
mocked_stream: StringIO,
35+
) -> None:
36+
test_message = "test logging message"
37+
38+
json_formatted_logger_instance.error(test_message, extra={"foo": "bar"})
39+
40+
assert json.loads(mocked_stream.getvalue()) == {
41+
"message": test_message,
42+
"foo": "bar",
43+
"levelname": "ERROR",
44+
"name": mocked_logger_name,
45+
"trace_id": mocked_trace_id,
46+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
"""JsonLoggerConfiguration class."""
2+
3+
from .bases.base_logger import LoggerConfigurationBase
4+
from .formatters.json import JsonFormatter
5+
6+
7+
class JsonLoggerConfiguration(LoggerConfigurationBase):
8+
"""JSON logging configuration."""
9+
10+
format_str = '%(message)%(levelname)%(name)%(asctime)'
11+
12+
def configure_formatter(self) -> None:
13+
"""Configure the logger's formatter."""
14+
15+
self.formatter = JsonFormatter(self.running_state.log_uuid, self.format_str)

pi_portal/modules/configuration/logging/tests/__init__.py

Whitespace-only changes.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
"""Shared fixtures for the formatters modules."""
2+
# pylint: disable=redefined-outer-name
3+
4+
import logging
5+
from io import StringIO
6+
7+
import pytest
8+
from pi_portal.modules.configuration.tests.fixtures import mock_state
9+
from ..json import JsonLoggerConfiguration
10+
11+
12+
@pytest.fixture
13+
def json_logger_configuration_instance() -> JsonLoggerConfiguration:
14+
with mock_state.mock_state_creator():
15+
return JsonLoggerConfiguration()
16+
17+
18+
@pytest.fixture
19+
def json_logger_stdout_instance(
20+
monkeypatch: pytest.MonkeyPatch,
21+
json_logger_configuration_instance: JsonLoggerConfiguration,
22+
mocked_logger_name: str,
23+
mocked_stream: StringIO,
24+
) -> logging.Logger:
25+
log = logging.getLogger(mocked_logger_name)
26+
json_logger = json_logger_configuration_instance
27+
json_logger.configure(log)
28+
monkeypatch.setattr(log.handlers[0], "stream", mocked_stream)
29+
return log
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
"""Test the JsonLoggerConfiguration class."""
2+
3+
import json
4+
import logging
5+
import os.path
6+
from io import StringIO
7+
from unittest import mock
8+
9+
from freezegun import freeze_time
10+
from pi_portal.modules.configuration import state
11+
from ..formatters.json import JsonFormatter
12+
from ..json import JsonLoggerConfiguration
13+
14+
15+
class TestJsonLoggerConfiguration:
16+
"""Test JsonLoggerConfiguration class."""
17+
18+
def test_initialization__attrs(
19+
self,
20+
json_logger_configuration_instance: JsonLoggerConfiguration,
21+
) -> None:
22+
23+
assert json_logger_configuration_instance.format_str == \
24+
'%(message)%(levelname)%(name)%(asctime)'
25+
26+
def test_configure__stdout(
27+
self,
28+
mocked_logger_name: str,
29+
json_logger_configuration_instance: JsonLoggerConfiguration,
30+
) -> None:
31+
mock_log = logging.getLogger(mocked_logger_name)
32+
33+
json_logger_configuration_instance.configure(mock_log)
34+
35+
assert len(mock_log.handlers) == 1
36+
assert isinstance(mock_log.handlers[0], logging.StreamHandler)
37+
assert isinstance(
38+
json_logger_configuration_instance.formatter, JsonFormatter
39+
)
40+
assert mock_log.handlers[0].formatter == \
41+
json_logger_configuration_instance.formatter
42+
43+
@mock.patch("os.open", mock.mock_open())
44+
def test_configure__file(
45+
self,
46+
mocked_logger_name: str,
47+
json_logger_configuration_instance: JsonLoggerConfiguration,
48+
) -> None:
49+
mock_log = logging.getLogger(mocked_logger_name)
50+
mock_file_name = "test.log"
51+
52+
json_logger_configuration_instance.configure(mock_log, mock_file_name)
53+
54+
assert len(mock_log.handlers) == 1
55+
assert isinstance(mock_log.handlers[0], logging.FileHandler)
56+
assert isinstance(
57+
json_logger_configuration_instance.formatter, JsonFormatter
58+
)
59+
assert mock_file_name == \
60+
os.path.basename(
61+
mock_log.handlers[0].baseFilename
62+
)
63+
assert mock_log.handlers[0].formatter == \
64+
json_logger_configuration_instance.formatter
65+
66+
@freeze_time("2012-01-14")
67+
def test_logging__error_message__no_fields(
68+
self,
69+
json_logger_stdout_instance: logging.Logger,
70+
mocked_logger_name: str,
71+
mocked_state: state.State,
72+
mocked_stream: StringIO,
73+
) -> None:
74+
test_message = "test logging message"
75+
76+
json_logger_stdout_instance.error(test_message)
77+
78+
assert json.loads(mocked_stream.getvalue()) == {
79+
"message": test_message,
80+
"levelname": "ERROR",
81+
"name": mocked_logger_name,
82+
"trace_id": mocked_state.log_uuid,
83+
"asctime": "2012-01-14 00:00:00,000",
84+
}

0 commit comments

Comments
 (0)