This is an automated email from the ASF dual-hosted git repository.
potiuk pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new 97f9daab8d8 Fix structlog positional formatting for single-dict
arguments (#62849)
97f9daab8d8 is described below
commit 97f9daab8d8669352f570ed18651cd1d451178b3
Author: Yoann <[email protected]>
AuthorDate: Sat Apr 4 14:26:43 2026 -0700
Fix structlog positional formatting for single-dict arguments (#62849)
* Fix dict args in structlog positional formatting
When a dict was passed as a positional argument to a log message
(e.g. log.warning('message %s', {'a': 10})), both the structlog
bound logger and the stdlib logging path would try named substitution
first, causing TypeError for positional format specifiers like %s.
Fix both paths to match CPython's stdlib logging behavior: try
positional formatting (msg % args) first, fall back to named
substitution (msg % args[0]) only on TypeError/KeyError.
- In _make_airflow_structlogger.meth(): try event % args first,
fall back to named substitution on failure
- Add positional_arguments_formatter() to replace structlog's built-in
PositionalArgumentsFormatter, which has the same ordering bug for
stdlib logging records
Fixes apache/airflow#62201
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
* fix: add missing blank line before parametrize decorator
---------
Co-authored-by: Claude Sonnet 4.6 <[email protected]>
---
.../src/airflow_shared/logging/structlog.py | 39 +++++++++--
shared/logging/tests/logging/test_structlog.py | 75 ++++++++++++++++++++++
2 files changed, 109 insertions(+), 5 deletions(-)
diff --git a/shared/logging/src/airflow_shared/logging/structlog.py
b/shared/logging/src/airflow_shared/logging/structlog.py
index 670f05ac8ad..d5b0b9a8bfe 100644
--- a/shared/logging/src/airflow_shared/logging/structlog.py
+++ b/shared/logging/src/airflow_shared/logging/structlog.py
@@ -95,10 +95,15 @@ def _make_airflow_structlogger(min_level):
if not args:
return self._proxy_to_logger(name, event, **kw)
- # See
https://github.com/python/cpython/blob/3.13/Lib/logging/__init__.py#L307-L326
for reason
- if args and len(args) == 1 and isinstance(args[0], Mapping) and
args[0]:
- return self._proxy_to_logger(name, event % args[0], **kw)
- return self._proxy_to_logger(name, event % args, **kw)
+ # Match CPython's stdlib logging behavior: try positional
formatting first,
+ # fall back to named substitution only if that fails.
+ # See
https://github.com/python/cpython/blob/3.13/Lib/logging/__init__.py#L307-L326
+ try:
+ return self._proxy_to_logger(name, event % args, **kw)
+ except (TypeError, KeyError):
+ if len(args) == 1 and isinstance(args[0], Mapping) and args[0]:
+ return self._proxy_to_logger(name, event % args[0], **kw)
+ raise
meth.__name__ = name
return meth
@@ -216,6 +221,30 @@ def drop_positional_args(logger: Any, method_name: Any,
event_dict: EventDict) -
return event_dict
+def positional_arguments_formatter(logger: Any, method_name: Any, event_dict:
EventDict) -> EventDict:
+ """
+ Format positional arguments matching CPython's stdlib logging behavior.
+
+ Replaces structlog's built-in PositionalArgumentsFormatter to correctly
handle the case
+ where a single dict is passed as a positional argument (e.g.
``log.warning('%s', {'a': 1})``).
+
+ CPython tries positional formatting first (``msg % args``), and only falls
back to named
+ substitution (``msg % args[0]``) if that raises TypeError or KeyError.
structlog's built-in
+ formatter does it the other way around, causing TypeError for ``%s``
format specifiers.
+ """
+ args = event_dict.get("positional_args")
+ if args:
+ try:
+ event_dict["event"] = event_dict["event"] % args
+ except (TypeError, KeyError):
+ if len(args) == 1 and isinstance(args[0], Mapping) and args[0]:
+ event_dict["event"] = event_dict["event"] % args[0]
+ else:
+ raise
+ del event_dict["positional_args"]
+ return event_dict
+
+
# This is a placeholder fn, that is "edited" in place via the
`suppress_logs_and_warning` decorator
# The reason we need to do it this way is that structlog caches loggers on
first use, and those include the
# configured processors, so we can't get away with changing the config as it
won't have any effect once the
@@ -268,7 +297,7 @@ def structlog_processors(
timestamper,
structlog.contextvars.merge_contextvars,
structlog.processors.add_log_level,
- structlog.stdlib.PositionalArgumentsFormatter(),
+ positional_arguments_formatter,
logger_name,
redact_jwt,
structlog.processors.StackInfoRenderer(),
diff --git a/shared/logging/tests/logging/test_structlog.py
b/shared/logging/tests/logging/test_structlog.py
index 4c9fe4cd927..8904aa19326 100644
--- a/shared/logging/tests/logging/test_structlog.py
+++ b/shared/logging/tests/logging/test_structlog.py
@@ -623,3 +623,78 @@ class TestShowwarning:
_showwarning("some warning", UserWarning, "foo.py", 1)
mock_get_logger.assert_called_once_with("py.warnings")
+
+
[email protected](
+ ("get_logger", "message", "args", "expected_event"),
+ [
+ # dict passed as positional %s arg — should use positional formatting,
not named
+ pytest.param(
+ logging.getLogger,
+ "Info message %s",
+ ({"a": 10},),
+ "Info message {'a': 10}",
+ id="stdlib-dict-positional",
+ ),
+ pytest.param(
+ structlog.get_logger,
+ "Info message %s",
+ ({"a": 10},),
+ "Info message {'a': 10}",
+ id="structlog-dict-positional",
+ ),
+ # named substitution with dict should still work
+ pytest.param(
+ logging.getLogger,
+ "%(a)s message",
+ ({"a": 10},),
+ "10 message",
+ id="stdlib-dict-named",
+ ),
+ pytest.param(
+ structlog.get_logger,
+ "%(a)s message",
+ ({"a": 10},),
+ "10 message",
+ id="structlog-dict-named",
+ ),
+ # simple non-dict positional arg
+ pytest.param(
+ logging.getLogger,
+ "message %s",
+ ("simple",),
+ "message simple",
+ id="stdlib-simple-positional",
+ ),
+ pytest.param(
+ structlog.get_logger,
+ "message %s",
+ ("simple",),
+ "message simple",
+ id="structlog-simple-positional",
+ ),
+ # no args
+ pytest.param(
+ logging.getLogger,
+ "message",
+ (),
+ "message",
+ id="stdlib-no-args",
+ ),
+ pytest.param(
+ structlog.get_logger,
+ "message",
+ (),
+ "message",
+ id="structlog-no-args",
+ ),
+ ],
+)
+def test_dict_positional_arg_formatting(structlog_config, get_logger, message,
args, expected_event):
+ """Regression test for dict args passed as positional log arguments
(GitHub issue #62201)."""
+ with structlog_config(json_output=True) as bio:
+ logger = get_logger("my.logger")
+ logger.warning(message, *args)
+
+ written = json.load(bio)
+ assert written["event"] == expected_event