Skip to content

Commit 1fd0d72

Browse files
authored
fix: resolve EXCEPTION is not a valid log level error
- Add comprehensive testing for logging configuration. - Make sure exception level is mapped to error before logging.
1 parent 4785c5a commit 1fd0d72

File tree

4 files changed

+1289
-29
lines changed

4 files changed

+1289
-29
lines changed

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ select = ["ALL"]
8686
[tool.ruff.per-file-ignores]
8787
"tests/**/test_*.py" = [
8888
"ANN201",
89+
"D205",
8990
"S101",
9091
]
9192

tests/test_utils.py

Lines changed: 276 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,288 @@
11
"""Tests for utility functions."""
22

3+
import io
4+
import logging
5+
import re
6+
from unittest.mock import MagicMock
7+
8+
import pytest
39
import structlog
10+
from pytest_mock import MockerFixture
11+
12+
from virtool_workflow.utils import configure_logs, normalize_log_level
13+
14+
15+
@pytest.fixture(autouse=True)
16+
def reset_logging():
17+
"""Reset structlog and logging configuration before each test."""
18+
# Store original state
19+
original_handlers = logging.getLogger().handlers.copy()
20+
21+
# Reset structlog configuration
22+
structlog.reset_defaults()
23+
24+
# Clear existing logging handlers
25+
logging.getLogger().handlers.clear()
26+
27+
yield
28+
29+
# Restore original state
30+
structlog.reset_defaults()
31+
logging.getLogger().handlers.clear()
32+
logging.getLogger().handlers.extend(original_handlers)
33+
34+
35+
def capture_log_output() -> tuple[io.StringIO, logging.Handler]:
36+
"""Capture log output for testing."""
37+
buffer = io.StringIO()
38+
handler = logging.StreamHandler(buffer)
39+
handler.setFormatter(logging.Formatter("%(message)s"))
40+
logging.getLogger().addHandler(handler)
41+
logging.getLogger().setLevel(logging.INFO)
42+
return buffer, handler
43+
44+
45+
class TestLogConfiguration:
46+
"""Tests for configure_logs function."""
47+
48+
def test_ok(self):
49+
"""Base success case for log configuration."""
50+
configure_logs(use_sentry=False)
51+
logger = structlog.get_logger("test_logger")
52+
logger.info("test message")
53+
54+
def test_handles_exception_level_without_error(self):
55+
"""Verify exception level is handled without raising ValueError."""
56+
buffer, _ = capture_log_output()
57+
configure_logs(use_sentry=False)
58+
logger = structlog.get_logger("test_logger")
59+
60+
# Test that calling logger.exception() doesn't raise an error
61+
try:
62+
msg = "Test error"
63+
raise ValueError(msg)
64+
except Exception:
65+
logger.exception("Test exception message")
66+
67+
# Verify the message was logged and level was mapped to error
68+
output = buffer.getvalue().strip()
69+
assert "Test exception message" in output
70+
assert "level=error" in output
71+
72+
def test_logger_reconfiguration_works(self):
73+
"""Verify existing loggers work properly after configure_logs is called."""
74+
buffer, _ = capture_log_output()
75+
76+
# Create logger before configure_logs (simulating the original issue)
77+
structlog.get_logger("runtime")
78+
79+
# Configure logs
80+
configure_logs(use_sentry=False)
81+
82+
# Recreate logger after configure_logs (simulating the fix)
83+
logger_after = structlog.get_logger("runtime")
84+
85+
# Test that exception logging works without raising ValueError
86+
try:
87+
msg = "Test error"
88+
raise ValueError(msg)
89+
except Exception:
90+
logger_after.exception("Test exception logging")
91+
92+
# Verify the exception was logged correctly
93+
output = buffer.getvalue().strip()
94+
assert "Test exception logging" in output
95+
assert "level=error" in output
96+
97+
def test_sentry_processor_configuration(self, mocker: MockerFixture):
98+
"""Verify SentryProcessor is configured correctly when use_sentry=True."""
99+
mock_sentry_processor_class = mocker.patch(
100+
"virtool_workflow.utils.SentryProcessor",
101+
)
102+
mock_processor_instance = MagicMock()
103+
mock_sentry_processor_class.return_value = mock_processor_instance
104+
105+
configure_logs(use_sentry=True)
106+
107+
# Verify SentryProcessor was instantiated with correct parameters
108+
mock_sentry_processor_class.assert_called_once_with(
109+
event_level=logging.WARNING,
110+
level=logging.INFO,
111+
)
112+
113+
def test_sentry_processor_receives_events(self, mocker: MockerFixture):
114+
"""Verify SentryProcessor receives and processes log events correctly."""
115+
mock_sentry_processor_class = mocker.patch(
116+
"virtool_workflow.utils.SentryProcessor",
117+
)
118+
mock_processor_instance = MagicMock()
119+
# Make the processor return the event_dict unchanged
120+
mock_processor_instance.side_effect = (
121+
lambda _logger, _method, event_dict: event_dict
122+
)
123+
mock_sentry_processor_class.return_value = mock_processor_instance
124+
125+
configure_logs(use_sentry=True)
126+
logger = structlog.get_logger("test_logger")
127+
128+
# Test regular error logging
129+
logger.error("test error message", key="value")
130+
131+
# Verify the processor was called
132+
assert mock_processor_instance.call_count > 0
133+
method_names = [call[0][1] for call in mock_processor_instance.call_args_list]
134+
assert "error" in method_names
135+
136+
# Test exception logging
137+
mock_processor_instance.reset_mock()
138+
139+
try:
140+
msg = "test exception"
141+
raise ValueError(msg)
142+
except Exception:
143+
logger.exception("exception occurred", extra_data="test")
144+
145+
# Verify SentryProcessor was called for the exception
146+
assert mock_processor_instance.call_count > 0
147+
method_names = [call[0][1] for call in mock_processor_instance.call_args_list]
148+
assert "exception" in method_names
149+
150+
151+
class TestLogFormatting:
152+
"""Tests for log output formatting."""
153+
154+
def test_logfmt_basic_structure(self):
155+
"""Verify basic logfmt output structure and content."""
156+
buffer, _ = capture_log_output()
157+
configure_logs(use_sentry=False)
158+
logger = structlog.get_logger("test_logger")
159+
160+
# Test basic log message with structured data
161+
logger.info("test message", key="value", number=42, bool_flag=True)
162+
163+
output = buffer.getvalue().strip()
164+
165+
# Verify basic components are present
166+
assert "test message" in output
167+
assert "test_logger" in output
168+
assert "level=info" in output
169+
assert "key=value" in output
170+
assert "number=42" in output
171+
assert "bool_flag" in output
172+
173+
def test_timestamp_format(self):
174+
"""Verify ISO 8601 timestamp format in output."""
175+
buffer, _ = capture_log_output()
176+
configure_logs(use_sentry=False)
177+
logger = structlog.get_logger("test_logger")
178+
179+
logger.info("test message")
180+
output = buffer.getvalue().strip()
181+
182+
# Verify timestamp format (ISO 8601)
183+
timestamp_pattern = r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z"
184+
assert re.search(timestamp_pattern, output), (
185+
f"No valid timestamp found in: {output}"
186+
)
187+
188+
def test_exception_level_maps_to_error(self):
189+
"""Verify exception logging maps to error level in output."""
190+
buffer, _ = capture_log_output()
191+
configure_logs(use_sentry=False)
192+
logger = structlog.get_logger("test_logger")
193+
194+
try:
195+
msg = "test exception"
196+
raise ValueError(msg)
197+
except Exception:
198+
logger.exception("exception occurred")
199+
200+
output = buffer.getvalue().strip()
201+
assert "exception occurred" in output
202+
assert "level=error" in output # exception should map to error level
203+
204+
def test_structured_data_formatting(self):
205+
"""Verify structured data is properly formatted in logfmt."""
206+
buffer, _ = capture_log_output()
207+
configure_logs(use_sentry=False)
208+
logger = structlog.get_logger("test_logger")
209+
210+
logger.warning("alert", user_id=123, action="login", success=True)
211+
212+
output = buffer.getvalue().strip()
213+
assert "level=warning" in output
214+
assert "user_id=123" in output
215+
assert "action=login" in output
216+
assert "success" in output
217+
218+
219+
class TestNormalizeLogLevel:
220+
"""Tests for normalize_log_level function."""
221+
222+
@pytest.mark.parametrize(
223+
("input_level", "expected_level"),
224+
[
225+
("exception", "error"),
226+
("info", "info"),
227+
("warning", "warning"),
228+
("error", "error"),
229+
("debug", "debug"),
230+
("critical", "critical"),
231+
],
232+
)
233+
def test_level_mapping(self, input_level: str, expected_level: str):
234+
"""Verify normalize_log_level maps levels correctly."""
235+
input_dict = {
236+
"event": "test message",
237+
"level": input_level,
238+
"timestamp": "2023-01-01T00:00:00Z",
239+
"logger": "test_logger",
240+
"extra_field": "extra_value",
241+
}
242+
result = normalize_log_level(None, "unused_method_name", input_dict)
4243

