amox 0.0.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- amox-0.0.1/PKG-INFO +25 -0
- amox-0.0.1/README.md +1 -0
- amox-0.0.1/pyproject.toml +71 -0
- amox-0.0.1/src/amox/__init__.py +17 -0
- amox-0.0.1/src/amox/dictConfig.json +30 -0
- amox-0.0.1/src/amox/formatters.py +383 -0
- amox-0.0.1/src/amox/handlers.py +55 -0
- amox-0.0.1/src/amox/logging_.py +145 -0
- amox-0.0.1/src/amox/parsers.py +192 -0
- amox-0.0.1/src/amox/py.typed +0 -0
- amox-0.0.1/src/amox/types_.py +552 -0
amox-0.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: amox
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Schema on read based logging
|
|
5
|
+
Keywords: json,logfmt,logging,structured-logging
|
|
6
|
+
Author: Kevin Montoya
|
|
7
|
+
Author-email: Kevin Montoya <me@kmontocam.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
18
|
+
Classifier: Topic :: System :: Logging
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.12
|
|
21
|
+
Project-URL: Issues, https://github.com/kmontocam/amox/issues
|
|
22
|
+
Project-URL: Repository, https://github.com/kmontocam/amox
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# amox
|
amox-0.0.1/README.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# amox
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
authors = [{ name = "Kevin Montoya", email = "me@kmontocam.com" }]
|
|
3
|
+
classifiers = [
|
|
4
|
+
"Development Status :: 3 - Alpha",
|
|
5
|
+
"Intended Audience :: Developers",
|
|
6
|
+
"Operating System :: OS Independent",
|
|
7
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
8
|
+
"Programming Language :: Python :: 3",
|
|
9
|
+
"Programming Language :: Python :: 3.12",
|
|
10
|
+
"Programming Language :: Python :: 3.13",
|
|
11
|
+
"Programming Language :: Python :: 3.14",
|
|
12
|
+
"Topic :: Software Development :: Libraries",
|
|
13
|
+
"Topic :: System :: Logging",
|
|
14
|
+
"Typing :: Typed",
|
|
15
|
+
]
|
|
16
|
+
dependencies = []
|
|
17
|
+
description = "Schema on read based logging"
|
|
18
|
+
keywords = ["json", "logfmt", "logging", "structured-logging"]
|
|
19
|
+
license = "MIT"
|
|
20
|
+
name = "amox"
|
|
21
|
+
readme = "README.md"
|
|
22
|
+
requires-python = ">=3.12"
|
|
23
|
+
version = "0.0.1"
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Issues = "https://github.com/kmontocam/amox/issues"
|
|
27
|
+
Repository = "https://github.com/kmontocam/amox"
|
|
28
|
+
|
|
29
|
+
[build-system]
|
|
30
|
+
build-backend = "uv_build"
|
|
31
|
+
requires = ["uv_build>=0.11.14,<0.12.0"]
|
|
32
|
+
|
|
33
|
+
[dependency-groups]
|
|
34
|
+
dev = [
|
|
35
|
+
"jsonschema==4.26.0",
|
|
36
|
+
"mbake==1.4.6",
|
|
37
|
+
"pre-commit==4.6.0",
|
|
38
|
+
"pytest==9.0.3",
|
|
39
|
+
"pyyaml==6.0.3",
|
|
40
|
+
"ruff==0.15.14",
|
|
41
|
+
"taplo==0.9.3",
|
|
42
|
+
"ty==0.0.38",
|
|
43
|
+
"uvicorn==0.47.0",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
[tool.pytest.ini_options]
|
|
47
|
+
addopts = ["-p", "no:logging"]
|
|
48
|
+
markers = [
|
|
49
|
+
"functional: subprocess-based full API tests",
|
|
50
|
+
"integration: tests with third-party dependencies",
|
|
51
|
+
]
|
|
52
|
+
pythonpath = ["."]
|
|
53
|
+
testpaths = ["tests"]
|
|
54
|
+
|
|
55
|
+
[tool.ruff]
|
|
56
|
+
line-length = 88
|
|
57
|
+
target-version = "py312"
|
|
58
|
+
|
|
59
|
+
[tool.ruff.lint]
|
|
60
|
+
ignore = ["COM812", "D203", "D212"]
|
|
61
|
+
select = ["ALL"]
|
|
62
|
+
|
|
63
|
+
[tool.ruff.lint.per-file-ignores]
|
|
64
|
+
"tests/**/*.py" = ["FBT001", "S101"]
|
|
65
|
+
|
|
66
|
+
[tool.ruff.format]
|
|
67
|
+
docstring-code-format = true
|
|
68
|
+
quote-style = "double"
|
|
69
|
+
|
|
70
|
+
[tool.pyright]
|
|
71
|
+
reportAny = false
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Amox."""
|
|
2
|
+
|
|
3
|
+
from amox.formatters import JsonFormatter, LogfmtFormatter, create_formatter
|
|
4
|
+
from amox.logging_ import config, get_logger, setup
|
|
5
|
+
from amox.types_ import FormatterOptions
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"FormatterOptions",
|
|
9
|
+
"JsonFormatter",
|
|
10
|
+
"LogfmtFormatter",
|
|
11
|
+
"config",
|
|
12
|
+
"create_formatter",
|
|
13
|
+
"get_logger",
|
|
14
|
+
"setup",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
__version__ = "0.0.1"
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../../schema/dictConfig.schema.json",
|
|
3
|
+
"disable_existing_loggers": false,
|
|
4
|
+
"formatters": {
|
|
5
|
+
"amox": {
|
|
6
|
+
"()": "amox.create_formatter"
|
|
7
|
+
}
|
|
8
|
+
},
|
|
9
|
+
"handlers": {
|
|
10
|
+
"amox": {
|
|
11
|
+
"class": "logging.StreamHandler",
|
|
12
|
+
"formatter": "amox",
|
|
13
|
+
"stream": "ext://sys.stderr"
|
|
14
|
+
},
|
|
15
|
+
"amox.queue_handler": {
|
|
16
|
+
"class": "amox.handlers.LiveQueueHandler",
|
|
17
|
+
"handlers": [
|
|
18
|
+
"amox"
|
|
19
|
+
],
|
|
20
|
+
"respect_handler_level": true
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"root": {
|
|
24
|
+
"handlers": [
|
|
25
|
+
"amox.queue_handler"
|
|
26
|
+
],
|
|
27
|
+
"level": "INFO"
|
|
28
|
+
},
|
|
29
|
+
"version": 1
|
|
30
|
+
}
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
"""Schema on read formatters."""
|
|
2
|
+
|
|
3
|
+
import datetime as dt
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import typing as t
|
|
9
|
+
import warnings
|
|
10
|
+
|
|
11
|
+
from amox.types_ import (
|
|
12
|
+
FieldRemap,
|
|
13
|
+
FormatterOptions,
|
|
14
|
+
IncludeFields,
|
|
15
|
+
Json,
|
|
16
|
+
Logfmt,
|
|
17
|
+
LogFormat,
|
|
18
|
+
LogRecordAttr,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
LOG_FORMAT_ENV = "AMOX_LOG_FORMAT"
|
|
22
|
+
"""
|
|
23
|
+
Convention environment variable name to configure log format.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
DEL_CHAR = 0x7F
|
|
27
|
+
"""
|
|
28
|
+
ASCII DEL character ordinal.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
LOG_RECORD_BUILTIN_ATTRS: set[LogRecordAttr] = set( # pyright: ignore[reportAssignmentType]
|
|
32
|
+
logging.makeLogRecord({"message": "", "asctime": ""}).__dict__.keys(),
|
|
33
|
+
) # ty: ignore[invalid-assignment]
|
|
34
|
+
"""
|
|
35
|
+
`logging.LogRecord` instance attributes to enrich logs.
|
|
36
|
+
|
|
37
|
+
Note:
|
|
38
|
+
`message` and `asctime` are seeded, since `makeLogRecord` materializes theme during
|
|
39
|
+
`Formatter.format()`.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
LOG_FORMATS: set[LogFormat] = {
|
|
44
|
+
"json",
|
|
45
|
+
"logfmt",
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
DEFAULT_FORMAT: LogFormat = "logfmt"
|
|
49
|
+
|
|
50
|
+
DEFAULT_FIELD_REMAP: FieldRemap = {
|
|
51
|
+
"created": "ts",
|
|
52
|
+
"levelname": "level",
|
|
53
|
+
"name": "logger",
|
|
54
|
+
"msg": "msg",
|
|
55
|
+
"exc_info": "exception",
|
|
56
|
+
}
|
|
57
|
+
"""
|
|
58
|
+
Default rename convention of `LogRecordAttr`.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
DEFAULT_INCLUDE: tuple[LogRecordAttr, ...] = (
|
|
62
|
+
"created",
|
|
63
|
+
"levelname",
|
|
64
|
+
"name",
|
|
65
|
+
"msg",
|
|
66
|
+
"exc_info",
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
VERBOSE_INCLUDE: tuple[LogRecordAttr, ...] = (
|
|
70
|
+
*DEFAULT_INCLUDE,
|
|
71
|
+
"filename",
|
|
72
|
+
"lineno",
|
|
73
|
+
"funcName",
|
|
74
|
+
"threadName",
|
|
75
|
+
"processName",
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
ALL_EXCLUDE: set[LogRecordAttr] = {
|
|
79
|
+
"args",
|
|
80
|
+
"exc_text",
|
|
81
|
+
"relativeCreated",
|
|
82
|
+
"msecs",
|
|
83
|
+
}
|
|
84
|
+
"""
|
|
85
|
+
Always excluded attributes from log record.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class AmoxFormatter(logging.Formatter):
|
|
90
|
+
"""Base formatter with shared field extraction logic."""
|
|
91
|
+
|
|
92
|
+
configurable: frozenset[
|
|
93
|
+
t.Literal[
|
|
94
|
+
"datefmt",
|
|
95
|
+
"include",
|
|
96
|
+
"snake_case",
|
|
97
|
+
"field_remap",
|
|
98
|
+
]
|
|
99
|
+
] = frozenset(
|
|
100
|
+
{
|
|
101
|
+
"datefmt",
|
|
102
|
+
"include",
|
|
103
|
+
"snake_case",
|
|
104
|
+
"field_remap",
|
|
105
|
+
},
|
|
106
|
+
)
|
|
107
|
+
tz: dt.tzinfo | None = None
|
|
108
|
+
|
|
109
|
+
def __init__( # noqa: PLR0913
|
|
110
|
+
self,
|
|
111
|
+
fmt: str | None = None,
|
|
112
|
+
datefmt: str | None = None,
|
|
113
|
+
style: t.Literal["%", "{", "$"] = "%",
|
|
114
|
+
validate: bool = True, # noqa: FBT001, FBT002
|
|
115
|
+
*,
|
|
116
|
+
defaults: dict[str, object] | None = None,
|
|
117
|
+
include: IncludeFields = "minimal",
|
|
118
|
+
snake_case: bool = True,
|
|
119
|
+
field_remap: FieldRemap = DEFAULT_FIELD_REMAP,
|
|
120
|
+
) -> None:
|
|
121
|
+
"""
|
|
122
|
+
Initialize the structured formatter.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
fmt: format string for `logging.Formatter` (e.g. `'%(message)s'`).
|
|
126
|
+
Included for protocol, not typically used with schema formatters.
|
|
127
|
+
datefmt: strftime format for `LogRecord.created` timestamp.
|
|
128
|
+
style: format string style character (`%`, `{`, or `$`).
|
|
129
|
+
validate: whether to validate the format string.
|
|
130
|
+
defaults: default values merged into every record's `__dict__`.
|
|
131
|
+
include: list of attributes of a record to include, or string based
|
|
132
|
+
convention.
|
|
133
|
+
snake_case: whether to snake_case the keys from the compiled records.
|
|
134
|
+
Applies to field_remap values as well.
|
|
135
|
+
field_remap: mapping to convert attribute keys to a different name
|
|
136
|
+
|
|
137
|
+
"""
|
|
138
|
+
super().__init__(
|
|
139
|
+
fmt=fmt,
|
|
140
|
+
datefmt=datefmt,
|
|
141
|
+
style=style,
|
|
142
|
+
validate=validate,
|
|
143
|
+
defaults=defaults,
|
|
144
|
+
)
|
|
145
|
+
self.snake_case: bool = snake_case
|
|
146
|
+
self.field_remap: FieldRemap = field_remap
|
|
147
|
+
|
|
148
|
+
self.include: tuple[LogRecordAttr, ...] = self.includes(include)
|
|
149
|
+
self.field_remap_keys: list[LogRecordAttr] = list(field_remap.keys())
|
|
150
|
+
|
|
151
|
+
def includes(self, fields: IncludeFields) -> tuple[LogRecordAttr, ...]:
|
|
152
|
+
"""Resolve record attributes to include on the record."""
|
|
153
|
+
if isinstance(fields, list):
|
|
154
|
+
return tuple(fields)
|
|
155
|
+
if fields == "minimal":
|
|
156
|
+
return DEFAULT_INCLUDE
|
|
157
|
+
if fields == "verbose":
|
|
158
|
+
return VERBOSE_INCLUDE
|
|
159
|
+
|
|
160
|
+
return tuple( # "all"
|
|
161
|
+
LOG_RECORD_BUILTIN_ATTRS - ALL_EXCLUDE,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
def output_key(self, source: str) -> str:
|
|
165
|
+
"""
|
|
166
|
+
Resolve the output key name for a `LogRecord` attribute.
|
|
167
|
+
|
|
168
|
+
Apply's `field_remap` rename if present, snake cases field if configured.
|
|
169
|
+
"""
|
|
170
|
+
if source in self.field_remap_keys:
|
|
171
|
+
source = self.field_remap[source] # ty: ignore[invalid-argument-type]
|
|
172
|
+
|
|
173
|
+
return self.to_snake(source) if self.snake_case else source
|
|
174
|
+
|
|
175
|
+
def value_from_record(
|
|
176
|
+
self,
|
|
177
|
+
record: logging.LogRecord,
|
|
178
|
+
source: LogRecordAttr,
|
|
179
|
+
) -> str | None:
|
|
180
|
+
"""
|
|
181
|
+
Extract a value from a `LogRecord`.
|
|
182
|
+
|
|
183
|
+
Apply dedicated reader to attributes that are enhanced by standard record
|
|
184
|
+
formatters or custom.
|
|
185
|
+
"""
|
|
186
|
+
if source == "msg":
|
|
187
|
+
return record.getMessage()
|
|
188
|
+
if source == "created":
|
|
189
|
+
return self.format_timestamp(record.created)
|
|
190
|
+
|
|
191
|
+
if source == "exc_info":
|
|
192
|
+
return (
|
|
193
|
+
self.formatException(exc)
|
|
194
|
+
if (exc := record.exc_info)
|
|
195
|
+
else record.exc_text
|
|
196
|
+
)
|
|
197
|
+
return getattr(record, source, None)
|
|
198
|
+
|
|
199
|
+
def compile_record(self, record: logging.LogRecord) -> dict[str, object]:
|
|
200
|
+
"""
|
|
201
|
+
Build dictionary from a `LogRecord`, ready to deserialize.
|
|
202
|
+
|
|
203
|
+
Pre-process a raw log record object with configuration options. Produce
|
|
204
|
+
a mapping ready for formatting.
|
|
205
|
+
"""
|
|
206
|
+
payload: dict[str, object] = {}
|
|
207
|
+
|
|
208
|
+
for key in self.include:
|
|
209
|
+
val = self.value_from_record(record, key)
|
|
210
|
+
if key == "exc_info" and val is None:
|
|
211
|
+
continue
|
|
212
|
+
payload[self.output_key(key)] = val
|
|
213
|
+
|
|
214
|
+
payload.update(
|
|
215
|
+
{
|
|
216
|
+
key: val
|
|
217
|
+
for key, val in record.__dict__.items()
|
|
218
|
+
if key not in LOG_RECORD_BUILTIN_ATTRS
|
|
219
|
+
},
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
return payload
|
|
223
|
+
|
|
224
|
+
def format_timestamp(self, created: float) -> str:
|
|
225
|
+
"""
|
|
226
|
+
Format a unix timestamp using `datefmt`.
|
|
227
|
+
|
|
228
|
+
When `tz` is set, formats in that timezone. UTC gets a `Z` suffix.
|
|
229
|
+
Defaults to local time with ISO 8601 foramt.
|
|
230
|
+
"""
|
|
231
|
+
timestamp = dt.datetime.fromtimestamp(created, tz=dt.UTC)
|
|
232
|
+
if self.tz is not None:
|
|
233
|
+
timestamp = timestamp.astimezone(self.tz)
|
|
234
|
+
else:
|
|
235
|
+
timestamp = timestamp.astimezone()
|
|
236
|
+
if self.datefmt:
|
|
237
|
+
return timestamp.strftime(self.datefmt)
|
|
238
|
+
iso = timestamp.isoformat()
|
|
239
|
+
if self.tz == dt.UTC:
|
|
240
|
+
return iso.replace("+00:00", "Z")
|
|
241
|
+
return iso
|
|
242
|
+
|
|
243
|
+
@staticmethod
|
|
244
|
+
def to_snake(camel: str) -> str:
|
|
245
|
+
"""
|
|
246
|
+
Convert a camelCase string to snake_case.
|
|
247
|
+
|
|
248
|
+
Reference:
|
|
249
|
+
`https://github.com/pydantic/pydantic/blob/main/pydantic/alias_generators.py`
|
|
250
|
+
"""
|
|
251
|
+
snake = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", camel)
|
|
252
|
+
snake = re.sub(r"([a-z])([A-Z])", r"\1_\2", snake)
|
|
253
|
+
snake = re.sub(r"([0-9])([A-Z])", r"\1_\2", snake)
|
|
254
|
+
snake = re.sub(r"([a-z])([0-9])", r"\1_\2", snake)
|
|
255
|
+
return snake.replace("-", "_").lower()
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
class LogfmtFormatter(AmoxFormatter):
|
|
259
|
+
"""
|
|
260
|
+
Formats log records as logfmt key=value pairs.
|
|
261
|
+
|
|
262
|
+
Reference:
|
|
263
|
+
`https://brandur.org/logfmt`
|
|
264
|
+
"""
|
|
265
|
+
|
|
266
|
+
@t.override
|
|
267
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
268
|
+
payload = self.compile_record(record)
|
|
269
|
+
return " ".join(
|
|
270
|
+
f"{key}={self.encode_value(val)}" for key, val in payload.items()
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
def encode_value(self, value: object) -> str:
|
|
274
|
+
"""
|
|
275
|
+
Encode a value for logfmt output.
|
|
276
|
+
|
|
277
|
+
Reference:
|
|
278
|
+
`https://github.com/go-logfmt/logfmt/blob/master/encode.go`
|
|
279
|
+
"""
|
|
280
|
+
if value is None:
|
|
281
|
+
return "null"
|
|
282
|
+
if isinstance(value, bool):
|
|
283
|
+
return "true" if value else "false"
|
|
284
|
+
if isinstance(value, (int, float)):
|
|
285
|
+
return str(value)
|
|
286
|
+
return self.quote(str(value))
|
|
287
|
+
|
|
288
|
+
def quote(self, s: str) -> str:
|
|
289
|
+
"""Apply logfmt quoting rules to a string value."""
|
|
290
|
+
if not s:
|
|
291
|
+
return '""'
|
|
292
|
+
if self.needs_quote(s):
|
|
293
|
+
escaped = (
|
|
294
|
+
s.replace("\\", "\\\\")
|
|
295
|
+
.replace('"', '\\"')
|
|
296
|
+
.replace("\n", "\\n")
|
|
297
|
+
.replace("\r", "\\r")
|
|
298
|
+
.replace("\t", "\\t")
|
|
299
|
+
)
|
|
300
|
+
return f'"{escaped}"'
|
|
301
|
+
return s
|
|
302
|
+
|
|
303
|
+
def needs_quote(self, s: str) -> bool:
|
|
304
|
+
"""
|
|
305
|
+
Whether a logfmt value needs quoting.
|
|
306
|
+
|
|
307
|
+
Reference:
|
|
308
|
+
`https://github.com/go-logfmt/logfmt/blob/master/encode.go`
|
|
309
|
+
"""
|
|
310
|
+
return any(c <= " " or c in {"=", '"', "\\"} or ord(c) == DEL_CHAR for c in s)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
class JsonFormatter(AmoxFormatter):
|
|
314
|
+
"""Formats log records as JSON."""
|
|
315
|
+
|
|
316
|
+
@t.override
|
|
317
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
318
|
+
payload = self.compile_record(record)
|
|
319
|
+
return json.dumps(payload, default=str)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
@t.overload
|
|
323
|
+
def create_formatter(
|
|
324
|
+
log_format: Json,
|
|
325
|
+
/,
|
|
326
|
+
**opts: t.Unpack[FormatterOptions],
|
|
327
|
+
) -> JsonFormatter: ...
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
@t.overload
|
|
331
|
+
def create_formatter(
|
|
332
|
+
log_format: Logfmt | None = None,
|
|
333
|
+
/,
|
|
334
|
+
**opts: t.Unpack[FormatterOptions],
|
|
335
|
+
) -> LogfmtFormatter: ...
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def create_formatter(
|
|
339
|
+
log_format: LogFormat | None = None,
|
|
340
|
+
/,
|
|
341
|
+
**opts: t.Unpack[FormatterOptions],
|
|
342
|
+
) -> JsonFormatter | LogfmtFormatter:
|
|
343
|
+
"""
|
|
344
|
+
Create a formatter from a format identifier string.
|
|
345
|
+
|
|
346
|
+
Resolve the log format and return the corresponding formatter instance.
|
|
347
|
+
Used as the factory for `dictConfig`'s `()` protocol.
|
|
348
|
+
"""
|
|
349
|
+
log_format = log_format or resolve_format()
|
|
350
|
+
tz = opts.get("tz")
|
|
351
|
+
field_remap: FieldRemap = {
|
|
352
|
+
**DEFAULT_FIELD_REMAP,
|
|
353
|
+
**(opts.get("field_remap") or {}),
|
|
354
|
+
}
|
|
355
|
+
cls = JsonFormatter if log_format == "json" else LogfmtFormatter
|
|
356
|
+
formatter = cls(
|
|
357
|
+
datefmt=opts.get("datefmt"),
|
|
358
|
+
include=opts.get("include", "minimal"),
|
|
359
|
+
snake_case=opts.get("snake_case", True),
|
|
360
|
+
field_remap=field_remap,
|
|
361
|
+
)
|
|
362
|
+
formatter.tz = tz
|
|
363
|
+
return formatter
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def resolve_format() -> LogFormat:
|
|
367
|
+
"""Resolve log format from environment variable convention."""
|
|
368
|
+
env = os.environ.get(LOG_FORMAT_ENV)
|
|
369
|
+
if env is None:
|
|
370
|
+
return DEFAULT_FORMAT
|
|
371
|
+
if env in LOG_FORMATS:
|
|
372
|
+
return env # ty: ignore[invalid-return-type]
|
|
373
|
+
|
|
374
|
+
warnings.warn(
|
|
375
|
+
(
|
|
376
|
+
f"{LOG_FORMAT_ENV}={env!r} is not valid."
|
|
377
|
+
f" Expected one of: {', '.join(sorted(LOG_FORMATS))}."
|
|
378
|
+
f" Falling back to {DEFAULT_FORMAT!r}."
|
|
379
|
+
),
|
|
380
|
+
UserWarning,
|
|
381
|
+
stacklevel=2,
|
|
382
|
+
)
|
|
383
|
+
return DEFAULT_FORMAT
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Handlers."""
|
|
2
|
+
|
|
3
|
+
import atexit
|
|
4
|
+
import logging
|
|
5
|
+
import traceback
|
|
6
|
+
import typing as t
|
|
7
|
+
from logging.handlers import QueueHandler, QueueListener
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class LiveQueueHandler(QueueHandler):
|
|
11
|
+
"""
|
|
12
|
+
Queue-based log handler with automatic listener lifecycle.
|
|
13
|
+
|
|
14
|
+
The `QueueListener` is started as soon as it is attached and stopped on
|
|
15
|
+
interpreter shutdown via `atexit`.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
listener: QueueListener | None = None
|
|
19
|
+
|
|
20
|
+
@t.override
|
|
21
|
+
def __setattr__(self, name: str, value: object) -> None:
|
|
22
|
+
super().__setattr__(name, value)
|
|
23
|
+
if name == "listener" and isinstance(value, QueueListener):
|
|
24
|
+
value.start()
|
|
25
|
+
_ = atexit.register(self.stop_listener)
|
|
26
|
+
|
|
27
|
+
def stop_listener(self) -> None:
|
|
28
|
+
"""
|
|
29
|
+
Stop the listener if it is still running.
|
|
30
|
+
|
|
31
|
+
Guards against double-stop when atexit fires after an explicit `listener.stop()`
|
|
32
|
+
call.
|
|
33
|
+
"""
|
|
34
|
+
if (listener := self.listener) is not None and listener._thread is not None: # noqa: SLF001
|
|
35
|
+
listener.stop()
|
|
36
|
+
|
|
37
|
+
@t.override
|
|
38
|
+
def prepare(self, record: logging.LogRecord) -> logging.LogRecord:
|
|
39
|
+
"""
|
|
40
|
+
Preserve `exc_text` for downstream formatters.
|
|
41
|
+
|
|
42
|
+
Python 3.12's stdlib `prepare()` embeds the traceback into `record.msg`
|
|
43
|
+
and clears `exc_text`, making exception info inaccessible to downstream
|
|
44
|
+
formatters. Format `exc_info` into `exc_text` and only clear the tuple
|
|
45
|
+
(which holds frame references and is unpicklable).
|
|
46
|
+
|
|
47
|
+
Reference:
|
|
48
|
+
`https://github.com/python/cpython/issues/107801`
|
|
49
|
+
"""
|
|
50
|
+
if record.exc_info:
|
|
51
|
+
record.exc_text = "".join(
|
|
52
|
+
traceback.format_exception(*record.exc_info),
|
|
53
|
+
).rstrip("\n")
|
|
54
|
+
record.exc_info = None
|
|
55
|
+
return record
|