dycw-utilities 0.129.14__py3-none-any.whl → 0.130.0__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.
- {dycw_utilities-0.129.14.dist-info → dycw_utilities-0.130.0.dist-info}/METADATA +26 -28
- {dycw_utilities-0.129.14.dist-info → dycw_utilities-0.130.0.dist-info}/RECORD +11 -13
- utilities/__init__.py +1 -1
- utilities/asyncio.py +9 -4
- utilities/logging.py +276 -498
- utilities/redis.py +3 -3
- utilities/sqlalchemy.py +8 -2
- utilities/sqlalchemy_polars.py +2 -2
- utilities/traceback.py +102 -901
- utilities/loguru.py +0 -144
- utilities/sys.py +0 -87
- {dycw_utilities-0.129.14.dist-info → dycw_utilities-0.130.0.dist-info}/WHEEL +0 -0
- {dycw_utilities-0.129.14.dist-info → dycw_utilities-0.130.0.dist-info}/licenses/LICENSE +0 -0
utilities/logging.py
CHANGED
@@ -2,14 +2,9 @@ from __future__ import annotations
|
|
2
2
|
|
3
3
|
import datetime as dt
|
4
4
|
import re
|
5
|
-
from contextlib import contextmanager
|
6
5
|
from dataclasses import dataclass, field
|
7
6
|
from functools import cached_property
|
8
|
-
from itertools import product
|
9
7
|
from logging import (
|
10
|
-
DEBUG,
|
11
|
-
ERROR,
|
12
|
-
NOTSET,
|
13
8
|
FileHandler,
|
14
9
|
Formatter,
|
15
10
|
Handler,
|
@@ -24,14 +19,14 @@ from logging import (
|
|
24
19
|
from logging.handlers import BaseRotatingHandler, TimedRotatingFileHandler
|
25
20
|
from pathlib import Path
|
26
21
|
from re import Pattern
|
27
|
-
from sys import stdout
|
28
22
|
from time import time
|
29
23
|
from typing import (
|
30
24
|
TYPE_CHECKING,
|
31
25
|
Any,
|
32
|
-
ClassVar,
|
33
26
|
Literal,
|
27
|
+
NotRequired,
|
34
28
|
Self,
|
29
|
+
TypedDict,
|
35
30
|
assert_never,
|
36
31
|
cast,
|
37
32
|
override,
|
@@ -40,29 +35,18 @@ from typing import (
|
|
40
35
|
from utilities.dataclasses import replace_non_sentinel
|
41
36
|
from utilities.datetime import (
|
42
37
|
SECOND,
|
43
|
-
maybe_sub_pct_y,
|
44
38
|
parse_datetime_compact,
|
45
39
|
round_datetime,
|
46
40
|
serialize_compact,
|
47
41
|
)
|
48
42
|
from utilities.errors import ImpossibleCaseError
|
49
43
|
from utilities.iterables import OneEmptyError, always_iterable, one
|
50
|
-
from utilities.pathlib import ensure_suffix, get_path
|
51
|
-
from utilities.reprlib import (
|
52
|
-
RICH_EXPAND_ALL,
|
53
|
-
RICH_INDENT_SIZE,
|
54
|
-
RICH_MAX_DEPTH,
|
55
|
-
RICH_MAX_LENGTH,
|
56
|
-
RICH_MAX_STRING,
|
57
|
-
RICH_MAX_WIDTH,
|
58
|
-
)
|
44
|
+
from utilities.pathlib import ensure_suffix, get_path
|
59
45
|
from utilities.sentinel import Sentinel, sentinel
|
60
|
-
from utilities.traceback import RichTracebackFormatter
|
61
46
|
|
62
47
|
if TYPE_CHECKING:
|
63
|
-
from collections.abc import Callable, Iterable,
|
48
|
+
from collections.abc import Callable, Iterable, Mapping
|
64
49
|
from logging import _FilterType
|
65
|
-
from zoneinfo import ZoneInfo
|
66
50
|
|
67
51
|
from utilities.types import (
|
68
52
|
LoggerOrName,
|
@@ -71,12 +55,256 @@ if TYPE_CHECKING:
|
|
71
55
|
MaybeIterable,
|
72
56
|
PathLike,
|
73
57
|
)
|
74
|
-
from utilities.version import MaybeCallableVersionLike
|
75
58
|
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
59
|
+
|
60
|
+
_DEFAULT_FORMAT = "{asctime} | {name}:{funcName}:{lineno} | {levelname:8} | {message}"
|
61
|
+
_DEFAULT_DATEFMT = "%Y-%m-%d %H:%M:%S"
|
62
|
+
_DEFAULT_BACKUP_COUNT: int = 100
|
63
|
+
_DEFAULT_MAX_BYTES: int = 10 * 1024**2
|
64
|
+
_DEFAULT_WHEN: _When = "D"
|
65
|
+
|
66
|
+
|
67
|
+
##
|
68
|
+
|
69
|
+
|
70
|
+
def add_filters(handler: Handler, /, *filters: _FilterType) -> None:
|
71
|
+
"""Add a set of filters to a handler."""
|
72
|
+
for filter_i in filters:
|
73
|
+
handler.addFilter(filter_i)
|
74
|
+
|
75
|
+
|
76
|
+
##
|
77
|
+
|
78
|
+
|
79
|
+
def basic_config(
|
80
|
+
*,
|
81
|
+
obj: LoggerOrName | Handler | None = None,
|
82
|
+
whenever: bool = False,
|
83
|
+
format_: str = _DEFAULT_FORMAT,
|
84
|
+
datefmt: str = _DEFAULT_DATEFMT,
|
85
|
+
level: LogLevel = "INFO",
|
86
|
+
filters: MaybeIterable[_FilterType] | None = None,
|
87
|
+
plain: bool = False,
|
88
|
+
color_field_styles: Mapping[str, _FieldStyleKeys] | None = None,
|
89
|
+
) -> None:
|
90
|
+
"""Do the basic config."""
|
91
|
+
match obj:
|
92
|
+
case None:
|
93
|
+
basicConfig(format=format_, datefmt=datefmt, style="{", level=level)
|
94
|
+
case Logger() as logger:
|
95
|
+
logger.setLevel(level)
|
96
|
+
logger.addHandler(handler := StreamHandler())
|
97
|
+
basic_config(
|
98
|
+
obj=handler,
|
99
|
+
whenever=whenever,
|
100
|
+
format_=format_,
|
101
|
+
datefmt=datefmt,
|
102
|
+
level=level,
|
103
|
+
filters=filters,
|
104
|
+
plain=plain,
|
105
|
+
color_field_styles=color_field_styles,
|
106
|
+
)
|
107
|
+
case str() as name:
|
108
|
+
basic_config(
|
109
|
+
obj=get_logger(logger=name),
|
110
|
+
whenever=whenever,
|
111
|
+
format_=format_,
|
112
|
+
datefmt=datefmt,
|
113
|
+
level=level,
|
114
|
+
filters=filters,
|
115
|
+
plain=plain,
|
116
|
+
color_field_styles=color_field_styles,
|
117
|
+
)
|
118
|
+
case Handler() as handler:
|
119
|
+
handler.setLevel(level)
|
120
|
+
if filters is not None:
|
121
|
+
add_filters(handler, *always_iterable(filters))
|
122
|
+
formatter = get_formatter(
|
123
|
+
whenever=whenever,
|
124
|
+
format_=format_,
|
125
|
+
datefmt=datefmt,
|
126
|
+
plain=plain,
|
127
|
+
color_field_styles=color_field_styles,
|
128
|
+
)
|
129
|
+
handler.setFormatter(formatter)
|
130
|
+
case _ as never:
|
131
|
+
assert_never(never)
|
132
|
+
|
133
|
+
|
134
|
+
##
|
135
|
+
|
136
|
+
|
137
|
+
def filter_for_key(
|
138
|
+
key: str, /, *, default: bool = False
|
139
|
+
) -> Callable[[LogRecord], bool]:
|
140
|
+
"""Make a filter for a given attribute."""
|
141
|
+
if (key in _FILTER_FOR_KEY_BLACKLIST) or key.startswith("__"):
|
142
|
+
raise FilterForKeyError(key=key)
|
143
|
+
|
144
|
+
def filter_(record: LogRecord, /) -> bool:
|
145
|
+
try:
|
146
|
+
value = getattr(record, key)
|
147
|
+
except AttributeError:
|
148
|
+
return default
|
149
|
+
return bool(value)
|
150
|
+
|
151
|
+
return filter_
|
152
|
+
|
153
|
+
|
154
|
+
# fmt: off
|
155
|
+
_FILTER_FOR_KEY_BLACKLIST = {
|
156
|
+
"args", "created", "exc_info", "exc_text", "filename", "funcName", "getMessage", "levelname", "levelno", "lineno", "module", "msecs", "msg", "name", "pathname", "process", "processName", "relativeCreated", "stack_info", "taskName", "thread", "threadName"
|
157
|
+
}
|
158
|
+
# fmt: on
|
159
|
+
|
160
|
+
|
161
|
+
@dataclass(kw_only=True, slots=True)
|
162
|
+
class FilterForKeyError(Exception):
|
163
|
+
key: str
|
164
|
+
|
165
|
+
@override
|
166
|
+
def __str__(self) -> str:
|
167
|
+
return f"Invalid key: {self.key!r}"
|
168
|
+
|
169
|
+
|
170
|
+
##
|
171
|
+
|
172
|
+
|
173
|
+
type _FieldStyleKeys = Literal[
|
174
|
+
"asctime", "hostname", "levelname", "name", "programname", "username"
|
175
|
+
]
|
176
|
+
|
177
|
+
|
178
|
+
class _FieldStyleDict(TypedDict):
|
179
|
+
color: str
|
180
|
+
bold: NotRequired[bool]
|
181
|
+
|
182
|
+
|
183
|
+
def get_formatter(
|
184
|
+
*,
|
185
|
+
whenever: bool = False,
|
186
|
+
format_: str = _DEFAULT_FORMAT,
|
187
|
+
datefmt: str = _DEFAULT_DATEFMT,
|
188
|
+
plain: bool = False,
|
189
|
+
color_field_styles: Mapping[str, _FieldStyleKeys] | None = None,
|
190
|
+
) -> Formatter:
|
191
|
+
"""Get the formatter; colored if available."""
|
192
|
+
if whenever:
|
193
|
+
from utilities.whenever import WheneverLogRecord
|
194
|
+
|
195
|
+
setLogRecordFactory(WheneverLogRecord)
|
196
|
+
format_ = format_.replace("{asctime}", "{zoned_datetime}")
|
197
|
+
if plain:
|
198
|
+
return _get_plain_formatter(format_=format_, datefmt=datefmt)
|
199
|
+
try:
|
200
|
+
from coloredlogs import DEFAULT_FIELD_STYLES, ColoredFormatter
|
201
|
+
except ModuleNotFoundError: # pragma: no cover
|
202
|
+
return _get_plain_formatter(format_=format_, datefmt=datefmt)
|
203
|
+
default = cast("dict[_FieldStyleKeys, _FieldStyleDict]", DEFAULT_FIELD_STYLES)
|
204
|
+
field_styles = {cast("str", k): v for k, v in default.items()}
|
205
|
+
if whenever:
|
206
|
+
field_styles["zoned_datetime"] = default["asctime"]
|
207
|
+
if color_field_styles is not None:
|
208
|
+
field_styles.update({k: default[v] for k, v in color_field_styles.items()})
|
209
|
+
return ColoredFormatter(
|
210
|
+
fmt=format_, datefmt=datefmt, style="{", field_styles=field_styles
|
211
|
+
)
|
212
|
+
|
213
|
+
|
214
|
+
def _get_plain_formatter(
|
215
|
+
*, format_: str = _DEFAULT_FORMAT, datefmt: str = _DEFAULT_DATEFMT
|
216
|
+
) -> Formatter:
|
217
|
+
"""Get the plain formatter."""
|
218
|
+
return Formatter(fmt=format_, datefmt=datefmt, style="{")
|
219
|
+
|
220
|
+
|
221
|
+
##
|
222
|
+
|
223
|
+
|
224
|
+
def get_logger(*, logger: LoggerOrName | None = None) -> Logger:
|
225
|
+
"""Get a logger."""
|
226
|
+
match logger:
|
227
|
+
case Logger():
|
228
|
+
return logger
|
229
|
+
case str() | None:
|
230
|
+
return getLogger(logger)
|
231
|
+
case _ as never:
|
232
|
+
assert_never(never)
|
233
|
+
|
234
|
+
|
235
|
+
##
|
236
|
+
|
237
|
+
|
238
|
+
def get_logging_level_number(level: LogLevel, /) -> int:
|
239
|
+
"""Get the logging level number."""
|
240
|
+
mapping = getLevelNamesMapping()
|
241
|
+
try:
|
242
|
+
return mapping[level]
|
243
|
+
except KeyError:
|
244
|
+
raise GetLoggingLevelNumberError(level=level) from None
|
245
|
+
|
246
|
+
|
247
|
+
@dataclass(kw_only=True, slots=True)
|
248
|
+
class GetLoggingLevelNumberError(Exception):
|
249
|
+
level: LogLevel
|
250
|
+
|
251
|
+
@override
|
252
|
+
def __str__(self) -> str:
|
253
|
+
return f"Invalid logging level: {self.level!r}"
|
254
|
+
|
255
|
+
|
256
|
+
##
|
257
|
+
|
258
|
+
|
259
|
+
def setup_logging(
|
260
|
+
*,
|
261
|
+
logger: LoggerOrName | None = None,
|
262
|
+
whenever: bool = False,
|
263
|
+
format_: str = _DEFAULT_FORMAT,
|
264
|
+
datefmt: str = _DEFAULT_DATEFMT,
|
265
|
+
console_level: LogLevel = "INFO",
|
266
|
+
console_prefix: str = "❯", # noqa: RUF001
|
267
|
+
console_filters: MaybeIterable[_FilterType] | None = None,
|
268
|
+
files_dir: MaybeCallablePathLike | None = None,
|
269
|
+
files_max_bytes: int = _DEFAULT_MAX_BYTES,
|
270
|
+
files_when: _When = _DEFAULT_WHEN,
|
271
|
+
files_interval: int = 1,
|
272
|
+
files_backup_count: int = _DEFAULT_BACKUP_COUNT,
|
273
|
+
files_filters: Iterable[_FilterType] | None = None,
|
274
|
+
) -> None:
|
275
|
+
"""Set up logger."""
|
276
|
+
basic_config(
|
277
|
+
obj=logger,
|
278
|
+
whenever=whenever,
|
279
|
+
format_=f"{console_prefix} {format_}",
|
280
|
+
datefmt=datefmt,
|
281
|
+
level=console_level,
|
282
|
+
filters=console_filters,
|
283
|
+
)
|
284
|
+
logger_use = get_logger(logger=logger)
|
285
|
+
name = logger_use.name
|
286
|
+
dir_ = get_path(path=files_dir)
|
287
|
+
levels: list[LogLevel] = ["DEBUG", "INFO", "ERROR"]
|
288
|
+
for level in levels:
|
289
|
+
lower = level.lower()
|
290
|
+
for stem in [lower, f"{name}-{lower}"]:
|
291
|
+
handler = SizeAndTimeRotatingFileHandler(
|
292
|
+
dir_.joinpath(stem).with_suffix(".txt"),
|
293
|
+
maxBytes=files_max_bytes,
|
294
|
+
when=files_when,
|
295
|
+
interval=files_interval,
|
296
|
+
backupCount=files_backup_count,
|
297
|
+
)
|
298
|
+
logger_use.addHandler(handler)
|
299
|
+
basic_config(
|
300
|
+
obj=handler,
|
301
|
+
whenever=whenever,
|
302
|
+
format_=format_,
|
303
|
+
datefmt=datefmt,
|
304
|
+
level=level,
|
305
|
+
filters=files_filters,
|
306
|
+
plain=True,
|
307
|
+
)
|
80
308
|
|
81
309
|
|
82
310
|
##
|
@@ -85,9 +313,6 @@ except ModuleNotFoundError: # pragma: no cover
|
|
85
313
|
type _When = Literal[
|
86
314
|
"S", "M", "H", "D", "midnight", "W0", "W1", "W2", "W3", "W4", "W5", "W6"
|
87
315
|
]
|
88
|
-
_BACKUP_COUNT: int = 100
|
89
|
-
_MAX_BYTES: int = 10 * 1024**2
|
90
|
-
_WHEN: _When = "D"
|
91
316
|
|
92
317
|
|
93
318
|
class SizeAndTimeRotatingFileHandler(BaseRotatingHandler):
|
@@ -103,10 +328,10 @@ class SizeAndTimeRotatingFileHandler(BaseRotatingHandler):
|
|
103
328
|
encoding: str | None = None,
|
104
329
|
delay: bool = False,
|
105
330
|
errors: Literal["strict", "ignore", "replace"] | None = None,
|
106
|
-
maxBytes: int =
|
107
|
-
when: _When =
|
331
|
+
maxBytes: int = _DEFAULT_MAX_BYTES,
|
332
|
+
when: _When = _DEFAULT_WHEN,
|
108
333
|
interval: int = 1,
|
109
|
-
backupCount: int =
|
334
|
+
backupCount: int = _DEFAULT_BACKUP_COUNT,
|
110
335
|
utc: bool = False,
|
111
336
|
atTime: dt.time | None = None,
|
112
337
|
) -> None:
|
@@ -134,9 +359,11 @@ class SizeAndTimeRotatingFileHandler(BaseRotatingHandler):
|
|
134
359
|
|
135
360
|
@override
|
136
361
|
def emit(self, record: LogRecord) -> None:
|
137
|
-
try:
|
362
|
+
try:
|
138
363
|
if (self._backup_count is not None) and self._should_rollover(record):
|
139
|
-
self._do_rollover(
|
364
|
+
self._do_rollover( # skipif-ci-and-windows
|
365
|
+
backup_count=self._backup_count
|
366
|
+
)
|
140
367
|
FileHandler.emit(self, record)
|
141
368
|
except Exception: # noqa: BLE001 # pragma: no cover
|
142
369
|
self.handleError(record)
|
@@ -199,20 +426,20 @@ def _compute_rollover_actions(
|
|
199
426
|
patterns: _RolloverPatterns | None = None,
|
200
427
|
backup_count: int = 1,
|
201
428
|
) -> _RolloverActions:
|
202
|
-
from utilities.tzlocal import get_now_local
|
429
|
+
from utilities.tzlocal import get_now_local
|
203
430
|
|
204
|
-
patterns = (
|
431
|
+
patterns = (
|
205
432
|
_compute_rollover_patterns(stem, suffix) if patterns is None else patterns
|
206
433
|
)
|
207
|
-
files = {
|
434
|
+
files = {
|
208
435
|
file
|
209
436
|
for path in directory.iterdir()
|
210
437
|
if (file := _RotatingLogFile.from_path(path, stem, suffix, patterns=patterns))
|
211
438
|
is not None
|
212
439
|
}
|
213
|
-
deletions: set[_Deletion] = set()
|
214
|
-
rotations: set[_Rotation] = set()
|
215
|
-
for file in files:
|
440
|
+
deletions: set[_Deletion] = set()
|
441
|
+
rotations: set[_Rotation] = set()
|
442
|
+
for file in files:
|
216
443
|
match file.index, file.start, file.end:
|
217
444
|
case int() as index, _, _ if index >= backup_count:
|
218
445
|
deletions.add(_Deletion(file=file))
|
@@ -234,9 +461,7 @@ def _compute_rollover_actions(
|
|
234
461
|
rotations.add(_Rotation(file=file, index=index + 1))
|
235
462
|
case _: # pragma: no cover
|
236
463
|
raise NotImplementedError
|
237
|
-
return _RolloverActions(
|
238
|
-
deletions=deletions, rotations=rotations
|
239
|
-
)
|
464
|
+
return _RolloverActions(deletions=deletions, rotations=rotations)
|
240
465
|
|
241
466
|
|
242
467
|
@dataclass(order=True, unsafe_hash=True, kw_only=True)
|
@@ -245,13 +470,11 @@ class _RolloverActions:
|
|
245
470
|
rotations: set[_Rotation] = field(default_factory=set)
|
246
471
|
|
247
472
|
def do(self) -> None:
|
248
|
-
from utilities.atomicwrites import move_many
|
473
|
+
from utilities.atomicwrites import move_many
|
249
474
|
|
250
|
-
for deletion in self.deletions:
|
475
|
+
for deletion in self.deletions:
|
251
476
|
deletion.delete()
|
252
|
-
move_many(
|
253
|
-
*((r.file.path, r.destination) for r in self.rotations)
|
254
|
-
)
|
477
|
+
move_many(*((r.file.path, r.destination) for r in self.rotations))
|
255
478
|
|
256
479
|
|
257
480
|
@dataclass(order=True, unsafe_hash=True, kw_only=True)
|
@@ -281,7 +504,7 @@ class _RotatingLogFile:
|
|
281
504
|
) -> Self | None:
|
282
505
|
if (not path.stem.startswith(stem)) or path.suffix != suffix:
|
283
506
|
return None
|
284
|
-
if patterns is None:
|
507
|
+
if patterns is None:
|
285
508
|
patterns = _compute_rollover_patterns(stem, suffix)
|
286
509
|
try:
|
287
510
|
(index,) = patterns.pattern1.findall(path.name)
|
@@ -343,9 +566,7 @@ class _RotatingLogFile:
|
|
343
566
|
start: dt.datetime | None | Sentinel = sentinel,
|
344
567
|
end: dt.datetime | None | Sentinel = sentinel,
|
345
568
|
) -> Self:
|
346
|
-
return replace_non_sentinel(
|
347
|
-
self, index=index, start=start, end=end
|
348
|
-
)
|
569
|
+
return replace_non_sentinel(self, index=index, start=start, end=end)
|
349
570
|
|
350
571
|
|
351
572
|
@dataclass(order=True, unsafe_hash=True, kw_only=True)
|
@@ -353,7 +574,7 @@ class _Deletion:
|
|
353
574
|
file: _RotatingLogFile
|
354
575
|
|
355
576
|
def delete(self) -> None:
|
356
|
-
self.file.path.unlink(missing_ok=True)
|
577
|
+
self.file.path.unlink(missing_ok=True)
|
357
578
|
|
358
579
|
|
359
580
|
@dataclass(order=True, unsafe_hash=True, kw_only=True)
|
@@ -364,467 +585,24 @@ class _Rotation:
|
|
364
585
|
end: dt.datetime | Sentinel = sentinel
|
365
586
|
|
366
587
|
def __post_init__(self) -> None:
|
367
|
-
if isinstance(self.start, dt.datetime):
|
588
|
+
if isinstance(self.start, dt.datetime):
|
368
589
|
self.start = round_datetime(self.start, SECOND)
|
369
|
-
if isinstance(self.end, dt.datetime):
|
590
|
+
if isinstance(self.end, dt.datetime):
|
370
591
|
self.end = round_datetime(self.end, SECOND)
|
371
592
|
|
372
593
|
@cached_property
|
373
594
|
def destination(self) -> Path:
|
374
|
-
return self.file.replace(
|
375
|
-
index=self.index, start=self.start, end=self.end
|
376
|
-
).path
|
377
|
-
|
378
|
-
|
379
|
-
##
|
380
|
-
|
381
|
-
|
382
|
-
class StandaloneFileHandler(Handler):
|
383
|
-
"""Handler for emitting tracebacks to individual files."""
|
384
|
-
|
385
|
-
@override
|
386
|
-
def __init__(
|
387
|
-
self, *, level: int = NOTSET, path: MaybeCallablePathLike | None = None
|
388
|
-
) -> None:
|
389
|
-
super().__init__(level=level)
|
390
|
-
self._path = get_path(path=path)
|
391
|
-
|
392
|
-
@override
|
393
|
-
def emit(self, record: LogRecord) -> None:
|
394
|
-
from utilities.atomicwrites import writer
|
395
|
-
from utilities.tzlocal import get_now_local
|
396
|
-
|
397
|
-
try:
|
398
|
-
path = self._path.joinpath(serialize_compact(get_now_local())).with_suffix(
|
399
|
-
".txt"
|
400
|
-
)
|
401
|
-
formatted = self.format(record)
|
402
|
-
with writer(path, overwrite=True) as temp, temp.open(mode="w") as fh:
|
403
|
-
_ = fh.write(formatted)
|
404
|
-
except Exception: # noqa: BLE001 # pragma: no cover
|
405
|
-
self.handleError(record)
|
406
|
-
|
407
|
-
|
408
|
-
##
|
409
|
-
|
410
|
-
|
411
|
-
def add_filters(handler: Handler, /, *filters: _FilterType) -> None:
|
412
|
-
"""Add a set of filters to a handler."""
|
413
|
-
for filter_i in filters:
|
414
|
-
handler.addFilter(filter_i)
|
415
|
-
|
416
|
-
|
417
|
-
##
|
418
|
-
|
419
|
-
|
420
|
-
def basic_config(
|
421
|
-
*,
|
422
|
-
obj: LoggerOrName | Handler | None = None,
|
423
|
-
format_: str = "{asctime} | {name} | {levelname:8} | {message}",
|
424
|
-
whenever: bool = False,
|
425
|
-
level: LogLevel = "INFO",
|
426
|
-
plain: bool = False,
|
427
|
-
) -> None:
|
428
|
-
"""Do the basic config."""
|
429
|
-
if whenever:
|
430
|
-
format_ = format_.replace("{asctime}", "{zoned_datetime}")
|
431
|
-
datefmt = maybe_sub_pct_y("%Y-%m-%d %H:%M:%S")
|
432
|
-
match obj:
|
433
|
-
case None:
|
434
|
-
basicConfig(format=format_, datefmt=datefmt, style="{", level=level)
|
435
|
-
case Logger() as logger:
|
436
|
-
logger.setLevel(level)
|
437
|
-
logger.addHandler(handler := StreamHandler())
|
438
|
-
basic_config(
|
439
|
-
obj=handler,
|
440
|
-
format_=format_,
|
441
|
-
whenever=whenever,
|
442
|
-
level=level,
|
443
|
-
plain=plain,
|
444
|
-
)
|
445
|
-
case str() as name:
|
446
|
-
basic_config(
|
447
|
-
obj=get_logger(logger=name),
|
448
|
-
format_=format_,
|
449
|
-
whenever=whenever,
|
450
|
-
level=level,
|
451
|
-
plain=plain,
|
452
|
-
)
|
453
|
-
case Handler() as handler:
|
454
|
-
handler.setLevel(level)
|
455
|
-
if plain:
|
456
|
-
formatter = Formatter(fmt=format_, datefmt=datefmt, style="{")
|
457
|
-
else:
|
458
|
-
try:
|
459
|
-
from coloredlogs import ColoredFormatter
|
460
|
-
except ModuleNotFoundError: # pragma: no cover
|
461
|
-
formatter = Formatter(fmt=format_, datefmt=datefmt, style="{")
|
462
|
-
else:
|
463
|
-
formatter = ColoredFormatter(
|
464
|
-
fmt=format_, datefmt=datefmt, style="{"
|
465
|
-
)
|
466
|
-
handler.setFormatter(formatter)
|
467
|
-
case _ as never:
|
468
|
-
assert_never(never)
|
469
|
-
|
470
|
-
|
471
|
-
##
|
472
|
-
|
473
|
-
|
474
|
-
def filter_for_key(
|
475
|
-
key: str, /, *, default: bool = False
|
476
|
-
) -> Callable[[LogRecord], bool]:
|
477
|
-
"""Make a filter for a given attribute."""
|
478
|
-
if (key in _FILTER_FOR_KEY_BLACKLIST) or key.startswith("__"):
|
479
|
-
raise FilterForKeyError(key=key)
|
480
|
-
|
481
|
-
def filter_(record: LogRecord, /) -> bool:
|
482
|
-
try:
|
483
|
-
value = getattr(record, key)
|
484
|
-
except AttributeError:
|
485
|
-
return default
|
486
|
-
return bool(value)
|
487
|
-
|
488
|
-
return filter_
|
489
|
-
|
490
|
-
|
491
|
-
# fmt: off
|
492
|
-
_FILTER_FOR_KEY_BLACKLIST = {
|
493
|
-
"args", "created", "exc_info", "exc_text", "filename", "funcName", "getMessage", "levelname", "levelno", "lineno", "module", "msecs", "msg", "name", "pathname", "process", "processName", "relativeCreated", "stack_info", "taskName", "thread", "threadName"
|
494
|
-
}
|
495
|
-
# fmt: on
|
496
|
-
|
497
|
-
|
498
|
-
@dataclass(kw_only=True, slots=True)
|
499
|
-
class FilterForKeyError(Exception):
|
500
|
-
key: str
|
501
|
-
|
502
|
-
@override
|
503
|
-
def __str__(self) -> str:
|
504
|
-
return f"Invalid key: {self.key!r}"
|
505
|
-
|
506
|
-
|
507
|
-
##
|
508
|
-
|
509
|
-
|
510
|
-
def get_default_logging_path() -> Path:
|
511
|
-
"""Get the logging default path."""
|
512
|
-
return get_root().joinpath(".logs")
|
513
|
-
|
514
|
-
|
515
|
-
##
|
516
|
-
|
517
|
-
|
518
|
-
def get_logger(*, logger: LoggerOrName | None = None) -> Logger:
|
519
|
-
"""Get a logger."""
|
520
|
-
match logger:
|
521
|
-
case Logger():
|
522
|
-
return logger
|
523
|
-
case str() | None:
|
524
|
-
return getLogger(logger)
|
525
|
-
case _ as never:
|
526
|
-
assert_never(never)
|
527
|
-
|
528
|
-
|
529
|
-
##
|
530
|
-
|
531
|
-
|
532
|
-
def get_logging_level_number(level: LogLevel, /) -> int:
|
533
|
-
"""Get the logging level number."""
|
534
|
-
mapping = getLevelNamesMapping()
|
535
|
-
try:
|
536
|
-
return mapping[level]
|
537
|
-
except KeyError:
|
538
|
-
raise GetLoggingLevelNumberError(level=level) from None
|
539
|
-
|
540
|
-
|
541
|
-
@dataclass(kw_only=True, slots=True)
|
542
|
-
class GetLoggingLevelNumberError(Exception):
|
543
|
-
level: LogLevel
|
544
|
-
|
545
|
-
@override
|
546
|
-
def __str__(self) -> str:
|
547
|
-
return f"Invalid logging level: {self.level!r}"
|
548
|
-
|
549
|
-
|
550
|
-
##
|
551
|
-
|
552
|
-
|
553
|
-
def setup_logging(
|
554
|
-
*,
|
555
|
-
logger: LoggerOrName | None = None,
|
556
|
-
console_level: LogLevel | None = "INFO",
|
557
|
-
console_filters: Iterable[_FilterType] | None = None,
|
558
|
-
console_fmt: str = "❯ {_zoned_datetime_str} | {name}:{funcName}:{lineno} | {message}", # noqa: RUF001
|
559
|
-
files_dir: MaybeCallablePathLike | None = get_default_logging_path,
|
560
|
-
files_when: _When = _WHEN,
|
561
|
-
files_interval: int = 1,
|
562
|
-
files_backup_count: int = _BACKUP_COUNT,
|
563
|
-
files_max_bytes: int = _MAX_BYTES,
|
564
|
-
files_filters: Iterable[_FilterType] | None = None,
|
565
|
-
files_fmt: str = "{_zoned_datetime_str} | {name}:{funcName}:{lineno} | {levelname:8} | {message}",
|
566
|
-
filters: MaybeIterable[_FilterType] | None = None,
|
567
|
-
formatter_version: MaybeCallableVersionLike | None = None,
|
568
|
-
formatter_max_width: int = RICH_MAX_WIDTH,
|
569
|
-
formatter_indent_size: int = RICH_INDENT_SIZE,
|
570
|
-
formatter_max_length: int | None = RICH_MAX_LENGTH,
|
571
|
-
formatter_max_string: int | None = RICH_MAX_STRING,
|
572
|
-
formatter_max_depth: int | None = RICH_MAX_DEPTH,
|
573
|
-
formatter_expand_all: bool = RICH_EXPAND_ALL,
|
574
|
-
extra: Callable[[LoggerOrName | None], None] | None = None,
|
575
|
-
) -> None:
|
576
|
-
"""Set up logger."""
|
577
|
-
# log record factory
|
578
|
-
from utilities.tzlocal import get_local_time_zone # skipif-ci-and-windows
|
579
|
-
|
580
|
-
class LogRecordNanoLocal( # skipif-ci-and-windows
|
581
|
-
_AdvancedLogRecord, time_zone=get_local_time_zone()
|
582
|
-
): ...
|
583
|
-
|
584
|
-
setLogRecordFactory(LogRecordNanoLocal) # skipif-ci-and-windows
|
585
|
-
|
586
|
-
console_fmt, files_fmt = [ # skipif-ci-and-windows
|
587
|
-
f.replace("{_zoned_datetime_str}", LogRecordNanoLocal.get_zoned_datetime_fmt())
|
588
|
-
for f in [console_fmt, files_fmt]
|
589
|
-
]
|
590
|
-
|
591
|
-
# logger
|
592
|
-
logger_use = get_logger(logger=logger) # skipif-ci-and-windows
|
593
|
-
logger_use.setLevel(DEBUG) # skipif-ci-and-windows
|
594
|
-
|
595
|
-
# filters
|
596
|
-
console_filters = ( # skipif-ci-and-windows
|
597
|
-
[] if console_filters is None else list(console_filters)
|
598
|
-
)
|
599
|
-
files_filters = ( # skipif-ci-and-windows
|
600
|
-
[] if files_filters is None else list(files_filters)
|
601
|
-
)
|
602
|
-
filters = ( # skipif-ci-and-windows
|
603
|
-
[] if filters is None else list(always_iterable(filters))
|
604
|
-
)
|
605
|
-
|
606
|
-
# formatters
|
607
|
-
try: # skipif-ci-and-windows
|
608
|
-
from coloredlogs import DEFAULT_FIELD_STYLES, ColoredFormatter
|
609
|
-
except ModuleNotFoundError: # pragma: no cover
|
610
|
-
console_formatter = Formatter(fmt=console_fmt, style="{")
|
611
|
-
files_formatter = Formatter(fmt=files_fmt, style="{")
|
612
|
-
else: # skipif-ci-and-windows
|
613
|
-
field_styles = DEFAULT_FIELD_STYLES | {
|
614
|
-
"_zoned_datetime_str": DEFAULT_FIELD_STYLES["asctime"]
|
615
|
-
}
|
616
|
-
console_formatter = ColoredFormatter(
|
617
|
-
fmt=console_fmt, style="{", field_styles=field_styles
|
618
|
-
)
|
619
|
-
files_formatter = ColoredFormatter(
|
620
|
-
fmt=files_fmt, style="{", field_styles=field_styles
|
621
|
-
)
|
622
|
-
plain_formatter = Formatter(fmt=files_fmt, style="{") # skipif-ci-and-windows
|
623
|
-
|
624
|
-
# console
|
625
|
-
if console_level is not None: # skipif-ci-and-windows
|
626
|
-
console_low_or_no_exc_handler = StreamHandler(stream=stdout)
|
627
|
-
add_filters(console_low_or_no_exc_handler, _console_low_or_no_exc_filter)
|
628
|
-
add_filters(console_low_or_no_exc_handler, *console_filters)
|
629
|
-
add_filters(console_low_or_no_exc_handler, *filters)
|
630
|
-
console_low_or_no_exc_handler.setFormatter(console_formatter)
|
631
|
-
console_low_or_no_exc_handler.setLevel(console_level)
|
632
|
-
logger_use.addHandler(console_low_or_no_exc_handler)
|
633
|
-
|
634
|
-
console_high_and_exc_handler = StreamHandler(stream=stdout)
|
635
|
-
add_filters(console_high_and_exc_handler, *console_filters)
|
636
|
-
add_filters(console_high_and_exc_handler, *filters)
|
637
|
-
_ = RichTracebackFormatter.create_and_set(
|
638
|
-
console_high_and_exc_handler,
|
639
|
-
version=formatter_version,
|
640
|
-
max_width=formatter_max_width,
|
641
|
-
indent_size=formatter_indent_size,
|
642
|
-
max_length=formatter_max_length,
|
643
|
-
max_string=formatter_max_string,
|
644
|
-
max_depth=formatter_max_depth,
|
645
|
-
expand_all=formatter_expand_all,
|
646
|
-
detail=True,
|
647
|
-
post=_ansi_wrap_red,
|
648
|
-
)
|
649
|
-
console_high_and_exc_handler.setLevel(
|
650
|
-
max(get_logging_level_number(console_level), ERROR)
|
651
|
-
)
|
652
|
-
logger_use.addHandler(console_high_and_exc_handler)
|
653
|
-
|
654
|
-
# debug & info
|
655
|
-
directory = get_path(path=files_dir) # skipif-ci-and-windows
|
656
|
-
levels: list[LogLevel] = ["DEBUG", "INFO"] # skipif-ci-and-windows
|
657
|
-
for level, (subpath, files_or_plain_formatter) in product( # skipif-ci-and-windows
|
658
|
-
levels, [(Path(), files_formatter), (Path("plain"), plain_formatter)]
|
659
|
-
):
|
660
|
-
path = ensure_suffix(directory.joinpath(subpath, level.lower()), ".txt")
|
661
|
-
path.parent.mkdir(parents=True, exist_ok=True)
|
662
|
-
file_handler = SizeAndTimeRotatingFileHandler(
|
663
|
-
filename=path,
|
664
|
-
when=files_when,
|
665
|
-
interval=files_interval,
|
666
|
-
backupCount=files_backup_count,
|
667
|
-
maxBytes=files_max_bytes,
|
668
|
-
)
|
669
|
-
add_filters(file_handler, *files_filters)
|
670
|
-
add_filters(file_handler, *filters)
|
671
|
-
file_handler.setFormatter(files_or_plain_formatter)
|
672
|
-
file_handler.setLevel(level)
|
673
|
-
logger_use.addHandler(file_handler)
|
674
|
-
|
675
|
-
# errors
|
676
|
-
standalone_file_handler = StandaloneFileHandler( # skipif-ci-and-windows
|
677
|
-
level=ERROR, path=directory.joinpath("errors")
|
678
|
-
)
|
679
|
-
add_filters(standalone_file_handler, _standalone_file_filter)
|
680
|
-
standalone_file_handler.setFormatter(
|
681
|
-
RichTracebackFormatter(
|
682
|
-
version=formatter_version,
|
683
|
-
max_width=formatter_max_width,
|
684
|
-
indent_size=formatter_indent_size,
|
685
|
-
max_length=formatter_max_length,
|
686
|
-
max_string=formatter_max_string,
|
687
|
-
max_depth=formatter_max_depth,
|
688
|
-
expand_all=formatter_expand_all,
|
689
|
-
detail=True,
|
690
|
-
)
|
691
|
-
)
|
692
|
-
logger_use.addHandler(standalone_file_handler) # skipif-ci-and-windows
|
693
|
-
|
694
|
-
# extra
|
695
|
-
if extra is not None: # skipif-ci-and-windows
|
696
|
-
extra(logger_use)
|
697
|
-
|
698
|
-
|
699
|
-
def _console_low_or_no_exc_filter(record: LogRecord, /) -> bool:
|
700
|
-
return (record.levelno < ERROR) or (
|
701
|
-
(record.levelno >= ERROR) and (record.exc_info is None)
|
702
|
-
)
|
703
|
-
|
704
|
-
|
705
|
-
def _standalone_file_filter(record: LogRecord, /) -> bool:
|
706
|
-
return record.exc_info is not None
|
707
|
-
|
708
|
-
|
709
|
-
##
|
710
|
-
|
711
|
-
|
712
|
-
@contextmanager
|
713
|
-
def temp_handler(
|
714
|
-
handler: Handler, /, *, logger: LoggerOrName | None = None
|
715
|
-
) -> Iterator[None]:
|
716
|
-
"""Context manager with temporary handler set."""
|
717
|
-
logger_use = get_logger(logger=logger)
|
718
|
-
logger_use.addHandler(handler)
|
719
|
-
try:
|
720
|
-
yield
|
721
|
-
finally:
|
722
|
-
_ = logger_use.removeHandler(handler)
|
723
|
-
|
724
|
-
|
725
|
-
##
|
726
|
-
|
727
|
-
|
728
|
-
@contextmanager
|
729
|
-
def temp_logger(
|
730
|
-
logger: LoggerOrName,
|
731
|
-
/,
|
732
|
-
*,
|
733
|
-
disabled: bool | None = None,
|
734
|
-
level: LogLevel | None = None,
|
735
|
-
propagate: bool | None = None,
|
736
|
-
) -> Iterator[Logger]:
|
737
|
-
"""Context manager with temporary logger settings."""
|
738
|
-
logger_use = get_logger(logger=logger)
|
739
|
-
init_disabled = logger_use.disabled
|
740
|
-
init_level = logger_use.level
|
741
|
-
init_propagate = logger_use.propagate
|
742
|
-
if disabled is not None:
|
743
|
-
logger_use.disabled = disabled
|
744
|
-
if level is not None:
|
745
|
-
logger_use.setLevel(level)
|
746
|
-
if propagate is not None:
|
747
|
-
logger_use.propagate = propagate
|
748
|
-
try:
|
749
|
-
yield logger_use
|
750
|
-
finally:
|
751
|
-
if disabled is not None:
|
752
|
-
logger_use.disabled = init_disabled
|
753
|
-
if level is not None:
|
754
|
-
logger_use.setLevel(init_level)
|
755
|
-
if propagate is not None:
|
756
|
-
logger_use.propagate = init_propagate
|
757
|
-
|
758
|
-
|
759
|
-
##
|
760
|
-
|
761
|
-
|
762
|
-
class _AdvancedLogRecord(LogRecord):
|
763
|
-
"""Advanced log record."""
|
764
|
-
|
765
|
-
time_zone: ClassVar[str] = NotImplemented
|
766
|
-
|
767
|
-
@override
|
768
|
-
def __init__(
|
769
|
-
self,
|
770
|
-
name: str,
|
771
|
-
level: int,
|
772
|
-
pathname: str,
|
773
|
-
lineno: int,
|
774
|
-
msg: object,
|
775
|
-
args: Any,
|
776
|
-
exc_info: Any,
|
777
|
-
func: str | None = None,
|
778
|
-
sinfo: str | None = None,
|
779
|
-
) -> None:
|
780
|
-
self._zoned_datetime = self.get_now() # skipif-ci-and-windows
|
781
|
-
self._zoned_datetime_str = ( # skipif-ci-and-windows
|
782
|
-
self._zoned_datetime.format_common_iso()
|
783
|
-
)
|
784
|
-
super().__init__( # skipif-ci-and-windows
|
785
|
-
name, level, pathname, lineno, msg, args, exc_info, func, sinfo
|
786
|
-
)
|
787
|
-
|
788
|
-
@override
|
789
|
-
def __init_subclass__(cls, *, time_zone: ZoneInfo, **kwargs: Any) -> None:
|
790
|
-
cls.time_zone = time_zone.key # skipif-ci-and-windows
|
791
|
-
super().__init_subclass__(**kwargs) # skipif-ci-and-windows
|
792
|
-
|
793
|
-
@classmethod
|
794
|
-
def get_now(cls) -> Any:
|
795
|
-
"""Get the current zoned datetime."""
|
796
|
-
return cast("Any", ZonedDateTime).now(cls.time_zone) # skipif-ci-and-windows
|
797
|
-
|
798
|
-
@classmethod
|
799
|
-
def get_zoned_datetime_fmt(cls) -> str:
|
800
|
-
"""Get the zoned datetime format string."""
|
801
|
-
length = len(cls.get_now().format_common_iso()) # skipif-ci-and-windows
|
802
|
-
return f"{{_zoned_datetime_str:{length}}}" # skipif-ci-and-windows
|
803
|
-
|
804
|
-
|
805
|
-
##
|
806
|
-
|
807
|
-
|
808
|
-
def _ansi_wrap_red(text: str, /) -> str:
|
809
|
-
try:
|
810
|
-
from humanfriendly.terminal import ansi_wrap
|
811
|
-
except ModuleNotFoundError: # pragma: no cover
|
812
|
-
return text
|
813
|
-
return ansi_wrap(text, color="red")
|
595
|
+
return self.file.replace(index=self.index, start=self.start, end=self.end).path
|
814
596
|
|
815
597
|
|
816
598
|
__all__ = [
|
817
599
|
"FilterForKeyError",
|
818
600
|
"GetLoggingLevelNumberError",
|
819
601
|
"SizeAndTimeRotatingFileHandler",
|
820
|
-
"StandaloneFileHandler",
|
821
602
|
"add_filters",
|
822
603
|
"basic_config",
|
823
604
|
"filter_for_key",
|
824
|
-
"get_default_logging_path",
|
825
605
|
"get_logger",
|
826
606
|
"get_logging_level_number",
|
827
607
|
"setup_logging",
|
828
|
-
"temp_handler",
|
829
|
-
"temp_logger",
|
830
608
|
]
|