diff --git a/CHANGELOG.md b/CHANGELOG.md index 768a9a54..42e0177b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/structlog/processors.py b/src/structlog/processors.py index 804b8d0d..496883d6 100644 --- a/src/structlog/processors.py +++ b/src/structlog/processors.py @@ -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. @@ -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. @@ -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 @@ -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, @@ -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 diff --git a/tests/processors/test_processors.py b/tests/processors/test_processors.py index 13f4478d..e43485e1 100644 --- a/tests/processors/test_processors.py +++ b/tests/processors/test_processors.py @@ -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: """ @@ -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"), @@ -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)