Skip to content

fix: handle a single-entry mapping in PositionalArgumentsFormatter - #844

Open
januththedev wants to merge 1 commit into
hynek:mainfrom
januththedev:fix/positional-args-single-mapping
Open

januththedev wants to merge 1 commit into
hynek:mainfrom
januththedev:fix/positional-args-single-mapping

Conversation

@januththedev

Copy link
Copy Markdown

PositionalArgumentsFormatter raises KeyError: 0 on a single-entry mapping

Description

PositionalArgumentsFormatter.__call__ starts with:

if len(args) == 1 and isinstance(args[0], dict) and args[0]:

This is a port of logging.LogRecord.__init__, where args is always a tuple produced by msg, *args = ..., and a lone mapping argument therefore still needs unwrapping.

structlog's positional_args is not always a tuple. ProcessorFormatter(pass_foreign_args=True) copies LogRecord.args straight into the event dict, and the stdlib has already unwrapped a single mapping argument by then. So args can be a dict.

When it is, len(args) == 1 short-circuits true and args[0] performs a dict lookup by integer key → KeyError: 0.

Reproduction

With the documented ProcessorFormatter setup (pass_foreign_args=True, use_get_message=False, PositionalArgumentsFormatter in processors):

1-key mapping -> event='hello world'   <- before the fix: '' (entry dropped)
2-key mapping -> event='hello 1 2'

Directly:

>>> PositionalArgumentsFormatter()(
...     None, None, {"event": "%(who)s", "who": "world"}
... )
KeyError: 0

Two keys work and one key does not — a plain arity asymmetry. Because KeyError is not a formatting error, logging cannot swallow it the way it swallows TypeError; the entry is silently dropped and only a --- Logging error --- trace on stderr remains. structlog's own suite already asserts this dict shape is produced: tests/test_stdlib.py::test_pass_foreign_args_true_sets_positional_args_key sets positional_args = {"foo": "bar"}.

The change

Skip the unwrap branch when positional_args already is a mapping, so it flows into the event % args line unchanged:

if (
    not isinstance(args, Mapping)
    and len(args) == 1
    and isinstance(args[0], dict)
    and args[0]
):
    args = args[0]

Every previously-working input takes exactly the same path, and a mapping is already the correct argument form for keyword placeholders. The docstring is updated to record that a mapping is used as-is.

Tests

  • TestPositionalArgumentsFormatter::test_formats_single_entry_mapping fails before the change with KeyError: 0 at src/structlog/stdlib.py:802, and passes after.
  • pytest tests/test_stdlib.py -q → 142 passed (141 before).
  • Full suite: 4 failed, 898 passed, 20 skipped. Those 4 are pre-existing and unrelated — tests/test_packaging.py::TestLegacyMetadataHack fails with PackageNotFoundError because the package is not pip-installed in my environment. The count went 897 → 898 by exactly this one new test.
  • ruff check and ruff format --check clean on both files.

I checked the 9 open PRs and 25 open issues: PR #832 wraps event_dict["event"] %= args in contextlib.suppress(TypeError, ValueError), but the KeyError happens earlier, on the args[0] subscript, so it does not cover this.

@codspeed

codspeed Bot commented Sep 27, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 10 untouched benchmarks


Comparing januththedev:fix/positional-args-single-mapping (21a44db) with main (bf3cfd0)1

Open in CodSpeed

Footnotes

  1. No successful run was found on main (73393f3) during the generation of this report, so bf3cfd0 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report. ↩

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant