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 @@ -57,6 +57,9 @@ You can find our backwards-compatibility policy [here](https://github.com/hynek/
- Native loggers now allow the passing of a dictionary for dictionary-based interpolation `log.info("hello %(name)s!", {"name": "world"})`.
[#748](https://github.com/hynek/structlog/pull/748)

- 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.


### Changed

Expand Down
24 changes: 19 additions & 5 deletions src/structlog/processors.py
Original file line number Diff line number Diff line change
Expand Up @@ -729,6 +729,9 @@ class CallsiteParameter(enum.Enum):
the callsite parameters in the event dictionary.

.. versionadded:: 21.5.0

.. versionadded:: 25.5.0
`QUAL_NAME` parameter.
"""

#: The full path to the python source file of the callsite.
Expand All @@ -742,6 +745,9 @@ class CallsiteParameter(enum.Enum):
MODULE = "module"
#: The name of the function that the callsite was in.
FUNC_NAME = "func_name"
#: The qualified name of the callsite (includes scope and class names).
#: Requires Python 3.11+.
QUAL_NAME = "qual_name"
#: The line number of the callsite.
LINENO = "lineno"
#: The ID of the thread the callsite was executed in.
Expand Down Expand Up @@ -770,6 +776,10 @@ def _get_callsite_func_name(module: str, frame: FrameType) -> Any:
return frame.f_code.co_name


def _get_callsite_qual_name(module: str, frame: FrameType) -> Any:
return frame.f_code.co_qualname # will crash on Python <3.11


def _get_callsite_lineno(module: str, frame: FrameType) -> Any:
return frame.f_lineno

Expand Down Expand Up @@ -837,6 +847,7 @@ class CallsiteParameterAdder:
CallsiteParameter.FILENAME: _get_callsite_filename,
CallsiteParameter.MODULE: _get_callsite_module,
CallsiteParameter.FUNC_NAME: _get_callsite_func_name,
CallsiteParameter.QUAL_NAME: _get_callsite_qual_name,
CallsiteParameter.LINENO: _get_callsite_lineno,
CallsiteParameter.THREAD: _get_callsite_thread,
CallsiteParameter.THREAD_NAME: _get_callsite_thread_name,
Expand Down Expand Up @@ -882,12 +893,15 @@ def __init__(
self._active_handlers.append(
(parameter, self._handlers[parameter])
)
self._record_mappings.append(
self._RecordMapping(
parameter.value,
self._record_attribute_map[parameter],
if (
record_attr := self._record_attribute_map.get(parameter)
) is not None:
self._record_mappings.append(
self._RecordMapping(
parameter.value,
record_attr,
)
)
)

def __call__(
self, logger: logging.Logger, name: str, event_dict: EventDict
Expand Down
55 changes: 53 additions & 2 deletions tests/processors/test_processors.py
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,13 @@ class TestCallsiteParameterAdder:
"process_name",
}

_all_parameters = set(CallsiteParameter)
# Exclude QUAL_NAME from the general set to keep parity with stdlib
# LogRecord-derived parameters. QUAL_NAME is tested separately.
_all_parameters = {
p
for p in set(CallsiteParameter)
if p is not CallsiteParameter.QUAL_NAME
}

def test_all_parameters(self) -> None:
"""
Expand All @@ -317,6 +323,50 @@ def test_all_parameters(self) -> None:
}
assert self.parameter_strings == self.get_callsite_parameters().keys()

@pytest.mark.skipif(
sys.version_info < (3, 11), reason="QUAL_NAME requires Python 3.11+"
)
def test_qual_name_structlog(self) -> None:
"""
QUAL_NAME is added for structlog-originated events on Python 3.11+.
"""
processor = CallsiteParameterAdder(
parameters={CallsiteParameter.QUAL_NAME}
)
event_dict: EventDict = {"event": "msg"}
actual = processor(None, None, event_dict)

assert actual["qual_name"].endswith(
f"{self.__class__.__name__}.test_qual_name_structlog"
)

def test_qual_name_logging_origin_absent(self) -> None:
"""
QUAL_NAME is not sourced from stdlib LogRecord and remains absent
(because it doesn't exist).
"""
processor = CallsiteParameterAdder(
parameters={CallsiteParameter.QUAL_NAME}
)
record = logging.LogRecord(
"name",
logging.INFO,
__file__,
0,
"message",
None,
None,
"func",
)
event_dict: EventDict = {
"event": "message",
"_record": record,
"_from_structlog": False,
}
actual = processor(None, None, event_dict)

assert "qual_name" not in actual

@pytest.mark.asyncio
@pytest.mark.parametrize(
("wrapper_class", "method_name"),
Expand Down Expand Up @@ -550,7 +600,8 @@ def make_processor(
"""
if parameter_strings is None:
return CallsiteParameterAdder(
additional_ignores=additional_ignores
parameters=cls._all_parameters,
additional_ignores=additional_ignores,
)

parameters = cls.filter_parameters(parameter_strings)
Expand Down