From 97decbd2555717570d06c92ee444dc817a0223cf Mon Sep 17 00:00:00 2001 From: Nicholas Hairs Date: Mon, 26 May 2025 19:50:52 +1000 Subject: [PATCH] [core] Support sequence of strings for Formatter fmt Fixes: #16 --- docs/changelog.md | 1 + src/pythonjsonlogger/core.py | 60 +++++++++++++++++++++++------------- tests/test_formatters.py | 26 ++++++++++++++++ 3 files changed, 65 insertions(+), 22 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index 624cdf3..5cb6ab7 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Allows using values like `ext://sys.stderr` in `fileConfig`/`dictConfig` value fields. - Support comma seperated lists for Formatter `fmt` (`style=","`) e.g. `"asctime,message,levelname"` [#15](https://github.com/nhairs/python-json-logger/issues/15) - Note that this style is specific to `python-json-logger` and thus care should be taken not to pass this format to other logging Formatter implementations. +- Supports sequences of strings (e.g. lists and tuples) of field names for Formatter `fmt`. ### Changed - Rename `pythonjsonlogger.core.LogRecord` and `log_record` arguemtns to avoid confusion / overlapping with `logging.LogRecord`. [#38](https://github.com/nhairs/python-json-logger/issues/38) diff --git a/src/pythonjsonlogger/core.py b/src/pythonjsonlogger/core.py index 2f2bcd2..a88b3c8 100644 --- a/src/pythonjsonlogger/core.py +++ b/src/pythonjsonlogger/core.py @@ -128,7 +128,7 @@ class BaseJsonFormatter(logging.Formatter): # pylint: disable=too-many-arguments,super-init-not-called def __init__( self, - fmt: Optional[str] = None, + fmt: Optional[Union[str, Sequence[str]]] = None, datefmt: Optional[str] = None, style: str = "%", validate: bool = True, @@ -145,11 +145,11 @@ def __init__( ) -> None: """ Args: - fmt: string representing fields to log + fmt: String format or `Sequence` of field names of fields to log. datefmt: format to use when formatting `asctime` field - style: how to extract log fields from `fmt` + style: how to extract log fields from `fmt`. Ignored if `fmt` is a `Sequence[str]`. validate: validate `fmt` against style, if implementing a custom `style` you - must set this to `False`. + must set this to `False`. Ignored if `fmt` is a `Sequence[str]`. defaults: a dictionary containing default fields that are added before all other fields and may be overridden. The supplied fields are still subject to `rename_fields`. prefix: an optional string prefix added at the beginning of @@ -181,23 +181,34 @@ def __init__( - `fmt` now supports comma seperated lists (`style=","`). Note that this style is specific 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. """ ## logging.Formatter compatibility ## --------------------------------------------------------------------- # Note: validate added in 3.8, defaults added in 3.10 - if style in logging._STYLES: - _style = logging._STYLES[style][0](fmt) # type: ignore[operator] - if validate: - _style.validate() - self._style = _style - self._fmt = _style._fmt - elif style == "," or not validate: - self._style = style - self._fmt = fmt + if fmt is None or isinstance(fmt, str): + if style in logging._STYLES: + _style = logging._STYLES[style][0](fmt) # type: ignore[operator] + if validate: + _style.validate() + self._style = _style + self._fmt = _style._fmt - else: - raise ValueError(f"Style must be one of: {','.join(logging._STYLES.keys())}") + elif style == "," or not validate: + self._style = style + self._fmt = fmt + + else: + raise ValueError(f"Style must be one of: {','.join(logging._STYLES.keys())}") + + self._required_fields = self.parse() + + # Note: we do this check second as string is still a Sequence[str] + elif isinstance(fmt, Sequence): + self._style = "__sequence__" + self._fmt = str(fmt) + self._required_fields = list(fmt) self.datefmt = datefmt @@ -219,7 +230,6 @@ def __init__( self.reserved_attrs = set(reserved_attrs if reserved_attrs is not None else RESERVED_ATTRS) self.timestamp = timestamp - self._required_fields = self.parse() self._skip_fields = set(self._required_fields) self._skip_fields.update(self.reserved_attrs) self.defaults = defaults if defaults is not None else {} @@ -282,12 +292,18 @@ def parse(self) -> List[str]: # (we already (mostly) check for valid style names in __init__ return [] - if isinstance(self._style, str) and self._style == ",": - # TODO: should we check that there are no empty fields? - # If yes we should do this in __init__ where we validate other styles? - # Do we drop empty fields? - # etc - return [field.strip() for field in self._fmt.split(",") if field.strip()] + if isinstance(self._style, str): + if self._style == "__sequence__": + raise RuntimeError("Must not call parse when fmt is a sequence of strings") + + if self._style == ",": + # TODO: should we check that there are no empty fields? + # If yes we should do this in __init__ where we validate other styles? + # Do we drop empty fields? + # etc + return [field.strip() for field in self._fmt.split(",") if field.strip()] + + raise ValueError(f"Style {self._style!r} is not supported") if isinstance(self._style, logging.StringTemplateStyle): formatter_style_pattern = STYLE_STRING_TEMPLATE_REGEX diff --git a/tests/test_formatters.py b/tests/test_formatters.py index 0a0f458..349a2e2 100644 --- a/tests/test_formatters.py +++ b/tests/test_formatters.py @@ -184,6 +184,32 @@ def test_comma_format(env: LoggingEnvironment, class_: type[BaseJsonFormatter]): return +@pytest.mark.parametrize("class_", ALL_FORMATTERS) +def test_sequence_list_format(env: LoggingEnvironment, class_: type[BaseJsonFormatter]): + env.set_formatter(class_(["levelname", "message", "filename", "lineno", "asctime"])) + + 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_sequence_tuple_format(env: LoggingEnvironment, class_: type[BaseJsonFormatter]): + env.set_formatter(class_(("levelname", "message", "filename", "lineno", "asctime"))) + + 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_defaults_field(env: LoggingEnvironment, class_: type[BaseJsonFormatter]): env.set_formatter(class_(defaults={"first": 1, "second": 2}))