Skip to content

Commit ce3dbf5

Browse files
authored
fix: logging source information (#558)
1 parent f9db12f commit ce3dbf5

3 files changed

Lines changed: 239 additions & 1 deletion

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "pykiso"
3-
version = "1.5.1"
3+
version = "1.5.2"
44
description = "Embedded integration testing framework."
55
authors = ["Sebastian Fischer <sebastian.fischer@de.bosch.com>"]
66
license = "Eclipse Public License - v 2.0"

src/pykiso/logging_initializer.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,9 +181,13 @@ def add_logging_level(level_name: str, level_num: int):
181181

182182
def log_for_level(self, message, *args, **kwargs):
183183
if self.isEnabledFor(level_num):
184+
# Use stacklevel=2 to skip this wrapper frame and show the actual caller
185+
kwargs.setdefault("stacklevel", 2)
184186
self._log(level_num, message, args, **kwargs)
185187

186188
def log_to_root(message, *args, **kwargs):
189+
# Use stacklevel=2 to skip this wrapper frame and show the actual caller
190+
kwargs.setdefault("stacklevel", 2)
187191
logging.log(level_num, message, *args, **kwargs)
188192

189193
if not hasattr(logging, level_name):
Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
##########################################################################
2+
# Copyright (c) 2010-2022 Robert Bosch GmbH
3+
# This program and the accompanying materials are made available under the
4+
# terms of the Eclipse Public License 2.0 which is available at
5+
# http://www.eclipse.org/legal/epl-2.0.
6+
#
7+
# SPDX-License-Identifier: EPL-2.0
8+
##########################################################################
9+
10+
"""
11+
Unit tests for logging source location fix
12+
******************************************
13+
14+
:module: test_logging_source_location
15+
16+
:synopsis: Test that custom logging levels show correct source location
17+
instead of always showing logging_initializer.py as the source.
18+
19+
.. currentmodule:: test_logging_source_location
20+
"""
21+
22+
import logging
23+
import unittest
24+
from io import StringIO
25+
from unittest.mock import patch
26+
27+
from pykiso.logging_initializer import add_internal_log_levels, add_logging_level
28+
29+
30+
class TestLoggingSourceLocation(unittest.TestCase):
31+
"""Test that custom logging levels show the correct source location."""
32+
33+
def setUp(self):
34+
"""Set up test fixtures before each test method."""
35+
# Create a string buffer to capture log output
36+
self.log_stream = StringIO()
37+
38+
# Create a test logger
39+
self.logger = logging.getLogger(f"test_logger_{id(self)}")
40+
self.logger.setLevel(1) # Set to lowest level to capture everything
41+
42+
# Clear any existing handlers
43+
self.logger.handlers.clear()
44+
45+
# Create a handler that captures logs with source location info
46+
self.handler = logging.StreamHandler(self.log_stream)
47+
formatter = logging.Formatter('%(levelname)s:%(filename)s:%(lineno)d:%(funcName)s - %(message)s')
48+
self.handler.setFormatter(formatter)
49+
self.handler.setLevel(1) # Set handler to lowest level too
50+
self.logger.addHandler(self.handler)
51+
52+
# Add internal log levels if not already present
53+
add_internal_log_levels()
54+
55+
def tearDown(self):
56+
"""Clean up after each test method."""
57+
# Remove handler before closing stream
58+
if hasattr(self, 'handler') and self.handler in self.logger.handlers:
59+
self.logger.removeHandler(self.handler)
60+
if hasattr(self, 'log_stream') and not self.log_stream.closed:
61+
self.log_stream.close()
62+
self.logger.handlers.clear()
63+
64+
def test_custom_level_shows_correct_source_location(self):
65+
"""Test that custom logging levels show the correct source location."""
66+
# This should show this test file and line number, not logging_initializer.py
67+
self.logger.internal_info("Test message from test method") # Line for verification
68+
69+
# Get the captured log output
70+
log_output = self.log_stream.getvalue()
71+
72+
# Verify that the log shows this test file, not logging_initializer.py
73+
self.assertIn("test_logging_source_location.py", log_output)
74+
self.assertNotIn("logging_initializer.py", log_output)
75+
76+
# Verify it shows the correct function name
77+
self.assertIn("test_custom_level_shows_correct_source_location", log_output)
78+
79+
# Verify the message is present
80+
self.assertIn("Test message from test method", log_output)
81+
82+
def test_nested_function_shows_correct_location(self):
83+
"""Test that logs from nested functions show the correct location."""
84+
def nested_function():
85+
self.logger.internal_info("Message from nested function") # Line for verification
86+
87+
# Call the nested function
88+
nested_function()
89+
90+
# Get the captured log output
91+
log_output = self.log_stream.getvalue()
92+
93+
# Verify it shows this test file and the nested function
94+
self.assertIn("test_logging_source_location.py", log_output)
95+
self.assertIn("nested_function", log_output)
96+
self.assertNotIn("logging_initializer.py", log_output)
97+
98+
def test_multiple_stack_levels(self):
99+
"""Test logging through multiple function calls."""
100+
def level_one():
101+
level_two()
102+
103+
def level_two():
104+
level_three()
105+
106+
def level_three():
107+
self.logger.internal_warning("Deep nested message") # Line for verification
108+
109+
# Clear previous output
110+
self.log_stream.seek(0)
111+
self.log_stream.truncate(0)
112+
113+
# Call through multiple levels
114+
level_one()
115+
116+
# Get the captured log output
117+
log_output = self.log_stream.getvalue()
118+
119+
# Should show level_three as the source, not logging_initializer.py
120+
self.assertIn("test_logging_source_location.py", log_output)
121+
self.assertIn("level_three", log_output)
122+
self.assertNotIn("logging_initializer.py", log_output)
123+
124+
def test_regular_logging_unaffected(self):
125+
"""Test that regular logging methods are not affected by the fix."""
126+
# Clear previous output
127+
self.log_stream.seek(0)
128+
self.log_stream.truncate(0)
129+
130+
# Use regular logging method
131+
self.logger.info("Regular info message") # Line for verification
132+
133+
# Get the captured log output
134+
log_output = self.log_stream.getvalue()
135+
136+
# Should still show correct location for regular logging
137+
self.assertIn("test_logging_source_location.py", log_output)
138+
self.assertIn("test_regular_logging_unaffected", log_output)
139+
140+
def test_custom_level_with_args_and_kwargs(self):
141+
"""Test that custom logging with arguments and keyword arguments works."""
142+
# Clear previous output
143+
self.log_stream.seek(0)
144+
self.log_stream.truncate(0)
145+
146+
# Test with arguments and keyword arguments
147+
test_value = 42
148+
self.logger.internal_info("Value is %d", test_value, extra={'custom_field': 'test'}) # Line for verification
149+
150+
# Get the captured log output
151+
log_output = self.log_stream.getvalue()
152+
153+
# Verify location is correct
154+
self.assertIn("test_logging_source_location.py", log_output)
155+
self.assertIn("test_custom_level_with_args_and_kwargs", log_output)
156+
self.assertNotIn("logging_initializer.py", log_output)
157+
158+
# Verify message formatting worked
159+
self.assertIn("Value is 42", log_output)
160+
161+
def test_dynamically_added_level(self):
162+
"""Test that dynamically added custom levels also show correct source."""
163+
# Add a new custom level
164+
add_logging_level("CUSTOM_TEST", 25)
165+
166+
# Clear previous output
167+
self.log_stream.seek(0)
168+
self.log_stream.truncate(0)
169+
170+
# Use the newly added level
171+
self.logger.custom_test("Message with custom level") # Line for verification
172+
173+
# Get the captured log output
174+
log_output = self.log_stream.getvalue()
175+
176+
# Verify location is correct
177+
self.assertIn("test_logging_source_location.py", log_output)
178+
self.assertIn("test_dynamically_added_level", log_output)
179+
self.assertNotIn("logging_initializer.py", log_output)
180+
181+
def test_root_logger_custom_levels(self):
182+
"""Test that root logger custom levels also work correctly."""
183+
# Create a separate handler for root logger testing
184+
root_stream = StringIO()
185+
root_handler = logging.StreamHandler(root_stream)
186+
formatter = logging.Formatter('%(levelname)s:%(filename)s:%(lineno)d:%(funcName)s - %(message)s')
187+
root_handler.setFormatter(formatter)
188+
root_handler.setLevel(1)
189+
190+
# Configure root logger with our handler
191+
root_logger = logging.getLogger()
192+
original_level = root_logger.level
193+
root_logger.setLevel(1)
194+
root_logger.addHandler(root_handler)
195+
196+
try:
197+
# Use root logger's custom method
198+
logging.internal_info("Root logger message") # Line for verification
199+
200+
# Get the captured log output
201+
log_output = root_stream.getvalue()
202+
203+
# Verify location is correct
204+
self.assertIn("test_logging_source_location.py", log_output)
205+
self.assertIn("test_root_logger_custom_levels", log_output)
206+
self.assertNotIn("logging_initializer.py", log_output)
207+
finally:
208+
# Clean up
209+
root_logger.removeHandler(root_handler)
210+
root_logger.setLevel(original_level)
211+
root_stream.close()
212+
213+
def test_stacklevel_parameter_preservation(self):
214+
"""Test that explicit stacklevel parameter is preserved if provided."""
215+
def wrapper_function():
216+
# Explicitly set stacklevel=3 to skip this wrapper frame
217+
self.logger.internal_info("Message with explicit stacklevel", stacklevel=3)
218+
219+
# Call the wrapper
220+
wrapper_function()
221+
222+
# Get the captured log output
223+
log_output = self.log_stream.getvalue()
224+
225+
# The important thing is that it doesn't show logging_initializer.py
226+
# and that the stacklevel parameter was respected (not showing wrapper_function)
227+
self.assertNotIn("logging_initializer.py", log_output)
228+
self.assertNotIn("wrapper_function", log_output)
229+
# Should show the test method name since we skipped the wrapper
230+
self.assertIn("test_stacklevel_parameter_preservation", log_output)
231+
232+
233+
if __name__ == '__main__':
234+
unittest.main()

0 commit comments

Comments
 (0)