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..6efc83c 100644 --- a/tests/test_formatters.py +++ b/tests/test_formatters.py @@ -183,6 +183,41 @@ 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 + env.set_formatter( + class_( + "{{literal}} {levelname:>{width}} {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