From 40a3d57e787d664c958cb563b9549c49017ad68f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Furga=C5=82a?= <83299832+00200200@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:10:20 +0200 Subject: [PATCH] Copy defaults on construction and treat a string reserved_attrs as one field. defaults was stored by reference, so mutating the caller's dict (or leaving a DictConfigurator ConvertingDict in place) could change later log output. Copy it the same way as rename_fields and static_fields. A string reserved_attrs value is a Sequence of characters, so wrap it as a single field name. --- docs/changelog.md | 2 ++ pyproject.toml | 2 +- src/pythonjsonlogger/core.py | 22 +++++++++++++++++----- tests/test_dictconfig.py | 30 ++++++++++++++++++++++++++++++ tests/test_formatters.py | 29 +++++++++++++++++++++++++++++ 5 files changed, 79 insertions(+), 6 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index b32bcb6..e958e9f 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -12,6 +12,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - `%` 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) +- `defaults` is copied on construction like `rename_fields` and `static_fields`, so DictConfigurator `ext://` prefixes are converted and mutating the caller's dict cannot change formatter state. +- A string passed as `reserved_attrs` is treated as a single field name instead of being iterated as characters. Thanks @dylanpulver diff --git a/pyproject.toml b/pyproject.toml index fe257b4..8c2af40 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "python-json-logger" -version = "4.2.1.dev1" +version = "4.2.1.dev2" 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 eabf90f..7f5c48e 100644 --- a/src/pythonjsonlogger/core.py +++ b/src/pythonjsonlogger/core.py @@ -153,7 +153,8 @@ def __init__( rename_fields_keep_missing: When renaming fields, include missing fields in the output. static_fields: an optional dict, used to add fields with static values to all logs reserved_attrs: an optional list of fields that will be skipped when - outputting json log record. Defaults to [all log record attributes][pythonjsonlogger.core.RESERVED_ATTRS]. + outputting json log record. A single field name may be passed as a + string. Defaults to [all log record attributes][pythonjsonlogger.core.RESERVED_ATTRS]. timestamp: an optional string/boolean field to add a timestamp when outputting the json log record. If string is passed, timestamp will be added to log record using string as key. If True boolean is passed, timestamp key @@ -180,6 +181,11 @@ def __init__( to `python-json-logger` and thus care should be taken to not to pass this format to other logging Formatter implementations. - `fmt` now supports sequences of strings (e.g. lists and tuples) of field names. + + *Changed in 4.2.1*: + + - `defaults` is copied on construction, matching `rename_fields` and `static_fields`. + - A string `reserved_attrs` value is treated as a single field name. """ ## logging.Formatter compatibility ## --------------------------------------------------------------------- @@ -213,8 +219,9 @@ def __init__( ## --------------------------------------------------------------------- self.prefix = prefix - # We recreate the dict in rename_fields and static_fields to support internal/external - # references which require getting the item to do the conversion. + # Recreate these dicts so DictConfigurator `ext://` / `cfg://` prefixes + # are converted (ConvertingDict only converts on `__getitem__`) and so + # later mutation of the caller's dict cannot change formatter state. # For more details see: https://github.com/nhairs/python-json-logger/pull/45 self.rename_fields = ( {key: rename_fields[key] for key in rename_fields} if rename_fields is not None else {} @@ -222,14 +229,19 @@ def __init__( self.static_fields = ( {key: static_fields[key] for key in static_fields} if static_fields is not None else {} ) + self.defaults = {key: defaults[key] for key in defaults} if defaults is not None else {} self.rename_fields_keep_missing = rename_fields_keep_missing - self.reserved_attrs = set(reserved_attrs if reserved_attrs is not None else RESERVED_ATTRS) + # `str` is a Sequence[str] of characters; treat a string as one field name. + if reserved_attrs is None: + reserved_attrs = RESERVED_ATTRS + elif isinstance(reserved_attrs, str): + reserved_attrs = (reserved_attrs,) + self.reserved_attrs = set(reserved_attrs) self.timestamp = timestamp self._skip_fields = set(self._required_fields) self._skip_fields.update(self.reserved_attrs) - self.defaults = defaults if defaults is not None else {} self.exc_info_as_array = exc_info_as_array self.stack_info_as_array = stack_info_as_array return diff --git a/tests/test_dictconfig.py b/tests/test_dictconfig.py index e2e9b44..d863185 100644 --- a/tests/test_dictconfig.py +++ b/tests/test_dictconfig.py @@ -95,3 +95,33 @@ def test_external_reference_support(env: LoggingEnvironment): assert log_json["ext-val"] == EXT_VAL assert log_json["dummy"] == "DUMMY" return + + +def test_defaults_external_reference_support() -> None: + logging.config.dictConfig( + { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "default": { + "()": "pythonjsonlogger.json.JsonFormatter", + "defaults": {"ext-val": "ext://tests.test_dictconfig.EXT_VAL"}, + } + }, + "handlers": { + "default": { + "level": "DEBUG", + "formatter": "default", + "class": "logging.StreamHandler", + "stream": "ext://sys.stdout", + }, + }, + "loggers": { + "": {"handlers": ["default"], "level": "WARNING", "propagate": False}, + }, + } + ) + formatter = logging.root.handlers[0].formatter + assert formatter.defaults["ext-val"] == EXT_VAL # type: ignore[union-attr] + assert type(formatter.defaults) is dict # type: ignore[union-attr] + return diff --git a/tests/test_formatters.py b/tests/test_formatters.py index 3f12fcc..d6b8592 100644 --- a/tests/test_formatters.py +++ b/tests/test_formatters.py @@ -260,6 +260,35 @@ def test_defaults_field(env: LoggingEnvironment, class_: type[BaseJsonFormatter] return +@pytest.mark.parametrize("class_", ALL_FORMATTERS) +def test_defaults_not_shared_with_caller(env: LoggingEnvironment, class_: type[BaseJsonFormatter]): + defaults = {"environment": "dev"} + formatter = class_(defaults=defaults) + assert formatter.defaults is not defaults + defaults["environment"] = "prod" + env.set_formatter(formatter) + + env.logger.info("testing defaults isolation") + log_json = env.load_json() + + assert log_json["environment"] == "dev" + return + + +@pytest.mark.parametrize("class_", ALL_FORMATTERS) +def test_reserved_attrs_string(env: LoggingEnvironment, class_: type[BaseJsonFormatter]): + env.set_formatter(class_(reserved_attrs="filename")) + + env.logger.info("hello", extra={"user": "x"}) + log_json = env.load_json() + + assert log_json["message"] == "hello" + assert log_json["user"] == "x" + assert "filename" not in log_json + assert "levelname" in log_json + return + + @pytest.mark.parametrize("class_", ALL_FORMATTERS) def test_rename_base_field(env: LoggingEnvironment, class_: type[BaseJsonFormatter]): env.set_formatter(class_(rename_fields={"message": "@message"}))