Skip to content

Commit 5204bc3

Browse files
authored
Add support for dict-based interpolation in native loggers (#748)
1 parent fa231cf commit 5204bc3

7 files changed

Lines changed: 92 additions & 12 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ You can find our backwards-compatibility policy [here](https://github.com/hynek/
2929
Passing `-1` for *width* is now deprecated and automatically replaced by `None`.
3030
[#717](https://github.com/hynek/structlog/pull/717)
3131

32+
- Native loggers now allow the passing of a dictionary for dictionary-based interpolation `log.info("hello %(name)s!", {"name": "world"})`.
33+
[#748](https://github.com/hynek/structlog/pull/748)
34+
3235

3336
### Changed
3437

docs/bound-loggers.md

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

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

104105
(filtering)=
105106

docs/getting-started.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,21 @@ As a result, the simplest possible usage looks like this:
3131
Here, *structlog* takes advantage of its default settings:
3232

3333
- Output is sent to **[standard out](https://en.wikipedia.org/wiki/Standard_out#Standard_output_.28stdout.29)** instead of doing nothing.
34+
3435
- It **imitates** standard library {mod}`logging`'s **log level names** for familiarity.
3536
By default, no level-based filtering is done, but it comes with a **very fast [filtering machinery](filtering)**.
37+
3638
- Like in `logging`, positional arguments are [**interpolated into the message string using %**](https://docs.python.org/3/library/stdtypes.html#old-string-formatting).
3739
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`.
3840
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.
41+
42+
*structlog* supports both positional and dict-based interpolation, therefore `log.info("hello %(name)s!", {"name": "world"})` generates the same output as above.
43+
3944
- All keywords are formatted using {class}`structlog.dev.ConsoleRenderer`.
4045
That in turn uses {func}`repr` to serialize **any value to a string**.
46+
4147
- It's rendered in nice **{doc}`colors <console-output>`**.
48+
4249
- If you have [Rich] or [*better-exceptions*] installed, **exceptions** will be rendered in **colors** and with additional **helpful information**.
4350

4451
Please note that even in most complex logging setups the example would still look just like that thanks to {doc}`configuration`.

docs/why.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,14 @@ You can still use string interpolation using positional arguments:
4242
2022-10-10 07:19:25 [info ] Hello, world!
4343
```
4444

45+
Dictionary-based interpolation works too:
46+
47+
```pycon
48+
>>> log.info("Hello, %(name)s!", {"name": "world"})
49+
2025-09-11 13:00:42 [info ] Hello, world!
50+
```
51+
52+
4553
### Data binding
4654

4755
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:

src/structlog/_native.py

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from __future__ import annotations
1111

1212
import asyncio
13+
import collections
1314
import contextvars
1415
import sys
1516

@@ -123,6 +124,26 @@ def make_filtering_bound_logger(
123124
return LEVEL_TO_FILTERING_LOGGER[min_level]
124125

125126

127+
def _maybe_interpolate(event: str, args: tuple[Any, ...]) -> str:
128+
"""
129+
Interpolate the event string with the given arguments.
130+
131+
If there's exactly one argument and it's a mapping, use it for dict-based
132+
interpolation. Otherwise, use the arguments for positional interpolation.
133+
"""
134+
if not args:
135+
return event
136+
137+
if (
138+
len(args) == 1
139+
and isinstance(args[0], collections.abc.Mapping)
140+
and args[0]
141+
):
142+
return event % args[0]
143+
144+
return event % args
145+
146+
126147
def _make_filtering_bound_logger(min_level: int) -> type[FilteringBoundLogger]:
127148
"""
128149
Create a new `FilteringBoundLogger` that only logs *min_level* or higher.
@@ -140,18 +161,16 @@ def make_method(
140161
name = LEVEL_TO_NAME[level]
141162

142163
def meth(self: Any, event: str, *args: Any, **kw: Any) -> Any:
143-
if not args:
144-
return self._proxy_to_logger(name, event, **kw)
145-
146-
return self._proxy_to_logger(name, event % args, **kw)
164+
return self._proxy_to_logger(
165+
name, _maybe_interpolate(event, args), **kw
166+
)
147167

148168
async def ameth(self: Any, event: str, *args: Any, **kw: Any) -> Any:
149169
"""
150170
.. versionchanged:: 23.3.0
151171
Callsite parameters are now also collected under asyncio.
152172
"""
153-
if args:
154-
event = event % args
173+
event = _maybe_interpolate(event, args)
155174

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

178-
if not args:
179-
return self._proxy_to_logger(name, event, **kw)
180-
181-
return self._proxy_to_logger(name, event % args, **kw)
197+
return self._proxy_to_logger(
198+
name, _maybe_interpolate(event, args), **kw
199+
)
182200

183201
async def alog(
184202
self: Any, level: int, event: str, *args: Any, **kw: Any
@@ -190,8 +208,7 @@ async def alog(
190208
if level < min_level:
191209
return None
192210
name = LEVEL_TO_NAME[level]
193-
if args:
194-
event = event % args
211+
event = _maybe_interpolate(event, args)
195212

196213
scs_token = _ASYNC_CALLING_STACK.set(sys._getframe().f_back) # type: ignore[arg-type]
197214
ctx = contextvars.copy_context()

src/structlog/typing.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,10 @@ class FilteringBoundLogger(BindableLogger, Protocol):
161161
.. versionchanged:: 22.3.0
162162
String interpolation is only attempted if positional arguments are
163163
passed.
164+
.. versionadded:: 25.5.0
165+
String interpolation using dictionary-based arguments if the first and
166+
only argument is a mapping.
167+
164168
"""
165169

166170
def bind(self, **new_values: Any) -> FilteringBoundLogger:

tests/test_native.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,14 @@ def test_log_interp(self, bl, cl):
158158

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

161+
def test_log_interp_dict(self, bl, cl):
162+
"""
163+
Dict-based interpolation happens if a mapping is passed.
164+
"""
165+
bl.log(logging.INFO, "answer is %(answer)d.", {"answer": 42})
166+
167+
assert "answer is 42." == cl.calls[0][2]["event"]
168+
161169
async def test_alog_interp(self, bl, cl):
162170
"""
163171
Interpolation happens if args are passed.
@@ -166,6 +174,14 @@ async def test_alog_interp(self, bl, cl):
166174

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

177+
async def test_alog_interp_dict(self, bl, cl):
178+
"""
179+
Dict-based interpolation happens if a mapping is passed.
180+
"""
181+
await bl.alog(logging.INFO, "answer is %(answer)d.", {"answer": 42})
182+
183+
assert "answer is 42." == cl.calls[0][2]["event"]
184+
169185
def test_filter_bound_below_missing_event_string(self, bl):
170186
"""
171187
Missing event arg causes exception below min_level.
@@ -222,11 +238,35 @@ def test_exception_positional_args(self, bl, cl):
222238
("error", (), {"event": "boom bastic", "exc_info": True})
223239
] == cl.calls
224240

241+
def test_exception_dict_args(self, bl, cl):
242+
"""
243+
exception allows for dict-based args
244+
"""
245+
bl.exception(
246+
"%(action)s %(what)s", {"action": "boom", "what": "bastic"}
247+
)
248+
249+
assert [
250+
("error", (), {"event": "boom bastic", "exc_info": True})
251+
] == cl.calls
252+
225253
async def test_aexception_positional_args(self, bl, cl):
226254
"""
227255
aexception allows for positional args
228256
"""
229257
await bl.aexception("%s %s", "boom", "bastic")
258+
259+
assert 1 == len(cl.calls)
260+
assert "boom bastic" == cl.calls[0][2]["event"]
261+
262+
async def test_aexception_dict_args(self, bl, cl):
263+
"""
264+
aexception allows for dict-based args
265+
"""
266+
await bl.aexception(
267+
"%(action)s %(what)s", {"action": "boom", "what": "bastic"}
268+
)
269+
230270
assert 1 == len(cl.calls)
231271
assert "boom bastic" == cl.calls[0][2]["event"]
232272

0 commit comments

Comments
 (0)