amox 0.0.1__py3-none-any.whl
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/__init__.py +17 -0
- amox/dictConfig.json +30 -0
- amox/formatters.py +383 -0
- amox/handlers.py +55 -0
- amox/logging_.py +145 -0
- amox/parsers.py +192 -0
- amox/py.typed +0 -0
- amox/types_.py +552 -0
- amox-0.0.1.dist-info/METADATA +25 -0
- amox-0.0.1.dist-info/RECORD +11 -0
- amox-0.0.1.dist-info/WHEEL +4 -0
amox/__init__.py
ADDED
|
@@ -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"
|
amox/dictConfig.json
ADDED
|
@@ -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
|
+
}
|
amox/formatters.py
ADDED
|
@@ -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
|
amox/handlers.py
ADDED
|
@@ -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
|
amox/logging_.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Core logging APIs."""
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
import functools
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import logging.config
|
|
8
|
+
import pathlib
|
|
9
|
+
import types
|
|
10
|
+
import typing as t
|
|
11
|
+
|
|
12
|
+
from amox.formatters import AmoxFormatter, create_formatter
|
|
13
|
+
from amox.types_ import (
|
|
14
|
+
DictConfig,
|
|
15
|
+
FormatterOptions,
|
|
16
|
+
LogFormat,
|
|
17
|
+
LoggerConfig,
|
|
18
|
+
LogLevel,
|
|
19
|
+
SetupOptions,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
LIB = f"{__package__}"
|
|
23
|
+
"""
|
|
24
|
+
Library name, reference for `dictConfig`'s custom objects.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
DEFAULT_EXISTING_LOGGER_LEVEL: LogLevel = "WARNING"
|
|
28
|
+
"""
|
|
29
|
+
Default log level on setup when viewing logs of third party packages.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def setup(**opts: t.Unpack[SetupOptions]) -> None:
|
|
34
|
+
"""
|
|
35
|
+
Configure the root logger with amox's structured formatters.
|
|
36
|
+
|
|
37
|
+
Installs a `StreamHandler` (optionally wrapped in a `QueueHandler`) on the root
|
|
38
|
+
logger. All loggers in the process inherit the handler and emit structured output.
|
|
39
|
+
|
|
40
|
+
The root logger level defaults to `INFO`. When `name` is provided, the named
|
|
41
|
+
logger is set to `DEBUG`: giving clients full verbosity while third-party libraries
|
|
42
|
+
stay at `INFO`, overridable via `loggers`.
|
|
43
|
+
"""
|
|
44
|
+
if has_handler():
|
|
45
|
+
return
|
|
46
|
+
|
|
47
|
+
cfg = config()
|
|
48
|
+
|
|
49
|
+
# forward formatter opts into the factory config
|
|
50
|
+
formatter_cfg: dict[str, object] = cfg["formatters"][LIB] # ty: ignore[invalid-assignment] # pyright: ignore[reportAssignmentType, reportTypedDictNotRequiredAccess]
|
|
51
|
+
for key in set(opts) & AmoxFormatter.configurable:
|
|
52
|
+
formatter_cfg[key] = opts[key]
|
|
53
|
+
|
|
54
|
+
# override format if explicitly passed (bypass env/factory)
|
|
55
|
+
if fmt := opts.get("format"):
|
|
56
|
+
formatter_cfg["format"] = fmt
|
|
57
|
+
|
|
58
|
+
# tz is non-serializable; use dictConfig's "." protocol for post-construction attr
|
|
59
|
+
# setting
|
|
60
|
+
if tz := opts.get("tz"):
|
|
61
|
+
formatter_cfg["."] = {"tz": tz}
|
|
62
|
+
|
|
63
|
+
use_queue = opts.get("queue", True)
|
|
64
|
+
if not use_queue:
|
|
65
|
+
_ = cfg["handlers"].pop(f"{LIB}.queue_handler") # type: ignore[misc]
|
|
66
|
+
cfg["root"] = {"handlers": [LIB], "level": "INFO"}
|
|
67
|
+
|
|
68
|
+
# app namespace: promote to DEBUG while root stays at INFO
|
|
69
|
+
if name := opts.get("name"):
|
|
70
|
+
loggers_section: dict[str, LoggerConfig] = cfg.get("loggers", {}) # type: ignore[assignment]
|
|
71
|
+
loggers_section[name] = {"level": "DEBUG"}
|
|
72
|
+
cfg["loggers"] = loggers_section
|
|
73
|
+
|
|
74
|
+
# logger level overrides
|
|
75
|
+
loggers_section = cfg.get("loggers", {}) # type: ignore[assignment]
|
|
76
|
+
|
|
77
|
+
for entry in opts.get("loggers", []):
|
|
78
|
+
if isinstance(entry, (str, types.ModuleType)):
|
|
79
|
+
entry_name = (
|
|
80
|
+
entry.__name__ if isinstance(entry, types.ModuleType) else entry
|
|
81
|
+
)
|
|
82
|
+
loggers_section[entry_name] = {"level": DEFAULT_EXISTING_LOGGER_LEVEL}
|
|
83
|
+
else:
|
|
84
|
+
mod = entry["module"]
|
|
85
|
+
entry_name = mod.__name__ if isinstance(mod, types.ModuleType) else mod
|
|
86
|
+
loggers_section[entry_name] = {"level": entry["level"]}
|
|
87
|
+
if loggers_section:
|
|
88
|
+
cfg["loggers"] = loggers_section
|
|
89
|
+
|
|
90
|
+
logging.config.dictConfig(cfg) # ty: ignore[invalid-argument-type] # pyright: ignore[reportArgumentType]
|
|
91
|
+
return
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def config() -> DictConfig:
|
|
95
|
+
"""Return amox's `dictConfig` mapping."""
|
|
96
|
+
return copy.deepcopy(read_config())
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def get_logger(
|
|
100
|
+
name: str | None = None,
|
|
101
|
+
*,
|
|
102
|
+
level: LogLevel | int = logging.DEBUG,
|
|
103
|
+
log_format: LogFormat | None = None,
|
|
104
|
+
handlers: list[logging.Handler] | None = None,
|
|
105
|
+
**opts: t.Unpack[FormatterOptions],
|
|
106
|
+
) -> logging.Logger:
|
|
107
|
+
"""
|
|
108
|
+
Return a logger with amox's structured formatting attached.
|
|
109
|
+
|
|
110
|
+
Creates a `StreamHandler` with a amox formatter on the named logger.
|
|
111
|
+
"""
|
|
112
|
+
logger = logging.getLogger(name)
|
|
113
|
+
|
|
114
|
+
# early exit on scenarios that did a `setup()` call
|
|
115
|
+
if has_handler():
|
|
116
|
+
return logger
|
|
117
|
+
|
|
118
|
+
# local logger
|
|
119
|
+
if not logger.handlers:
|
|
120
|
+
stream = logging.StreamHandler()
|
|
121
|
+
stream.setFormatter(create_formatter(log_format, **opts)) # ty: ignore[no-matching-overload]
|
|
122
|
+
logger.addHandler(stream)
|
|
123
|
+
for h in handlers or []:
|
|
124
|
+
logger.addHandler(h)
|
|
125
|
+
logger.setLevel(level)
|
|
126
|
+
|
|
127
|
+
return logger
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def has_handler(name: str = LIB) -> bool:
|
|
131
|
+
"""
|
|
132
|
+
Whether any handler on the root logger is named after a giving name.
|
|
133
|
+
|
|
134
|
+
`dictConfig` sets `handler.name` to the dict key, so handlers installed via
|
|
135
|
+
`setup()` will have names starting with the package name.
|
|
136
|
+
"""
|
|
137
|
+
return any(h.name and h.name.startswith(name) for h in logging.getLogger().handlers)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@functools.cache
|
|
141
|
+
def read_config() -> DictConfig:
|
|
142
|
+
"""Load and cache the bundled dictConfig JSON file."""
|
|
143
|
+
config_file = pathlib.Path(__file__).parent / "dictConfig.json"
|
|
144
|
+
with open(config_file) as f: # noqa: PTH123
|
|
145
|
+
return json.load(f)
|