xtr-logging 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. xtr_logging-1.0.0/LICENSE +21 -0
  2. xtr_logging-1.0.0/PKG-INFO +536 -0
  3. xtr_logging-1.0.0/README.md +509 -0
  4. xtr_logging-1.0.0/pyproject.toml +155 -0
  5. xtr_logging-1.0.0/pyproject.toml.orig +149 -0
  6. xtr_logging-1.0.0/src/xtr_logging/__init__.py +161 -0
  7. xtr_logging-1.0.0/src/xtr_logging/bridge/__init__.py +8 -0
  8. xtr_logging-1.0.0/src/xtr_logging/bridge/stdlib/__init__.py +34 -0
  9. xtr_logging-1.0.0/src/xtr_logging/bridge/stdlib/level_mapping.py +83 -0
  10. xtr_logging-1.0.0/src/xtr_logging/bridge/stdlib/stdlib_capture.py +248 -0
  11. xtr_logging-1.0.0/src/xtr_logging/bridge/stdlib/stdlib_capture_handler.py +134 -0
  12. xtr_logging-1.0.0/src/xtr_logging/bridge/stdlib/stdlib_handler.py +121 -0
  13. xtr_logging-1.0.0/src/xtr_logging/bridge/stdlib/stdlib_logger.py +67 -0
  14. xtr_logging-1.0.0/src/xtr_logging/config/__init__.py +89 -0
  15. xtr_logging-1.0.0/src/xtr_logging/config/capture_spec.py +85 -0
  16. xtr_logging-1.0.0/src/xtr_logging/config/channel_filter.py +54 -0
  17. xtr_logging-1.0.0/src/xtr_logging/config/formatter_builder.py +58 -0
  18. xtr_logging-1.0.0/src/xtr_logging/config/formatter_specs.py +52 -0
  19. xtr_logging-1.0.0/src/xtr_logging/config/handler_builder.py +252 -0
  20. xtr_logging-1.0.0/src/xtr_logging/config/handler_specs.py +162 -0
  21. xtr_logging-1.0.0/src/xtr_logging/config/logging_config.py +191 -0
  22. xtr_logging-1.0.0/src/xtr_logging/config/processor_builder.py +69 -0
  23. xtr_logging-1.0.0/src/xtr_logging/config/processor_specs.py +116 -0
  24. xtr_logging-1.0.0/src/xtr_logging/config/services.py +63 -0
  25. xtr_logging-1.0.0/src/xtr_logging/config/wrapper_handler_specs.py +159 -0
  26. xtr_logging-1.0.0/src/xtr_logging/decorator/__init__.py +5 -0
  27. xtr_logging-1.0.0/src/xtr_logging/decorator/as_processor.py +61 -0
  28. xtr_logging-1.0.0/src/xtr_logging/exception/__init__.py +35 -0
  29. xtr_logging-1.0.0/src/xtr_logging/exception/capture_conflict_error.py +25 -0
  30. xtr_logging-1.0.0/src/xtr_logging/exception/circular_handler_reference_error.py +18 -0
  31. xtr_logging-1.0.0/src/xtr_logging/exception/empty_stack_error.py +25 -0
  32. xtr_logging-1.0.0/src/xtr_logging/exception/invalid_configuration_error.py +22 -0
  33. xtr_logging-1.0.0/src/xtr_logging/exception/invalid_option_error.py +26 -0
  34. xtr_logging-1.0.0/src/xtr_logging/exception/mixed_channel_filter_error.py +25 -0
  35. xtr_logging-1.0.0/src/xtr_logging/exception/not_processable_handler_error.py +18 -0
  36. xtr_logging-1.0.0/src/xtr_logging/exception/unknown_channel_error.py +25 -0
  37. xtr_logging-1.0.0/src/xtr_logging/exception/unknown_handler_error.py +30 -0
  38. xtr_logging-1.0.0/src/xtr_logging/exception/unknown_service_error.py +27 -0
  39. xtr_logging-1.0.0/src/xtr_logging/formatter/__init__.py +18 -0
  40. xtr_logging-1.0.0/src/xtr_logging/formatter/console_formatter.py +94 -0
  41. xtr_logging-1.0.0/src/xtr_logging/formatter/formatter_interface.py +25 -0
  42. xtr_logging-1.0.0/src/xtr_logging/formatter/json_batch_mode.py +19 -0
  43. xtr_logging-1.0.0/src/xtr_logging/formatter/json_formatter.py +104 -0
  44. xtr_logging-1.0.0/src/xtr_logging/formatter/line_formatter.py +155 -0
  45. xtr_logging-1.0.0/src/xtr_logging/formatter/normalizer.py +163 -0
  46. xtr_logging-1.0.0/src/xtr_logging/handler/__init__.py +53 -0
  47. xtr_logging-1.0.0/src/xtr_logging/handler/abstract_handler.py +81 -0
  48. xtr_logging-1.0.0/src/xtr_logging/handler/abstract_processing_handler.py +107 -0
  49. xtr_logging-1.0.0/src/xtr_logging/handler/buffer_handler.py +138 -0
  50. xtr_logging-1.0.0/src/xtr_logging/handler/console_handler.py +131 -0
  51. xtr_logging-1.0.0/src/xtr_logging/handler/deduplication_handler.py +168 -0
  52. xtr_logging-1.0.0/src/xtr_logging/handler/fallback_group_handler.py +67 -0
  53. xtr_logging-1.0.0/src/xtr_logging/handler/filter_handler.py +162 -0
  54. xtr_logging-1.0.0/src/xtr_logging/handler/fingers_crossed/__init__.py +11 -0
  55. xtr_logging-1.0.0/src/xtr_logging/handler/fingers_crossed/activation_strategy_interface.py +25 -0
  56. xtr_logging-1.0.0/src/xtr_logging/handler/fingers_crossed/channel_level_activation_strategy.py +53 -0
  57. xtr_logging-1.0.0/src/xtr_logging/handler/fingers_crossed/error_level_activation_strategy.py +42 -0
  58. xtr_logging-1.0.0/src/xtr_logging/handler/fingers_crossed_handler.py +193 -0
  59. xtr_logging-1.0.0/src/xtr_logging/handler/formattable_handler_interface.py +23 -0
  60. xtr_logging-1.0.0/src/xtr_logging/handler/group_handler.py +105 -0
  61. xtr_logging-1.0.0/src/xtr_logging/handler/handler_interface.py +43 -0
  62. xtr_logging-1.0.0/src/xtr_logging/handler/null_handler.py +35 -0
  63. xtr_logging-1.0.0/src/xtr_logging/handler/processable_handler_interface.py +31 -0
  64. xtr_logging-1.0.0/src/xtr_logging/handler/queue_handler.py +141 -0
  65. xtr_logging-1.0.0/src/xtr_logging/handler/rotating_file_handler.py +124 -0
  66. xtr_logging-1.0.0/src/xtr_logging/handler/sampling_handler.py +130 -0
  67. xtr_logging-1.0.0/src/xtr_logging/handler/stream_handler.py +119 -0
  68. xtr_logging-1.0.0/src/xtr_logging/handler/syslog_handler.py +156 -0
  69. xtr_logging-1.0.0/src/xtr_logging/handler/test_handler.py +108 -0
  70. xtr_logging-1.0.0/src/xtr_logging/handler/what_failure_group_handler.py +56 -0
  71. xtr_logging-1.0.0/src/xtr_logging/integration/__init__.py +1 -0
  72. xtr_logging-1.0.0/src/xtr_logging/integration/wireup.py +88 -0
  73. xtr_logging-1.0.0/src/xtr_logging/log_context.py +73 -0
  74. xtr_logging-1.0.0/src/xtr_logging/log_record.py +75 -0
  75. xtr_logging-1.0.0/src/xtr_logging/logger.py +246 -0
  76. xtr_logging-1.0.0/src/xtr_logging/logger_factory.py +250 -0
  77. xtr_logging-1.0.0/src/xtr_logging/processor/__init__.py +25 -0
  78. xtr_logging-1.0.0/src/xtr_logging/processor/context_vars_processor.py +43 -0
  79. xtr_logging-1.0.0/src/xtr_logging/processor/hostname_processor.py +40 -0
  80. xtr_logging-1.0.0/src/xtr_logging/processor/introspection_processor.py +92 -0
  81. xtr_logging-1.0.0/src/xtr_logging/processor/placeholder_processor.py +115 -0
  82. xtr_logging-1.0.0/src/xtr_logging/processor/process_id_processor.py +31 -0
  83. xtr_logging-1.0.0/src/xtr_logging/processor/processor_interface.py +29 -0
  84. xtr_logging-1.0.0/src/xtr_logging/processor/processor_registry.py +81 -0
  85. xtr_logging-1.0.0/src/xtr_logging/processor/tag_processor.py +45 -0
  86. xtr_logging-1.0.0/src/xtr_logging/processor/uid_processor.py +65 -0
  87. xtr_logging-1.0.0/src/xtr_logging/py.typed +0 -0
  88. xtr_logging-1.0.0/src/xtr_logging/verbosity.py +48 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 xterr
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,536 @@
1
+ Metadata-Version: 2.4
2
+ Name: xtr-logging
3
+ Version: 1.0.0
4
+ Summary: Channels, handlers, processors and formatters for Python, behind one logger interface.
5
+ Keywords: logging,channels,handlers,processors,structured-logging
6
+ Author: Razvan Ceana
7
+ Author-email: Razvan Ceana <razvan@ceana.ro>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Programming Language :: Python :: 3.14
16
+ Classifier: Topic :: System :: Logging
17
+ Classifier: Typing :: Typed
18
+ Requires-Dist: msgspec>=0.18
19
+ Requires-Dist: typing-extensions>=4.4
20
+ Requires-Dist: xtr-clock>=1.0,<2
21
+ Requires-Dist: xtr-logging-contracts>=1.0,<2
22
+ Requires-Dist: xtr-service-contracts>=1.0,<2
23
+ Requires-Dist: wireup>=2.12 ; extra == 'wireup'
24
+ Requires-Python: >=3.11
25
+ Provides-Extra: wireup
26
+ Description-Content-Type: text/markdown
27
+
28
+ <div align="center">
29
+
30
+ # xtr-logging
31
+
32
+ **Channels, handlers, processors and formatters for Python — behind one logger interface.**
33
+
34
+ <img alt="python 3.11+" src="https://img.shields.io/badge/python-%E2%89%A5%203.11-3776AB?logo=python&logoColor=white">
35
+ <img alt="core dependencies: 5" src="https://img.shields.io/badge/core%20deps-5-3FB950">
36
+ <img alt="typed" src="https://img.shields.io/badge/typed-ty%20%2B%20basedpyright-1f6feb">
37
+ <img alt="license MIT" src="https://img.shields.io/badge/license-MIT-blue">
38
+
39
+ </div>
40
+
41
+ ---
42
+
43
+ ## Why?
44
+
45
+ Code that logs should depend on one small interface, not on how logging is set up. Where records
46
+ go — a file, syslog, standard error, nowhere unless something failed — is configuration.
47
+
48
+ What you get:
49
+
50
+ - 🧩 **One `LoggerInterface`** — eight severities plus `log()`, with a context mapping. A
51
+ library takes one and defaults to `NullLogger`.
52
+ - 📡 **Channels** — one logger per concern (`app`, `security`, `db`), sharing handlers.
53
+ - 🫧 **Handlers bubble** — a stack consulted in order; a record stops where a handler keeps it.
54
+ - 🤞 **Fingers crossed** — buffer a request's whole log, and write it only if something failed.
55
+ - 🔧 **Processors** — enrich every record: placeholders, request ids, hostnames, call sites,
56
+ ambient context.
57
+ - 📋 **Configuration as data** — channels, handlers and processors, readable from TOML or JSON.
58
+ - 🔁 **Standard library bridge** — third-party `logging` output flows into your channels, and
59
+ yours can flow out.
60
+ - 🕰️ **An injectable clock** — record times come from [xtr-clock](https://github.com/xterr/python-xtr-clock),
61
+ so a test freezes them.
62
+ - 🤝 **A contract a library can depend on alone** — the interface lives in
63
+ [xtr-logging-contracts](https://github.com/xterr/python-xtr-logging-contracts), which has one
64
+ dependency, so a library that only logs never installs any of this.
65
+ - 🪶 **Five core dependencies** — `msgspec`, `typing-extensions`, `xtr-clock`,
66
+ `xtr-logging-contracts` and `xtr-service-contracts`.
67
+
68
+ ```python
69
+ logger.error("payment {order} failed", {"order": order.id, "exception": error})
70
+ ```
71
+
72
+ ## Install
73
+
74
+ ```sh
75
+ uv add xtr-logging # everything but the container integration
76
+ uv add "xtr-logging[wireup]" # + a logger per channel from a wireup container
77
+ ```
78
+
79
+ Requires Python 3.11+.
80
+
81
+ ## Quick start
82
+
83
+ ```python
84
+ from xtr_logging import Logger, PlaceholderProcessor, StreamHandler
85
+ from xtr_logging_contracts import Level
86
+
87
+ logger = Logger(
88
+ "app",
89
+ handlers=[StreamHandler("var/log/app.log", Level.INFO)],
90
+ processors=[PlaceholderProcessor()],
91
+ )
92
+
93
+ logger.info("user {user} logged in", {"user": "ana"})
94
+ # [2026-09-24T12:30:45.123456+03:00] app.INFO: user ana logged in {"user":"ana"} []
95
+ ```
96
+
97
+ Code that only *uses* a logger asks for the interface:
98
+
99
+ ```python
100
+ from xtr_logging_contracts import LoggerInterface, NullLogger
101
+
102
+
103
+ class Checkout:
104
+ def __init__(self, logger: LoggerInterface | None = None) -> None:
105
+ self._logger = logger or NullLogger()
106
+ ```
107
+
108
+ ## The logger interface
109
+
110
+ The interface, `Level`, `Context`, `NullLogger`, `AbstractLogger` and `LoggerAware` belong to
111
+ [xtr-logging-contracts](https://github.com/xterr/python-xtr-logging-contracts) and are imported
112
+ from there, not from here. This package exports only what it owns — loggers, handlers,
113
+ processors, formatters and configuration — so there is exactly one place each name comes from and
114
+ no chance of two packages disagreeing about what `LoggerInterface` is.
115
+
116
+ So a library that only logs depends on `xtr-logging-contracts` at runtime and keeps `xtr-logging`
117
+ as a dev dependency for its tests; an application depends on both and wires them together.
118
+
119
+ ```python
120
+ class LoggerInterface(Protocol):
121
+ def emergency(self, message: str, /, context: Context | None = None) -> None: ...
122
+ def alert(self, message: str, /, context: Context | None = None) -> None: ...
123
+ def critical(self, message: str, /, context: Context | None = None) -> None: ...
124
+ def error(self, message: str, /, context: Context | None = None) -> None: ...
125
+ def warning(self, message: str, /, context: Context | None = None) -> None: ...
126
+ def notice(self, message: str, /, context: Context | None = None) -> None: ...
127
+ def info(self, message: str, /, context: Context | None = None) -> None: ...
128
+ def debug(self, message: str, /, context: Context | None = None) -> None: ...
129
+ def log(self, level: LevelLike, message: str, /, context: Context | None = None) -> None: ...
130
+ ```
131
+
132
+ The rules:
133
+
134
+ - `context` is a mapping of anything. Formatters describe what they cannot serialise; a value
135
+ never makes logging fail.
136
+ - An exception to report goes under `context["exception"]`. Formatters print its class, message,
137
+ origin and cause, and the traceback if asked.
138
+ - `{key}` placeholders in the message are filled from context by `PlaceholderProcessor`, not by
139
+ the logger, so a handler can still see the template and the values apart.
140
+
141
+ `AbstractLogger` implements the eight methods on top of `log()`, so an implementation writes one
142
+ method. With the `LoggerAware` mixin a class gets a `logger` that is a `NullLogger` until
143
+ `set_logger()` is called.
144
+
145
+ > ruff's `PLE1205` assumes every `logger.info(...)` is the standard library's and flags the
146
+ > context mapping as a stray format argument. Ignore it in projects using this interface.
147
+
148
+ ### Levels
149
+
150
+ The eight RFC 5424 severities, valued so they compare as integers:
151
+
152
+ | Level | Value | RFC 5424 | | Level | Value | RFC 5424 |
153
+ | --- | --- | --- | --- | --- | --- | --- |
154
+ | `DEBUG` | 100 | 7 | | `ERROR` | 400 | 3 |
155
+ | `INFO` | 200 | 6 | | `CRITICAL` | 500 | 2 |
156
+ | `NOTICE` | 250 | 5 | | `ALERT` | 550 | 1 |
157
+ | `WARNING` | 300 | 4 | | `EMERGENCY` | 600 | 0 |
158
+
159
+ Anywhere a level is accepted, `Level.parse` reads it: a `Level`, its value, an RFC 5424 severity,
160
+ or a name in any case (`"error"`). Anything else raises `InvalidLevelError`.
161
+
162
+ ## Records, handlers and bubbling
163
+
164
+ Each call becomes an immutable `LogRecord`: `datetime`, `channel`, `level`, `message`, `context`,
165
+ and `extra` (what processors added, kept apart so a processor never overwrites the caller).
166
+
167
+ A logger offers the record to its handlers in stack order. A handler handles records at its
168
+ `level` or above; with `bubble=False` a record it handled goes no further:
169
+
170
+ ```python
171
+ logger = Logger(
172
+ "app",
173
+ [
174
+ StreamHandler("var/log/errors.log", Level.ERROR, bubble=False), # errors stop here
175
+ StreamHandler("var/log/app.log"), # everything else
176
+ ],
177
+ )
178
+ ```
179
+
180
+ `push_handler()` puts a handler on top, `pop_handler()` takes it off. `with_name("security")`
181
+ returns a logger for another channel that shares these handlers.
182
+
183
+ A handler or processor that raises propagates to the caller. Pass
184
+ `exception_handler=` to a `Logger` to receive such failures instead, or wrap handlers in a
185
+ `WhatFailureGroupHandler`. A handler that logs while handling a record is stopped three levels
186
+ deep, with a warning, rather than recursing until the stack overflows.
187
+
188
+ ### Handlers
189
+
190
+ | Handler | Does |
191
+ | --- | --- |
192
+ | `StreamHandler` | Writes to a stream or a file, opened on first write, parent directories created |
193
+ | `RotatingFileHandler` | One file per day (or any `date_format`), keeping the newest `max_files` |
194
+ | `SyslogHandler` | Syslog over UDP or a socket such as `/dev/log`, with the right severity |
195
+ | `ConsoleHandler` | Standard error, or any stream set later, its level following `-v` verbosity, coloured on a terminal |
196
+ | `NullHandler` | Swallows records at its level |
197
+ | `TestHandler` | Keeps records in memory for assertions |
198
+ | `FingersCrossedHandler` | Buffers everything; writes it all once one record is bad enough |
199
+ | `BufferHandler` | Buffers records and writes them as a batch on `close()` |
200
+ | `GroupHandler` | Sends every record to every member |
201
+ | `WhatFailureGroupHandler` | A group where a failing member never stops the others |
202
+ | `FallbackGroupHandler` | Tries members in order until one succeeds |
203
+ | `FilterHandler` | Passes a level range, or a list of levels, to the handler it wraps |
204
+ | `DeduplicationHandler` | Drops an error already written in the last `time` seconds |
205
+ | `SamplingHandler` | Passes one record in `factor` |
206
+ | `QueueHandler` | Writes on a background thread, so logging never waits on I/O |
207
+ | `StdlibHandler` | Hands records to a standard-library logger |
208
+
209
+ ### Fingers crossed
210
+
211
+ The handler to run in production. Records are buffered; nothing is written until one
212
+ reaches the action level, and then the whole buffer is — so a failed request leaves its full
213
+ story, and a healthy one leaves nothing:
214
+
215
+ ```python
216
+ from xtr_logging import FingersCrossedHandler, Logger, StreamHandler
217
+ from xtr_logging_contracts import Level
218
+
219
+ logger = Logger("app", [FingersCrossedHandler(StreamHandler("var/log/app.log"), Level.ERROR)])
220
+ ```
221
+
222
+ `ChannelLevelActivationStrategy` sets a different trigger per channel, `buffer_size` caps the
223
+ buffer, and `passthru_level` keeps records at that level even when nothing triggered. Call
224
+ `reset()` between the requests or messages of a long-running process so one unit of work does not
225
+ bleed into the next.
226
+
227
+ ## Processors
228
+
229
+ A processor is any callable `(LogRecord) -> LogRecord`. Logger processors run once per record,
230
+ and only once some handler will handle it:
231
+
232
+ | Processor | Adds |
233
+ | --- | --- |
234
+ | `PlaceholderProcessor` | Fills `{placeholders}` in the message from context |
235
+ | `ContextVarsProcessor` | Whatever is bound with `bind_context()` / `bound_context()` |
236
+ | `UidProcessor` | A random id shared by every record until `reset()` |
237
+ | `IntrospectionProcessor` | The file, line, function and module that logged |
238
+ | `HostnameProcessor`, `ProcessIdProcessor` | The machine, the process |
239
+ | `TagProcessor` | A fixed list of tags |
240
+
241
+ Ambient context rides along without being passed to every call. It lives in a context variable,
242
+ so it is separate per thread and per asyncio or anyio task:
243
+
244
+ ```python
245
+ from xtr_logging import bound_context
246
+
247
+ with bound_context({"request_id": request.id, "user": user.id}):
248
+ handle(request) # every record logged in here carries both
249
+ ```
250
+
251
+ Declare a processor where it is written, and a factory attaches it:
252
+
253
+ ```python
254
+ from xtr_logging import LogRecord, as_processor
255
+
256
+
257
+ @as_processor(channel="billing")
258
+ def add_tenant(record: LogRecord) -> LogRecord:
259
+ return record.with_extra({"tenant": current_tenant()})
260
+ ```
261
+
262
+ `channel=` limits a processor to one channel. `handler=` attaches it to a handler instead;
263
+ handlers are shared, so it then applies on every channel that handler serves. Higher `priority`
264
+ runs first.
265
+
266
+ ## Formatters
267
+
268
+ | Formatter | Renders |
269
+ | --- | --- |
270
+ | `LineFormatter` | `[%datetime%] %channel%.%level_name%: %message% %context% %extra%` |
271
+ | `JsonFormatter` | One JSON object per record, or a batch as an array or as lines |
272
+ | `ConsoleFormatter` | A short line with the level coloured |
273
+
274
+ `LineFormatter` also knows `%level%` and `%context.KEY%` / `%extra.KEY%` for one entry. It keeps
275
+ each record on one line unless `allow_inline_line_breaks` is set, and prints tracebacks when
276
+ `include_stacktraces` is set. `Normalizer`, which both formatters build on, reduces any value to
277
+ plain data, cutting nesting and collections short at a limit.
278
+
279
+ ## Configuration
280
+
281
+ A `LoggingConfig` describes channels, handlers and processors as data. It builds nothing; a
282
+ `LoggerFactory` does:
283
+
284
+ ```python
285
+ from xtr_logging import LoggerFactory, LoggingConfig
286
+ from xtr_logging.config import (
287
+ ConsoleHandlerSpec,
288
+ FingersCrossedHandlerSpec,
289
+ PlaceholderProcessorSpec,
290
+ StreamHandlerSpec,
291
+ )
292
+
293
+ CONFIG = LoggingConfig(
294
+ channels=("security", "billing"),
295
+ handlers={
296
+ "main": FingersCrossedHandlerSpec(action_level="error", handler="file"),
297
+ "file": StreamHandlerSpec(path="var/log/prod.log"),
298
+ "console": ConsoleHandlerSpec(channels=("!event",)),
299
+ },
300
+ processors=(PlaceholderProcessorSpec(),),
301
+ )
302
+
303
+ factory = LoggerFactory(CONFIG)
304
+ security = factory.logger("security")
305
+ ```
306
+
307
+ Or read it from a file, TOML here:
308
+
309
+ ```toml
310
+ channels = ["security"]
311
+
312
+ [handlers.main]
313
+ type = "fingers_crossed"
314
+ action_level = "error"
315
+ handler = "file"
316
+
317
+ [handlers.file]
318
+ type = "stream"
319
+ path = "var/log/prod.log"
320
+ formatter = { type = "json" }
321
+
322
+ [handlers.audit]
323
+ type = "rotating_file"
324
+ path = "var/log/audit.log"
325
+ max_files = 30
326
+ channels = ["security"]
327
+ level = "notice"
328
+
329
+ [[processors]]
330
+ type = "placeholder"
331
+ ```
332
+
333
+ ```python
334
+ config = LoggingConfig.from_mapping(tomllib.loads(Path("logging.toml").read_text()))
335
+ ```
336
+
337
+ The rules:
338
+
339
+ - **Channels.** `app` (`default_channel`) always exists. `channels` adds more, and a channel
340
+ named in any handler's `channels` is declared too. Asking for any other channel raises
341
+ `UnknownChannelError`.
342
+ - **Channel filters.** `channels: "security"` or `["a", "b"]` includes; `"!event"` or
343
+ `["!a", "!b"]` excludes. Mixing the two raises `MixedChannelFilterError`. A filter applies only
344
+ to a handler on a channel's stack, not to one nested in another handler.
345
+ - **Nesting.** A wrapper names what it wraps (`handler: file`, `members: [a, b]`). A handler
346
+ named that way, or marked `nested`, is left off every channel's stack.
347
+ - **Priority.** Higher is consulted first; ties keep declaration order.
348
+ - **Services.** `type: service` names an object you supply, as do a formatter given by name and
349
+ an `activation_strategy`:
350
+
351
+ ```python
352
+ LoggerFactory(CONFIG, services=Services(handlers={"sentry": SentryHandler(dsn)}))
353
+ ```
354
+
355
+ Everything is checked as the configuration is made. A typo'd key, a level that does not exist or
356
+ a value of the wrong type raises `InvalidConfigurationError` naming the path. A wrapper naming a
357
+ missing handler raises `UnknownHandlerError`, and wrappers nesting each other in a loop raise
358
+ `CircularHandlerReferenceError`.
359
+
360
+ Handler types: `stream`, `rotating_file`, `syslog`, `console`, `null`, `stdlib`, `service`,
361
+ `fingers_crossed`, `buffer`, `filter`, `deduplication`, `sampling`, `queue`, `group`,
362
+ `whatfailuregroup`, `fallbackgroup`. Processor types: `placeholder`, `context_vars`, `uid`,
363
+ `introspection`, `hostname`, `process_id`, `tags`, `service`.
364
+
365
+ The factory builds every handler once, as it is made, and shares each between channels. Files
366
+ open on first write. `reset()` ends a unit of work. `close()` writes whatever is buffered or
367
+ queued; use the factory as a context manager, or close it on shutdown. `set_verbosity()` sets
368
+ every console handler at once, from command-line flags:
369
+
370
+ ```python
371
+ factory.set_verbosity(Verbosity.from_count(args.verbose, quiet=args.quiet, silent=args.silent))
372
+ ```
373
+
374
+ The map: `--silent` prints nothing, `-q`
375
+ errors and up, no flag warnings and up, `-v` notices, `-vv` info, `-vvv` everything.
376
+ `set_console_stream(stream, colors=...)` points every console handler at another stream — a
377
+ command's error output — with colours forced on or off. [xtr-console](https://github.com/xterr/python-xtr-console)
378
+ does both for every command it runs when its container provides the factory.
379
+
380
+ ## The standard library
381
+
382
+ Libraries you depend on — httpx, SQLAlchemy, uvicorn — log through `logging`. Left alone, their
383
+ records go wherever `logging` is set up to send them, and none of your channels see them. A
384
+ `capture` section brings them in:
385
+
386
+ ```toml
387
+ channels = ["db"]
388
+
389
+ [capture]
390
+ level = "warning" # the threshold for every stdlib logger not listed below
391
+
392
+ [capture.loggers]
393
+ httpx = "info" # a level of its own; httpx._client and every child follow it
394
+ "sqlalchemy.engine" = { level = "warning", channel = "db" } # and a channel of its own
395
+ ```
396
+
397
+ Captured records become records on a channel — `app` unless `channel` or a logger's entry says
398
+ otherwise — and go through its processors and handlers like any other: fingers-crossed, JSON,
399
+ files. `extra=` values become context, `exc_info` becomes `context["exception"]`, and the
400
+ original time is kept. A logger's entry covers its children; the most specific name wins.
401
+
402
+ **Nothing is written twice.** Capture does not add a handler next to the ones already there; it
403
+ takes the standard library's output over:
404
+
405
+ - every existing stdlib handler is moved aside — a root `StreamHandler` from `basicConfig`, one
406
+ a library put on its own logger — and every logger propagates to the root, where one capture
407
+ handler is the only output;
408
+ - a handler attached while capture is on — by `addHandler`, `basicConfig` or `dictConfig` — is
409
+ held aside rather than attached, and the capture's own handler cannot be removed;
410
+ - a record with nowhere else to go, from a logger reconfigured not to propagate, is captured
411
+ instead of printed raw by `logging.lastResort`.
412
+
413
+ The factory installs the capture as it is built and gives everything back — handlers, levels,
414
+ flags, `Logger.addHandler` itself — on `close()`. A `stdlib` handler, which sends records into
415
+ `logging`, cannot be combined with a capture: its records would come straight back and be lost,
416
+ so the configuration refuses it with `CaptureConflictError`.
417
+
418
+ Without a factory, `StdlibCapture` does the same, as a context manager or with `install()` and
419
+ `release()`:
420
+
421
+ ```python
422
+ from xtr_logging.bridge.stdlib import StdlibCapture
423
+
424
+ with StdlibCapture(logger, levels={"httpx": "info"}, routes={"sqlalchemy": db_logger}):
425
+ serve()
426
+ ```
427
+
428
+ The other way round, `StdlibHandler` hands records to a stdlib logger, keeping their time,
429
+ channel and context. `StdlibLogger` puts the interface in front of a plain `logging.Logger` for
430
+ code that keeps `logging` as its backend.
431
+
432
+ ## Wiring with a container
433
+
434
+ With the `wireup` extra, a service asks for the default channel by the interface, and for any
435
+ other by qualifying it with the channel's name:
436
+
437
+ ```python
438
+ from typing import Annotated
439
+
440
+ from wireup import Inject, injectable
441
+
442
+ from xtr_logging_contracts import LoggerInterface
443
+
444
+
445
+ @injectable
446
+ class Checkout:
447
+ def __init__(
448
+ self,
449
+ logger: LoggerInterface,
450
+ audit: Annotated[LoggerInterface, Inject(qualifier="security")],
451
+ ) -> None: ...
452
+ ```
453
+
454
+ ```python
455
+ from xtr_logging.integration import wireup as logging_integration
456
+
457
+ container = wireup.create_async_container(
458
+ injectables=[app.services, *logging_integration.injectables(CONFIG)],
459
+ )
460
+ ```
461
+
462
+ The container also provides the `LoggerFactory`, to close or reset it.
463
+
464
+ ## Time
465
+
466
+ Every record is stamped by an [xtr-clock](https://github.com/xterr/python-xtr-clock)
467
+ `ClockInterface`. Pass one as `clock=` to a `Logger`, a `LoggerFactory` or a
468
+ `DeduplicationHandler`; without one they read whichever clock is in force, so a test freezes
469
+ every record's time without touching the logger:
470
+
471
+ ```python
472
+ from xtr_clock import Clock, MockClock
473
+
474
+ with Clock.using(MockClock("2026-09-24 12:00:00")):
475
+ logger.info("frozen") # stamped 2026-09-24 12:00:00+00:00
476
+ ```
477
+
478
+ Records captured from the standard library keep their original time, in the local zone like a
479
+ native record.
480
+
481
+ ## Testing your application
482
+
483
+ `TestHandler` keeps what it handles:
484
+
485
+ ```python
486
+ from xtr_logging import Logger, TestHandler
487
+ from xtr_logging_contracts import Level
488
+
489
+ handler = TestHandler()
490
+ checkout = Checkout(Logger("app", [handler]))
491
+
492
+ checkout.pay(order)
493
+
494
+ assert handler.has_record_that_contains("payment", Level.ERROR)
495
+ ```
496
+
497
+ It also answers `has_records(level)`, `has_record(message, level, context)`,
498
+ `has_record_that_matches(pattern, level)` and `has_record_that_passes(predicate, level)`, and
499
+ keeps the rendered text in `formatted`. With a factory, configure a `service` handler and pass a
500
+ `TestHandler`, or fetch any configured handler with `factory.handler(name)`.
501
+
502
+ ## Errors
503
+
504
+ Everything the library raises derives from `LoggingError` and carries typed attributes.
505
+
506
+ | Error | Raised when |
507
+ | --- | --- |
508
+ | `InvalidLevelError` | A value names no level (also a `ValueError`) |
509
+ | `InvalidOptionError` | An option has a value its handler or processor cannot use |
510
+ | `EmptyStackError` | A handler or processor is popped from an empty stack |
511
+ | `InvalidConfigurationError` | Configuration data does not fit, with the path to what is wrong |
512
+ | `MixedChannelFilterError` | A channel list mixes `foo` and `!foo` |
513
+ | `UnknownChannelError` | A logger or processor names a channel that is not declared |
514
+ | `UnknownHandlerError` | A wrapper, processor or lookup names a handler that is not defined |
515
+ | `CircularHandlerReferenceError` | Wrappers nest each other in a loop |
516
+ | `UnknownServiceError` | Configuration names a service that was not supplied |
517
+ | `NotProcessableHandlerError` | A processor targets a handler that runs none |
518
+ | `CaptureConflictError` | A `stdlib` handler is configured while capture is on |
519
+
520
+ ## Development
521
+
522
+ Developed in the [python-xtr](https://github.com/xterr/python-xtr) monorepo, under
523
+ `packages/xtr-logging`; run the commands below from there. The `python-xtr-logging` repository is a
524
+ read-only copy, so send issues and pull requests to the monorepo.
525
+
526
+ ```sh
527
+ uv sync --all-extras
528
+ uv run ruff check . && uv run ruff format --check .
529
+ uv run basedpyright
530
+ uv run ty check
531
+ uv run pytest
532
+ ```
533
+
534
+ ## License
535
+
536
+ MIT — see [LICENSE](LICENSE).