From ad4d0d11622a9a50ddaee9c7b565719d94ef3b67 Mon Sep 17 00:00:00 2001 From: Nicholas Hairs Date: Sun, 22 Mar 2026 20:28:30 +1100 Subject: [PATCH 1/4] [simplejson,ultrajson] Implement formatters for simplejson and ultrajson (ujson) --- docs/changelog.md | 2 + pylintrc | 2 +- pyproject.toml | 5 +- src/pythonjsonlogger/__init__.py | 4 +- src/pythonjsonlogger/simplejson.py | 86 +++++++++++++++++++++++++++++ src/pythonjsonlogger/ultrajson.py | 89 ++++++++++++++++++++++++++++++ tests/test_formatters.py | 55 ++++++++++++++++-- 7 files changed, 235 insertions(+), 8 deletions(-) create mode 100644 src/pythonjsonlogger/simplejson.py create mode 100644 src/pythonjsonlogger/ultrajson.py diff --git a/docs/changelog.md b/docs/changelog.md index 6d2368a..1440b2e 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -8,6 +8,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add support for Python 3.14, PyPy 3.11 +- Add support for [simplejson](https://github.com/simplejson/simplejson) +- Add support for [ultrajson](https://github.com/ultrajson/ultrajson/tree/main?tab=readme-ov-file#project-status) (`ujson`) ## [4.0.0](https://github.com/nhairs/python-json-logger/compare/v3.3.3...v4.0.0) - 2025-10-06 diff --git a/pylintrc b/pylintrc index 779decc..da76d5c 100644 --- a/pylintrc +++ b/pylintrc @@ -3,7 +3,7 @@ # A comma-separated list of package or module names from where C extensions may # be loaded. Extensions are loading into the active Python interpreter and may # run arbitrary code. -extension-pkg-whitelist=orjson +extension-pkg-whitelist=orjson,ujson # Add files or directories to the blacklist. They should be base names, not # paths. diff --git a/pyproject.toml b/pyproject.toml index b2d718c..c5d92ee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,8 +47,11 @@ GitHub = "https://github.com/nhairs/python-json-logger" [project.optional-dependencies] dev = [ ## Optional but required for dev - "orjson;implementation_name!='pypy'", "msgspec;implementation_name!='pypy'", + "orjson;implementation_name!='pypy'", + "simplejson", + "types-simplejson", + "ujson", ## Lint "validate-pyproject[all]", "black", diff --git a/src/pythonjsonlogger/__init__.py b/src/pythonjsonlogger/__init__.py index 298a3fe..ae3c67b 100644 --- a/src/pythonjsonlogger/__init__.py +++ b/src/pythonjsonlogger/__init__.py @@ -13,5 +13,7 @@ ### CONSTANTS ### ============================================================================ -ORJSON_AVAILABLE = utils.package_is_available("orjson") MSGSPEC_AVAILABLE = utils.package_is_available("msgspec") +ORJSON_AVAILABLE = utils.package_is_available("orjson") +SIMPLEJSON_AVAILABLE = utils.package_is_available("simplejson") +ULTRAJSON_AVAILABLE = utils.package_is_available("ujson") diff --git a/src/pythonjsonlogger/simplejson.py b/src/pythonjsonlogger/simplejson.py new file mode 100644 index 0000000..a2e7d78 --- /dev/null +++ b/src/pythonjsonlogger/simplejson.py @@ -0,0 +1,86 @@ +"""JSON Formatter using [simplejson](https://github.com/simplejson/simplejson/tree/master)""" + +### IMPORTS +### ============================================================================ +## Future +from __future__ import annotations + +## Standard Library +from typing import Any, Optional, Union, Callable + +## Installed + +## Application +from . import core +from . import defaults as d +from .utils import package_is_available + +# We import simplejson after checking it is available +package_is_available("simplejson", throw_error=True) +import simplejson # pylint: disable=wrong-import-position,wrong-import-order + + +### FUNCTIONS +### ============================================================================ +def simplejson_default(obj: Any) -> Any: + """simplejson default encoder function for non-standard types""" + if d.use_exception_default(obj): + return d.exception_default(obj) + + if d.use_traceback_default(obj): + return d.traceback_default(obj) + + if d.use_enum_default(obj): + return d.enum_default(obj) + + if d.use_dataclass_default(obj): + return d.dataclass_default(obj) + + if d.use_type_default(obj): + return d.type_default(obj) + + return d.unknown_default(obj) + + +### CLASSES +### ============================================================================ +class SimpleJsonFormatter(core.BaseJsonFormatter): + """JSON formatter using [simplejson](https://github.com/simplejson/simplejson/tree/master) for encoding. + + !!! warning "Note that simplejson handles certain input different to other encoders in python-json-logger." + + `datetime.datetime` objects use `' '` as the delimiter instead of `'T'`. + + `bytes` can only be encoded if they are valid `utf-8`. + + + """ + + def __init__( + self, + *args, + json_default: Optional[Callable] = simplejson_default, + json_indent: Optional[Union[int, str]] = None, + **kwargs, + ) -> None: + """ + Args: + args: see [BaseJsonFormatter][pythonjsonlogger.core.BaseJsonFormatter] + json_default: a function for encoding non-standard objects + json_indent: indent output with this number of spaces or with the given string. + kwargs: see [BaseJsonFormatter][pythonjsonlogger.core.BaseJsonFormatter] + """ + super().__init__(*args, **kwargs) + + # TODO: consider supporting for_json + # REF: https://github.com/simplejson/simplejson/blob/master/simplejson/encoder.py#L220 + + self.json_encoder = simplejson.JSONEncoder( + default=json_default, + indent=json_indent, + ) + return + + def jsonify_log_record(self, log_data: core.LogData) -> str: + """Returns a json string of the log data.""" + return self.json_encoder.encode(log_data) diff --git a/src/pythonjsonlogger/ultrajson.py b/src/pythonjsonlogger/ultrajson.py new file mode 100644 index 0000000..8150049 --- /dev/null +++ b/src/pythonjsonlogger/ultrajson.py @@ -0,0 +1,89 @@ +"""JSON Formatter using [ultrajson](https://github.com/ultrajson/ultrajson)""" + +### IMPORTS +### ============================================================================ +## Future +from __future__ import annotations + +## Standard Library +from typing import Any, Optional, Callable + +## Installed + +## Application +from . import core +from . import defaults as d +from .utils import package_is_available + +# We import ujson after checking it is available +package_is_available("ujson", throw_error=True) +import ujson # pylint: disable=wrong-import-position,wrong-import-order + + +### FUNCTIONS +### ============================================================================ +def ujson_default(obj: Any) -> Any: + """ujson default encoder function for non-standard types""" + + if d.use_exception_default(obj): + return d.exception_default(obj) + + if d.use_traceback_default(obj): + return d.traceback_default(obj) + + if d.use_enum_default(obj): + return d.enum_default(obj) + + if d.use_dataclass_default(obj): + return d.dataclass_default(obj) + + if d.use_type_default(obj): + return d.type_default(obj) + + return d.unknown_default(obj) + + +### CLASSES +### ============================================================================ +class UltraJsonFormatter(core.BaseJsonFormatter): + """JSON formatter using [ultrajson](https://github.com/ultrajson/ultrajson) (`ujson`) for encoding. + + !!! warning "UltraJSON is in maintenance mode" + [Per README](https://github.com/ultrajson/ultrajson/tree/main?tab=readme-ov-file#project-status) users + are encouraged to move to another JSON encoder such as `orjson`. + + !!! warning "Note that ultrajson handles certain input different to other encoders in python-json-logger." + + `datetime.datetime` objects use `' '` as the delimiter instead of `'T'`. + + `bytes` can only be encoded if they are valid `utf-8`. + + + """ + + def __init__( + self, + *args, + json_default: Optional[Callable] = ujson_default, + json_indent: int = 0, + **kwargs, + ) -> None: + """ + Args: + args: see [BaseJsonFormatter][pythonjsonlogger.core.BaseJsonFormatter] + json_default: a function for encoding non-standard objects + json_indent: indent output with this number of spaces. + kwargs: see [BaseJsonFormatter][pythonjsonlogger.core.BaseJsonFormatter] + """ + super().__init__(*args, **kwargs) + + self.json_default = json_default + self.json_indent = json_indent + + return + + def jsonify_log_record(self, log_data: core.LogData) -> str: + """Returns a json string of the log data.""" + return ujson.dumps( + log_data, indent=self.json_indent, default=self.json_default, reject_bytes=False + ) diff --git a/tests/test_formatters.py b/tests/test_formatters.py index 2e16e96..62c0660 100644 --- a/tests/test_formatters.py +++ b/tests/test_formatters.py @@ -31,20 +31,34 @@ from pythonjsonlogger.core import RESERVED_ATTRS, BaseJsonFormatter, merge_record_extra from pythonjsonlogger.json import JsonFormatter +if pythonjsonlogger.MSGSPEC_AVAILABLE: + from pythonjsonlogger.msgspec import MsgspecFormatter + if pythonjsonlogger.ORJSON_AVAILABLE: from pythonjsonlogger.orjson import OrjsonFormatter -if pythonjsonlogger.MSGSPEC_AVAILABLE: - from pythonjsonlogger.msgspec import MsgspecFormatter +if pythonjsonlogger.SIMPLEJSON_AVAILABLE: + from pythonjsonlogger.simplejson import SimpleJsonFormatter + +if pythonjsonlogger.ULTRAJSON_AVAILABLE: + from pythonjsonlogger.ultrajson import UltraJsonFormatter ### SETUP ### ============================================================================ ALL_FORMATTERS: list[type[BaseJsonFormatter]] = [JsonFormatter] -if pythonjsonlogger.ORJSON_AVAILABLE: - ALL_FORMATTERS.append(OrjsonFormatter) + if pythonjsonlogger.MSGSPEC_AVAILABLE: ALL_FORMATTERS.append(MsgspecFormatter) +if pythonjsonlogger.ORJSON_AVAILABLE: + ALL_FORMATTERS.append(OrjsonFormatter) + +if pythonjsonlogger.SIMPLEJSON_AVAILABLE: + ALL_FORMATTERS.append(SimpleJsonFormatter) + +if pythonjsonlogger.ULTRAJSON_AVAILABLE: + ALL_FORMATTERS.append(UltraJsonFormatter) + _LOGGER_COUNT = 0 @@ -543,7 +557,15 @@ def json_default(obj: Any) -> Any: env.logger.info("Hello") log_json = env.load_json() - assert log_json["timestamp"] == "2017-07-14T02:40:00+00:00" + expected = "2017-07-14T02:40:00+00:00" + + if (pythonjsonlogger.SIMPLEJSON_AVAILABLE and class_ is SimpleJsonFormatter) or ( + pythonjsonlogger.ULTRAJSON_AVAILABLE and class_ is UltraJsonFormatter + ): + # simplejson and ujson do not use sep=T + expected = expected.replace("T", " ") + + assert log_json["timestamp"] == expected return @@ -616,6 +638,29 @@ def test_common_types_encoded( ): pytest.xfail() + if (pythonjsonlogger.SIMPLEJSON_AVAILABLE and class_ is SimpleJsonFormatter) or ( + pythonjsonlogger.ULTRAJSON_AVAILABLE and class_ is UltraJsonFormatter + ): + if obj == b"fancy-bytes-\xf0\xf1": + # simplejson attempts to encode using `utf-8` and thus does not support arbitrary bytes + # ultrajson prevents bytes or errors when receiving non `utf-8` bytes + pytest.xfail() + + ## Overrides + if (pythonjsonlogger.SIMPLEJSON_AVAILABLE and class_ is SimpleJsonFormatter) or ( + pythonjsonlogger.ULTRAJSON_AVAILABLE and class_ is UltraJsonFormatter + ): + if isinstance(obj, datetime.datetime): + # simplejson and ujson do not use sep=T + expected = expected.replace("T", " ") + + elif obj is MultiEnum.BYTES or obj == b"some-bytes": + expected = "some-bytes" + + elif obj is MultiEnum: + expected = list(expected) + expected[4] = "some-bytes" + ## Test env.set_formatter(class_()) extra = { From 8a5bdd3accbd008cdee95da9f13afe4a0f46440d Mon Sep 17 00:00:00 2001 From: Nicholas Hairs Date: Sun, 22 Mar 2026 20:56:36 +1100 Subject: [PATCH 2/4] Add changelog information to docstrings --- src/pythonjsonlogger/simplejson.py | 1 + src/pythonjsonlogger/ultrajson.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/pythonjsonlogger/simplejson.py b/src/pythonjsonlogger/simplejson.py index a2e7d78..2c4534c 100644 --- a/src/pythonjsonlogger/simplejson.py +++ b/src/pythonjsonlogger/simplejson.py @@ -54,6 +54,7 @@ class SimpleJsonFormatter(core.BaseJsonFormatter): `bytes` can only be encoded if they are valid `utf-8`. + New in `4.1` """ def __init__( diff --git a/src/pythonjsonlogger/ultrajson.py b/src/pythonjsonlogger/ultrajson.py index 8150049..3ca56b9 100644 --- a/src/pythonjsonlogger/ultrajson.py +++ b/src/pythonjsonlogger/ultrajson.py @@ -58,7 +58,7 @@ class UltraJsonFormatter(core.BaseJsonFormatter): `bytes` can only be encoded if they are valid `utf-8`. - + New in `4.1` """ def __init__( From 5f13ff67701c74dcf490ec99c5734bfe02ca5821 Mon Sep 17 00:00:00 2001 From: Nicholas Hairs Date: Sun, 22 Mar 2026 21:02:36 +1100 Subject: [PATCH 3/4] Don't install ujson on pypy --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index c5d92ee..9187405 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,7 +51,7 @@ dev = [ "orjson;implementation_name!='pypy'", "simplejson", "types-simplejson", - "ujson", + "ujson;implementation_name!='pypy'", ## Lint "validate-pyproject[all]", "black", From b1736ae774caf27a4f772b318002389f77ac2551 Mon Sep 17 00:00:00 2001 From: Nicholas Hairs Date: Sun, 22 Mar 2026 21:03:42 +1100 Subject: [PATCH 4/4] Revert "Don't install ujson on pypy" This reverts commit 5f13ff67701c74dcf490ec99c5734bfe02ca5821. The bug is only on pypy + mac runner --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 9187405..c5d92ee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,7 +51,7 @@ dev = [ "orjson;implementation_name!='pypy'", "simplejson", "types-simplejson", - "ujson;implementation_name!='pypy'", + "ujson", ## Lint "validate-pyproject[all]", "black",