This is an automated email from the ASF dual-hosted git repository.

potiuk pushed a commit to branch v3-2-test
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/v3-2-test by this push:
     new e137899474e [v3-2-test] Backport #62849: Fix structlog positional 
formatting for single-dict arguments (#64773)
e137899474e is described below

commit e137899474e81a5c12c26ee46f336aca4619431a
Author: Yoann <[email protected]>
AuthorDate: Mon Apr 6 15:32:34 2026 -0700

    [v3-2-test] Backport #62849: Fix structlog positional formatting for 
single-dict arguments (#64773)
    
    * 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]>
    (cherry picked from commit e1e4bdbf7ee63529e87a1be4059db44683ee96bf)
    
    * fix: add missing blank line before parametrize decorator
    
    (cherry picked from commit 9c6e3a4a72e8d67c59dc12250b945d6fed2b0a34)
    
    ---------
    
    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

Reply via email to