Skip to content

Commit b37c54b

Browse files
authored
Improve non-standard type encoding (#12)
This PR improves the JSON encoding of non-standard types by introducing and using the `.defaults` module. The `.defaults` module adds helper functions that can test and apply formatting for types not supported by a given encoder. Please note that in doing so, some outputs of the `JsonFormatter` have changed. That said these changes return more "reasonable" results rather the the original `str(o)` fallback. For more detailed list of changes to the encoders see the CHANGELOG. ## Test Plan Have added additional tests and now check for specific output.
1 parent 59439e9 commit b37c54b

File tree

8 files changed

+327
-112
lines changed

8 files changed

+327
-112
lines changed

CHANGELOG.md

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,27 @@ All notable changes to this project will be documented in this file.
44
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
55
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

7-
## [3.1.0.rc1](https://github.com/nhairs/python-json-logger/compare/v3.0.1...v3.1.0.rc1) - 2023-05-03
7+
## [3.1.0.rc2](https://github.com/nhairs/python-json-logger/compare/v3.0.1...v3.1.0.rc2) - 2023-05-03
88

99
This splits common funcitonality out to allow supporting other JSON encoders. Although this is a large refactor, backwards compatibility has been maintained.
1010

1111
### Added
1212
- `.core` - more details below.
13-
- Orjson encoder support via `.orjson.OrjsonFormatter`.
14-
- MsgSpec encoder support via `.msgspec.MsgspecFormatter`.
13+
- `.defaults` module that provides many functions for handling unsupported types.
14+
- Orjson encoder support via `.orjson.OrjsonFormatter` with the following additions:
15+
- bytes are URL safe base64 encoded.
16+
- Exceptions are "pretty printed" using the exception name and message e.g. `"ValueError: bad value passed"`
17+
- Enum values use their value, Enum classes now return all values as a list.
18+
- Tracebacks are supported
19+
- Classes (aka types) are support
20+
- Will fallback on `__str__` if available, else `__repr__` if available, else will use `__could_not_encode__`
21+
- MsgSpec encoder support via `.msgspec.MsgspecFormatter` with the following additions:
22+
- Exceptions are "pretty printed" using the exception name and message e.g. `"ValueError: bad value passed"`
23+
- Enum classes now return all values as a list.
24+
- Tracebacks are supported
25+
- Classes (aka types) are support
26+
- Will fallback on `__str__` if available, else `__repr__` if available, else will use `__could_not_encode__`
27+
- Note: msgspec only supprts enum values of type `int` or `str` [jcrist/msgspec#680](https://github.com/jcrist/msgspec/issues/680)
1528

1629
### Changed
1730
- `.jsonlogger` has been moved to `.json` with core functionality moved to `.core`.
@@ -21,6 +34,12 @@ This splits common funcitonality out to allow supporting other JSON encoders. Al
2134
- `style` can now support non-standard arguments by setting `validate` to `False`
2235
- `validate` allows non-standard `style` arguments or prevents calling `validate` on standard `style` arguments.
2336
- `default` is ignored.
37+
- `.json.JsonEncoder` default encodings changed:
38+
- bytes are URL safe base64 encoded.
39+
- Exception formatting detected using `BaseException` instead of `Exception`. Now "pretty prints" the exception using the exception name and message e.g. `"ValueError: bad value passed"`
40+
- Dataclasses are now supported
41+
- Enum values now use their value, Enum classes now return all values as a list.
42+
- Will fallback on `__str__` if available, else `__repr__` if available, else will use `__could_not_encode__`
2443

2544
### Deprecated
2645
- `.jsonlogger` is now `.json`

pylintrc

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,8 +75,9 @@ disable=raw-checker-failed,
7575
# cases. Disable rules that can cause conflicts
7676
line-too-long,
7777
# Module docstrings are not required
78-
missing-module-docstring
78+
missing-module-docstring,
7979
## Project Disables
80+
duplicate-code
8081

8182
# Enable the message, report, category or checker with the given id(s). You can
8283
# either give multiple identifier separated by comma (,) or put this option

pyproject.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "python-json-logger"
7-
version = "3.1.0.rc1"
7+
version = "3.1.0.rc2"
88
description = "JSON Log Formatter for the Python Logging Package"
99
authors = [
1010
{name = "Zakaria Zajac", email = "[email protected]"},
@@ -55,6 +55,8 @@ dev = [
5555
## Test
5656
"pytest",
5757
"freezegun",
58+
"backports.zoneinfo;python_version<'3.9'",
59+
"tzdata",
5860
## Build
5961
"build",
6062
]

src/pythonjsonlogger/defaults.py

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
# pylint: disable=missing-function-docstring
2+
3+
### IMPORTS
4+
### ============================================================================
5+
## Future
6+
from __future__ import annotations
7+
8+
## Standard Library
9+
import base64
10+
import dataclasses
11+
import datetime
12+
import enum
13+
import sys
14+
from types import TracebackType
15+
from typing import Any
16+
import traceback
17+
import uuid
18+
19+
if sys.version_info >= (3, 10):
20+
from typing import TypeGuard
21+
else:
22+
from typing_extensions import TypeGuard
23+
24+
## Installed
25+
26+
## Application
27+
28+
29+
### FUNCTIONS
30+
### ============================================================================
31+
def unknown_default(obj: Any) -> str:
32+
try:
33+
return str(obj)
34+
except Exception: # pylint: disable=broad-exception-caught
35+
pass
36+
try:
37+
return repr(obj)
38+
except Exception: # pylint: disable=broad-exception-caught
39+
pass
40+
return "__could_not_encode__"
41+
42+
43+
## Types
44+
## -----------------------------------------------------------------------------
45+
def use_type_default(obj: Any) -> TypeGuard[type]:
46+
return isinstance(obj, type)
47+
48+
49+
def type_default(obj: type) -> str:
50+
return obj.__name__
51+
52+
53+
## Dataclasses
54+
## -----------------------------------------------------------------------------
55+
def use_dataclass_default(obj: Any) -> bool:
56+
return dataclasses.is_dataclass(obj) and not isinstance(obj, type)
57+
58+
59+
def dataclass_default(obj) -> dict[str, Any]:
60+
return dataclasses.asdict(obj)
61+
62+
63+
## Dates and Times
64+
## -----------------------------------------------------------------------------
65+
def use_time_default(obj: Any) -> TypeGuard[datetime.time]:
66+
return isinstance(obj, datetime.time)
67+
68+
69+
def time_default(obj: datetime.time) -> str:
70+
return obj.isoformat()
71+
72+
73+
def use_date_default(obj: Any) -> TypeGuard[datetime.date]:
74+
return isinstance(obj, datetime.date)
75+
76+
77+
def date_default(obj: datetime.date) -> str:
78+
return obj.isoformat()
79+
80+
81+
def use_datetime_default(obj: Any) -> TypeGuard[datetime.datetime]:
82+
return isinstance(obj, datetime.datetime)
83+
84+
85+
def datetime_default(obj: datetime.datetime) -> str:
86+
return obj.isoformat()
87+
88+
89+
def use_datetime_any(obj: Any) -> TypeGuard[datetime.time | datetime.date | datetime.datetime]:
90+
return isinstance(obj, (datetime.time, datetime.date, datetime.datetime))
91+
92+
93+
def datetime_any(obj: datetime.time | datetime.date | datetime.date) -> str:
94+
return obj.isoformat()
95+
96+
97+
## Exception and Tracebacks
98+
## -----------------------------------------------------------------------------
99+
def use_exception_default(obj: Any) -> TypeGuard[BaseException]:
100+
return isinstance(obj, BaseException)
101+
102+
103+
def exception_default(obj: BaseException) -> str:
104+
return f"{obj.__class__.__name__}: {obj}"
105+
106+
107+
def use_traceback_default(obj: Any) -> TypeGuard[TracebackType]:
108+
return isinstance(obj, TracebackType)
109+
110+
111+
def traceback_default(obj: TracebackType) -> str:
112+
return "".join(traceback.format_tb(obj)).strip()
113+
114+
115+
## Enums
116+
## -----------------------------------------------------------------------------
117+
def use_enum_default(obj: Any) -> TypeGuard[enum.Enum | enum.EnumMeta]:
118+
return isinstance(obj, (enum.Enum, enum.EnumMeta))
119+
120+
121+
def enum_default(obj: enum.Enum | enum.EnumMeta) -> Any | list[Any]:
122+
if isinstance(obj, enum.Enum):
123+
return obj.value
124+
return [e.value for e in obj] # type: ignore[var-annotated]
125+
126+
127+
## UUIDs
128+
## -----------------------------------------------------------------------------
129+
def use_uuid_default(obj: Any) -> TypeGuard[uuid.UUID]:
130+
return isinstance(obj, uuid.UUID)
131+
132+
133+
def uuid_default(obj: uuid.UUID) -> str:
134+
return str(obj)
135+
136+
137+
## Bytes
138+
## -----------------------------------------------------------------------------
139+
def use_bytes_default(obj: Any) -> TypeGuard[bytes | bytearray]:
140+
return isinstance(obj, (bytes, bytearray))
141+
142+
143+
def bytes_default(obj: bytes | bytearray, url_safe: bool = True) -> str:
144+
if url_safe:
145+
return base64.urlsafe_b64encode(obj).decode("utf8")
146+
return base64.b64encode(obj).decode("utf8")

src/pythonjsonlogger/json.py

Lines changed: 22 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,14 @@
1010
from __future__ import annotations
1111

1212
## Standard Library
13-
from datetime import date, datetime, time
14-
from inspect import istraceback
13+
import datetime
1514
import json
16-
import traceback
1715
from typing import Any, Callable, Optional, Union
1816
import warnings
1917

2018
## Application
2119
from . import core
20+
from . import defaults as d
2221

2322

2423
### CLASSES
@@ -31,33 +30,39 @@ class JsonEncoder(json.JSONEncoder):
3130
"""
3231

3332
def default(self, o: Any) -> Any:
34-
if isinstance(o, (date, datetime, time)):
33+
if d.use_datetime_any(o):
3534
return self.format_datetime_obj(o)
3635

37-
if istraceback(o):
38-
return "".join(traceback.format_tb(o)).strip()
36+
if d.use_exception_default(o):
37+
return d.exception_default(o)
3938

40-
# pylint: disable=unidiomatic-typecheck
41-
if type(o) == Exception or isinstance(o, Exception) or type(o) == type:
42-
return str(o)
39+
if d.use_traceback_default(o):
40+
return d.traceback_default(o)
41+
42+
if d.use_enum_default(o):
43+
return d.enum_default(o)
44+
45+
if d.use_bytes_default(o):
46+
return d.bytes_default(o)
47+
48+
if d.use_dataclass_default(o):
49+
return d.dataclass_default(o)
50+
51+
if d.use_type_default(o):
52+
return d.type_default(o)
4353

4454
try:
4555
return super().default(o)
46-
4756
except TypeError:
48-
try:
49-
return str(o)
50-
51-
except Exception: # pylint: disable=broad-exception-caught
52-
return None
57+
return d.unknown_default(o)
5358

54-
def format_datetime_obj(self, o):
59+
def format_datetime_obj(self, o: datetime.time | datetime.date | datetime.datetime) -> str:
5560
"""Format datetime objects found in self.default
5661
5762
This allows subclasses to change the datetime format without understanding the
5863
internals of the default method.
5964
"""
60-
return o.isoformat()
65+
return d.datetime_any(o)
6166

6267

6368
class JsonFormatter(core.BaseJsonFormatter):

src/pythonjsonlogger/msgspec.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,29 @@
44
from __future__ import annotations
55

66
## Standard Library
7+
from typing import Any
78

89
## Installed
910
import msgspec.json
1011

1112
## Application
1213
from . import core
14+
from . import defaults as d
15+
16+
17+
### FUNCTIONS
18+
### ============================================================================
19+
def msgspec_default(obj: Any) -> Any:
20+
"""msgspec default encoder function for non-standard types"""
21+
if d.use_exception_default(obj):
22+
return d.exception_default(obj)
23+
if d.use_traceback_default(obj):
24+
return d.traceback_default(obj)
25+
if d.use_enum_default(obj):
26+
return d.enum_default(obj)
27+
if d.use_type_default(obj):
28+
return d.type_default(obj)
29+
return d.unknown_default(obj)
1330

1431

1532
### CLASSES
@@ -25,7 +42,7 @@ class MsgspecFormatter(core.BaseJsonFormatter):
2542
def __init__(
2643
self,
2744
*args,
28-
json_default: core.OptionalCallableOrStr = None,
45+
json_default: core.OptionalCallableOrStr = msgspec_default,
2946
**kwargs,
3047
) -> None:
3148
"""

src/pythonjsonlogger/orjson.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,31 @@
44
from __future__ import annotations
55

66
## Standard Library
7+
from typing import Any
78

89
## Installed
910
import orjson
1011

1112
## Application
1213
from . import core
14+
from . import defaults as d
15+
16+
17+
### FUNCTIONS
18+
### ============================================================================
19+
def orjson_default(obj: Any) -> Any:
20+
"""orjson default encoder function for non-standard types"""
21+
if d.use_exception_default(obj):
22+
return d.exception_default(obj)
23+
if d.use_traceback_default(obj):
24+
return d.traceback_default(obj)
25+
if d.use_bytes_default(obj):
26+
return d.bytes_default(obj)
27+
if d.use_enum_default(obj):
28+
return d.enum_default(obj)
29+
if d.use_type_default(obj):
30+
return d.type_default(obj)
31+
return d.unknown_default(obj)
1332

1433

1534
### CLASSES
@@ -25,7 +44,7 @@ class OrjsonFormatter(core.BaseJsonFormatter):
2544
def __init__(
2645
self,
2746
*args,
28-
json_default: core.OptionalCallableOrStr = None,
47+
json_default: core.OptionalCallableOrStr = orjson_default,
2948
json_indent: bool = False,
3049
**kwargs,
3150
) -> None:

0 commit comments

Comments
 (0)