xtr-logging 1.0.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.
- xtr_logging/__init__.py +161 -0
- xtr_logging/bridge/__init__.py +8 -0
- xtr_logging/bridge/stdlib/__init__.py +34 -0
- xtr_logging/bridge/stdlib/level_mapping.py +83 -0
- xtr_logging/bridge/stdlib/stdlib_capture.py +248 -0
- xtr_logging/bridge/stdlib/stdlib_capture_handler.py +134 -0
- xtr_logging/bridge/stdlib/stdlib_handler.py +121 -0
- xtr_logging/bridge/stdlib/stdlib_logger.py +67 -0
- xtr_logging/config/__init__.py +89 -0
- xtr_logging/config/capture_spec.py +85 -0
- xtr_logging/config/channel_filter.py +54 -0
- xtr_logging/config/formatter_builder.py +58 -0
- xtr_logging/config/formatter_specs.py +52 -0
- xtr_logging/config/handler_builder.py +252 -0
- xtr_logging/config/handler_specs.py +162 -0
- xtr_logging/config/logging_config.py +191 -0
- xtr_logging/config/processor_builder.py +69 -0
- xtr_logging/config/processor_specs.py +116 -0
- xtr_logging/config/services.py +63 -0
- xtr_logging/config/wrapper_handler_specs.py +159 -0
- xtr_logging/decorator/__init__.py +5 -0
- xtr_logging/decorator/as_processor.py +61 -0
- xtr_logging/exception/__init__.py +35 -0
- xtr_logging/exception/capture_conflict_error.py +25 -0
- xtr_logging/exception/circular_handler_reference_error.py +18 -0
- xtr_logging/exception/empty_stack_error.py +25 -0
- xtr_logging/exception/invalid_configuration_error.py +22 -0
- xtr_logging/exception/invalid_option_error.py +26 -0
- xtr_logging/exception/mixed_channel_filter_error.py +25 -0
- xtr_logging/exception/not_processable_handler_error.py +18 -0
- xtr_logging/exception/unknown_channel_error.py +25 -0
- xtr_logging/exception/unknown_handler_error.py +30 -0
- xtr_logging/exception/unknown_service_error.py +27 -0
- xtr_logging/formatter/__init__.py +18 -0
- xtr_logging/formatter/console_formatter.py +94 -0
- xtr_logging/formatter/formatter_interface.py +25 -0
- xtr_logging/formatter/json_batch_mode.py +19 -0
- xtr_logging/formatter/json_formatter.py +104 -0
- xtr_logging/formatter/line_formatter.py +155 -0
- xtr_logging/formatter/normalizer.py +163 -0
- xtr_logging/handler/__init__.py +53 -0
- xtr_logging/handler/abstract_handler.py +81 -0
- xtr_logging/handler/abstract_processing_handler.py +107 -0
- xtr_logging/handler/buffer_handler.py +138 -0
- xtr_logging/handler/console_handler.py +131 -0
- xtr_logging/handler/deduplication_handler.py +168 -0
- xtr_logging/handler/fallback_group_handler.py +67 -0
- xtr_logging/handler/filter_handler.py +162 -0
- xtr_logging/handler/fingers_crossed/__init__.py +11 -0
- xtr_logging/handler/fingers_crossed/activation_strategy_interface.py +25 -0
- xtr_logging/handler/fingers_crossed/channel_level_activation_strategy.py +53 -0
- xtr_logging/handler/fingers_crossed/error_level_activation_strategy.py +42 -0
- xtr_logging/handler/fingers_crossed_handler.py +193 -0
- xtr_logging/handler/formattable_handler_interface.py +23 -0
- xtr_logging/handler/group_handler.py +105 -0
- xtr_logging/handler/handler_interface.py +43 -0
- xtr_logging/handler/null_handler.py +35 -0
- xtr_logging/handler/processable_handler_interface.py +31 -0
- xtr_logging/handler/queue_handler.py +141 -0
- xtr_logging/handler/rotating_file_handler.py +124 -0
- xtr_logging/handler/sampling_handler.py +130 -0
- xtr_logging/handler/stream_handler.py +119 -0
- xtr_logging/handler/syslog_handler.py +156 -0
- xtr_logging/handler/test_handler.py +108 -0
- xtr_logging/handler/what_failure_group_handler.py +56 -0
- xtr_logging/integration/__init__.py +1 -0
- xtr_logging/integration/wireup.py +88 -0
- xtr_logging/log_context.py +73 -0
- xtr_logging/log_record.py +75 -0
- xtr_logging/logger.py +246 -0
- xtr_logging/logger_factory.py +250 -0
- xtr_logging/processor/__init__.py +25 -0
- xtr_logging/processor/context_vars_processor.py +43 -0
- xtr_logging/processor/hostname_processor.py +40 -0
- xtr_logging/processor/introspection_processor.py +92 -0
- xtr_logging/processor/placeholder_processor.py +115 -0
- xtr_logging/processor/process_id_processor.py +31 -0
- xtr_logging/processor/processor_interface.py +29 -0
- xtr_logging/processor/processor_registry.py +81 -0
- xtr_logging/processor/tag_processor.py +45 -0
- xtr_logging/processor/uid_processor.py +65 -0
- xtr_logging/py.typed +0 -0
- xtr_logging/verbosity.py +48 -0
- xtr_logging-1.0.0.dist-info/METADATA +536 -0
- xtr_logging-1.0.0.dist-info/RECORD +87 -0
- xtr_logging-1.0.0.dist-info/WHEEL +4 -0
- xtr_logging-1.0.0.dist-info/licenses/LICENSE +21 -0
xtr_logging/__init__.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Channels, handlers, processors and formatters, behind one logger interface.
|
|
2
|
+
|
|
3
|
+
Code logs through :class:`LoggerInterface`. A :class:`Logger` is one channel:
|
|
4
|
+
it turns each call into an immutable :class:`LogRecord`, runs its processors,
|
|
5
|
+
and offers the record to a stack of handlers, which format it and write it
|
|
6
|
+
somewhere — stopping where a handler does not let it bubble.
|
|
7
|
+
|
|
8
|
+
:class:`LoggingConfig` describes channels, handlers and processors as data,
|
|
9
|
+
and :class:`LoggerFactory` builds loggers from it. The
|
|
10
|
+
per-type specs live in :mod:`xtr_logging.config`; the standard-library bridge
|
|
11
|
+
in :mod:`xtr_logging.bridge.stdlib`.
|
|
12
|
+
|
|
13
|
+
The contract itself — :class:`LoggerInterface`, :class:`Level`,
|
|
14
|
+
:class:`NullLogger` and what else a caller needs to log — lives in
|
|
15
|
+
``xtr-logging-contracts``, so a library can depend on it without depending on
|
|
16
|
+
any of this. It is re-exported here, never redefined: ``xtr_logging.X`` and
|
|
17
|
+
``xtr_logging_contracts.X`` are the same object, which is what lets a container
|
|
18
|
+
register a logger under the interface and have a library that never imported
|
|
19
|
+
this package receive it.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
23
|
+
|
|
24
|
+
from .config import LoggingConfig, Services
|
|
25
|
+
from .decorator import as_processor
|
|
26
|
+
from .exception import (
|
|
27
|
+
CaptureConflictError,
|
|
28
|
+
CircularHandlerReferenceError,
|
|
29
|
+
EmptyStackError,
|
|
30
|
+
InvalidConfigurationError,
|
|
31
|
+
InvalidOptionError,
|
|
32
|
+
MixedChannelFilterError,
|
|
33
|
+
NotProcessableHandlerError,
|
|
34
|
+
UnknownChannelError,
|
|
35
|
+
UnknownHandlerError,
|
|
36
|
+
UnknownServiceError,
|
|
37
|
+
)
|
|
38
|
+
from .formatter import (
|
|
39
|
+
ConsoleFormatter,
|
|
40
|
+
FormatterInterface,
|
|
41
|
+
JsonBatchMode,
|
|
42
|
+
JsonFormatter,
|
|
43
|
+
LineFormatter,
|
|
44
|
+
Normalized,
|
|
45
|
+
Normalizer,
|
|
46
|
+
)
|
|
47
|
+
from .handler import (
|
|
48
|
+
AbstractHandler,
|
|
49
|
+
AbstractProcessingHandler,
|
|
50
|
+
ActivationStrategyInterface,
|
|
51
|
+
BufferHandler,
|
|
52
|
+
ChannelLevelActivationStrategy,
|
|
53
|
+
ConsoleHandler,
|
|
54
|
+
DeduplicationHandler,
|
|
55
|
+
ErrorLevelActivationStrategy,
|
|
56
|
+
FallbackGroupHandler,
|
|
57
|
+
FilterHandler,
|
|
58
|
+
FingersCrossedHandler,
|
|
59
|
+
FormattableHandlerInterface,
|
|
60
|
+
GroupHandler,
|
|
61
|
+
HandlerInterface,
|
|
62
|
+
NullHandler,
|
|
63
|
+
ProcessableHandlerInterface,
|
|
64
|
+
QueueHandler,
|
|
65
|
+
RotatingFileHandler,
|
|
66
|
+
SamplingHandler,
|
|
67
|
+
StreamHandler,
|
|
68
|
+
SyslogHandler,
|
|
69
|
+
TestHandler,
|
|
70
|
+
WhatFailureGroupHandler,
|
|
71
|
+
)
|
|
72
|
+
from .log_context import bind_context, bound_context, clear_context, current_context, unbind_context
|
|
73
|
+
from .log_record import LogRecord
|
|
74
|
+
from .logger import Logger
|
|
75
|
+
from .logger_factory import LoggerFactory
|
|
76
|
+
from .processor import (
|
|
77
|
+
ContextVarsProcessor,
|
|
78
|
+
HostnameProcessor,
|
|
79
|
+
IntrospectionProcessor,
|
|
80
|
+
PlaceholderProcessor,
|
|
81
|
+
ProcessIdProcessor,
|
|
82
|
+
ProcessorInterface,
|
|
83
|
+
ProcessorRegistry,
|
|
84
|
+
TagProcessor,
|
|
85
|
+
UidProcessor,
|
|
86
|
+
default_processor_registry,
|
|
87
|
+
)
|
|
88
|
+
from .verbosity import Verbosity
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
__version__ = version("xtr-logging")
|
|
92
|
+
except PackageNotFoundError: # pragma: no cover
|
|
93
|
+
# Running from a source tree or a vendored copy, with no installed
|
|
94
|
+
# metadata to read. Having no version is better than refusing to import.
|
|
95
|
+
__version__ = "0+unknown"
|
|
96
|
+
|
|
97
|
+
__all__ = [
|
|
98
|
+
"AbstractHandler",
|
|
99
|
+
"AbstractProcessingHandler",
|
|
100
|
+
"ActivationStrategyInterface",
|
|
101
|
+
"BufferHandler",
|
|
102
|
+
"CaptureConflictError",
|
|
103
|
+
"ChannelLevelActivationStrategy",
|
|
104
|
+
"CircularHandlerReferenceError",
|
|
105
|
+
"ConsoleFormatter",
|
|
106
|
+
"ConsoleHandler",
|
|
107
|
+
"ContextVarsProcessor",
|
|
108
|
+
"DeduplicationHandler",
|
|
109
|
+
"EmptyStackError",
|
|
110
|
+
"ErrorLevelActivationStrategy",
|
|
111
|
+
"FallbackGroupHandler",
|
|
112
|
+
"FilterHandler",
|
|
113
|
+
"FingersCrossedHandler",
|
|
114
|
+
"FormattableHandlerInterface",
|
|
115
|
+
"FormatterInterface",
|
|
116
|
+
"GroupHandler",
|
|
117
|
+
"HandlerInterface",
|
|
118
|
+
"HostnameProcessor",
|
|
119
|
+
"IntrospectionProcessor",
|
|
120
|
+
"InvalidConfigurationError",
|
|
121
|
+
"InvalidOptionError",
|
|
122
|
+
"JsonBatchMode",
|
|
123
|
+
"JsonFormatter",
|
|
124
|
+
"LineFormatter",
|
|
125
|
+
"LogRecord",
|
|
126
|
+
"Logger",
|
|
127
|
+
"LoggerFactory",
|
|
128
|
+
"LoggingConfig",
|
|
129
|
+
"MixedChannelFilterError",
|
|
130
|
+
"Normalized",
|
|
131
|
+
"Normalizer",
|
|
132
|
+
"NotProcessableHandlerError",
|
|
133
|
+
"NullHandler",
|
|
134
|
+
"PlaceholderProcessor",
|
|
135
|
+
"ProcessIdProcessor",
|
|
136
|
+
"ProcessableHandlerInterface",
|
|
137
|
+
"ProcessorInterface",
|
|
138
|
+
"ProcessorRegistry",
|
|
139
|
+
"QueueHandler",
|
|
140
|
+
"RotatingFileHandler",
|
|
141
|
+
"SamplingHandler",
|
|
142
|
+
"Services",
|
|
143
|
+
"StreamHandler",
|
|
144
|
+
"SyslogHandler",
|
|
145
|
+
"TagProcessor",
|
|
146
|
+
"TestHandler",
|
|
147
|
+
"UidProcessor",
|
|
148
|
+
"UnknownChannelError",
|
|
149
|
+
"UnknownHandlerError",
|
|
150
|
+
"UnknownServiceError",
|
|
151
|
+
"Verbosity",
|
|
152
|
+
"WhatFailureGroupHandler",
|
|
153
|
+
"__version__",
|
|
154
|
+
"as_processor",
|
|
155
|
+
"bind_context",
|
|
156
|
+
"bound_context",
|
|
157
|
+
"clear_context",
|
|
158
|
+
"current_context",
|
|
159
|
+
"default_processor_registry",
|
|
160
|
+
"unbind_context",
|
|
161
|
+
]
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Adapters between this library and something that logs its own way.
|
|
2
|
+
|
|
3
|
+
Kept apart from the core because everything there stands on its own: a
|
|
4
|
+
:class:`~xtr_logging.logger.Logger` needs no third party to work. A bridge
|
|
5
|
+
does — it speaks to the standard library's :mod:`logging`, or to whatever
|
|
6
|
+
else an application already logs through — so it lives here, reached only
|
|
7
|
+
when that other side is actually in play.
|
|
8
|
+
"""
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""The bridge to the standard library's :mod:`logging`, both ways across.
|
|
2
|
+
|
|
3
|
+
Out of this library and into :mod:`logging`:
|
|
4
|
+
|
|
5
|
+
* :class:`~xtr_logging.bridge.stdlib.stdlib_handler.StdlibHandler` relays a
|
|
6
|
+
channel's records to a :class:`logging.Logger`, keeping their time.
|
|
7
|
+
|
|
8
|
+
Into this library from :mod:`logging`:
|
|
9
|
+
|
|
10
|
+
* :class:`~xtr_logging.bridge.stdlib.stdlib_capture_handler.StdlibCaptureHandler`
|
|
11
|
+
turns a third party's standard-library records into records on a channel, and
|
|
12
|
+
:class:`~xtr_logging.bridge.stdlib.stdlib_capture.StdlibCapture` takes over
|
|
13
|
+
the standard library's output with one, so no record is written twice.
|
|
14
|
+
|
|
15
|
+
And for code that wants this library's interface over a standard backend,
|
|
16
|
+
:class:`~xtr_logging.bridge.stdlib.stdlib_logger.StdlibLogger`. The two
|
|
17
|
+
directions recognise each other's records, so wiring both never loops.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from .level_mapping import from_stdlib, register_level_names, to_stdlib
|
|
21
|
+
from .stdlib_capture import StdlibCapture
|
|
22
|
+
from .stdlib_capture_handler import StdlibCaptureHandler
|
|
23
|
+
from .stdlib_handler import StdlibHandler
|
|
24
|
+
from .stdlib_logger import StdlibLogger
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"StdlibCapture",
|
|
28
|
+
"StdlibCaptureHandler",
|
|
29
|
+
"StdlibHandler",
|
|
30
|
+
"StdlibLogger",
|
|
31
|
+
"from_stdlib",
|
|
32
|
+
"register_level_names",
|
|
33
|
+
"to_stdlib",
|
|
34
|
+
]
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Translating levels between this library's eight and the standard library's five.
|
|
2
|
+
|
|
3
|
+
:mod:`logging` knows DEBUG, INFO, WARNING, ERROR and CRITICAL. RFC 5424 — and
|
|
4
|
+
so this library — also knows NOTICE, ALERT and EMERGENCY. They are slotted in
|
|
5
|
+
by number where they belong (NOTICE between INFO and WARNING, ALERT and
|
|
6
|
+
EMERGENCY above CRITICAL) so a threshold set on either side keeps its meaning,
|
|
7
|
+
and :func:`register_level_names` teaches :mod:`logging` their names so a
|
|
8
|
+
formatter prints ``NOTICE`` rather than ``Level 25``.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
from typing import TYPE_CHECKING, Final
|
|
15
|
+
|
|
16
|
+
from xtr_logging_contracts import Level
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from collections.abc import Mapping
|
|
20
|
+
|
|
21
|
+
__all__ = ["from_stdlib", "register_level_names", "to_stdlib"]
|
|
22
|
+
|
|
23
|
+
_TO_STDLIB: Final[Mapping[Level, int]] = {
|
|
24
|
+
Level.DEBUG: logging.DEBUG,
|
|
25
|
+
Level.INFO: logging.INFO,
|
|
26
|
+
Level.NOTICE: 25,
|
|
27
|
+
Level.WARNING: logging.WARNING,
|
|
28
|
+
Level.ERROR: logging.ERROR,
|
|
29
|
+
Level.CRITICAL: logging.CRITICAL,
|
|
30
|
+
Level.ALERT: 55,
|
|
31
|
+
Level.EMERGENCY: 60,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
# The extra three, paired with the name logging should print for each. The five
|
|
35
|
+
# standard levels already have names, so registering them would only restate
|
|
36
|
+
# what logging knows.
|
|
37
|
+
_EXTRA_NAMES: Final[Mapping[int, str]] = {
|
|
38
|
+
_TO_STDLIB[Level.NOTICE]: Level.NOTICE.name,
|
|
39
|
+
_TO_STDLIB[Level.ALERT]: Level.ALERT.name,
|
|
40
|
+
_TO_STDLIB[Level.EMERGENCY]: Level.EMERGENCY.name,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
# Ascending boundaries paired with the level a number below each falls into, so
|
|
44
|
+
# a value between two known levels rounds down to the less severe one.
|
|
45
|
+
_FROM_STDLIB_BOUNDARIES: Final[tuple[tuple[int, Level], ...]] = (
|
|
46
|
+
(_TO_STDLIB[Level.INFO], Level.DEBUG),
|
|
47
|
+
(_TO_STDLIB[Level.NOTICE], Level.INFO),
|
|
48
|
+
(_TO_STDLIB[Level.WARNING], Level.NOTICE),
|
|
49
|
+
(_TO_STDLIB[Level.ERROR], Level.WARNING),
|
|
50
|
+
(_TO_STDLIB[Level.CRITICAL], Level.ERROR),
|
|
51
|
+
(_TO_STDLIB[Level.ALERT], Level.CRITICAL),
|
|
52
|
+
(_TO_STDLIB[Level.EMERGENCY], Level.ALERT),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def to_stdlib(level: Level) -> int:
|
|
57
|
+
"""Return the :mod:`logging` number a record at ``level`` should carry."""
|
|
58
|
+
return _TO_STDLIB[level]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def from_stdlib(levelno: int) -> Level:
|
|
62
|
+
"""Return the level a :mod:`logging` number falls into.
|
|
63
|
+
|
|
64
|
+
A number between two known levels rounds down to the less severe one, the
|
|
65
|
+
way a threshold does: ``logging.WARNING + 1`` is still a warning. Anything
|
|
66
|
+
at or above EMERGENCY's number is an emergency; there is nothing higher.
|
|
67
|
+
"""
|
|
68
|
+
for boundary, level in _FROM_STDLIB_BOUNDARIES:
|
|
69
|
+
if levelno < boundary:
|
|
70
|
+
return level
|
|
71
|
+
return Level.EMERGENCY
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def register_level_names() -> None:
|
|
75
|
+
"""Teach :mod:`logging` the names of NOTICE, ALERT and EMERGENCY.
|
|
76
|
+
|
|
77
|
+
Called from the constructors that bridge to :mod:`logging`, never at
|
|
78
|
+
import, so importing this library leaves the global level table untouched
|
|
79
|
+
until something actually crosses the bridge. :func:`logging.addLevelName`
|
|
80
|
+
overwrites, so repeated calls settle on the same three names.
|
|
81
|
+
"""
|
|
82
|
+
for number, name in _EXTRA_NAMES.items():
|
|
83
|
+
logging.addLevelName(number, name)
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""Taking over the standard library's output, so every record is written once.
|
|
2
|
+
|
|
3
|
+
Capturing is not adding a handler. A handler added next to the ones already
|
|
4
|
+
there — a root ``StreamHandler`` from :func:`logging.basicConfig`, a handler a
|
|
5
|
+
library attached to its own logger — leaves them printing too, and every
|
|
6
|
+
record comes out twice: once through the standard library, once through a
|
|
7
|
+
channel. So a capture *replaces* the standard library's output instead. While
|
|
8
|
+
it is installed the one capture handler, on the root logger, is the only
|
|
9
|
+
handler anywhere in the tree; everything else it moved aside comes back when
|
|
10
|
+
it is released.
|
|
11
|
+
|
|
12
|
+
That has to hold for handlers attached later, too: the standard library calls
|
|
13
|
+
a logger's own handlers before its parents', so one attached to a library's
|
|
14
|
+
logger after the capture started would print a record before the capture ever
|
|
15
|
+
saw it. While a capture is installed, :meth:`logging.Logger.addHandler` and
|
|
16
|
+
:meth:`logging.Logger.removeHandler` are therefore routed through it — a
|
|
17
|
+
handler attached anywhere is held aside, to be attached for real when the
|
|
18
|
+
capture is released, and the capture's own handler cannot be taken off the
|
|
19
|
+
root. Both methods are the standard library's own again the moment the last
|
|
20
|
+
capture is released.
|
|
21
|
+
|
|
22
|
+
A record whose logger has no handler to reach — one reconfigured not to
|
|
23
|
+
propagate, its handlers held aside — would fall through to
|
|
24
|
+
:data:`logging.lastResort` and be printed raw to standard error. While
|
|
25
|
+
installed, the capture handler *is* the last resort, so that record is
|
|
26
|
+
captured instead.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import logging
|
|
32
|
+
from dataclasses import dataclass
|
|
33
|
+
from typing import TYPE_CHECKING, Final, final
|
|
34
|
+
|
|
35
|
+
from xtr_logging_contracts import Level
|
|
36
|
+
|
|
37
|
+
from .level_mapping import register_level_names, to_stdlib
|
|
38
|
+
from .stdlib_capture_handler import StdlibCaptureHandler
|
|
39
|
+
|
|
40
|
+
if TYPE_CHECKING:
|
|
41
|
+
from collections.abc import Mapping
|
|
42
|
+
from types import TracebackType
|
|
43
|
+
from typing import Self
|
|
44
|
+
|
|
45
|
+
from xtr_logging_contracts import LevelLike
|
|
46
|
+
|
|
47
|
+
from xtr_logging.logger import Logger
|
|
48
|
+
|
|
49
|
+
__all__ = ["StdlibCapture"]
|
|
50
|
+
|
|
51
|
+
_ADD_HANDLER: Final = logging.Logger.addHandler
|
|
52
|
+
_REMOVE_HANDLER: Final = logging.Logger.removeHandler
|
|
53
|
+
|
|
54
|
+
# Installed captures, most recent last. Only the most recent owns the output.
|
|
55
|
+
_captures: list[StdlibCapture] = []
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# Named as the standard library names them: these stand in for its methods.
|
|
59
|
+
def _add_handler(self: logging.Logger, hdlr: logging.Handler) -> None:
|
|
60
|
+
"""``Logger.addHandler`` while a capture is installed: hold the handler aside."""
|
|
61
|
+
_captures[-1].hold(self, hdlr)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _remove_handler(self: logging.Logger, hdlr: logging.Handler) -> None:
|
|
65
|
+
"""``Logger.removeHandler`` while a capture is installed: forget a held handler."""
|
|
66
|
+
_captures[-1].drop(self, hdlr)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _intercept(active: bool) -> None:
|
|
70
|
+
"""Route ``addHandler`` and ``removeHandler`` through the capture, or give them back."""
|
|
71
|
+
logging.Logger.addHandler = _add_handler if active else _ADD_HANDLER
|
|
72
|
+
logging.Logger.removeHandler = _remove_handler if active else _REMOVE_HANDLER
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(slots=True)
|
|
76
|
+
class _Saved:
|
|
77
|
+
"""How a standard logger was set up before the capture changed it."""
|
|
78
|
+
|
|
79
|
+
handlers: list[logging.Handler]
|
|
80
|
+
level: int
|
|
81
|
+
propagate: bool
|
|
82
|
+
disabled: bool
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@final
|
|
86
|
+
class StdlibCapture:
|
|
87
|
+
"""Routes every standard-library record into channels, and nowhere else.
|
|
88
|
+
|
|
89
|
+
On :meth:`install`:
|
|
90
|
+
|
|
91
|
+
- every existing standard logger loses its handlers, is re-enabled, and
|
|
92
|
+
propagates again, so each record reaches the root;
|
|
93
|
+
- the root loses its handlers and gets the one capture handler, at
|
|
94
|
+
``level``;
|
|
95
|
+
- each logger named in ``levels`` is set to its own level.
|
|
96
|
+
|
|
97
|
+
Every record the capture handler sees is also checked against the chain of
|
|
98
|
+
loggers it came through. A handler some code attached after installation
|
|
99
|
+
Handlers attached while it is installed — through ``addHandler``, as
|
|
100
|
+
:func:`logging.basicConfig` and :func:`logging.config.dictConfig` attach
|
|
101
|
+
them — are held aside rather than attached. A logger reconfigured not to
|
|
102
|
+
propagate is made to again by its first record. And as a last line, every
|
|
103
|
+
record the capture handler sees is checked against the loggers it came
|
|
104
|
+
through, for anything put straight into a ``handlers`` list.
|
|
105
|
+
|
|
106
|
+
Captures nest: the most recent one owns the output until it is released.
|
|
107
|
+
|
|
108
|
+
:meth:`release` puts back every handler, level and flag it changed.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
__slots__ = ("_handler", "_last_resort", "_levels", "_root_level", "_saved")
|
|
112
|
+
|
|
113
|
+
def __init__(
|
|
114
|
+
self,
|
|
115
|
+
logger: Logger,
|
|
116
|
+
*,
|
|
117
|
+
level: LevelLike = Level.WARNING,
|
|
118
|
+
levels: Mapping[str, LevelLike] | None = None,
|
|
119
|
+
routes: Mapping[str, Logger] | None = None,
|
|
120
|
+
) -> None:
|
|
121
|
+
"""Capture into ``logger``, or into the channel ``routes`` gives a logger.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
logger: The channel records arrive on unless a route says otherwise.
|
|
125
|
+
level: The root's level: the threshold for every standard logger
|
|
126
|
+
that sets none of its own.
|
|
127
|
+
levels: A level per standard logger name, for loggers that should
|
|
128
|
+
say more or less than the root lets through.
|
|
129
|
+
routes: A channel per standard logger name; a logger's children
|
|
130
|
+
follow it, and the most specific name wins.
|
|
131
|
+
|
|
132
|
+
Raises:
|
|
133
|
+
InvalidLevelError: If a level names no level.
|
|
134
|
+
"""
|
|
135
|
+
self._handler: StdlibCaptureHandler = StdlibCaptureHandler(logger, routes=routes)
|
|
136
|
+
self._handler.addFilter(self._take_over_origin)
|
|
137
|
+
self._root_level: int = to_stdlib(Level.parse(level))
|
|
138
|
+
self._levels: dict[str, int] = {
|
|
139
|
+
name: to_stdlib(Level.parse(value)) for name, value in (levels or {}).items()
|
|
140
|
+
}
|
|
141
|
+
self._saved: dict[logging.Logger, _Saved] = {}
|
|
142
|
+
self._last_resort: logging.Handler | None = None
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def installed(self) -> bool:
|
|
146
|
+
"""Whether the capture currently owns the standard library's output."""
|
|
147
|
+
return bool(self._saved)
|
|
148
|
+
|
|
149
|
+
def install(self) -> None:
|
|
150
|
+
"""Take over every standard logger. Installing twice changes nothing."""
|
|
151
|
+
if self.installed:
|
|
152
|
+
return
|
|
153
|
+
register_level_names()
|
|
154
|
+
if not _captures:
|
|
155
|
+
_intercept(active=True)
|
|
156
|
+
_captures.append(self)
|
|
157
|
+
root = logging.getLogger()
|
|
158
|
+
existing = [
|
|
159
|
+
candidate
|
|
160
|
+
for candidate in logging.Logger.manager.loggerDict.values()
|
|
161
|
+
if isinstance(candidate, logging.Logger)
|
|
162
|
+
]
|
|
163
|
+
for candidate in (*existing, root):
|
|
164
|
+
self._take_over(candidate)
|
|
165
|
+
_ADD_HANDLER(root, self._handler)
|
|
166
|
+
root.setLevel(self._root_level)
|
|
167
|
+
self._last_resort = logging.lastResort
|
|
168
|
+
logging.lastResort = self._handler
|
|
169
|
+
for name, level in self._levels.items():
|
|
170
|
+
named = logging.getLogger(name)
|
|
171
|
+
self._take_over(named)
|
|
172
|
+
named.setLevel(level)
|
|
173
|
+
|
|
174
|
+
def release(self) -> None:
|
|
175
|
+
"""Give every standard logger back exactly as it was found.
|
|
176
|
+
|
|
177
|
+
Handlers attached while the capture was installed are attached now.
|
|
178
|
+
"""
|
|
179
|
+
if not self.installed:
|
|
180
|
+
return
|
|
181
|
+
_captures.remove(self)
|
|
182
|
+
if not _captures:
|
|
183
|
+
_intercept(active=False)
|
|
184
|
+
_REMOVE_HANDLER(logging.getLogger(), self._handler)
|
|
185
|
+
logging.lastResort = self._last_resort
|
|
186
|
+
for taken, saved in self._saved.items():
|
|
187
|
+
taken.handlers = list(saved.handlers)
|
|
188
|
+
taken.setLevel(saved.level)
|
|
189
|
+
taken.propagate = saved.propagate
|
|
190
|
+
taken.disabled = saved.disabled
|
|
191
|
+
self._saved.clear()
|
|
192
|
+
|
|
193
|
+
def __enter__(self) -> Self:
|
|
194
|
+
"""Install for the ``with`` block."""
|
|
195
|
+
self.install()
|
|
196
|
+
return self
|
|
197
|
+
|
|
198
|
+
def __exit__(
|
|
199
|
+
self,
|
|
200
|
+
exc_type: type[BaseException] | None,
|
|
201
|
+
exc_value: BaseException | None,
|
|
202
|
+
traceback: TracebackType | None,
|
|
203
|
+
) -> None:
|
|
204
|
+
"""Release, whatever happened in the block."""
|
|
205
|
+
self.release()
|
|
206
|
+
|
|
207
|
+
def hold(self, logger: logging.Logger, handler: logging.Handler) -> None:
|
|
208
|
+
"""Keep ``handler`` for ``logger`` until release, instead of attaching it."""
|
|
209
|
+
if handler is self._handler:
|
|
210
|
+
_ADD_HANDLER(logger, handler)
|
|
211
|
+
return
|
|
212
|
+
self._take_over(logger)
|
|
213
|
+
held = self._saved[logger].handlers
|
|
214
|
+
if handler not in held:
|
|
215
|
+
held.append(handler)
|
|
216
|
+
|
|
217
|
+
def drop(self, logger: logging.Logger, handler: logging.Handler) -> None:
|
|
218
|
+
"""Forget a held ``handler``; the capture's own handler stays where it is."""
|
|
219
|
+
if handler is self._handler:
|
|
220
|
+
return
|
|
221
|
+
_REMOVE_HANDLER(logger, handler)
|
|
222
|
+
saved = self._saved.get(logger)
|
|
223
|
+
if saved is not None and handler in saved.handlers:
|
|
224
|
+
saved.handlers.remove(handler)
|
|
225
|
+
|
|
226
|
+
def _take_over(self, taken: logging.Logger) -> None:
|
|
227
|
+
"""Move ``taken``'s handlers aside and make it propagate, remembering how it was."""
|
|
228
|
+
saved = self._saved.get(taken)
|
|
229
|
+
if saved is None:
|
|
230
|
+
saved = _Saved([], taken.level, taken.propagate, taken.disabled)
|
|
231
|
+
self._saved[taken] = saved
|
|
232
|
+
foreign = [handler for handler in taken.handlers if handler is not self._handler]
|
|
233
|
+
saved.handlers.extend(foreign)
|
|
234
|
+
# Assigned rather than mutated: the standard library may be iterating
|
|
235
|
+
# the old list for the record being handled right now.
|
|
236
|
+
taken.handlers = [handler for handler in taken.handlers if handler is self._handler]
|
|
237
|
+
taken.propagate = True
|
|
238
|
+
taken.disabled = False
|
|
239
|
+
|
|
240
|
+
def _take_over_origin(self, record: logging.LogRecord) -> bool:
|
|
241
|
+
"""Move aside anything put on the record's way up since installation."""
|
|
242
|
+
current: logging.Logger | None = logging.getLogger(record.name)
|
|
243
|
+
while current is not None:
|
|
244
|
+
foreign = any(handler is not self._handler for handler in current.handlers)
|
|
245
|
+
if foreign or not current.propagate or current.disabled:
|
|
246
|
+
self._take_over(current)
|
|
247
|
+
current = current.parent
|
|
248
|
+
return True
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""A :mod:`logging` handler that feeds standard-library records into a channel.
|
|
2
|
+
|
|
3
|
+
Third-party code — uvicorn, SQLAlchemy, httpx, anything built on :mod:`logging`
|
|
4
|
+
— writes through the standard library, not through this one. Installed on those
|
|
5
|
+
loggers, this handler turns each of their records into a
|
|
6
|
+
:class:`~xtr_logging.log_record.LogRecord` on one of this library's channels,
|
|
7
|
+
keeping its time, its exception and whatever it attached through ``extra=``. A
|
|
8
|
+
record this library itself relayed out through
|
|
9
|
+
:class:`~xtr_logging.bridge.stdlib.stdlib_handler.StdlibHandler` is recognised
|
|
10
|
+
and dropped, so wiring both directions does not send a record round in circles.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
from typing import TYPE_CHECKING, Final, final
|
|
17
|
+
|
|
18
|
+
from typing_extensions import override
|
|
19
|
+
from xtr_clock import DatePoint, local_timezone
|
|
20
|
+
from xtr_logging_contracts import EXCEPTION_KEY
|
|
21
|
+
|
|
22
|
+
from .level_mapping import from_stdlib, register_level_names
|
|
23
|
+
from .stdlib_handler import BRIDGED_MARKER
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from collections.abc import Mapping
|
|
27
|
+
|
|
28
|
+
from xtr_logging.logger import Logger
|
|
29
|
+
|
|
30
|
+
__all__ = ["StdlibCaptureHandler"]
|
|
31
|
+
|
|
32
|
+
# Everything a bare logging.LogRecord already carries; anything else on a record
|
|
33
|
+
# was put there by a caller through `extra=` and so belongs in the context.
|
|
34
|
+
# `message` and `asctime` are set by formatting rather than __init__, and
|
|
35
|
+
# `taskName` exists only on newer Pythons, so all three are named explicitly.
|
|
36
|
+
_STANDARD_ATTRIBUTES: Final[frozenset[str]] = frozenset(vars(logging.makeLogRecord({}))) | {
|
|
37
|
+
"message",
|
|
38
|
+
"asctime",
|
|
39
|
+
"taskName",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@final
|
|
44
|
+
class StdlibCaptureHandler(logging.Handler):
|
|
45
|
+
"""Turns the standard library's records into records on a channel.
|
|
46
|
+
|
|
47
|
+
The channel is chosen per record: the ``routes`` entry for the most
|
|
48
|
+
specific standard logger name that matches — ``httpx`` catches
|
|
49
|
+
``httpx._client`` too — or, with ``channel_from_name``, one named after the
|
|
50
|
+
standard logger the record came from, so ``sqlalchemy.engine`` and
|
|
51
|
+
``uvicorn.access`` stay apart the way they were; otherwise the channel this
|
|
52
|
+
handler was built for. Per-name channels are made once and reused.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
logger: Logger,
|
|
58
|
+
*,
|
|
59
|
+
routes: Mapping[str, Logger] | None = None,
|
|
60
|
+
channel_from_name: bool = False,
|
|
61
|
+
level: int = logging.NOTSET,
|
|
62
|
+
) -> None:
|
|
63
|
+
"""Capture into ``logger``.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
logger: The channel captured records are added to, or the channel
|
|
67
|
+
whose handlers and processors the per-name channels share.
|
|
68
|
+
routes: The channel for records from a standard logger and its
|
|
69
|
+
children, by standard logger name; the longest match wins.
|
|
70
|
+
channel_from_name: Route a record no route matches to a channel
|
|
71
|
+
named after the standard logger it came from, rather than to
|
|
72
|
+
``logger`` itself.
|
|
73
|
+
level: The standard-library level below which records are dropped
|
|
74
|
+
before this handler sees them; the loggers it is installed on
|
|
75
|
+
have their own levels too.
|
|
76
|
+
"""
|
|
77
|
+
super().__init__(level)
|
|
78
|
+
register_level_names()
|
|
79
|
+
self._logger: Logger = logger
|
|
80
|
+
self._channel_from_name: bool = channel_from_name
|
|
81
|
+
# Longest name first, so the first match is the most specific.
|
|
82
|
+
self._routes: tuple[tuple[str, Logger], ...] = tuple(
|
|
83
|
+
sorted((routes or {}).items(), key=lambda route: -len(route[0])),
|
|
84
|
+
)
|
|
85
|
+
self._by_name: dict[str, Logger] = {}
|
|
86
|
+
|
|
87
|
+
@override
|
|
88
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
89
|
+
"""Add ``record`` to a channel, unless this library sent it out.
|
|
90
|
+
|
|
91
|
+
Keeps :class:`logging.Handler`'s contract of never letting an error
|
|
92
|
+
escape: anything that goes wrong is reported through
|
|
93
|
+
:meth:`logging.Handler.handleError`, which respects
|
|
94
|
+
:data:`logging.raiseExceptions`, rather than propagating to the code
|
|
95
|
+
that happened to be logging.
|
|
96
|
+
"""
|
|
97
|
+
marker: object = getattr(record, BRIDGED_MARKER, False)
|
|
98
|
+
if marker:
|
|
99
|
+
return
|
|
100
|
+
try:
|
|
101
|
+
self._capture(record)
|
|
102
|
+
except RecursionError: # pragma: no cover - stdlib re-raises to surface a stack overflow
|
|
103
|
+
raise
|
|
104
|
+
except Exception: # noqa: BLE001 - Handler.emit reports via handleError, never propagates
|
|
105
|
+
self.handleError(record)
|
|
106
|
+
|
|
107
|
+
def _capture(self, record: logging.LogRecord) -> None:
|
|
108
|
+
attributes: dict[str, object] = vars(record)
|
|
109
|
+
context: dict[str, object] = {
|
|
110
|
+
name: value for name, value in attributes.items() if name not in _STANDARD_ATTRIBUTES
|
|
111
|
+
}
|
|
112
|
+
exc_info = record.exc_info
|
|
113
|
+
exception = exc_info[1] if exc_info is not None else None
|
|
114
|
+
if isinstance(exception, BaseException):
|
|
115
|
+
context[EXCEPTION_KEY] = exception
|
|
116
|
+
when = DatePoint.fromtimestamp(record.created, local_timezone())
|
|
117
|
+
_ = self._target_for(record.name).add_record(
|
|
118
|
+
from_stdlib(record.levelno),
|
|
119
|
+
record.getMessage(),
|
|
120
|
+
context,
|
|
121
|
+
datetime=when,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
def _target_for(self, name: str) -> Logger:
|
|
125
|
+
for prefix, routed in self._routes:
|
|
126
|
+
if name == prefix or name.startswith(f"{prefix}."):
|
|
127
|
+
return routed
|
|
128
|
+
if not self._channel_from_name:
|
|
129
|
+
return self._logger
|
|
130
|
+
cached = self._by_name.get(name)
|
|
131
|
+
if cached is None:
|
|
132
|
+
cached = self._logger.with_name(name)
|
|
133
|
+
self._by_name[name] = cached
|
|
134
|
+
return cached
|