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
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ ci:

repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.8.6
rev: v0.9.1
hooks:
- id: ruff
args: [--fix, --exit-non-zero-on-fix]
Expand Down
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@ 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 All @@ -27,6 +35,9 @@ You can find our backwards-compatibility policy [here](https://github.com/hynek/

[#647](https://github.com/hynek/structlog/pull/647)

- `structlog.stdlib.recreate_defaults()` now also adds `structlog.stdlib.PositionalArgumentsFormatter`.
In default native mode, this is done by the loggers at the edge.


## Fixed

Expand Down
4 changes: 3 additions & 1 deletion docs/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,8 @@ API Reference
.. autoclass:: LoggerFactory
:members: __call__

.. autofunction:: render_to_log_args_and_kwargs

.. autofunction:: render_to_log_kwargs

.. autofunction:: filter_by_level
Expand All @@ -313,7 +315,7 @@ API Reference

.. autofunction:: add_logger_name

.. autofunction:: ExtraAdder
.. autoclass:: ExtraAdder

.. autoclass:: PositionalArgumentsFormatter

Expand Down
2 changes: 1 addition & 1 deletion docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@
# General information about the project.
project = "structlog"
author = "Hynek Schlawack"
copyright = f"2013, { author }"
copyright = f"2013, {author}"

# The version info for the project you're documenting, acts as replacement for
# |version| and |release|, also used in various other places throughout the
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 @@ -117,6 +116,12 @@ To use it, {doc}`configure <configuration>` *structlog* to use `AsyncBoundLogger

The mapping of names to numbers is in `structlog.stdlib._NAME_TO_LEVEL`.

{class}`~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.PositionalArgumentsFormatter`:

: 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)`.
Expand Down
5 changes: 1 addition & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,7 @@ raw-options = { local_scheme = "no-local-version" }
addopts = ["-ra", "--strict-markers", "--strict-config"]
testpaths = "tests"
xfail_strict = true
filterwarnings = [
"once::Warning",
'ignore:datetime.datetime.utcfromtimestamp\(\) is deprecated:DeprecationWarning:dateutil.tz',
]
filterwarnings = ["once::Warning"]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"

Expand Down
48 changes: 43 additions & 5 deletions src/structlog/stdlib.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
"filter_by_level",
"get_logger",
"recreate_defaults",
"render_to_log_args_and_kwargs",
"render_to_log_kwargs",
]

Expand All @@ -75,6 +76,7 @@ def recreate_defaults(*, log_level: int | None = logging.NOTSET) -> None:

.. versionadded:: 22.1.0
.. versionchanged:: 23.3.0 Added `add_logger_name`.
.. versionchanged:: 25.1.0 Added `PositionalArgumentsFormatter`.
"""
if log_level is not None:
kw = {"force": True}
Expand All @@ -89,6 +91,7 @@ def recreate_defaults(*, log_level: int | None = logging.NOTSET) -> None:
_config.reset_defaults()
_config.configure(
processors=[
PositionalArgumentsFormatter(), # handled by native loggers
merge_contextvars,
add_log_level,
add_logger_name,
Expand Down Expand Up @@ -885,15 +888,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:: 25.1.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 @@ -909,7 +947,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
2 changes: 1 addition & 1 deletion src/structlog/testing.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ def __init__(self) -> None:
self.calls = []

def __repr__(self) -> str:
return f"<CapturingLogger with { len(self.calls) } call(s)>"
return f"<CapturingLogger with {len(self.calls)} call(s)>"

def __getattr__(self, name: str) -> Any:
"""
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 @@ -706,7 +707,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
Loading
Loading