Skip to content

Commit ecaa15a

Browse files
authored
stdlib: add support for stacklevel (#763)
* stdlib: add support for stacklevel * Add changelog entry * Keep order * Fix wrong adjustment * Clarify comment * Clarify changelog
1 parent 7f7a221 commit ecaa15a

File tree

4 files changed

+66
-4
lines changed

4 files changed

+66
-4
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,9 @@ You can find our backwards-compatibility policy [here](https://github.com/hynek/
6060
- On Python 3.11+, `structlog.processors.CallsiteParameterAdder` now supports `CallsiteParameter.QUAL_NAME` that adds the qualified name of the callsite, including scope and class names.
6161
This is only available for *structlog*-originated events since the standard library has no equivalent.
6262

63+
- `structlog.stdlib.LoggerFactory` now supports the *stacklevel* parameter.
64+
[#763](https://github.com/hynek/structlog/pull/763)
65+
6366

6467
### Changed
6568

src/structlog/_frames.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ def _format_exception(exc_info: ExcInfo) -> str:
3636
def _find_first_app_frame_and_name(
3737
additional_ignores: list[str] | None = None,
3838
*,
39+
stacklevel: int | None = None,
3940
_getframe: Callable[[], FrameType] = sys._getframe,
4041
) -> tuple[FrameType, str]:
4142
"""
@@ -45,22 +46,34 @@ def _find_first_app_frame_and_name(
4546
additional_ignores:
4647
Additional names with which the first frame must not start.
4748
49+
stacklevel:
50+
After getting out of structlog, skip this many frames.
51+
4852
_getframe:
4953
Callable to find current frame. Only for testing to avoid
5054
monkeypatching of sys._getframe.
5155
5256
Returns:
5357
tuple of (frame, name)
5458
"""
55-
ignores = tuple(["structlog"] + (additional_ignores or []))
59+
ignores = ("structlog", *tuple(additional_ignores or ()))
5660
f = _ASYNC_CALLING_STACK.get(_getframe())
5761
name = f.f_globals.get("__name__") or "?"
62+
5863
while name.startswith(ignores):
5964
if f.f_back is None:
6065
name = "?"
6166
break
6267
f = f.f_back
6368
name = f.f_globals.get("__name__") or "?"
69+
70+
if stacklevel is not None:
71+
for _ in range(stacklevel):
72+
if f.f_back is None:
73+
break
74+
f = f.f_back
75+
name = f.f_globals.get("__name__") or "?"
76+
6477
return f, name
6578

6679

src/structlog/stdlib.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,15 @@ def findCaller(
130130
This logger gets set as the default one when using LoggerFactory.
131131
"""
132132
sinfo: str | None
133-
f, _name = _find_first_app_frame_and_name(["logging"])
133+
# stdlib logging passes stacklevel=1 from log methods like .warning(),
134+
# but we've already skipped those frames by ignoring "logging", so we
135+
# need to adjust stacklevel down by 1. We need to manually drop
136+
# logging frames, because there's cases where we call logging methods
137+
# from within structlog and the stacklevel offsets don't work anymore.
138+
adjusted_stacklevel = max(0, stacklevel - 1) if stacklevel else None
139+
f, _name = _find_first_app_frame_and_name(
140+
["logging"], stacklevel=adjusted_stacklevel
141+
)
134142
sinfo = _format_stack(f) if stack_info else None
135143

136144
return f.f_code.co_filename, f.f_lineno, f.f_code.co_name, sinfo
@@ -323,12 +331,16 @@ def setLevel(self, level: int) -> None:
323331
self._logger.setLevel(level)
324332

325333
def findCaller(
326-
self, stack_info: bool = False
334+
self, stack_info: bool = False, stacklevel: int = 1
327335
) -> tuple[str, int, str, str | None]:
328336
"""
329337
Calls :meth:`logging.Logger.findCaller` with unmodified arguments.
330338
"""
331-
return self._logger.findCaller(stack_info=stack_info)
339+
# No need for stacklevel-adjustments since we're within structlog and
340+
# our frames are ignored unconditionally.
341+
return self._logger.findCaller(
342+
stack_info=stack_info, stacklevel=stacklevel
343+
)
332344

333345
def makeRecord(
334346
self,

tests/test_frames.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,40 @@ def test_ignoring_of_additional_frame_names_works(self):
4242

4343
assert (f1, "test") == (f, n)
4444

45+
def test_stacklevel(self):
46+
"""
47+
stacklevel is respected.
48+
"""
49+
f0 = stub(
50+
f_globals={"__name__": "test"},
51+
f_back=stub(f_globals={"__name__": "too far"}, f_back=None),
52+
)
53+
f1 = stub(f_globals={"__name__": "skipped"}, f_back=f0)
54+
f2 = stub(f_globals={"__name__": "ignored.bar"}, f_back=f1)
55+
f3 = stub(f_globals={"__name__": "structlog.blubb"}, f_back=f2)
56+
57+
f, n = _find_first_app_frame_and_name(
58+
additional_ignores=["ignored"], stacklevel=1, _getframe=lambda: f3
59+
)
60+
61+
assert (f0, "test") == (f, n)
62+
63+
def test_stacklevel_capped(self):
64+
"""
65+
stacklevel is capped at the number of frames.
66+
"""
67+
f0 = stub(f_globals={"__name__": "test"}, f_back=None)
68+
f1 = stub(f_globals={"__name__": "skipped"}, f_back=f0)
69+
f2 = stub(f_globals={"__name__": "ignored.bar"}, f_back=f1)
70+
f3 = stub(f_globals={"__name__": "structlog.blubb"}, f_back=f2)
71+
72+
f, n = _find_first_app_frame_and_name(
73+
additional_ignores=["ignored"],
74+
stacklevel=100,
75+
_getframe=lambda: f3,
76+
)
77+
assert (f0, "test") == (f, n)
78+
4579
def test_tolerates_missing_name(self):
4680
"""
4781
Use ``?`` if `f_globals` lacks a `__name__` key

0 commit comments

Comments
 (0)