Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down
20 changes: 17 additions & 3 deletions src/pythonjsonlogger/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -65,7 +66,8 @@
r"\$(?:\$|\{(?P<braced>.+?)\}|(?P<named>[_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<named>.+?)\))", re.IGNORECASE) # % style

## Type Aliases
## -----------------------------------------------------------------------------
Expand Down Expand Up @@ -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")

Expand Down
35 changes: 35 additions & 0 deletions tests/test_formatters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down