5-
from virtool_workflow.utils import configure_logs
244+
# Check level is correctly mapped
245+
assert result["level"] == expected_level
6246

247+
def test_preserves_other_fields(self):
248+
"""Verify other fields remain unchanged."""
249+
input_dict = {
250+
"event": "test message",
251+
"level": "exception",
252+
"timestamp": "2023-01-01T00:00:00Z",
253+
"logger": "test_logger",
254+
"extra_field": "extra_value",
255+
}
256+
result = normalize_log_level(None, "unused_method_name", input_dict)
7257

8-
def test_configure_logs_handles_exception_level():
9-
"""Test that configure_logs properly handles the EXCEPTION log level."""
10-
configure_logs(use_sentry=False)
11-
logger = structlog.get_logger("test_logger")
258+
# Check other fields are unchanged
259+
assert result["event"] == "test message"
260+
assert result["timestamp"] == "2023-01-01T00:00:00Z"
261+
assert result["logger"] == "test_logger"
262+
assert result["extra_field"] == "extra_value"
12263

13-
try:
14-
raise ValueError("Test error")
15-
except Exception:
16-
logger.exception()
264+
def test_mutates_in_place(self):
265+
"""Verify dict is mutated in place, not copied."""
266+
input_dict = {"level": "exception", "event": "test"}
267+
result = normalize_log_level(None, "unused_method_name", input_dict)
17268

