├── .github
└── workflows
│ ├── release.yml
│ └── test.yml
├── .gitignore
├── .pre-commit-config.yaml
├── CHANGELOG.md
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── _config.yml
├── logging_json
├── __init__.py
├── _formatter.py
├── py.typed
└── version.py
├── pyproject.toml
└── tests
├── __init__.py
└── test_formatter.py
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release
2 |
3 | on:
4 | push:
5 | branches:
6 | - master
7 |
8 | jobs:
9 | build:
10 |
11 | runs-on: ubuntu-latest
12 |
13 | steps:
14 | - uses: actions/checkout@v4
15 | - name: Set up Python
16 | uses: actions/setup-python@v5
17 | with:
18 | python-version: '3.13'
19 | - name: Install dependencies
20 | run: |
21 | python -m pip install --upgrade pip
22 | python -m pip install .[testing]
23 | - name: Test with pytest
24 | run: |
25 | pytest --cov=logging_json --cov-fail-under=100 --cov-report=term-missing
26 | - name: Create packages
27 | run: |
28 | python -m pip install build
29 | python -m build .
30 | - name: Publish packages
31 | run: |
32 | python -m pip install twine
33 | python -m twine upload dist/* --skip-existing --username __token__ --password ${{ secrets.pypi_password }}
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: Test
2 |
3 | on: [push]
4 |
5 | jobs:
6 | build:
7 |
8 | runs-on: ubuntu-latest
9 | strategy:
10 | matrix:
11 | python-version: ['3.9', '3.10', '3.11', '3.12', '3.13']
12 |
13 | steps:
14 | - uses: actions/checkout@v4
15 | - name: Set up Python ${{ matrix.python-version }}
16 | uses: actions/setup-python@v5
17 | with:
18 | python-version: ${{ matrix.python-version }}
19 | - name: Install dependencies
20 | run: |
21 | python -m pip install --upgrade pip
22 | python -m pip install .[testing]
23 | - name: Test with pytest
24 | run: |
25 | pytest --cov=logging_json --cov-fail-under=100 --cov-report=term-missing
26 | - name: Create packages
27 | run: |
28 | python -m pip install build
29 | python -m build .
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /.idea/
2 | /logging_json.egg-info/
3 |
--------------------------------------------------------------------------------
/.pre-commit-config.yaml:
--------------------------------------------------------------------------------
1 | repos:
2 | - repo: https://github.com/psf/black
3 | rev: 25.1.0
4 | hooks:
5 | - id: black
6 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 | All notable changes to this project will be documented in this file.
3 |
4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6 |
7 | ## [Unreleased]
8 |
9 | ## [0.6.0] - 2025-02-04
10 | ### Fixed
11 | - Using [`logging.exception`](https://docs.python.org/3/library/logging.html#logging.exception) outside an exception context will not prevent log from being formatted anymore.
12 |
13 | ### Removed
14 | - Drop support for python `3.7`.
15 | - Drop support for python `3.8`.
16 |
17 | ### Added
18 | - Explicit support for python `3.13`.
19 |
20 | ## [0.5.0] - 2024-01-17
21 | ### Added
22 | - Explicit support for python `3.12`. Meaning `taskName` is now considered a reserved keyword where value is supposed to be contained in the record itself (otherwise value will be `taskName` for python < 3.12 when specified).
23 | - New parameters `default_time_format` (default to `%Y-%m-%d %H:%M:%S`) and `default_msec_format` (default to `%s,%03d`) allowing to change the formatting of `asctime`.
24 | - More details can be found in the documentation on what the impact is when changing those values.
25 |
26 | ### Fixed
27 | - non-ASCII but valid UTF-8 values and field names will now be output as provided (they will not be escaped anymore).
28 |
29 | ## [0.4.0] - 2023-01-09
30 | ### Changed
31 | - Default message key is now `message` instead of `msg` to stay in line with python default. If you still want previous behavior, set `message_field_name` to `msg` at formatter creation.
32 |
33 | ### Removed
34 | - Drop support for python `3.6`.
35 |
36 | ## [0.3.0] - 2022-12-02
37 | ### Added
38 | - Added `exception_field_name` parameter.
39 |
40 | ## [0.2.1] - 2022-01-26
41 | ### Fixed
42 | - `datetime`, `time` and `date` instances are now represented following [ISO-8601](https://www.iso.org/iso-8601-date-and-time-format.html) format instead of raising a `TypeError`.
43 | - Default to the `str` representation of value instead of raising a `TypeError` for non-standard python types.
44 |
45 | ## [0.2.0] - 2021-11-24
46 | ### Added
47 | - Added `message_field_name` parameter.
48 |
49 | ## [0.1.0] - 2021-10-04
50 | ### Fixed
51 | - Handle `extra` logging parameter.
52 |
53 | ## [0.0.1] - 2020-10-15
54 | ### Added
55 | - Public release.
56 |
57 | [Unreleased]: https://github.com/Colin-b/logging_json/compare/v0.6.0...HEAD
58 | [0.6.0]: https://github.com/Colin-b/logging_json/compare/v0.5.0...v0.6.0
59 | [0.5.0]: https://github.com/Colin-b/logging_json/compare/v0.4.0...v0.5.0
60 | [0.4.0]: https://github.com/Colin-b/logging_json/compare/v0.3.0...v0.4.0
61 | [0.3.0]: https://github.com/Colin-b/logging_json/compare/v0.2.1...v0.3.0
62 | [0.2.1]: https://github.com/Colin-b/logging_json/compare/v0.2.0...v0.2.1
63 | [0.2.0]: https://github.com/Colin-b/logging_json/compare/v0.1.0...v0.2.0
64 | [0.1.0]: https://github.com/Colin-b/logging_json/compare/v0.0.1...v0.1.0
65 | [0.0.1]: https://github.com/Colin-b/logging_json/releases/tag/v0.0.1
66 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # How to contribute
2 |
3 | Everyone is free to contribute on this project.
4 |
5 | There are 2 ways to contribute:
6 |
7 | - [Submit an issue.](#submitting-an-issue)
8 | - [Submit a pull request.](#submitting-a-pull-request)
9 |
10 | ## Submitting an issue
11 |
12 | Before creating an issue please make sure that it was not already reported.
13 |
14 | ### When?
15 |
16 | - You encountered an issue.
17 | - You have a change proposal.
18 | - You have a feature request.
19 |
20 | ### How?
21 |
22 | 1) Go to the *Issues* tab and click on the *New issue* button.
23 | 2) Title should be a small sentence describing the request.
24 | 3) The comment should contains as much information as possible
25 | * Actual behavior (including the version you used)
26 | * Expected behavior
27 | * Steps to reproduce
28 |
29 | ## Submitting a pull request
30 |
31 | ### When?
32 |
33 | - You fixed an issue.
34 | - You changed something.
35 | - You added a new feature.
36 |
37 | ### How?
38 |
39 | #### Code
40 |
41 | 1) Create a new branch based on `develop` branch.
42 | 2) Fetch all dev dependencies.
43 | * Install required python modules using [`pip`](https://pypi.org/project/pip/): **python -m pip install .[testing]**
44 | 3) Ensure tests are ok by running them using [`pytest`](http://doc.pytest.org/en/latest/index.html).
45 | 4) Follow [Black](https://black.readthedocs.io/en/stable/) code formatting.
46 | * Install [pre-commit](https://pre-commit.com) python module using pip: **python -m pip install pre-commit**
47 | * To add the [pre-commit](https://pre-commit.com) hook, after the installation run: **pre-commit install**
48 | 5) Add your changes.
49 | * The commit should only contain small changes and should be atomic.
50 | * The commit message should follow [those rules](https://chris.beams.io/posts/git-commit/).
51 | 6) Add or update at least one [`pytest`](http://doc.pytest.org/en/latest/index.html) test case.
52 | * Unless it is an internal refactoring request or a documentation update.
53 | * Each line of code should be covered by the test cases.
54 | 7) Add related [changelog entry](https://keepachangelog.com/en/1.1.0/) in the Unreleased section.
55 | * Unless it is a documentation update.
56 |
57 | #### Enter pull request
58 |
59 | 1) Go to the *Pull requests* tab and click on the *New pull request* button.
60 | 2) *base* should always be set to `develop` and it should be compared to your branch.
61 | 3) Title should be a small sentence describing the request.
62 | 4) The comment should contains as much information as possible
63 | * Actual behavior (before the new code)
64 | * Expected behavior (with the new code)
65 | 5) A pull request can contain more than one commit, but the entire content should still be [atomic](#what-is-an-atomic-pull-request).
66 |
67 | ##### What is an atomic pull request
68 |
69 | It is important for a Pull Request to be atomic. But with a Pull Request, we measure the "succeed" as the ability to deliver the smallest possible piece of functionality, it can either be composed by one or many atomic commits.
70 |
71 | One of the bad practices of a Pull Request is changing things that are not concerned with the functionality that is being addressed, like whitespace changes, typo fixes, variable renaming, etc. If those things are not related to the concern of the Pull Request, it should probably be done in a different one.
72 |
73 | One might argue that this practice of not mixing different concerns and small fixes in the same Pull Request violates the Boy Scout Rule because it doesn't allow frequent cleanup. However, cleanup doesn't need to be done in the same Pull Request, the important thing is not leaving the codebase in a bad state after finishing the functionality. If you must, refactor the code in a separate Pull Request, and preferably before the actually concerned functionality is developed, because then if there is a need in the near future to revert the Pull Request, the likelihood of code conflict will be lower. [source](https://medium.com/@fagnerbrack/one-pull-request-one-concern-e84a27dfe9f1)
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 Colin Bounouar
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
JSON formatter for logging
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 | This module provides a JSON formatter for the python [`logging`](https://docs.python.org/3/library/logging.html) module that will format to JSON formatted string.
13 |
14 | Using this formatter allows to have the proper format for logging to `Splunk` or `ElasticSearch`, but it can also be used for logging to stdout as a string is issued.
15 |
16 | - [Features](#features)
17 | - [Custom fields](#adding-additional-fields-and-values)
18 | - [dict logging](#logging-with-a-dictionary)
19 | - [str logging](#logging-with-anything-else-such-as-a-string)
20 | - [Configuration](#configuration)
21 | - [Using dictConfig](#using-loggingconfigdictconfig)
22 |
23 | ## Features
24 |
25 | ### Adding additional fields and values
26 |
27 | You can add fields to every message that is being logged.
28 | To do so, specify the `fields` parameter to the `logging_json.JSONFormatter` instance.
29 |
30 | It must be a dictionary where keys are the keys to be appended to the resulting JSON dictionary (if not already present) and the values can be one of the following:
31 | * An attribute of the logging record (non-exhaustive list can be found on [the python logging documentation](https://docs.python.org/3/library/logging.html#logrecord-attributes)).
32 | * If not found on the record, the value will be linked to the key.
33 |
34 | #### Logging exceptions, a specific case
35 |
36 | If [an exception is logged](https://docs.python.org/3/library/logging.html#logging.exception), the `exception` key will be appended to the resulting JSON dictionary.
37 |
38 | This dictionary will contain 3 keys:
39 | * `type`: The name of the exception class (useful when the message is blank).
40 | * `message`: The str representation of the exception (usually the provided error message).
41 | * `stack`: The stack trace, formatted as a string.
42 |
43 | You can rename the exception field key by setting the `exception_field_name` parameter with a new name for the key.
44 | It is also possible to disable this behaviour by setting the `exception_field_name` parameter to `None` or an empty string
45 |
46 | ### Logging with a dictionary
47 |
48 | This formatter allows you to log dictionary as in the following:
49 |
50 | ```python
51 | import logging
52 |
53 | logging.info({"key": "value", "other key": "other value"})
54 | ```
55 |
56 | The resulting JSON dictionary will be the one you provided (with the [additional fields](#adding-additional-fields-and-values)).
57 |
58 | ### Logging with anything else (such as a string)
59 |
60 | Anything not logged using a dictionary will be handled by the standard formatter, and it can result in one of the 2 output:
61 | * A JSON dictionary, if [additional fields](#adding-additional-fields-and-values) are set or if `extra` parameter is used while logging, with the message available in the `message` key of the resulting JSON dictionary.
62 | Default `message` key name can be changed by `message_field_name` parameter of the `logging_json.JSONFormatter` instance.
63 | * The formatted record, if no [additional fields](#adding-additional-fields-and-values) are set.
64 |
65 | This handles the usual string logging as in the following:
66 |
67 | ```python
68 | import logging
69 |
70 | logging.info("This is my message")
71 | ```
72 |
73 | ### Changing asctime representation
74 |
75 | You can override the default representation of asctime (`2003-07-08 16:49:45,896`) based on two different scenarii:
76 |
77 | #### Without milliseconds
78 |
79 | Set `datefmt` parameter.
80 |
81 | Setting `datefmt` to `%Y-%m-%dT%H:%M:%S` would result in `2003-07-08T16:49:45`.
82 |
83 | #### With milliseconds
84 |
85 | Set `default_time_format` to something else than `%Y-%m-%d %H:%M:%S` to change the representation part without milliseconds.
86 | Set `default_msec_format` to something else than `%s,%03d` to change the representation milliseconds.
87 | Note that `%s` in `default_msec_format` is going to be replaced by the representation without milliseconds.
88 |
89 | Setting `default_time_format` to `%Y-%m-%dT%H:%M:%S` and `default_msec_format` to `%s.%03d` would result in `2003-07-08T16:49:45.896`.
90 |
91 | ## Configuration
92 |
93 | You can create a formatter instance yourself as in the following, or you can use a logging configuration.
94 |
95 | ```python
96 | import logging_json
97 |
98 | formatter = logging_json.JSONFormatter(fields={
99 | "level_name": "levelname",
100 | "thread_name": "threadName",
101 | "process_name": "processName"
102 | })
103 | ```
104 |
105 | ### Using logging.config.dictConfig
106 |
107 | You can configure your logging as advertise by python, by using the `logging.config.dictConfig` function.
108 |
109 | #### dict configuration
110 |
111 | ```python
112 | import logging.config
113 |
114 | logging.config.dictConfig({
115 | "version": 1,
116 | "formatters": {
117 | "json": {
118 | '()': 'logging_json.JSONFormatter',
119 | 'fields':{
120 | "level_name": "levelname",
121 | "thread_name": "threadName",
122 | "process_name": "processName"
123 | }
124 | }
125 | },
126 | "handlers": {
127 | "standard_output": {
128 | 'class': 'logging.StreamHandler',
129 | 'formatter': 'json',
130 | 'stream': 'ext://sys.stdout'
131 | },
132 | },
133 | "loggers": {
134 | "my_app": {"level": "DEBUG"}
135 | },
136 | "root": {
137 | "level": "INFO",
138 | "handlers": ["standard_output"]
139 | }
140 | })
141 | ```
142 |
143 | #### YAML logging configuration
144 |
145 | You can use YAML to store your logging configuration, as in the following sample:
146 |
147 | ```python
148 | import logging.config
149 | import yaml
150 |
151 | with open("path/to/logging_configuration.yaml", "r") as config_file:
152 | logging.config.dictConfig(yaml.load(config_file))
153 | ```
154 |
155 | Where `logging_configuration.yaml` can be a file containing the following sample:
156 |
157 | ```yaml
158 | version: 1
159 | formatters:
160 | json:
161 | '()': logging_json.JSONFormatter
162 | fields:
163 | level_name: levelname
164 | thread_name: threadName
165 | process_name: processName
166 | handlers:
167 | standard_output:
168 | class: logging.StreamHandler
169 | formatter: json
170 | stream: ext://sys.stdout
171 | loggers:
172 | my_app:
173 | level: DEBUG
174 | root:
175 | level: INFO
176 | handlers: [standard_output]
177 | ```
178 |
179 | ## How to install
180 | 1. [python 3.7+](https://www.python.org/downloads/) must be installed
181 | 2. Use pip to install module:
182 | ```sh
183 | python -m pip install logging_json
184 | ```
185 |
--------------------------------------------------------------------------------
/_config.yml:
--------------------------------------------------------------------------------
1 | theme: jekyll-theme-cayman
--------------------------------------------------------------------------------
/logging_json/__init__.py:
--------------------------------------------------------------------------------
1 | from logging_json.version import __version__
2 | from logging_json._formatter import JSONFormatter
3 |
--------------------------------------------------------------------------------
/logging_json/_formatter.py:
--------------------------------------------------------------------------------
1 | import collections
2 | import datetime
3 | import json
4 | import logging
5 | from typing import Any, Dict, Optional
6 |
7 | standard_attributes = (
8 | "name",
9 | "msg",
10 | "args",
11 | "levelname",
12 | "levelno",
13 | "pathname",
14 | "filename",
15 | "module",
16 | "exc_info",
17 | "exc_text",
18 | "stack_info",
19 | "lineno",
20 | "funcName",
21 | "created",
22 | "msecs",
23 | "relativeCreated",
24 | "thread",
25 | "threadName",
26 | "processName",
27 | "process",
28 | "message",
29 | "asctime",
30 | "taskName",
31 | )
32 |
33 |
34 | def _extra_attributes(record: logging.LogRecord) -> Dict[str, Any]:
35 | return {
36 | name: record.__dict__[name]
37 | for name in set(record.__dict__).difference(standard_attributes)
38 | }
39 |
40 |
41 | def _value(record: logging.LogRecord, field_name_or_value: Any) -> Any:
42 | """
43 | Retrieve value from record if possible. Otherwise use value.
44 | :param record: The record to extract a field named as in field_name_or_value.
45 | :param field_name_or_value: The field name to extract from record or the default value to use if not present.
46 | """
47 | try:
48 | return getattr(record, field_name_or_value)
49 | except:
50 | return field_name_or_value
51 |
52 |
53 | def default_converter(obj: Any) -> str:
54 | if isinstance(obj, datetime.datetime):
55 | return obj.isoformat()
56 | return str(obj)
57 |
58 |
59 | def has_exception_info(record: logging.LogRecord) -> bool:
60 | return record.exc_info and record.exc_info[0] is not None
61 |
62 |
63 | class JSONFormatter(logging.Formatter):
64 | def __init__(
65 | self,
66 | *args,
67 | fields: Dict[str, Any] = None,
68 | message_field_name: str = "message",
69 | exception_field_name: Optional[str] = "exception",
70 | default_time_format: str = "%Y-%m-%d %H:%M:%S",
71 | default_msec_format: str = "%s,%03d",
72 | **kwargs,
73 | ):
74 | # Allow to provide any formatter setting (useful to provide a custom date format)
75 | super().__init__(*args, **kwargs)
76 | self.fields = fields or {}
77 | self.message_field_name = message_field_name
78 | self.exception_field_name = exception_field_name
79 | # (Allows to) overrides logging.Formatter default behavior
80 | self.usesTime = lambda: "asctime" in self.fields.values()
81 | self.default_time_format = default_time_format
82 | self.default_msec_format = default_msec_format
83 |
84 | def format(self, record: logging.LogRecord):
85 | # Let python set every additional record field
86 | super().format(record)
87 |
88 | message = {
89 | field_name: _value(record, field_value)
90 | for field_name, field_value in self.fields.items()
91 | }
92 | if isinstance(record.msg, collections.abc.Mapping):
93 | message.update(record.msg)
94 | else:
95 | message[self.message_field_name] = super().formatMessage(record)
96 |
97 | message.update(_extra_attributes(record))
98 |
99 | if self.exception_field_name and has_exception_info(record):
100 | message[self.exception_field_name] = {
101 | "type": record.exc_info[0].__name__,
102 | "message": str(record.exc_info[1]),
103 | "stack": self.formatException(record.exc_info),
104 | }
105 |
106 | if len(message) == 1 and self.message_field_name in message:
107 | return super().formatMessage(record)
108 |
109 | return json.dumps(message, ensure_ascii=False, default=default_converter)
110 |
111 | def formatMessage(self, record: logging.LogRecord) -> str:
112 | # Speed up this step by doing nothing
113 | return ""
114 |
--------------------------------------------------------------------------------
/logging_json/py.typed:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Colin-b/logging_json/fcff26106bc3de505ef41824666f6763cddcc960/logging_json/py.typed
--------------------------------------------------------------------------------
/logging_json/version.py:
--------------------------------------------------------------------------------
1 | # Version number as Major.Minor.Patch
2 | # The version modification must respect the following rules:
3 | # Major should be incremented in case there is a breaking change. (eg: 2.5.8 -> 3.0.0)
4 | # Minor should be incremented in case there is an enhancement. (eg: 2.5.8 -> 2.6.0)
5 | # Patch should be incremented in case there is a bug fix. (eg: 2.5.8 -> 2.5.9)
6 | __version__ = "0.6.0"
7 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [build-system]
2 | requires = ["setuptools", "setuptools_scm"]
3 | build-backend = "setuptools.build_meta"
4 |
5 | [project]
6 | name = "logging_json"
7 | description = "JSON formatter for python logging"
8 | readme = "README.md"
9 | requires-python = ">=3.9"
10 | license = {file = "LICENSE"}
11 | authors = [
12 | {name = "Colin Bounouar", email = "colin.bounouar.dev@gmail.com" }
13 | ]
14 | maintainers = [
15 | {name = "Colin Bounouar", email = "colin.bounouar.dev@gmail.com" }
16 | ]
17 | keywords = ["logging", "json"]
18 | classifiers=[
19 | "Development Status :: 5 - Production/Stable",
20 | "Intended Audience :: Developers",
21 | "License :: OSI Approved :: MIT License",
22 | "Natural Language :: English",
23 | "Typing :: Typed",
24 | "Programming Language :: Python",
25 | "Programming Language :: Python :: 3",
26 | "Programming Language :: Python :: 3.9",
27 | "Programming Language :: Python :: 3.10",
28 | "Programming Language :: Python :: 3.11",
29 | "Programming Language :: Python :: 3.12",
30 | "Programming Language :: Python :: 3.13",
31 | "Topic :: Software Development :: Build Tools",
32 | ]
33 | dependencies = []
34 | dynamic = ["version"]
35 |
36 | [project.urls]
37 | documentation = "https://colin-b.github.io/logging_json/"
38 | repository = "https://github.com/Colin-b/logging_json"
39 | changelog = "https://github.com/Colin-b/logging_json/blob/master/CHANGELOG.md"
40 | issues = "https://github.com/Colin-b/logging_json/issues"
41 |
42 | [project.optional-dependencies]
43 | testing = [
44 | # Used to freeze time
45 | "time-machine==2.*",
46 | # Used to check coverage
47 | "pytest-cov==6.*",
48 | ]
49 |
50 | [tool.setuptools.packages.find]
51 | exclude = ["tests*"]
52 |
53 | [tool.setuptools.dynamic]
54 | version = {attr = "logging_json.version.__version__"}
55 |
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Colin-b/logging_json/fcff26106bc3de505ef41824666f6763cddcc960/tests/__init__.py
--------------------------------------------------------------------------------
/tests/test_formatter.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import json
3 | import datetime
4 | import pytest
5 | import sys
6 | import time_machine
7 |
8 | import logging_json
9 |
10 |
11 | class MyException(Exception):
12 | pass
13 |
14 |
15 | def test_empty_dict_message(caplog):
16 | caplog.set_level("INFO")
17 | logging.info({})
18 | assert fmt(caplog) == "{}"
19 |
20 |
21 | def test_dict_message(caplog):
22 | caplog.set_level("INFO")
23 | logging.info({"key 1": "value 1", "key 2": 2})
24 | assert fmt(caplog) == '{"key 1": "value 1", "key 2": 2}'
25 |
26 |
27 | def test_str_message(caplog):
28 | caplog.set_level("INFO")
29 | logging.info("message 1")
30 | assert fmt(caplog) == "message 1"
31 |
32 |
33 | def test_str_with_args_message(caplog):
34 | caplog.set_level("INFO")
35 | logging.info("message %s", "1")
36 | assert fmt(caplog) == "message 1"
37 |
38 |
39 | def test_str_with_message_field_name(caplog):
40 | caplog.set_level("INFO")
41 | logging.info("message 1")
42 | assert fmt(caplog, message_field_name="msg") == "message 1"
43 |
44 |
45 | def test_str_with_extra_message(caplog):
46 | caplog.set_level("INFO")
47 | logging.info("message 1", extra={"key1": "value 1"})
48 | assert dict_fmt(caplog) == {"message": "message 1", "key1": "value 1"}
49 |
50 |
51 | def test_str_with_message_field_name_and_fields(caplog):
52 | caplog.set_level("INFO")
53 | logging.info("message 1")
54 | assert dict_fmt(
55 | caplog, message_field_name="msg", fields={"level": "levelname"}
56 | ) == {"msg": "message 1", "level": "INFO"}
57 |
58 |
59 | def test_str_with_args_and_extra_message(caplog):
60 | caplog.set_level("INFO")
61 | logging.info("message %s", "1", extra={"key1": "value 1"})
62 | assert dict_fmt(caplog) == {"message": "message 1", "key1": "value 1"}
63 |
64 |
65 | @time_machine.travel("2020-09-10 13:12:33.007667")
66 | def test_dict_message_with_asctime(caplog):
67 | caplog.set_level("INFO")
68 | logging.info({"key 1": "value 1", "key 2": 2})
69 | actual = dict_fmt(caplog, fields={"date_time": "asctime"})
70 | assert actual.pop("date_time") == "2020-09-10 13:12:33,007"
71 | assert actual == {"key 1": "value 1", "key 2": 2}
72 |
73 |
74 | @time_machine.travel("2020-09-10 13:12:33.007667")
75 | def test_str_message_with_asctime(caplog):
76 | caplog.set_level("INFO")
77 | logging.info("message 1")
78 | actual = dict_fmt(caplog, fields={"date_time": "asctime"})
79 | assert actual.pop("date_time") == "2020-09-10 13:12:33,007"
80 | assert actual == {"message": "message 1"}
81 |
82 |
83 | @time_machine.travel("2020-09-10 13:12:33.007667")
84 | def test_str_with_args_message_with_asctime(caplog):
85 | caplog.set_level("INFO")
86 | logging.info("message %s", "1")
87 | actual = dict_fmt(caplog, fields={"date_time": "asctime"})
88 | assert actual.pop("date_time") == "2020-09-10 13:12:33,007"
89 | assert actual == {"message": "message 1"}
90 |
91 |
92 | @time_machine.travel("2020-09-10 13:12:33.007667")
93 | def test_str_with_args_and_extra_message_with_asctime(caplog):
94 | caplog.set_level("INFO")
95 | logging.info("message %s", "1", extra={"key1": "value 1"})
96 | actual = dict_fmt(caplog, fields={"date_time": "asctime"})
97 | assert actual.pop("date_time") == "2020-09-10 13:12:33,007"
98 | assert actual == {"message": "message 1", "key1": "value 1"}
99 |
100 |
101 | def test_dict_message_at_exception_level(caplog):
102 | caplog.set_level("INFO")
103 | try:
104 | raise MyException("this is the exception message")
105 | except MyException:
106 | logging.exception({"key 1": "value 1", "key 2": 2})
107 | actual = dict_fmt(caplog)
108 | actual["exception"].pop("stack")
109 | assert actual == {
110 | "key 1": "value 1",
111 | "key 2": 2,
112 | "exception": {
113 | "message": "this is the exception message",
114 | "type": "MyException",
115 | },
116 | }
117 |
118 |
119 | def test_dict_message_at_exception_level_with_different_field_name(caplog):
120 | caplog.set_level("INFO")
121 | try:
122 | raise MyException("this is the exception message")
123 | except MyException:
124 | logging.exception({"key 1": "value 1", "key 2": 2})
125 | actual = dict_fmt(caplog, exception_field_name="info_about_exception")
126 | actual["info_about_exception"].pop("stack")
127 | assert actual == {
128 | "key 1": "value 1",
129 | "key 2": 2,
130 | "info_about_exception": {
131 | "message": "this is the exception message",
132 | "type": "MyException",
133 | },
134 | }
135 |
136 |
137 | @pytest.mark.parametrize("value", [None, ""])
138 | def test_dict_message_at_exception_level_without_exception_field(caplog, value):
139 | caplog.set_level("INFO")
140 | try:
141 | raise MyException("this is the exception message")
142 | except MyException:
143 | logging.exception({"key 1": "value 1", "key 2": 2})
144 | actual = dict_fmt(caplog, exception_field_name=value)
145 | assert actual == {
146 | "key 1": "value 1",
147 | "key 2": 2,
148 | }
149 |
150 |
151 | def test_str_message_at_exception_level(caplog):
152 | caplog.set_level("INFO")
153 | try:
154 | raise MyException("this is the exception message")
155 | except MyException:
156 | logging.exception("message 1")
157 | actual = dict_fmt(caplog)
158 | actual["exception"].pop("stack")
159 | assert actual == {
160 | "message": "message 1",
161 | "exception": {
162 | "message": "this is the exception message",
163 | "type": "MyException",
164 | },
165 | }
166 |
167 |
168 | def test_str_with_args_message_at_exception_level(caplog):
169 | caplog.set_level("INFO")
170 | try:
171 | raise MyException("this is the exception message")
172 | except MyException:
173 | logging.exception("message %s", "1")
174 | actual = dict_fmt(caplog)
175 | actual["exception"].pop("stack")
176 | assert actual == {
177 | "message": "message 1",
178 | "exception": {
179 | "message": "this is the exception message",
180 | "type": "MyException",
181 | },
182 | }
183 |
184 |
185 | @time_machine.travel("2020-09-10T13:12:33.007667+00:00", tick=False)
186 | def test_documented_record_attributes(caplog):
187 | caplog.set_level("INFO")
188 | logging.info({})
189 | actual = dict_fmt(
190 | caplog,
191 | fields={
192 | "logger_name": "name",
193 | "level_number": "levelno",
194 | "level_name": "levelname",
195 | "file_path": "pathname",
196 | "file_name": "filename",
197 | "module_name": "module",
198 | "line_number": "lineno",
199 | "function_name": "funcName",
200 | "timestamp": "created",
201 | "timestamp_milliseconds": "msecs",
202 | "relative_timestamp": "relativeCreated",
203 | "thread_id": "thread",
204 | "thread_name": "threadName",
205 | "process_id": "process",
206 | "process_name": "processName",
207 | "task_name": "taskName",
208 | "record_message": "message",
209 | "extra": "this is a value",
210 | },
211 | )
212 | actual.pop("file_path")
213 | actual.pop("thread_id")
214 | actual.pop("process_id")
215 | actual.pop("relative_timestamp")
216 | actual.pop("line_number")
217 | python_minor = sys.version_info.minor
218 | assert actual == {
219 | "extra": "this is a value",
220 | "file_name": "test_formatter.py",
221 | "function_name": "test_documented_record_attributes",
222 | "level_name": "INFO",
223 | "level_number": 20,
224 | "logger_name": "root",
225 | "module_name": "test_formatter",
226 | "process_name": "MainProcess",
227 | "record_message": "{}",
228 | "thread_name": "MainThread",
229 | "task_name": None if python_minor >= 12 else "taskName",
230 | "timestamp": 1599743553.0076668,
231 | "timestamp_milliseconds": 7.0 if python_minor >= 10 else 7.666826248168945,
232 | }
233 |
234 |
235 | def test_with_extra_in_fields_and_message(caplog):
236 | caplog.set_level("INFO")
237 | logging.info("message 1", extra={"key1": "value 1"})
238 | assert dict_fmt(caplog, fields={"extra": "key1", "key2": "value 2",},) == {
239 | "extra": "value 1",
240 | "key1": "value 1",
241 | "key2": "value 2",
242 | "message": "message 1",
243 | }
244 |
245 |
246 | @time_machine.travel("2020-09-10 13:12:33.0076675")
247 | def test_asctime_with_datefmt(caplog):
248 | caplog.set_level("INFO")
249 | logging.info("message 1")
250 | actual = dict_fmt(caplog, fields={"date_time": "asctime"}, datefmt="%Y-%m-%dT%H:%M:%S")
251 | assert actual.pop("date_time") == "2020-09-10T13:12:33"
252 | assert actual == {"message": "message 1"}
253 |
254 |
255 | @time_machine.travel("2020-09-10 13:12:33.0076675")
256 | def test_asctime_with_default_time_format_and_default_msec_format(caplog):
257 | caplog.set_level("INFO")
258 | logging.info("message 1")
259 | actual = dict_fmt(caplog, fields={"date_time": "asctime"}, default_time_format="%Y-%m-%dT%H:%M:%S", default_msec_format="%s.%03d")
260 | assert actual.pop("date_time") == "2020-09-10T13:12:33.007"
261 | assert actual == {"message": "message 1"}
262 |
263 |
264 | def test_encoding_with_str(caplog):
265 | caplog.set_level("INFO")
266 | logging.info("테스트")
267 | actual = fmt(caplog)
268 | assert actual == "테스트"
269 |
270 |
271 | def test_encoding_with_dict(caplog):
272 | caplog.set_level("INFO")
273 | logging.info("테스트", extra={"테스트": "테스트"})
274 | actual = fmt(caplog)
275 | assert actual == '{"message": "테스트", "테스트": "테스트"}'
276 |
277 |
278 | def test_json_dumps_error(caplog):
279 | class CustomWithoutStr:
280 | pass
281 |
282 | custom = CustomWithoutStr()
283 |
284 | class CustomWithStr:
285 | def __str__(self):
286 | return "Custom instance"
287 |
288 | caplog.set_level("INFO")
289 | logging.info(
290 | {
291 | "my_datetime": datetime.datetime(
292 | 2020, 1, 10, 3, 14, 11, tzinfo=datetime.timezone.utc
293 | ),
294 | "my_date": datetime.date(2020, 1, 10),
295 | "my_time": datetime.time(3, 14, 11, tzinfo=datetime.timezone.utc),
296 | "my_custom_obj": custom,
297 | "my_custom_obj_with_str": CustomWithStr(),
298 | }
299 | )
300 | assert dict_fmt(caplog,) == {
301 | "my_custom_obj": str(custom),
302 | "my_custom_obj_with_str": "Custom instance",
303 | "my_date": "2020-01-10",
304 | "my_datetime": "2020-01-10T03:14:11+00:00",
305 | "my_time": "03:14:11+00:00",
306 | }
307 |
308 |
309 | def test_exception_logging_without_exception_info(caplog):
310 | caplog.set_level("INFO")
311 | logging.exception("There is no exception context")
312 |
313 | assert fmt(caplog) == "There is no exception context"
314 |
315 |
316 | def test_exception_logging_in_finally(caplog):
317 | caplog.set_level("INFO")
318 | try:
319 | raise MyException("this is the exception message")
320 | except MyException:
321 | pass
322 | finally:
323 | logging.exception("Something happened")
324 |
325 | assert fmt(caplog) == "Something happened"
326 |
327 |
328 | def fmt(caplog, *formatter_args, **formatter_kwargs) -> str:
329 | return logging_json.JSONFormatter(*formatter_args, **formatter_kwargs).format(
330 | caplog.records[0]
331 | )
332 |
333 |
334 | def dict_fmt(caplog, *formatter_args, **formatter_kwargs) -> dict:
335 | return json.loads(fmt(caplog, *formatter_args, **formatter_kwargs))
336 |
--------------------------------------------------------------------------------