Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ You can find our backwards-compatibility policy [here](https://github.com/hynek/
- 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.
This is only available for *structlog*-originated events since the standard library has no equivalent.

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


### Changed

Expand Down
15 changes: 14 additions & 1 deletion src/structlog/_frames.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ def _format_exception(exc_info: ExcInfo) -> str:
def _find_first_app_frame_and_name(
additional_ignores: list[str] | None = None,
*,
stacklevel: int | None = None,
_getframe: Callable[[], FrameType] = sys._getframe,
) -> tuple[FrameType, str]:
"""
Expand All @@ -45,22 +46,34 @@ def _find_first_app_frame_and_name(
additional_ignores:
Additional names with which the first frame must not start.

stacklevel:
After getting out of structlog, skip this many frames.

_getframe:
Callable to find current frame. Only for testing to avoid
monkeypatching of sys._getframe.

Returns:
tuple of (frame, name)
"""
ignores = tuple(["structlog"] + (additional_ignores or []))
ignores = ("structlog", *tuple(additional_ignores or ()))
f = _ASYNC_CALLING_STACK.get(_getframe())
name = f.f_globals.get("__name__") or "?"

while name.startswith(ignores):
if f.f_back is None:
name = "?"
break
f = f.f_back
name = f.f_globals.get("__name__") or "?"

if stacklevel is not None:
for _ in range(stacklevel):
if f.f_back is None:
break
f = f.f_back
name = f.f_globals.get("__name__") or "?"

return f, name


Expand Down
18 changes: 15 additions & 3 deletions src/structlog/stdlib.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,15 @@ def findCaller(
This logger gets set as the default one when using LoggerFactory.
"""
sinfo: str | None
f, _name = _find_first_app_frame_and_name(["logging"])
# stdlib logging passes stacklevel=1 from log methods like .warning(),
# but we've already skipped those frames by ignoring "logging", so we
# need to adjust stacklevel down by 1. We need to manually drop
# logging frames, because there's cases where we call logging methods
# from within structlog and the stacklevel offsets don't work anymore.
adjusted_stacklevel = max(0, stacklevel - 1) if stacklevel else None
f, _name = _find_first_app_frame_and_name(
["logging"], stacklevel=adjusted_stacklevel
)
sinfo = _format_stack(f) if stack_info else None

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

def findCaller(
self, stack_info: bool = False
self, stack_info: bool = False, stacklevel: int = 1
) -> tuple[str, int, str, str | None]:
"""
Calls :meth:`logging.Logger.findCaller` with unmodified arguments.
"""
return self._logger.findCaller(stack_info=stack_info)
# No need for stacklevel-adjustments since we're within structlog and
# our frames are ignored unconditionally.
return self._logger.findCaller(
stack_info=stack_info, stacklevel=stacklevel
)

def makeRecord(
self,
Expand Down
34 changes: 34 additions & 0 deletions tests/test_frames.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,40 @@ def test_ignoring_of_additional_frame_names_works(self):

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

def test_stacklevel(self):
"""
stacklevel is respected.
"""
f0 = stub(
f_globals={"__name__": "test"},
f_back=stub(f_globals={"__name__": "too far"}, f_back=None),
)
f1 = stub(f_globals={"__name__": "skipped"}, f_back=f0)
f2 = stub(f_globals={"__name__": "ignored.bar"}, f_back=f1)
f3 = stub(f_globals={"__name__": "structlog.blubb"}, f_back=f2)

f, n = _find_first_app_frame_and_name(
additional_ignores=["ignored"], stacklevel=1, _getframe=lambda: f3
)

assert (f0, "test") == (f, n)

def test_stacklevel_capped(self):
"""
stacklevel is capped at the number of frames.
"""
f0 = stub(f_globals={"__name__": "test"}, f_back=None)
f1 = stub(f_globals={"__name__": "skipped"}, f_back=f0)
f2 = stub(f_globals={"__name__": "ignored.bar"}, f_back=f1)
f3 = stub(f_globals={"__name__": "structlog.blubb"}, f_back=f2)

f, n = _find_first_app_frame_and_name(
additional_ignores=["ignored"],
stacklevel=100,
_getframe=lambda: f3,
)
assert (f0, "test") == (f, n)

def test_tolerates_missing_name(self):
"""
Use ``?`` if `f_globals` lacks a `__name__` key
Expand Down