Skip to content

Commit

Permalink
Add 'structlog.stdlib.render_to_log_args_and_kwargs' processor
Browse files Browse the repository at this point in the history
  • Loading branch information
Denis Savran committed Nov 7, 2024
1 parent 6aaa0e2 commit ca0c731
Show file tree
Hide file tree
Showing 6 changed files with 241 additions and 18 deletions.
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@ You can find our backwards-compatibility policy [here](https://github.com/hynek/

## [Unreleased](https://github.com/hynek/structlog/compare/24.4.0...HEAD)

### Added

- Add `structlog.stdlib.render_to_log_args_and_kwargs` processor.
Same as `structlog.stdlib.render_to_log_kwargs`, but also allows to pass positional arguments to `logging`.
With it, you do not need to add `structlog.stdlib.PositionalArgumentsFormatter` processor to format positional arguments from *structlog* loggers.
[#668](https://github.com/hynek/structlog/pull/668)

## Changed

- `structlog.typing.BindableLogger` protocol now returns `Self` instead of `BindableLogger`.
Expand Down
10 changes: 6 additions & 4 deletions docs/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -303,20 +303,22 @@ API Reference
.. autoclass:: LoggerFactory
:members: __call__

.. autofunction:: render_to_log_args_and_kwargs

.. autofunction:: render_to_log_kwargs

.. autofunction:: filter_by_level

.. autofunction:: add_logger_name

.. autofunction:: add_log_level

.. autofunction:: add_log_level_number

.. autofunction:: add_logger_name

.. autofunction:: ExtraAdder

.. autoclass:: PositionalArgumentsFormatter

.. autoclass:: ExtraAdder

.. autoclass:: ProcessorFormatter
:members: wrap_for_formatter, remove_processors_meta

Expand Down
21 changes: 13 additions & 8 deletions docs/standard-library.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,11 +77,16 @@ To use it, {doc}`configure <configuration>` *structlog* to use `AsyncBoundLogger

*structlog* comes with a few standard library-specific processors:

{func}`~structlog.stdlib.render_to_log_kwargs`:
{func}`~structlog.stdlib.render_to_log_args_and_kwargs`:

: Renders the event dictionary into keyword arguments for `logging.log` that attaches everything except the `event` field to the *extra* argument.
: Renders the event dictionary into positional and keyword arguments for `logging.Logger` logging methods.
This is useful if you want to render your log entries entirely within `logging`.

{func}`~structlog.stdlib.render_to_log_kwargs`:

: Same as above, but does not support passing positional arguments from *structlog* loggers to `logging.Logger` logging methods as positional arguments.
*structlog* positional arguments are still passed to `logging` under `positional_args` key of `extra` keyword argument.

{func}`~structlog.stdlib.filter_by_level`:

: Checks the log entry's log level against the configuration of standard library's logging.
Expand All @@ -92,12 +97,6 @@ To use it, {doc}`configure <configuration>` *structlog* to use `AsyncBoundLogger

: Adds the name of the logger to the event dictionary under the key `logger`.

{func}`~structlog.stdlib.ExtraAdder`:

: Add extra attributes of `logging.LogRecord` objects to the event dictionary.

This processor can be used for adding data passed in the `extra` parameter of the `logging` module's log methods to the event dictionary.

{func}`~structlog.stdlib.add_log_level`:

: Adds the log level to the event dictionary under the key `level`.
Expand All @@ -121,6 +120,12 @@ To use it, {doc}`configure <configuration>` *structlog* to use `AsyncBoundLogger

: This processes and formats positional arguments (if any) passed to log methods in the same way the `logging` module would do, for example, `logger.info("Hello, %s", name)`.

{func}`~structlog.stdlib.ExtraAdder`:

: Add extra attributes of `logging.LogRecord` objects to the event dictionary.

This processor can be used for adding data passed in the `extra` parameter of the `logging` module's log methods to the event dictionary.

*structlog* also comes with {class}`~structlog.stdlib.ProcessorFormatter` which is a `logging.Formatter` that enables you to format non-*structlog* log entries using *structlog* renderers *and* multiplex *structlog*’s output with different renderers (see [below](processor-formatter) for an example).

(stdlib-config)=
Expand Down
46 changes: 41 additions & 5 deletions src/structlog/stdlib.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
"PositionalArgumentsFormatter",
"ProcessorFormatter",
"recreate_defaults",
"render_to_log_args_and_kwargs",
"render_to_log_kwargs",
]

Expand Down Expand Up @@ -873,15 +874,50 @@ def _copy_allowed(
event_dict[key] = record.__dict__[key]


LOG_KWARG_NAMES = ("exc_info", "stack_info", "stacklevel")


def render_to_log_args_and_kwargs(
_: logging.Logger, __: str, event_dict: EventDict
) -> tuple[tuple[Any, ...], dict[str, Any]]:
"""
Render ``event_dict`` into positional and keyword arguments for
`logging.Logger` logging methods.
See `logging.Logger.debug` method for keyword arguments reference.
The ``event`` field is passed in the first positional argument, positional
arguments from ``positional_args`` field are passed in subsequent positional
arguments, keyword arguments are extracted from the *event_dict* and the
rest of the *event_dict* is added as ``extra``.
This allows you to defer formatting to `logging`.
.. versionadded:: 24.5.0
"""
args = (event_dict.pop("event"), *event_dict.pop("positional_args", ()))

kwargs = {
kwarg_name: event_dict.pop(kwarg_name)
for kwarg_name in LOG_KWARG_NAMES
if kwarg_name in event_dict
}
if event_dict:
kwargs["extra"] = event_dict

return args, kwargs


def render_to_log_kwargs(
_: logging.Logger, __: str, event_dict: EventDict
) -> EventDict:
"""
Render ``event_dict`` into keyword arguments for `logging.log`.
See `logging.Logger`'s ``_log`` method for kwargs reference.
Render ``event_dict`` into keyword arguments for `logging.Logger` logging
methods.
See `logging.Logger.debug` method for keyword arguments reference.
The ``event`` field is translated into ``msg`` and the rest of the
*event_dict* is added as ``extra``.
The ``event`` field is translated into ``msg``, keyword arguments are
extracted from the *event_dict* and the rest of the *event_dict* is added as
``extra``.
This allows you to defer formatting to `logging`.
Expand All @@ -897,7 +933,7 @@ def render_to_log_kwargs(
"extra": event_dict,
**{
kw: event_dict.pop(kw)
for kw in ("exc_info", "stack_info", "stacklevel")
for kw in LOG_KWARG_NAMES
if kw in event_dict
},
}
Expand Down
167 changes: 166 additions & 1 deletion tests/test_stdlib.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
filter_by_level,
get_logger,
recreate_defaults,
render_to_log_args_and_kwargs,
render_to_log_kwargs,
)
from structlog.testing import CapturedCall
Expand Down Expand Up @@ -683,7 +684,171 @@ def _stdlib_logger():
logging.basicConfig()


class TestRenderToLogKW:
class TestRenderToLogArgsAndKwargs:
def test_default(self, stdlib_logger: logging.Logger):
"""
Passes `event` key from `event_dict` in the first positional argument
and handles otherwise empty `event_dict`.
"""
method_name = "debug"
event = "message"
args, kwargs = render_to_log_args_and_kwargs(
stdlib_logger, method_name, {"event": event}
)

assert (event,) == args
assert {} == kwargs

with patch.object(stdlib_logger, "_log") as mock_log:
getattr(stdlib_logger, method_name)(*args, **kwargs)

mock_log.assert_called_once_with(logging.DEBUG, event, ())

def test_pass_remaining_event_dict_as_extra(
self, stdlib_logger: logging.Logger, event_dict: dict[str, Any]
):
"""
Passes remaining `event_dict` as `extra`.
"""
expected_extra = event_dict.copy()

method_name = "info"
event = "message"
event_dict["event"] = event

args, kwargs = render_to_log_args_and_kwargs(
stdlib_logger, method_name, event_dict
)

assert (event,) == args
assert {"extra": expected_extra} == kwargs

with patch.object(stdlib_logger, "_log") as mock_log:
getattr(stdlib_logger, method_name)(*args, **kwargs)

mock_log.assert_called_once_with(
logging.INFO, event, (), extra=expected_extra
)

def test_pass_positional_args_from_event_dict_as_args(
self, stdlib_logger: logging.Logger, event_dict: dict[str, Any]
):
"""
Passes items from "positional_args" key from `event_dict` as positional
arguments.
"""
expected_extra = event_dict.copy()

method_name = "warning"
event = "message: a = %s, b = %d"
positional_args = ("foo", 123)
event_dict["event"] = event
event_dict["positional_args"] = positional_args

args, kwargs = render_to_log_args_and_kwargs(
stdlib_logger, method_name, event_dict
)

assert (event, *(positional_args)) == args
assert {"extra": expected_extra} == kwargs

with patch.object(stdlib_logger, "_log") as mock_log:
getattr(stdlib_logger, method_name)(*args, **kwargs)

mock_log.assert_called_once_with(
logging.WARNING, event, positional_args, extra=expected_extra
)

def test_pass_kwargs_from_event_dict_as_kwargs(
self, stdlib_logger: logging.Logger, event_dict: dict[str, Any]
):
"""
Passes "exc_info", "stack_info", and "stacklevel" keys from `event_dict`
as keyword arguments.
"""
expected_extra = event_dict.copy()

method_name = "info"
event = "message"
exc_info = True
stack_info = False
stacklevel = 2
event_dict["event"] = event
event_dict["exc_info"] = exc_info
event_dict["stack_info"] = stack_info
event_dict["stacklevel"] = stacklevel

args, kwargs = render_to_log_args_and_kwargs(
stdlib_logger, method_name, event_dict
)

assert (event,) == args
assert {
"exc_info": exc_info,
"stack_info": stack_info,
"stacklevel": stacklevel,
"extra": expected_extra,
} == kwargs

with patch.object(stdlib_logger, "_log") as mock_log:
getattr(stdlib_logger, method_name)(*args, **kwargs)

mock_log.assert_called_once_with(
logging.INFO,
event,
(),
exc_info=exc_info,
stack_info=stack_info,
stacklevel=stacklevel,
extra=expected_extra,
)

def test_integration(
self, stdlib_logger: logging.Logger, event_dict: dict[str, Any]
):
"""
`render_to_log_args_and_kwargs` with a wrapped logger calls the stdlib
logger correctly.
Reserved stdlib keyword arguments are in `logging.Logger._log`.
https://github.com/python/cpython/blob/60403a5409ff2c3f3b07dd2ca91a7a3e096839c7/Lib/logging/__init__.py#L1640
"""
event = "message: a = %s, b = %d"
arg_1 = "foo"
arg_2 = 123
exc_info = False
stack_info = True
stacklevel = 3

struct_logger = wrap_logger(
stdlib_logger,
processors=[render_to_log_args_and_kwargs],
wrapper_class=BoundLogger,
)

with patch.object(stdlib_logger, "_log") as mock_log:
struct_logger.info(
event,
arg_1,
arg_2,
exc_info=exc_info,
stack_info=stack_info,
stacklevel=stacklevel,
**event_dict,
)

mock_log.assert_called_once_with(
logging.INFO,
event,
(arg_1, arg_2),
exc_info=exc_info,
stack_info=stack_info,
stacklevel=stacklevel,
extra=event_dict,
)


class TestRenderToLogKwargs:
def test_default(self, stdlib_logger):
"""
Translates `event` to `msg` and handles otherwise empty `event_dict`s.
Expand Down
8 changes: 8 additions & 0 deletions tests/typing/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,14 @@ def bytes_dumps(
processors=[structlog.processors.JSONRenderer(serializer=bytes_dumps)]
)

structlog.configure(
processors=[
structlog.stdlib.render_to_log_args_and_kwargs,
],
logger_factory=structlog.stdlib.LoggerFactory(),
wrapper_class=structlog.stdlib.BoundLogger,
cache_logger_on_first_use=True,
)

structlog.configure(
processors=[
Expand Down

0 comments on commit ca0c731

Please sign in to comment.