269+
# Check same object is returned (mutation, not copy)
270+
assert result is input_dict
18271

19-
def test_logger_reconfiguration_after_configure_logs():
20-
"""Test that existing loggers work properly after configure_logs is called."""
21-
# Create logger before configure_logs (simulating the original issue)
22-
structlog.get_logger("runtime")
272+
def test_handles_missing_level(self):
273+
"""Verify function handles dict without level field."""
274+
input_dict = {"event": "test message", "logger": "test_logger"}
275+
result = normalize_log_level(None, "unused_method_name", input_dict)
23276

24-
# Configure logs
25-
configure_logs(use_sentry=False)
277+
# Should return unchanged dict
278+
assert result is input_dict
279+
assert "level" not in result
26280

27-
# Recreate logger after configure_logs (simulating the fix)
28-
logger_after = structlog.get_logger("runtime")
281+
def test_handles_none_level(self):
282+
"""Verify function handles None level value."""
283+
input_dict = {"level": None, "event": "test message"}
284+
result = normalize_log_level(None, "unused_method_name", input_dict)
29285

30-
# Test that exception logging works without raising ValueError
31-
try:
32-
raise ValueError("Test error")
33-
except Exception:
34-
logger_after.exception("Test exception logging")
286+
# Should return unchanged dict
287+
assert result is input_dict
288+
assert result["level"] is None

0 commit comments

Comments
 (0)