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 @@ -29,6 +29,9 @@ You can find our backwards-compatibility policy [here](https://github.com/hynek/
Passing `-1` for *width* is now deprecated and automatically replaced by `None`.
[#717](https://github.com/hynek/structlog/pull/717)

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


### Changed

Expand Down
1 change: 1 addition & 0 deletions docs/bound-loggers.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ If you call `log.info("Hello, %s!", "world", number=42)` now, the following happ
For example, if you wanted JSON logs, you just have to replace the last processor with {class}`structlog.processors.JSONRenderer`.

[^interpolation]: String interpolation only takes place if you pass positional arguments.
If the first and only argument is a mapping, it will be used for dict-based interpolation.

(filtering)=

Expand Down
7 changes: 7 additions & 0 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,21 @@ As a result, the simplest possible usage looks like this:
Here, *structlog* takes advantage of its default settings:

- Output is sent to **[standard out](https://en.wikipedia.org/wiki/Standard_out#Standard_output_.28stdout.29)** instead of doing nothing.

- It **imitates** standard library {mod}`logging`'s **log level names** for familiarity.
By default, no level-based filtering is done, but it comes with a **very fast [filtering machinery](filtering)**.

- Like in `logging`, positional arguments are [**interpolated into the message string using %**](https://docs.python.org/3/library/stdtypes.html#old-string-formatting).
That might look dated, but it's *much* faster than using {any}`str.format` and allows *structlog* to be used as drop-in replacement for {mod}`logging`.
If you *know* that the log entry is *always* gonna be logged out, just use [f-strings](https://docs.python.org/3/tutorial/inputoutput.html#formatted-string-literals) which are the fastest.

*structlog* supports both positional and dict-based interpolation, therefore `log.info("hello %(name)s!", {"name": "world"})` generates the same output as above.

- All keywords are formatted using {class}`structlog.dev.ConsoleRenderer`.
That in turn uses {func}`repr` to serialize **any value to a string**.

- It's rendered in nice **{doc}`colors <console-output>`**.

- If you have [Rich] or [*better-exceptions*] installed, **exceptions** will be rendered in **colors** and with additional **helpful information**.

Please note that even in most complex logging setups the example would still look just like that thanks to {doc}`configuration`.
Expand Down
8 changes: 8 additions & 0 deletions docs/why.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,14 @@ You can still use string interpolation using positional arguments:
2022-10-10 07:19:25 [info ] Hello, world!
```

Dictionary-based interpolation works too:

```pycon
>>> log.info("Hello, %(name)s!", {"name": "world"})
2025-09-11 13:00:42 [info ] Hello, world!
```


### Data binding

Since log entries are dictionaries, you can start binding and re-binding key-value pairs to your loggers to ensure they are present in every following logging call:
Expand Down
41 changes: 29 additions & 12 deletions src/structlog/_native.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from __future__ import annotations

import asyncio
import collections
import contextvars
import sys

Expand Down Expand Up @@ -123,6 +124,26 @@ def make_filtering_bound_logger(
return LEVEL_TO_FILTERING_LOGGER[min_level]


def _maybe_interpolate(event: str, args: tuple[Any, ...]) -> str:
"""
Interpolate the event string with the given arguments.

If there's exactly one argument and it's a mapping, use it for dict-based
interpolation. Otherwise, use the arguments for positional interpolation.
"""
if not args:
return event

if (
len(args) == 1
and isinstance(args[0], collections.abc.Mapping)
and args[0]
):
return event % args[0]

return event % args


def _make_filtering_bound_logger(min_level: int) -> type[FilteringBoundLogger]:
"""
Create a new `FilteringBoundLogger` that only logs *min_level* or higher.
Expand All @@ -140,18 +161,16 @@ def make_method(
name = LEVEL_TO_NAME[level]

def meth(self: Any, event: str, *args: Any, **kw: Any) -> Any:
if not args:
return self._proxy_to_logger(name, event, **kw)

return self._proxy_to_logger(name, event % args, **kw)
return self._proxy_to_logger(
name, _maybe_interpolate(event, args), **kw
)

async def ameth(self: Any, event: str, *args: Any, **kw: Any) -> Any:
"""
.. versionchanged:: 23.3.0
Callsite parameters are now also collected under asyncio.
"""
if args:
event = event % args
event = _maybe_interpolate(event, args)

scs_token = _ASYNC_CALLING_STACK.set(sys._getframe().f_back) # type: ignore[arg-type]
ctx = contextvars.copy_context()
Expand All @@ -175,10 +194,9 @@ def log(self: Any, level: int, event: str, *args: Any, **kw: Any) -> Any:
return None
name = LEVEL_TO_NAME[level]

if not args:
return self._proxy_to_logger(name, event, **kw)

return self._proxy_to_logger(name, event % args, **kw)
return self._proxy_to_logger(
name, _maybe_interpolate(event, args), **kw
)

async def alog(
self: Any, level: int, event: str, *args: Any, **kw: Any
Expand All @@ -190,8 +208,7 @@ async def alog(
if level < min_level:
return None
name = LEVEL_TO_NAME[level]
if args:
event = event % args
event = _maybe_interpolate(event, args)

scs_token = _ASYNC_CALLING_STACK.set(sys._getframe().f_back) # type: ignore[arg-type]
ctx = contextvars.copy_context()
Expand Down
4 changes: 4 additions & 0 deletions src/structlog/typing.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,10 @@ class FilteringBoundLogger(BindableLogger, Protocol):
.. versionchanged:: 22.3.0
String interpolation is only attempted if positional arguments are
passed.
.. versionadded:: 25.5.0
String interpolation using dictionary-based arguments if the first and
only argument is a mapping.

"""

def bind(self, **new_values: Any) -> FilteringBoundLogger:
Expand Down
40 changes: 40 additions & 0 deletions tests/test_native.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,14 @@ def test_log_interp(self, bl, cl):

assert "answer is 42." == cl.calls[0][2]["event"]

def test_log_interp_dict(self, bl, cl):
"""
Dict-based interpolation happens if a mapping is passed.
"""
bl.log(logging.INFO, "answer is %(answer)d.", {"answer": 42})

assert "answer is 42." == cl.calls[0][2]["event"]

async def test_alog_interp(self, bl, cl):
"""
Interpolation happens if args are passed.
Expand All @@ -166,6 +174,14 @@ async def test_alog_interp(self, bl, cl):

assert "answer is 42." == cl.calls[0][2]["event"]

async def test_alog_interp_dict(self, bl, cl):
"""
Dict-based interpolation happens if a mapping is passed.
"""
await bl.alog(logging.INFO, "answer is %(answer)d.", {"answer": 42})

assert "answer is 42." == cl.calls[0][2]["event"]

def test_filter_bound_below_missing_event_string(self, bl):
"""
Missing event arg causes exception below min_level.
Expand Down Expand Up @@ -222,11 +238,35 @@ def test_exception_positional_args(self, bl, cl):
("error", (), {"event": "boom bastic", "exc_info": True})
] == cl.calls

def test_exception_dict_args(self, bl, cl):
"""
exception allows for dict-based args
"""
bl.exception(
"%(action)s %(what)s", {"action": "boom", "what": "bastic"}
)

assert [
("error", (), {"event": "boom bastic", "exc_info": True})
] == cl.calls

async def test_aexception_positional_args(self, bl, cl):
"""
aexception allows for positional args
"""
await bl.aexception("%s %s", "boom", "bastic")

assert 1 == len(cl.calls)
assert "boom bastic" == cl.calls[0][2]["event"]

async def test_aexception_dict_args(self, bl, cl):
"""
aexception allows for dict-based args
"""
await bl.aexception(
"%(action)s %(what)s", {"action": "boom", "what": "bastic"}
)

assert 1 == len(cl.calls)
assert "boom bastic" == cl.calls[0][2]["event"]

Expand Down
Loading