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.
Files changed (87) hide show
  1. xtr_logging/__init__.py +161 -0
  2. xtr_logging/bridge/__init__.py +8 -0
  3. xtr_logging/bridge/stdlib/__init__.py +34 -0
  4. xtr_logging/bridge/stdlib/level_mapping.py +83 -0
  5. xtr_logging/bridge/stdlib/stdlib_capture.py +248 -0
  6. xtr_logging/bridge/stdlib/stdlib_capture_handler.py +134 -0
  7. xtr_logging/bridge/stdlib/stdlib_handler.py +121 -0
  8. xtr_logging/bridge/stdlib/stdlib_logger.py +67 -0
  9. xtr_logging/config/__init__.py +89 -0
  10. xtr_logging/config/capture_spec.py +85 -0
  11. xtr_logging/config/channel_filter.py +54 -0
  12. xtr_logging/config/formatter_builder.py +58 -0
  13. xtr_logging/config/formatter_specs.py +52 -0
  14. xtr_logging/config/handler_builder.py +252 -0
  15. xtr_logging/config/handler_specs.py +162 -0
  16. xtr_logging/config/logging_config.py +191 -0
  17. xtr_logging/config/processor_builder.py +69 -0
  18. xtr_logging/config/processor_specs.py +116 -0
  19. xtr_logging/config/services.py +63 -0
  20. xtr_logging/config/wrapper_handler_specs.py +159 -0
  21. xtr_logging/decorator/__init__.py +5 -0
  22. xtr_logging/decorator/as_processor.py +61 -0
  23. xtr_logging/exception/__init__.py +35 -0
  24. xtr_logging/exception/capture_conflict_error.py +25 -0
  25. xtr_logging/exception/circular_handler_reference_error.py +18 -0
  26. xtr_logging/exception/empty_stack_error.py +25 -0
  27. xtr_logging/exception/invalid_configuration_error.py +22 -0
  28. xtr_logging/exception/invalid_option_error.py +26 -0
  29. xtr_logging/exception/mixed_channel_filter_error.py +25 -0
  30. xtr_logging/exception/not_processable_handler_error.py +18 -0
  31. xtr_logging/exception/unknown_channel_error.py +25 -0
  32. xtr_logging/exception/unknown_handler_error.py +30 -0
  33. xtr_logging/exception/unknown_service_error.py +27 -0
  34. xtr_logging/formatter/__init__.py +18 -0
  35. xtr_logging/formatter/console_formatter.py +94 -0
  36. xtr_logging/formatter/formatter_interface.py +25 -0
  37. xtr_logging/formatter/json_batch_mode.py +19 -0
  38. xtr_logging/formatter/json_formatter.py +104 -0
  39. xtr_logging/formatter/line_formatter.py +155 -0
  40. xtr_logging/formatter/normalizer.py +163 -0
  41. xtr_logging/handler/__init__.py +53 -0
  42. xtr_logging/handler/abstract_handler.py +81 -0
  43. xtr_logging/handler/abstract_processing_handler.py +107 -0
  44. xtr_logging/handler/buffer_handler.py +138 -0
  45. xtr_logging/handler/console_handler.py +131 -0
  46. xtr_logging/handler/deduplication_handler.py +168 -0
  47. xtr_logging/handler/fallback_group_handler.py +67 -0
  48. xtr_logging/handler/filter_handler.py +162 -0
  49. xtr_logging/handler/fingers_crossed/__init__.py +11 -0
  50. xtr_logging/handler/fingers_crossed/activation_strategy_interface.py +25 -0
  51. xtr_logging/handler/fingers_crossed/channel_level_activation_strategy.py +53 -0
  52. xtr_logging/handler/fingers_crossed/error_level_activation_strategy.py +42 -0
  53. xtr_logging/handler/fingers_crossed_handler.py +193 -0
  54. xtr_logging/handler/formattable_handler_interface.py +23 -0
  55. xtr_logging/handler/group_handler.py +105 -0
  56. xtr_logging/handler/handler_interface.py +43 -0
  57. xtr_logging/handler/null_handler.py +35 -0
  58. xtr_logging/handler/processable_handler_interface.py +31 -0
  59. xtr_logging/handler/queue_handler.py +141 -0
  60. xtr_logging/handler/rotating_file_handler.py +124 -0
  61. xtr_logging/handler/sampling_handler.py +130 -0
  62. xtr_logging/handler/stream_handler.py +119 -0
  63. xtr_logging/handler/syslog_handler.py +156 -0
  64. xtr_logging/handler/test_handler.py +108 -0
  65. xtr_logging/handler/what_failure_group_handler.py +56 -0
  66. xtr_logging/integration/__init__.py +1 -0
  67. xtr_logging/integration/wireup.py +88 -0
  68. xtr_logging/log_context.py +73 -0
  69. xtr_logging/log_record.py +75 -0
  70. xtr_logging/logger.py +246 -0
  71. xtr_logging/logger_factory.py +250 -0
  72. xtr_logging/processor/__init__.py +25 -0
  73. xtr_logging/processor/context_vars_processor.py +43 -0
  74. xtr_logging/processor/hostname_processor.py +40 -0
  75. xtr_logging/processor/introspection_processor.py +92 -0
  76. xtr_logging/processor/placeholder_processor.py +115 -0
  77. xtr_logging/processor/process_id_processor.py +31 -0
  78. xtr_logging/processor/processor_interface.py +29 -0
  79. xtr_logging/processor/processor_registry.py +81 -0
  80. xtr_logging/processor/tag_processor.py +45 -0
  81. xtr_logging/processor/uid_processor.py +65 -0
  82. xtr_logging/py.typed +0 -0
  83. xtr_logging/verbosity.py +48 -0
  84. xtr_logging-1.0.0.dist-info/METADATA +536 -0
  85. xtr_logging-1.0.0.dist-info/RECORD +87 -0
  86. xtr_logging-1.0.0.dist-info/WHEEL +4 -0
  87. xtr_logging-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -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