From b48dec63b8205976c1f26b6e1f068c5a845c92ea Mon Sep 17 00:00:00 2001 From: Dylan Pulver Date: Thu, 3 Sep 2026 22:55:44 +0300 Subject: [PATCH 1/3] [core] fix field detection for escaped % and { format styles `parse()` read `%%(name)s` and `{{name}}` as fields even though both are escaped literals, and read a `{` style conversion or format spec as part of the field name, so `{levelname:>8}` produced a `"levelname:>8": null` key and dropped `levelname`. `%` now skips `%%` the same way the `$` style skips `$$` (#69), and `{` uses `string.Formatter`, which is what `logging.StrFormatStyle.validate` parses the same string with. Assisted-by: claude-opus-5 --- docs/changelog.md | 9 +++++++++ pyproject.toml | 2 +- src/pythonjsonlogger/core.py | 20 +++++++++++++++++--- tests/test_formatters.py | 32 ++++++++++++++++++++++++++++++++ 4 files changed, 59 insertions(+), 4 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index 901974b..ba2eefe 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -4,6 +4,15 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [4.2.1.dev1](https://github.com/nhairs/python-json-logger/compare/v4.2.0...main) - unreleased + +### Fixed +- `%` style formats no longer treat the escaped literal `%%` as the start of a field, so + `"%%(notafield)s"` is correctly read as literal text. +- `{` style formats now use `string.Formatter` (as `logging.StrFormatStyle.validate` does) to find + fields, so escaped literal braces (`{{`/`}}`) are skipped and a conversion (`{message!r}`) or + format spec (`{levelname:>8}`) is no longer treated as part of the field name. + ## [4.2.0](https://github.com/nhairs/python-json-logger/compare/v4.1.0...v4.2.0) - 2026-08-15 ### Changed diff --git a/pyproject.toml b/pyproject.toml index e35bd58..fe257b4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "python-json-logger" -version = "4.2.0" +version = "4.2.1.dev1" description = "JSON Log Formatter for the Python Logging Package" authors = [ {name = "Zakaria Zajac", email = "zak@madzak.com"}, diff --git a/src/pythonjsonlogger/core.py b/src/pythonjsonlogger/core.py index 40282c2..6696017 100644 --- a/src/pythonjsonlogger/core.py +++ b/src/pythonjsonlogger/core.py @@ -9,6 +9,7 @@ from datetime import datetime, timezone import logging import re +import string import sys from typing import TypeAlias, Any from collections.abc import Container, Sequence @@ -65,7 +66,8 @@ r"\$(?:\$|\{(?P.+?)\}|(?P[_a-z][_a-z0-9]*))", re.IGNORECASE ) # $ style STYLE_STRING_FORMAT_REGEX = re.compile(r"\{(.+?)\}", re.IGNORECASE) # { style -STYLE_PERCENT_REGEX = re.compile(r"%\((.+?)\)", re.IGNORECASE) # % style +# Deprecated: no longer used by `parse`, which uses `string.Formatter` instead. +STYLE_PERCENT_REGEX = re.compile(r"%(?:%|\((?P.+?)\))", re.IGNORECASE) # % style ## Type Aliases ## ----------------------------------------------------------------------------- @@ -311,12 +313,24 @@ def parse(self) -> list[str]: ] if isinstance(self._style, logging.StrFormatStyle): - return STYLE_STRING_FORMAT_REGEX.findall(self._fmt) + # str.format escapes literal braces as {{ and }}, and a replacement field may + # carry a conversion (!r) or a format spec (:>10) that is not part of its name. + # string.Formatter is what logging.StrFormatStyle.validate itself parses with. + return [ + field_name + for _, field_name, _, _ in string.Formatter().parse(self._fmt) + if field_name + ] if isinstance(self._style, logging.PercentStyle): # PercentStyle is parent class of StringTemplateStyle and StrFormatStyle # so it must be checked last. - return STYLE_PERCENT_REGEX.findall(self._fmt) + # %% is an escaped literal percent, so %%(name)s is not a field. + return [ + match.group("named") + for match in STYLE_PERCENT_REGEX.finditer(self._fmt) + if match.group("named") + ] raise ValueError(f"Style {self._style!r} is not supported") diff --git a/tests/test_formatters.py b/tests/test_formatters.py index bc234ec..8427590 100644 --- a/tests/test_formatters.py +++ b/tests/test_formatters.py @@ -183,6 +183,38 @@ def test_string_template_format(env: LoggingEnvironment, class_: type[BaseJsonFo return +@pytest.mark.parametrize("class_", ALL_FORMATTERS) +def test_percentage_format_escaped_percent( + env: LoggingEnvironment, class_: type[BaseJsonFormatter] +): + # Note: %% is an escaped literal percent, so %%(notafield)s is not a field + env.set_formatter(class_("%(levelname)s %(message)s 100%% %%(notafield)s")) + + msg = "testing logging format" + env.logger.info(msg) + log_json = env.load_json() + + assert log_json["message"] == msg + assert log_json.keys() == {"levelname", "message"} + return + + +@pytest.mark.parametrize("class_", ALL_FORMATTERS) +def test_str_format_format(env: LoggingEnvironment, class_: type[BaseJsonFormatter]): + # Note: {{ }} is an escaped literal brace, and !r / :>8 are not part of a field name + env.set_formatter( + class_("{{literal}} {levelname:>8} {message!r} {filename} {lineno} {asctime}", style="{") + ) + + msg = "testing logging format" + env.logger.info(msg) + log_json = env.load_json() + + assert log_json["message"] == msg + assert log_json.keys() == {"levelname", "message", "filename", "lineno", "asctime"} + return + + @pytest.mark.parametrize("class_", ALL_FORMATTERS) def test_comma_format(env: LoggingEnvironment, class_: type[BaseJsonFormatter]): # Note: we have double comma `,,` to test handling "empty" names From 96079173139b8935d44b8638b46bd447eabbe885 Mon Sep 17 00:00:00 2001 From: Dylan Pulver Date: Thu, 3 Sep 2026 22:57:26 +0300 Subject: [PATCH 2/3] [tests] cover a nested format spec in the { style test A regex-only fix passes the plain `{levelname:>8}` case but reads `{width}` out of `{levelname:>{width}}`, which stdlib accepts. Assisted-by: claude-opus-5 --- tests/test_formatters.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/test_formatters.py b/tests/test_formatters.py index 8427590..6efc83c 100644 --- a/tests/test_formatters.py +++ b/tests/test_formatters.py @@ -201,9 +201,12 @@ def test_percentage_format_escaped_percent( @pytest.mark.parametrize("class_", ALL_FORMATTERS) def test_str_format_format(env: LoggingEnvironment, class_: type[BaseJsonFormatter]): - # Note: {{ }} is an escaped literal brace, and !r / :>8 are not part of a field name + # Note: {{ }} is an escaped literal brace, and !r / :>{width} are not part of a field name env.set_formatter( - class_("{{literal}} {levelname:>8} {message!r} {filename} {lineno} {asctime}", style="{") + class_( + "{{literal}} {levelname:>{width}} {message!r} {filename} {lineno} {asctime}", + style="{", + ) ) msg = "testing logging format" From 98f2e172a83e1d6d152533f24dc4be3bb398ec37 Mon Sep 17 00:00:00 2001 From: Dylan Pulver Date: Tue, 15 Sep 2026 00:13:36 +0200 Subject: [PATCH 3/3] Apply review feedback Remove STYLE_STRING_FORMAT_REGEX rather than deprecating it, drop the explanatory comments in parse, merge the escaped-percent test into test_percentage_format, and fill in the changelog heading and references. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011M5uTyCU4WcNTsPvGrErDo --- docs/changelog.md | 14 ++++++++------ src/pythonjsonlogger/core.py | 6 ------ tests/test_formatters.py | 26 ++++++++------------------ 3 files changed, 16 insertions(+), 30 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index ba2eefe..b32bcb6 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -4,14 +4,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [4.2.1.dev1](https://github.com/nhairs/python-json-logger/compare/v4.2.0...main) - unreleased +## [4.2.1](https://github.com/nhairs/python-json-logger/compare/v4.2.0...v4.2.1) - UNRELEASED + +### Removed +- `STYLE_STRING_FORMAT_REGEX`, which is no longer used to find `{` style fields. [#75](https://github.com/nhairs/python-json-logger/pull/75) ### Fixed -- `%` style formats no longer treat the escaped literal `%%` as the start of a field, so - `"%%(notafield)s"` is correctly read as literal text. -- `{` style formats now use `string.Formatter` (as `logging.StrFormatStyle.validate` does) to find - fields, so escaped literal braces (`{{`/`}}`) are skipped and a conversion (`{message!r}`) or - format spec (`{levelname:>8}`) is no longer treated as part of the field name. +- `%` style formats no longer treat the escaped literal `%%` as the start of a field, so `"%%(notafield)s"` is correctly read as literal text. [#75](https://github.com/nhairs/python-json-logger/pull/75) +- `{` style formats now use `string.Formatter` (as `logging.StrFormatStyle.validate` does) to find fields, so escaped literal braces (`{{`/`}}`) are skipped and a conversion (`{message!r}`) or format spec (`{levelname:>8}`) is no longer treated as part of the field name. [#75](https://github.com/nhairs/python-json-logger/pull/75) + +Thanks @dylanpulver ## [4.2.0](https://github.com/nhairs/python-json-logger/compare/v4.1.0...v4.2.0) - 2026-08-15 diff --git a/src/pythonjsonlogger/core.py b/src/pythonjsonlogger/core.py index 6696017..eabf90f 100644 --- a/src/pythonjsonlogger/core.py +++ b/src/pythonjsonlogger/core.py @@ -65,8 +65,6 @@ STYLE_STRING_TEMPLATE_REGEX = re.compile( r"\$(?:\$|\{(?P.+?)\}|(?P[_a-z][_a-z0-9]*))", re.IGNORECASE ) # $ style -STYLE_STRING_FORMAT_REGEX = re.compile(r"\{(.+?)\}", re.IGNORECASE) # { style -# Deprecated: no longer used by `parse`, which uses `string.Formatter` instead. STYLE_PERCENT_REGEX = re.compile(r"%(?:%|\((?P.+?)\))", re.IGNORECASE) # % style ## Type Aliases @@ -313,9 +311,6 @@ def parse(self) -> list[str]: ] if isinstance(self._style, logging.StrFormatStyle): - # str.format escapes literal braces as {{ and }}, and a replacement field may - # carry a conversion (!r) or a format spec (:>10) that is not part of its name. - # string.Formatter is what logging.StrFormatStyle.validate itself parses with. return [ field_name for _, field_name, _, _ in string.Formatter().parse(self._fmt) @@ -325,7 +320,6 @@ def parse(self) -> list[str]: if isinstance(self._style, logging.PercentStyle): # PercentStyle is parent class of StringTemplateStyle and StrFormatStyle # so it must be checked last. - # %% is an escaped literal percent, so %%(name)s is not a field. return [ match.group("named") for match in STYLE_PERCENT_REGEX.finditer(self._fmt) diff --git a/tests/test_formatters.py b/tests/test_formatters.py index 6efc83c..3f12fcc 100644 --- a/tests/test_formatters.py +++ b/tests/test_formatters.py @@ -155,8 +155,14 @@ def test_default_format(env: LoggingEnvironment, class_: type[BaseJsonFormatter] @pytest.mark.parametrize("class_", ALL_FORMATTERS) def test_percentage_format(env: LoggingEnvironment, class_: type[BaseJsonFormatter]): - # Note: We use different %s styles in the format to check the regex correctly collects them - env.set_formatter(class_("[%(levelname)8s] %(message)s %(filename)s:%(lineno)d %(asctime)")) + # Note: We use different %s styles in the format to check the regex correctly collects them, + # and %% is an escaped literal percent so %%(notafield)s is not a field + env.set_formatter( + class_( + "[%(levelname)8s] %(message)s %(filename)s:%(lineno)d" + " 100%% %%(notafield)s %(asctime)" + ) + ) msg = "testing logging format" env.logger.info(msg) @@ -183,22 +189,6 @@ def test_string_template_format(env: LoggingEnvironment, class_: type[BaseJsonFo return -@pytest.mark.parametrize("class_", ALL_FORMATTERS) -def test_percentage_format_escaped_percent( - env: LoggingEnvironment, class_: type[BaseJsonFormatter] -): - # Note: %% is an escaped literal percent, so %%(notafield)s is not a field - env.set_formatter(class_("%(levelname)s %(message)s 100%% %%(notafield)s")) - - msg = "testing logging format" - env.logger.info(msg) - log_json = env.load_json() - - assert log_json["message"] == msg - assert log_json.keys() == {"levelname", "message"} - return - - @pytest.mark.parametrize("class_", ALL_FORMATTERS) def test_str_format_format(env: LoggingEnvironment, class_: type[BaseJsonFormatter]): # Note: {{ }} is an escaped literal brace, and !r / :>{width} are not part of a field name