Skip to content

Commit 49f69fb

Browse files
authored
Split core functionality and support orjson and msgspec (#9)
## Summary of changes ### Refactor common functionality into base module This allows support multiple JSON encoders by having common functionality in `pythonjsonlogger.core` and then specialist formatters for each encoder. This is useful / needed, as not all JSON encoders support the `json.dumps` or `json.JSONEncoder` interfaces exactly. This enables us to support other JSON encoders like orjson and msgspec. In the future we may add support for other encoders. ### Better handling for custom styles Achieved by mimicking `logging.Formatter.__init__` without actually calling it. A code snippet is worth `2**10` words: ```python from pythonjsonlogger.core import BaseJsonLogger class CommaSupport(BaseJsonFormatter): def parse(self) -> list[str]: if isinstance(self._style, str) and self._style == ",": return self._fmt.split(",") return super().parse() f = CommaSupport("message,asctime", style=",", validate=False) ``` ### Rename `jsonlogger` module to `json` module Compatibility is maintained for the moment using `__getattr__` in `__init__`. This is to enable more consistent naming of implementation specific module names. It also stops throwing around the word "logger" when this module only contains formatters. ### Add support for orjson [orjson](https://github.com/ijl/orjson) is a high performance (and more JSON spec correct) encoder. Given how many logging calls may occur - having a performant formatter available is important. This includes ensuring it is covered in tests on appropriate platforms. Note: orjson is not supported on pypy, and currently does not build for py313. ### Add support for msgspec [msgspec](https://jcristharif.com/msgspec/index.html) is another library containing a high performance JSON encoder. Note: msgspec is not supported on pypy, and currently does not build for py313. ### Drops python 3.7 support This is primary due do making use of the [`validate`](https://docs.python.org/3/library/logging.html#formatter-objects) argument. I was also having issues with CI because python 3.7 is not support on most "latest"
1 parent 2767589 commit 49f69fb

15 files changed

+1126
-659
lines changed

.github/workflows/test-suite.yml

+2-2
Original file line numberDiff line numberDiff line change
@@ -33,18 +33,18 @@ jobs:
3333
needs: [lint]
3434
runs-on: "${{ matrix.os }}"
3535
strategy:
36+
fail-fast: false # allow tests to run on all platforms
3637
matrix:
3738
python-version:
38-
- "pypy-3.7"
3939
- "pypy-3.8"
4040
- "pypy-3.9"
4141
- "pypy-3.10"
42-
- "3.7"
4342
- "3.8"
4443
- "3.9"
4544
- "3.10"
4645
- "3.11"
4746
- "3.12"
47+
- "3.13-dev"
4848
os:
4949
- ubuntu-latest
5050
- windows-latest

CHANGELOG.md

+27
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,33 @@ 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
8+
9+
This splits common funcitonality out to allow supporting other JSON encoders. Although this is a large refactor, backwards compatibility has been maintained.
10+
11+
### Added
12+
- `.core` - more details below.
13+
- Orjson encoder support via `.orjson.OrjsonFormatter`.
14+
- MsgSpec encoder support via `.msgspec.MsgspecFormatter`.
15+
16+
### Changed
17+
- `.jsonlogger` has been moved to `.json` with core functionality moved to `.core`.
18+
- `.core.BaseJsonFormatter` properly supports all `logging.Formatter` arguments:
19+
- `fmt` is unchanged.
20+
- `datefmt` is unchanged.
21+
- `style` can now support non-standard arguments by setting `validate` to `False`
22+
- `validate` allows non-standard `style` arguments or prevents calling `validate` on standard `style` arguments.
23+
- `default` is ignored.
24+
25+
### Deprecated
26+
- `.jsonlogger` is now `.json`
27+
- `.jsonlogger.RESERVED_ATTRS` is now `.core.RESERVED_ATTRS`.
28+
- `.jsonlogger.merge_record_extra` is now `.core.merge_record_extra`.
29+
30+
### Removed
31+
- Python 3.7 support dropped
32+
- `.jsonlogger.JsonFormatter._str_to_fn` replaced with `.core.str_to_object`.
33+
734
## [3.0.1](https://github.com/nhairs/python-json-logger/compare/v3.0.0...v3.0.1) - 2023-04-01
835

936
### Fixes

README.md

+51-29
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ Until the PEP 541 request is complete you will need to install directly from git
2626
To install from releases:
2727

2828
```shell
29-
# 3.0.0 wheel
29+
# e.g. 3.0.0 wheel
3030
pip install 'python-json-logger@https://github.com/nhairs/python-json-logger/releases/download/v3.0.0/python_json_logger-3.0.0-py3-none-any.whl'
3131
```
3232

@@ -53,38 +53,30 @@ pip install -e .
5353

5454
## Usage
5555

56+
Python JSON Logger provides `logging.Formatter`s that encode the logged message into JSON. Although a variety of JSON encoders are supported, in the following examples we will use the `pythonjsonlogger.json.JsonFormatter` which uses the the `json` module from the standard library.
57+
5658
### Integrating with Python's logging framework
5759

58-
Json outputs are provided by the JsonFormatter logging formatter. You can add the custom formatter like below:
60+
To produce JSON output, attach the formatter to a logging handler:
5961

6062
```python
6163
import logging
62-
from pythonjsonlogger import jsonlogger
64+
from pythonjsonlogger.json import JsonFormatter
6365

6466
logger = logging.getLogger()
6567

6668
logHandler = logging.StreamHandler()
67-
formatter = jsonlogger.JsonFormatter()
69+
formatter = JsonFormatter()
6870
logHandler.setFormatter(formatter)
6971
logger.addHandler(logHandler)
7072
```
7173

72-
### Customizing fields
73-
74-
The fmt parser can also be overidden if you want to have required fields that differ from the default of just `message`.
74+
### Output fields
7575

76-
These two invocations are equivalent:
76+
You can control the logged fields by setting the `fmt` argument when creating the formatter. By default formatters will follow the same `style` of `fmt` as the `logging` module: `%`, `$`, and `{`. All [`LogRecord` attributes](https://docs.python.org/3/library/logging.html#logrecord-attributes) can be output using their name.
7777

7878
```python
79-
class CustomJsonFormatter(jsonlogger.JsonFormatter):
80-
def parse(self):
81-
return self._fmt.split(';')
82-
83-
formatter = CustomJsonFormatter('one;two')
84-
85-
# is equivalent to:
86-
87-
formatter = jsonlogger.JsonFormatter('%(one)s %(two)s')
79+
formatter = JsonFormatter("{message}{asctime}{exc_info}", style="{")
8880
```
8981

9082
You can also add extra fields to your json output by specifying a dict in place of message, as well as by specifying an `extra={}` argument.
@@ -94,9 +86,9 @@ Contents of these dictionaries will be added at the root level of the entry and
9486
You can also use the `add_fields` method to add to or generally normalize the set of default set of fields, it is called for every log event. For example, to unify default fields with those provided by [structlog](http://www.structlog.org/) you could do something like this:
9587

9688
```python
97-
class CustomJsonFormatter(jsonlogger.JsonFormatter):
89+
class CustomJsonFormatter(JsonFormatter):
9890
def add_fields(self, log_record, record, message_dict):
99-
super(CustomJsonFormatter, self).add_fields(log_record, record, message_dict)
91+
super().add_fields(log_record, record, message_dict)
10092
if not log_record.get('timestamp'):
10193
# this doesn't use record.created, so it is slightly off
10294
now = datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%S.%fZ')
@@ -105,32 +97,55 @@ class CustomJsonFormatter(jsonlogger.JsonFormatter):
10597
log_record['level'] = log_record['level'].upper()
10698
else:
10799
log_record['level'] = record.levelname
100+
return
108101

109102
formatter = CustomJsonFormatter('%(timestamp)s %(level)s %(name)s %(message)s')
110103
```
111104

112105
Items added to the log record will be included in *every* log message, no matter what the format requires.
113106

114-
### Adding custom object serialization
107+
You can also override the `process_log_record` method to modify fields before they are serialized to JSON.
108+
109+
```python
110+
class SillyFormatter(JsonFormatter):
111+
def process_log_record(log_record):
112+
new_record = {k[::-1]: v for k, v in log_record.items()}
113+
return new_record
114+
```
115+
116+
#### Supporting custom styles
117+
118+
It is possible to support custom `style`s by setting `validate=False` and overriding the `parse` method.
119+
120+
For example:
121+
122+
```python
123+
class CommaSupport(JsonFormatter):
124+
def parse(self) -> list[str]:
125+
if isinstance(self._style, str) and self._style == ",":
126+
return self._fmt.split(",")
127+
return super().parse()
128+
129+
formatter = CommaSupport("message,asctime", style=",", validate=False)
130+
```
131+
132+
### Custom object serialization
133+
134+
Most formatters support `json_default` which is used to control how objects are serialized.
115135

116136
For custom handling of object serialization you can specify default json object translator or provide a custom encoder
117137

118138
```python
119-
def json_translate(obj):
139+
def my_default(obj):
120140
if isinstance(obj, MyClass):
121141
return {"special": obj.special}
122142

123-
formatter = jsonlogger.JsonFormatter(json_default=json_translate,
124-
json_encoder=json.JSONEncoder)
125-
logHandler.setFormatter(formatter)
126-
127-
logger.info({"special": "value", "run": 12})
128-
logger.info("classic message", extra={"special": "value", "run": 12})
143+
formatter = JsonFormatter(json_default=my_default)
129144
```
130145

131146
### Using a Config File
132147

133-
To use the module with a config file using the [`fileConfig` function](https://docs.python.org/3/library/logging.config.html#logging.config.fileConfig), use the class `pythonjsonlogger.jsonlogger.JsonFormatter`. Here is a sample config file.
148+
To use the module with a config file using the [`fileConfig` function](https://docs.python.org/3/library/logging.config.html#logging.config.fileConfig), use the class `pythonjsonlogger.json.JsonFormatter`. Here is a sample config file.
134149

135150
```ini
136151
[loggers]
@@ -161,6 +176,13 @@ format = %(message)s
161176
class = pythonjsonlogger.jsonlogger.JsonFormatter
162177
```
163178

179+
### Alternate JSON Encoders
180+
181+
The following JSON encoders are also supported:
182+
183+
- [orjson](https://github.com/ijl/orjson) - `pythonjsonlogger.orjon.OrjsonFormatter`
184+
- [msgspec](https://github.com/jcrist/msgspec) - `pythonjsonlogger.msgspec.MsgspecFormatter`
185+
164186
## Example Output
165187

166188
Sample JSON with a full formatter (basically the log message from the unit test). Every log message will appear on 1 line like a typical logger.
@@ -180,7 +202,7 @@ Sample JSON with a full formatter (basically the log message from the unit test)
180202
"msecs": 506.24799728393555,
181203
"pathname": "tests/tests.py",
182204
"lineno": 60,
183-
"asctime": ["12-05-05 22:11:08,506248"],
205+
"asctime": "12-05-05 22:11:08,506248",
184206
"message": "testing logging format",
185207
"filename": "tests.py",
186208
"levelname": "INFO",

pylintrc

+1-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
# A comma-separated list of package or module names from where C extensions may
44
# be loaded. Extensions are loading into the active Python interpreter and may
55
# run arbitrary code.
6-
extension-pkg-whitelist=
6+
extension-pkg-whitelist=orjson
77

88
# Add files or directories to the blacklist. They should be base names, not
99
# paths.

pyproject.toml

+13-8
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,21 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "python-json-logger"
7-
version = "3.0.1"
7+
version = "3.1.0.rc1"
88
description = "JSON Log Formatter for the Python Logging Package"
99
authors = [
1010
{name = "Zakaria Zajac", email = "[email protected]"},
11+
{name = "Nicholas Hairs", email = "[email protected]"},
1112
]
1213
maintainers = [
1314
{name = "Nicholas Hairs", email = "[email protected]"},
1415
]
1516

1617
# Dependency Information
17-
requires-python = ">=3.7"
18-
# dependencies = []
18+
requires-python = ">=3.8"
19+
dependencies = [
20+
"typing_extensions",
21+
]
1922

2023
# Extra information
2124
readme = "README.md"
@@ -26,7 +29,6 @@ classifiers = [
2629
"License :: OSI Approved :: BSD License",
2730
"Operating System :: OS Independent",
2831
"Programming Language :: Python :: 3 :: Only",
29-
"Programming Language :: Python :: 3.7",
3032
"Programming Language :: Python :: 3.8",
3133
"Programming Language :: Python :: 3.9",
3234
"Programming Language :: Python :: 3.10",
@@ -41,15 +43,18 @@ classifiers = [
4143
GitHub = "https://github.com/nhairs/python-json-logger"
4244

4345
[project.optional-dependencies]
44-
lint = [
46+
dev = [
47+
## Optional but required for dev
48+
"orjson;implementation_name!='pypy' and python_version<'3.13'",
49+
"msgspec;implementation_name!='pypy' and python_version<'3.13'",
50+
## Lint
4551
"validate-pyproject[all]",
4652
"black",
4753
"pylint",
4854
"mypy",
49-
]
50-
51-
test = [
55+
## Test
5256
"pytest",
57+
"freezegun",
5358
]
5459

5560
[tool.setuptools.packages.find]

src/pythonjsonlogger/__init__.py

+40
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
### IMPORTS
2+
### ============================================================================
3+
## Future
4+
5+
## Standard Library
6+
import warnings
7+
8+
## Installed
9+
10+
## Application
11+
import pythonjsonlogger.json
12+
13+
### CONSTANTS
14+
### ============================================================================
15+
try:
16+
import orjson
17+
18+
ORJSON_AVAILABLE = True
19+
except ImportError:
20+
ORJSON_AVAILABLE = False
21+
22+
23+
try:
24+
import msgspec
25+
26+
MSGSPEC_AVAILABLE = True
27+
except ImportError:
28+
MSGSPEC_AVAILABLE = False
29+
30+
31+
### DEPRECATED COMPATIBILITY
32+
### ============================================================================
33+
def __getattr__(name: str):
34+
if name == "jsonlogger":
35+
warnings.warn(
36+
"pythonjsonlogger.jsonlogger has been moved to pythonjsonlogger.json",
37+
DeprecationWarning,
38+
)
39+
return pythonjsonlogger.json
40+
raise AttributeError(f"module {__name__} has no attribute {name}")

0 commit comments

Comments
 (0)