diff --git a/CHANGELOG.md b/CHANGELOG.md index 3177c0f4..5937f430 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/bound-loggers.md b/docs/bound-loggers.md index deb7254f..bb797963 100644 --- a/docs/bound-loggers.md +++ b/docs/bound-loggers.md @@ -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)= diff --git a/docs/getting-started.md b/docs/getting-started.md index d0042c98..161c0078 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -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 `**. + - 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`. diff --git a/docs/why.md b/docs/why.md index 98d8a898..b8d34236 100644 --- a/docs/why.md +++ b/docs/why.md @@ -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: diff --git a/src/structlog/_native.py b/src/structlog/_native.py index 43d732b5..4d0926de 100644 --- a/src/structlog/_native.py +++ b/src/structlog/_native.py @@ -10,6 +10,7 @@ from __future__ import annotations import asyncio +import collections import contextvars import sys @@ -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. @@ -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() @@ -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 @@ -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() diff --git a/src/structlog/typing.py b/src/structlog/typing.py index c86d2808..9b2ee5a2 100644 --- a/src/structlog/typing.py +++ b/src/structlog/typing.py @@ -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: diff --git a/tests/test_native.py b/tests/test_native.py index 2d152e72..90b1ebf3 100644 --- a/tests/test_native.py +++ b/tests/test_native.py @@ -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. @@ -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. @@ -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"]