logdruid 0.1.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.
- logdruid/__init__.py +22 -0
- logdruid/logdruid.py +720 -0
- logdruid-0.1.0.dist-info/METADATA +78 -0
- logdruid-0.1.0.dist-info/RECORD +6 -0
- logdruid-0.1.0.dist-info/WHEEL +4 -0
- logdruid-0.1.0.dist-info/licenses/LICENSE +21 -0
logdruid/__init__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""A logging setup layer for multi-process applications, built on loguru.
|
|
2
|
+
|
|
3
|
+
Reporting uses loguru's own logger unchanged. logdruid sets it up and adds
|
|
4
|
+
one time-ordered output for all processes, the takeover of the standard
|
|
5
|
+
``logging`` module, and a configuration file of its own.
|
|
6
|
+
|
|
7
|
+
Documentation: https://fherb2.github.io/logdruid/
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from .logdruid import (
|
|
11
|
+
CONFIG_FILE_NAME,
|
|
12
|
+
LogLink,
|
|
13
|
+
attach,
|
|
14
|
+
check_config,
|
|
15
|
+
link,
|
|
16
|
+
logger,
|
|
17
|
+
setup,
|
|
18
|
+
shutdown,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
__all__ = ["logger", "setup", "check_config", "link", "attach", "shutdown",
|
|
22
|
+
"LogLink", "CONFIG_FILE_NAME"]
|
logdruid/logdruid.py
ADDED
|
@@ -0,0 +1,720 @@
|
|
|
1
|
+
"""Implementation of the logdruid package.
|
|
2
|
+
|
|
3
|
+
Public interface and usage: see the package docstring in
|
|
4
|
+
``logdruid/__init__.py``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import atexit
|
|
8
|
+
import copy
|
|
9
|
+
import heapq
|
|
10
|
+
import inspect
|
|
11
|
+
import itertools
|
|
12
|
+
import logging
|
|
13
|
+
import multiprocessing as mp
|
|
14
|
+
import sys
|
|
15
|
+
import threading
|
|
16
|
+
import time
|
|
17
|
+
import tomllib
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from multiprocessing.connection import wait
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any, Dict, List, Optional
|
|
22
|
+
|
|
23
|
+
from loguru import logger
|
|
24
|
+
|
|
25
|
+
# --- Module parameters ------------------------------------------------
|
|
26
|
+
|
|
27
|
+
CONFIG_FILE_NAME = "logdruid.toml"
|
|
28
|
+
"""Name of the configuration file setup() looks for next to the entry point."""
|
|
29
|
+
|
|
30
|
+
# Name under which logdruid writes its own warnings. They bypass the
|
|
31
|
+
# minimum levels: a level set too high must not hide a configuration error.
|
|
32
|
+
OWN_NAME = "logdruid"
|
|
33
|
+
|
|
34
|
+
# Lowest layer: what applies when neither API, file nor command line
|
|
35
|
+
# say anything. No translation, one level for everything.
|
|
36
|
+
MODULE_DEFAULTS: Dict[str, Any] = {
|
|
37
|
+
"std_level": "INFO",
|
|
38
|
+
"sort_window_ms": 100,
|
|
39
|
+
"output": {
|
|
40
|
+
"console": {"enabled": True, "diagnose": False},
|
|
41
|
+
"file": {"enabled": False, "diagnose": False},
|
|
42
|
+
},
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
# Keys of an output table that logdruid handles itself.
|
|
46
|
+
OWN_OUTPUT_KEYS = {"enabled", "path"}
|
|
47
|
+
|
|
48
|
+
# Parameters of loguru's add() that logdruid sets itself or forbids:
|
|
49
|
+
# level and filter come from the configuration, the sink from the table,
|
|
50
|
+
# and enqueue would bring back the shared lock.
|
|
51
|
+
RESERVED_OUTPUT_KEYS = {"sink", "level", "filter", "enqueue", "context"}
|
|
52
|
+
|
|
53
|
+
# Parameters loguru accepts for file sinks only (loguru API, add()).
|
|
54
|
+
FILE_SINK_KEYS = {"rotation", "retention", "compression", "delay", "watch",
|
|
55
|
+
"mode", "buffering", "encoding"}
|
|
56
|
+
|
|
57
|
+
# Format of the handlers that collect or send messages. loguru appends a
|
|
58
|
+
# newline and the formatted exception to every string format by itself,
|
|
59
|
+
# so the text carries the traceback, which cannot be sent as an object.
|
|
60
|
+
COLLECT_FORMAT = "{message}"
|
|
61
|
+
|
|
62
|
+
# Upper bound on reading the pipes to their end in shutdown(). They end
|
|
63
|
+
# by themselves once the children have ended; this only keeps a forgotten
|
|
64
|
+
# release() from hanging the exit.
|
|
65
|
+
SHUTDOWN_TIMEOUT_S = 5.0
|
|
66
|
+
|
|
67
|
+
# Longest wait for a pipe to become readable when no message is due. It
|
|
68
|
+
# bounds how late a newly linked pipe is watched.
|
|
69
|
+
COLLECT_POLL_S = 0.05
|
|
70
|
+
|
|
71
|
+
# --- Module state -----------------------------------------------------
|
|
72
|
+
|
|
73
|
+
# Resolved configuration of this process; set by setup() or attach().
|
|
74
|
+
_settings: Optional["Settings"] = None
|
|
75
|
+
|
|
76
|
+
# Threshold per (process, name), filled on first use.
|
|
77
|
+
_threshold_cache: Dict[tuple, int] = {}
|
|
78
|
+
|
|
79
|
+
# Main process only: the collector and the output logger it writes to.
|
|
80
|
+
_collector: Optional["_Collector"] = None
|
|
81
|
+
_output = None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass
|
|
85
|
+
class Settings:
|
|
86
|
+
"""Resolved configuration, small enough to go to a child process.
|
|
87
|
+
|
|
88
|
+
Attributes
|
|
89
|
+
----------
|
|
90
|
+
layers : list of dict
|
|
91
|
+
One entry per layer, highest priority first. Each holds
|
|
92
|
+
``std_level`` (level number or None), ``processes`` (process
|
|
93
|
+
name -> level number) and ``loggers`` (name -> dict with an
|
|
94
|
+
optional ``level`` number and an optional ``translate`` mapping
|
|
95
|
+
of level names to level names or False).
|
|
96
|
+
sort_window_s : float
|
|
97
|
+
Ordering window in seconds; 0 switches the ordering off.
|
|
98
|
+
outputs : dict
|
|
99
|
+
Enabled outputs ('console', 'file') with the keyword arguments
|
|
100
|
+
for loguru's add(), the file path under 'path'.
|
|
101
|
+
backtrace : bool
|
|
102
|
+
Passed to the handlers that format exceptions where they arise.
|
|
103
|
+
diagnose : bool
|
|
104
|
+
Same, for the display of variable values in tracebacks.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
layers: List[Dict[str, Any]]
|
|
108
|
+
sort_window_s: float
|
|
109
|
+
outputs: Dict[str, Dict[str, Any]]
|
|
110
|
+
backtrace: bool
|
|
111
|
+
diagnose: bool
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass
|
|
115
|
+
class LogLink:
|
|
116
|
+
"""What a child process needs to log into the main process.
|
|
117
|
+
|
|
118
|
+
Created by link() in the main process and handed to the child as an
|
|
119
|
+
argument; the child passes it to attach(). Its fields are internal;
|
|
120
|
+
only release() is part of the public interface.
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
process_name: str
|
|
124
|
+
conn: Any
|
|
125
|
+
settings: Settings
|
|
126
|
+
|
|
127
|
+
def release(self) -> None:
|
|
128
|
+
"""Drop the main process's copy of the write end.
|
|
129
|
+
|
|
130
|
+
Call right after the child process has started. Without it the
|
|
131
|
+
pipe never reaches its end, and shutdown() waits for nothing.
|
|
132
|
+
"""
|
|
133
|
+
self.conn.close()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# --- Public functions -------------------------------------------------
|
|
137
|
+
|
|
138
|
+
def setup(config_path: Optional[Path] = None, *,
|
|
139
|
+
defaults: Optional[Dict[str, Any]] = None,
|
|
140
|
+
cli_level: Optional[str] = None,
|
|
141
|
+
cli_log_file: Optional[str] = None,
|
|
142
|
+
process_name: str = "main") -> None:
|
|
143
|
+
"""Set up logging in the main process.
|
|
144
|
+
|
|
145
|
+
Call once, before any child process is created. Problems in the
|
|
146
|
+
configuration are reported as warnings once the output exists; they
|
|
147
|
+
never stop the application.
|
|
148
|
+
|
|
149
|
+
Parameters
|
|
150
|
+
----------
|
|
151
|
+
config_path : Path, optional
|
|
152
|
+
Configuration file. Without it, ``logdruid.toml`` is looked for
|
|
153
|
+
next to the entry point of the application.
|
|
154
|
+
defaults : dict, optional
|
|
155
|
+
Settings of the application, laid over the module defaults and
|
|
156
|
+
under the file; same layout as the file.
|
|
157
|
+
cli_level : str, optional
|
|
158
|
+
Level of everything, from the command line. Overrides every
|
|
159
|
+
level of file and API; translations stay.
|
|
160
|
+
cli_log_file : str, optional
|
|
161
|
+
Log file from the command line. Takes effect only when the file
|
|
162
|
+
output is enabled.
|
|
163
|
+
process_name : str
|
|
164
|
+
Name this process carries in its messages.
|
|
165
|
+
"""
|
|
166
|
+
global _settings, _collector, _output
|
|
167
|
+
|
|
168
|
+
mp.current_process().name = process_name
|
|
169
|
+
warnings: List[str] = []
|
|
170
|
+
_settings = _load_settings(config_path, defaults, cli_level, cli_log_file,
|
|
171
|
+
warnings)
|
|
172
|
+
_threshold_cache.clear()
|
|
173
|
+
|
|
174
|
+
logger.remove()
|
|
175
|
+
_output = copy.deepcopy(logger)
|
|
176
|
+
_add_outputs(_output, _settings, warnings)
|
|
177
|
+
|
|
178
|
+
_collector = _Collector(_output, _settings.sort_window_s)
|
|
179
|
+
_collector.start()
|
|
180
|
+
logger.add(_collector.collect, level=_min_threshold(process_name),
|
|
181
|
+
filter=_level_filter, format=COLLECT_FORMAT,
|
|
182
|
+
backtrace=_settings.backtrace, diagnose=_settings.diagnose)
|
|
183
|
+
_take_over_std_logging(process_name)
|
|
184
|
+
atexit.register(shutdown)
|
|
185
|
+
|
|
186
|
+
for text in warnings:
|
|
187
|
+
_output.patch(_own_name).warning(text)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def check_config(config_path: Optional[Path] = None, *,
|
|
191
|
+
defaults: Optional[Dict[str, Any]] = None,
|
|
192
|
+
cli_level: Optional[str] = None,
|
|
193
|
+
cli_log_file: Optional[str] = None) -> List[str]:
|
|
194
|
+
"""Check a configuration without setting anything up.
|
|
195
|
+
|
|
196
|
+
Resolves the same layers as setup() and returns the warnings setup()
|
|
197
|
+
would report about them. Whether an output can actually be created,
|
|
198
|
+
for example whether the path of the log file is writable, only setup()
|
|
199
|
+
finds out.
|
|
200
|
+
|
|
201
|
+
Parameters
|
|
202
|
+
----------
|
|
203
|
+
config_path, defaults, cli_level, cli_log_file
|
|
204
|
+
As for setup().
|
|
205
|
+
|
|
206
|
+
Returns
|
|
207
|
+
-------
|
|
208
|
+
list of str
|
|
209
|
+
The warnings; empty if the configuration is sound.
|
|
210
|
+
"""
|
|
211
|
+
warnings: List[str] = []
|
|
212
|
+
_load_settings(config_path, defaults, cli_level, cli_log_file, warnings)
|
|
213
|
+
return warnings
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def link(process_name: str) -> LogLink:
|
|
217
|
+
"""Prepare logging for one child process; main process only.
|
|
218
|
+
|
|
219
|
+
Parameters
|
|
220
|
+
----------
|
|
221
|
+
process_name : str
|
|
222
|
+
Name the child process will carry.
|
|
223
|
+
|
|
224
|
+
Returns
|
|
225
|
+
-------
|
|
226
|
+
LogLink
|
|
227
|
+
To be handed to the child process as an argument.
|
|
228
|
+
"""
|
|
229
|
+
if _collector is None:
|
|
230
|
+
raise RuntimeError("logdruid.setup() has to come first")
|
|
231
|
+
read_end, write_end = mp.get_context("spawn").Pipe(duplex=False)
|
|
232
|
+
_collector.add_pipe(read_end, process_name)
|
|
233
|
+
return LogLink(process_name, write_end, _settings)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def attach(log_link: LogLink) -> None:
|
|
237
|
+
"""Set up logging in a child process.
|
|
238
|
+
|
|
239
|
+
Call first in the process function, after ignoring SIGINT. Replaces
|
|
240
|
+
loguru's default output by a handler that writes into the pipe of
|
|
241
|
+
this process, and takes over the standard ``logging`` module.
|
|
242
|
+
|
|
243
|
+
Parameters
|
|
244
|
+
----------
|
|
245
|
+
log_link : LogLink
|
|
246
|
+
Handed over by the main process.
|
|
247
|
+
"""
|
|
248
|
+
global _settings
|
|
249
|
+
|
|
250
|
+
mp.current_process().name = log_link.process_name
|
|
251
|
+
_settings = log_link.settings
|
|
252
|
+
_threshold_cache.clear()
|
|
253
|
+
logger.remove()
|
|
254
|
+
logger.add(_PipeSink(log_link.conn),
|
|
255
|
+
level=_min_threshold(log_link.process_name),
|
|
256
|
+
filter=_level_filter, format=COLLECT_FORMAT,
|
|
257
|
+
backtrace=_settings.backtrace, diagnose=_settings.diagnose)
|
|
258
|
+
_take_over_std_logging(log_link.process_name)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def shutdown() -> None:
|
|
262
|
+
"""Write out everything and close the output; main process only.
|
|
263
|
+
|
|
264
|
+
Call at the end, after the child processes have ended. Reads every
|
|
265
|
+
pipe to its end, writes what is still held back, and from then on
|
|
266
|
+
writes the main process's messages directly, unsorted. Safe to call
|
|
267
|
+
more than once.
|
|
268
|
+
"""
|
|
269
|
+
if _collector is None or not _collector.is_alive():
|
|
270
|
+
return
|
|
271
|
+
_collector.stop(SHUTDOWN_TIMEOUT_S)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
# --- Configuration ----------------------------------------------------
|
|
275
|
+
|
|
276
|
+
def _load_settings(config_path: Optional[Path],
|
|
277
|
+
defaults: Optional[Dict[str, Any]],
|
|
278
|
+
cli_level: Optional[str],
|
|
279
|
+
cli_log_file: Optional[str],
|
|
280
|
+
warnings: List[str]) -> Settings:
|
|
281
|
+
"""Resolve the four layers into Settings, collecting the warnings."""
|
|
282
|
+
file_layer = _read_config_file(config_path, warnings)
|
|
283
|
+
cli_layer: Dict[str, Any] = {}
|
|
284
|
+
if cli_level is not None:
|
|
285
|
+
cli_layer["std_level"] = cli_level
|
|
286
|
+
if cli_log_file is not None:
|
|
287
|
+
cli_layer["output"] = {"file": {"path": cli_log_file}}
|
|
288
|
+
raw_layers = [cli_layer, file_layer, defaults or {}, MODULE_DEFAULTS]
|
|
289
|
+
return _resolve(raw_layers, warnings)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _read_config_file(config_path: Optional[Path],
|
|
293
|
+
warnings: List[str]) -> Dict[str, Any]:
|
|
294
|
+
"""Return the file layer, or an empty one with a warning."""
|
|
295
|
+
if config_path is None:
|
|
296
|
+
main_file = getattr(sys.modules.get("__main__"), "__file__", None)
|
|
297
|
+
if main_file is None:
|
|
298
|
+
warnings.append(f"No entry point file, so no {CONFIG_FILE_NAME} "
|
|
299
|
+
f"is looked for; using API and defaults only")
|
|
300
|
+
return {}
|
|
301
|
+
config_path = Path(main_file).resolve().parent / CONFIG_FILE_NAME
|
|
302
|
+
try:
|
|
303
|
+
with open(config_path, "rb") as file:
|
|
304
|
+
return tomllib.load(file)
|
|
305
|
+
except FileNotFoundError:
|
|
306
|
+
warnings.append(f"{config_path} not found; using API and defaults "
|
|
307
|
+
f"only")
|
|
308
|
+
except (OSError, tomllib.TOMLDecodeError) as exc:
|
|
309
|
+
warnings.append(f"{config_path} cannot be read ({exc}); using API "
|
|
310
|
+
f"and defaults only")
|
|
311
|
+
return {}
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _resolve(raw_layers: List[Dict[str, Any]],
|
|
315
|
+
warnings: List[str]) -> Settings:
|
|
316
|
+
"""Turn the raw layers, highest first, into Settings."""
|
|
317
|
+
layers = [_resolve_levels(raw, warnings) for raw in raw_layers]
|
|
318
|
+
|
|
319
|
+
window_ms = next((raw["sort_window_ms"] for raw in raw_layers
|
|
320
|
+
if "sort_window_ms" in raw), 0)
|
|
321
|
+
if not isinstance(window_ms, (int, float)) or window_ms < 0:
|
|
322
|
+
warnings.append(f"sort_window_ms must be a number >= 0, not "
|
|
323
|
+
f"{window_ms!r}; ordering switched off")
|
|
324
|
+
window_ms = 0
|
|
325
|
+
|
|
326
|
+
outputs: Dict[str, Dict[str, Any]] = {}
|
|
327
|
+
for kind in ("console", "file"):
|
|
328
|
+
merged: Dict[str, Any] = {}
|
|
329
|
+
for raw in reversed(raw_layers):
|
|
330
|
+
merged.update(raw.get("output", {}).get(kind, {}))
|
|
331
|
+
if merged.pop("enabled", False):
|
|
332
|
+
outputs[kind] = _checked_output(kind, merged, warnings)
|
|
333
|
+
if "file" in outputs and not outputs["file"].get("path"):
|
|
334
|
+
warnings.append("File output enabled without a path; switched off")
|
|
335
|
+
del outputs["file"]
|
|
336
|
+
if not outputs:
|
|
337
|
+
warnings.append("No output enabled; writing to the console")
|
|
338
|
+
outputs["console"] = {}
|
|
339
|
+
|
|
340
|
+
backtrace = any(params.pop("backtrace", True)
|
|
341
|
+
for params in outputs.values())
|
|
342
|
+
diagnose = any(params.pop("diagnose", False)
|
|
343
|
+
for params in outputs.values())
|
|
344
|
+
return Settings(layers, window_ms / 1000, outputs, backtrace, diagnose)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _resolve_levels(raw: Dict[str, Any],
|
|
348
|
+
warnings: List[str]) -> Dict[str, Any]:
|
|
349
|
+
"""Pick the levels and translations out of one raw layer."""
|
|
350
|
+
known = {"std_level", "sort_window_ms", "output", "processes", "loggers"}
|
|
351
|
+
for key in raw.keys() - known:
|
|
352
|
+
warnings.append(f"Unknown logging setting '{key}' ignored")
|
|
353
|
+
|
|
354
|
+
layer: Dict[str, Any] = {
|
|
355
|
+
"std_level": _level_no(raw.get("std_level"), "std_level", warnings),
|
|
356
|
+
"processes": {},
|
|
357
|
+
"loggers": {},
|
|
358
|
+
}
|
|
359
|
+
for name, entry in raw.get("processes", {}).items():
|
|
360
|
+
for key in entry.keys() - {"level"}:
|
|
361
|
+
warnings.append(f"Unknown key '{key}' for process '{name}' "
|
|
362
|
+
f"ignored")
|
|
363
|
+
level = _level_no(entry.get("level"), f"process '{name}'", warnings)
|
|
364
|
+
if level is not None:
|
|
365
|
+
layer["processes"][name] = level
|
|
366
|
+
for name, entry in raw.get("loggers", {}).items():
|
|
367
|
+
for key in entry.keys() - {"level", "translate"}:
|
|
368
|
+
warnings.append(f"Unknown key '{key}' for logger '{name}' "
|
|
369
|
+
f"ignored")
|
|
370
|
+
resolved: Dict[str, Any] = {}
|
|
371
|
+
level = _level_no(entry.get("level"), f"logger '{name}'", warnings)
|
|
372
|
+
if level is not None:
|
|
373
|
+
resolved["level"] = level
|
|
374
|
+
if "translate" in entry:
|
|
375
|
+
resolved["translate"] = _checked_translation(
|
|
376
|
+
name, entry["translate"], warnings)
|
|
377
|
+
if resolved:
|
|
378
|
+
layer["loggers"][name] = resolved
|
|
379
|
+
return layer
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _level_no(level: Any, what: str, warnings: List[str]) -> Optional[int]:
|
|
383
|
+
"""Return the number of a level name, or None with a warning."""
|
|
384
|
+
if level is None or level == "":
|
|
385
|
+
return None
|
|
386
|
+
try:
|
|
387
|
+
return logger.level(str(level).upper()).no
|
|
388
|
+
except ValueError:
|
|
389
|
+
warnings.append(f"Unknown level '{level}' for {what} ignored")
|
|
390
|
+
return None
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _checked_translation(name: str, translation: Any,
|
|
394
|
+
warnings: List[str]) -> Dict[str, Any]:
|
|
395
|
+
"""Keep the valid pairs of a translation: level name -> name or False."""
|
|
396
|
+
checked: Dict[str, Any] = {}
|
|
397
|
+
if not isinstance(translation, dict):
|
|
398
|
+
warnings.append(f"translate of logger '{name}' must be a table; "
|
|
399
|
+
f"ignored")
|
|
400
|
+
return checked
|
|
401
|
+
for source, target in translation.items():
|
|
402
|
+
if target is False:
|
|
403
|
+
checked[source.upper()] = False
|
|
404
|
+
elif _level_no(target, f"translation {source} of logger '{name}'",
|
|
405
|
+
warnings) is not None:
|
|
406
|
+
checked[source.upper()] = str(target).upper()
|
|
407
|
+
return checked
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _checked_output(kind: str, params: Dict[str, Any],
|
|
411
|
+
warnings: List[str]) -> Dict[str, Any]:
|
|
412
|
+
"""Keep the parameters loguru accepts for this kind of output."""
|
|
413
|
+
allowed = set(inspect.signature(logger.add).parameters) - {"kwargs"}
|
|
414
|
+
allowed -= RESERVED_OUTPUT_KEYS
|
|
415
|
+
allowed |= OWN_OUTPUT_KEYS
|
|
416
|
+
if kind == "file":
|
|
417
|
+
allowed |= FILE_SINK_KEYS
|
|
418
|
+
else:
|
|
419
|
+
allowed -= {"path"}
|
|
420
|
+
checked = {}
|
|
421
|
+
for key, value in params.items():
|
|
422
|
+
if key in allowed:
|
|
423
|
+
checked[key] = value
|
|
424
|
+
else:
|
|
425
|
+
warnings.append(f"Parameter '{key}' of output '{kind}' ignored")
|
|
426
|
+
return checked
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _add_outputs(output, settings: Settings, warnings: List[str]) -> None:
|
|
430
|
+
"""Create console and file handlers on the output logger."""
|
|
431
|
+
created = 0
|
|
432
|
+
for kind, params in settings.outputs.items():
|
|
433
|
+
params = dict(params, backtrace=settings.backtrace,
|
|
434
|
+
diagnose=settings.diagnose)
|
|
435
|
+
sink = params.pop("path") if kind == "file" else sys.stderr
|
|
436
|
+
try:
|
|
437
|
+
output.add(sink, level=0, **params)
|
|
438
|
+
created += 1
|
|
439
|
+
except (OSError, TypeError, ValueError) as exc:
|
|
440
|
+
warnings.append(f"Output '{kind}' could not be created ({exc}); "
|
|
441
|
+
f"writing to the console")
|
|
442
|
+
if not created:
|
|
443
|
+
output.add(sys.stderr, level=0)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
# --- Levels -----------------------------------------------------------
|
|
447
|
+
|
|
448
|
+
def _threshold(process: str, name: Optional[str]) -> int:
|
|
449
|
+
"""Level a message of this process and name must at least have."""
|
|
450
|
+
key = (process, name)
|
|
451
|
+
if key not in _threshold_cache:
|
|
452
|
+
_threshold_cache[key] = _lookup_threshold(process, name or "")
|
|
453
|
+
return _threshold_cache[key]
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _lookup_threshold(process: str, name: str) -> int:
|
|
457
|
+
"""Walk the layers, highest first; the first one with a value wins."""
|
|
458
|
+
for layer in _settings.layers:
|
|
459
|
+
entry = _longest_match(layer["loggers"], name, "level")
|
|
460
|
+
if entry is not None:
|
|
461
|
+
return entry
|
|
462
|
+
if process in layer["processes"]:
|
|
463
|
+
return layer["processes"][process]
|
|
464
|
+
if layer["std_level"] is not None:
|
|
465
|
+
return layer["std_level"]
|
|
466
|
+
return 0
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _longest_match(entries: Dict[str, Dict[str, Any]], name: str,
|
|
470
|
+
field: str) -> Any:
|
|
471
|
+
"""Return the field of the entry for name or its nearest parent."""
|
|
472
|
+
while True:
|
|
473
|
+
if name in entries and field in entries[name]:
|
|
474
|
+
return entries[name][field]
|
|
475
|
+
if not name:
|
|
476
|
+
return None
|
|
477
|
+
cut = name.rfind(".")
|
|
478
|
+
name = name[:cut] if cut != -1 else ""
|
|
479
|
+
if not name:
|
|
480
|
+
return None
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _min_threshold(process: str) -> int:
|
|
484
|
+
"""Lowest level any message of this process could pass with."""
|
|
485
|
+
candidates = []
|
|
486
|
+
for layer in _settings.layers:
|
|
487
|
+
candidates += [entry["level"] for entry in layer["loggers"].values()
|
|
488
|
+
if "level" in entry]
|
|
489
|
+
if process in layer["processes"]:
|
|
490
|
+
candidates.append(layer["processes"][process])
|
|
491
|
+
break
|
|
492
|
+
if layer["std_level"] is not None:
|
|
493
|
+
candidates.append(layer["std_level"])
|
|
494
|
+
break
|
|
495
|
+
return min(candidates, default=0)
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def _level_filter(record: Dict[str, Any]) -> bool:
|
|
499
|
+
"""loguru filter: pass what reaches the threshold of its origin."""
|
|
500
|
+
return record["level"].no >= _threshold(record["process"].name,
|
|
501
|
+
record["name"])
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _translation(name: str) -> Dict[str, Any]:
|
|
505
|
+
"""Translation for a logger: from the highest layer that has one."""
|
|
506
|
+
for layer in _settings.layers:
|
|
507
|
+
found = _longest_match(layer["loggers"], name, "translate")
|
|
508
|
+
if found is not None:
|
|
509
|
+
return found
|
|
510
|
+
return {}
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
# --- Taking over the standard logging module ----------------------------
|
|
514
|
+
|
|
515
|
+
class _InterceptHandler(logging.Handler):
|
|
516
|
+
"""Hands every record of ``logging`` to loguru (loguru's own recipe).
|
|
517
|
+
|
|
518
|
+
The record keeps the logger name as its name, so that the entries
|
|
519
|
+
per logger apply; its level is translated first.
|
|
520
|
+
"""
|
|
521
|
+
|
|
522
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
523
|
+
"""Pass one record on, or drop it if its translation says so."""
|
|
524
|
+
target = _translation(record.name).get(record.levelname,
|
|
525
|
+
record.levelname)
|
|
526
|
+
if target is False:
|
|
527
|
+
return
|
|
528
|
+
try:
|
|
529
|
+
level = logger.level(target).name
|
|
530
|
+
except ValueError:
|
|
531
|
+
level = record.levelno
|
|
532
|
+
|
|
533
|
+
# Find the caller outside of logging, so that {function} and
|
|
534
|
+
# {line} point into the library that logged.
|
|
535
|
+
frame, depth = inspect.currentframe(), 0
|
|
536
|
+
while frame is not None and (depth == 0 or
|
|
537
|
+
frame.f_code.co_filename == logging.__file__):
|
|
538
|
+
frame = frame.f_back
|
|
539
|
+
depth += 1
|
|
540
|
+
|
|
541
|
+
logger.patch(lambda r: r.update(name=record.name)).opt(
|
|
542
|
+
depth=depth, exception=record.exc_info).log(
|
|
543
|
+
level, record.getMessage())
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _take_over_std_logging(process: str) -> None:
|
|
547
|
+
"""Route ``logging`` of this process into loguru."""
|
|
548
|
+
lowest = _min_threshold(process)
|
|
549
|
+
for layer in _settings.layers:
|
|
550
|
+
for entry in layer["loggers"].values():
|
|
551
|
+
for source, target in entry.get("translate", {}).items():
|
|
552
|
+
if target is not False and logger.level(target).no >= lowest:
|
|
553
|
+
std_no = logging.getLevelName(source)
|
|
554
|
+
if isinstance(std_no, int):
|
|
555
|
+
lowest = min(lowest, std_no)
|
|
556
|
+
logging.basicConfig(handlers=[_InterceptHandler()], level=lowest,
|
|
557
|
+
force=True)
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
# --- Transport between the processes ------------------------------------
|
|
561
|
+
|
|
562
|
+
def _own_name(record: Dict[str, Any]) -> None:
|
|
563
|
+
"""loguru patcher: mark a message as logdruid's own warning."""
|
|
564
|
+
record.update(name=OWN_NAME)
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def _to_item(message) -> Dict[str, Any]:
|
|
568
|
+
"""What travels for one message: its data, not loguru's objects."""
|
|
569
|
+
record = message.record
|
|
570
|
+
return {
|
|
571
|
+
"time": record["time"],
|
|
572
|
+
"level": record["level"].name,
|
|
573
|
+
"process": record["process"].name,
|
|
574
|
+
"pid": record["process"].id,
|
|
575
|
+
"name": record["name"],
|
|
576
|
+
"module": record["module"],
|
|
577
|
+
"function": record["function"],
|
|
578
|
+
"line": record["line"],
|
|
579
|
+
"text": str(message).rstrip("\n"),
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
class _PipeSink:
|
|
584
|
+
"""loguru sink of a child process: sends each message into its pipe.
|
|
585
|
+
|
|
586
|
+
Only this process writes into the pipe, so no lock is shared with
|
|
587
|
+
another process. loguru serialises the calls within the process.
|
|
588
|
+
"""
|
|
589
|
+
|
|
590
|
+
def __init__(self, conn):
|
|
591
|
+
self._conn = conn
|
|
592
|
+
|
|
593
|
+
def __call__(self, message) -> None:
|
|
594
|
+
self._conn.send(_to_item(message))
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
class _Collector(threading.Thread):
|
|
598
|
+
"""Main process: gathers all messages and writes them sorted by time.
|
|
599
|
+
|
|
600
|
+
Watches the pipes of the children with multiprocessing.connection.
|
|
601
|
+
wait(), takes the main process's own messages through collect(), and
|
|
602
|
+
writes a message once it is older than the ordering window. A
|
|
603
|
+
message that arrives later than that is written at once.
|
|
604
|
+
"""
|
|
605
|
+
|
|
606
|
+
def __init__(self, output, window_s: float):
|
|
607
|
+
super().__init__(name="logdruid-collector", daemon=True)
|
|
608
|
+
self._output = output
|
|
609
|
+
self._window_s = window_s
|
|
610
|
+
self._pipes: Dict[Any, str] = {}
|
|
611
|
+
self._heap: List[tuple] = []
|
|
612
|
+
self._order = itertools.count()
|
|
613
|
+
self._lock = threading.Lock()
|
|
614
|
+
self._stopping = False
|
|
615
|
+
|
|
616
|
+
def add_pipe(self, conn, process_name: str) -> None:
|
|
617
|
+
"""Watch the read end of a child's pipe from now on."""
|
|
618
|
+
with self._lock:
|
|
619
|
+
self._pipes[conn] = process_name
|
|
620
|
+
|
|
621
|
+
def collect(self, message) -> None:
|
|
622
|
+
"""loguru sink of the main process: queue its own messages."""
|
|
623
|
+
item = _to_item(message)
|
|
624
|
+
if self.is_alive():
|
|
625
|
+
self._push(item)
|
|
626
|
+
else:
|
|
627
|
+
self._write(item)
|
|
628
|
+
|
|
629
|
+
def stop(self, timeout_s: float) -> None:
|
|
630
|
+
"""Read the pipes to their end, write everything, then return."""
|
|
631
|
+
self._stopping = True
|
|
632
|
+
self.join(timeout_s)
|
|
633
|
+
if self.is_alive():
|
|
634
|
+
self._output.patch(_own_name).warning(
|
|
635
|
+
f"Logging pipes of {sorted(self._pipes.values())} did not "
|
|
636
|
+
f"end within {timeout_s} s; their remaining messages are lost")
|
|
637
|
+
with self._lock:
|
|
638
|
+
self._flush(due_only=False)
|
|
639
|
+
|
|
640
|
+
def run(self) -> None:
|
|
641
|
+
"""Read, order and write until stop() and the end of every pipe."""
|
|
642
|
+
while True:
|
|
643
|
+
with self._lock:
|
|
644
|
+
pipes = list(self._pipes)
|
|
645
|
+
if self._stopping and not pipes:
|
|
646
|
+
break
|
|
647
|
+
for conn in (wait(pipes, timeout=self._poll_time()) if pipes
|
|
648
|
+
else self._sleep()):
|
|
649
|
+
self._read(conn)
|
|
650
|
+
with self._lock:
|
|
651
|
+
self._flush(due_only=True)
|
|
652
|
+
with self._lock:
|
|
653
|
+
self._flush(due_only=False)
|
|
654
|
+
|
|
655
|
+
def _poll_time(self) -> float:
|
|
656
|
+
"""How long to wait: until the next message is due, at most a bit."""
|
|
657
|
+
with self._lock:
|
|
658
|
+
if not self._heap:
|
|
659
|
+
return COLLECT_POLL_S
|
|
660
|
+
due = self._heap[0][0] + self._window_s - time.time()
|
|
661
|
+
return max(0.0, min(due, COLLECT_POLL_S))
|
|
662
|
+
|
|
663
|
+
def _sleep(self) -> list:
|
|
664
|
+
"""Wait a little when no pipe is open yet; return nothing to read."""
|
|
665
|
+
time.sleep(COLLECT_POLL_S)
|
|
666
|
+
return []
|
|
667
|
+
|
|
668
|
+
def _read(self, conn) -> None:
|
|
669
|
+
"""Take one message from a pipe, or drop the pipe at its end."""
|
|
670
|
+
try:
|
|
671
|
+
item = conn.recv()
|
|
672
|
+
except EOFError:
|
|
673
|
+
self._drop(conn, None)
|
|
674
|
+
return
|
|
675
|
+
except Exception as exc:
|
|
676
|
+
# A child killed while sending leaves half a message behind.
|
|
677
|
+
self._drop(conn, exc)
|
|
678
|
+
return
|
|
679
|
+
self._push(item)
|
|
680
|
+
|
|
681
|
+
def _drop(self, conn, exc: Optional[Exception]) -> None:
|
|
682
|
+
"""Stop watching a pipe; report it if it did not end cleanly."""
|
|
683
|
+
with self._lock:
|
|
684
|
+
name = self._pipes.pop(conn, "?")
|
|
685
|
+
conn.close()
|
|
686
|
+
if exc is not None:
|
|
687
|
+
self._output.patch(_own_name).warning(
|
|
688
|
+
f"Logging pipe of {name} broke off ({type(exc).__name__}); "
|
|
689
|
+
f"later messages of that process are lost")
|
|
690
|
+
|
|
691
|
+
def _push(self, item: Dict[str, Any]) -> None:
|
|
692
|
+
"""Queue a message by its time stamp."""
|
|
693
|
+
with self._lock:
|
|
694
|
+
heapq.heappush(self._heap, (item["time"].timestamp(),
|
|
695
|
+
next(self._order), item))
|
|
696
|
+
if self._window_s == 0:
|
|
697
|
+
with self._lock:
|
|
698
|
+
self._flush(due_only=False)
|
|
699
|
+
|
|
700
|
+
def _flush(self, due_only: bool) -> None:
|
|
701
|
+
"""Write queued messages; with due_only those past the window.
|
|
702
|
+
|
|
703
|
+
The caller holds the lock.
|
|
704
|
+
"""
|
|
705
|
+
limit = time.time() - self._window_s
|
|
706
|
+
while self._heap and (not due_only or self._heap[0][0] <= limit):
|
|
707
|
+
_, _, item = heapq.heappop(self._heap)
|
|
708
|
+
self._write(item)
|
|
709
|
+
|
|
710
|
+
def _write(self, item: Dict[str, Any]) -> None:
|
|
711
|
+
"""Write one message with the data it arose with."""
|
|
712
|
+
|
|
713
|
+
def restore(record):
|
|
714
|
+
record.update(time=item["time"], name=item["name"],
|
|
715
|
+
module=item["module"], function=item["function"],
|
|
716
|
+
line=item["line"],
|
|
717
|
+
process=type(record["process"])(item["pid"],
|
|
718
|
+
item["process"]))
|
|
719
|
+
|
|
720
|
+
self._output.patch(restore).log(item["level"], item["text"])
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: logdruid
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A drop-in logging setup layer for multi-process applications, built on loguru.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Author: github.com/fherb2
|
|
8
|
+
Requires-Python: >=3.11,<4.0
|
|
9
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
10
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Requires-Dist: loguru (>=0.7.3)
|
|
16
|
+
Project-URL: Changelog, https://fherb2.github.io/logdruid/changelog/
|
|
17
|
+
Project-URL: Documentation, https://fherb2.github.io/logdruid/
|
|
18
|
+
Project-URL: Issues, https://github.com/fherb2/logdruid/issues
|
|
19
|
+
Project-URL: Repository, https://github.com/fherb2/logdruid
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# logdruid
|
|
23
|
+
|
|
24
|
+
[](https://pypi.org/project/logdruid/)
|
|
25
|
+
[](https://github.com/fherb2/logdruid/actions/workflows/tests.yml)
|
|
26
|
+
|
|
27
|
+
A drop-in logging setup layer for multi-process applications, built on
|
|
28
|
+
[loguru](https://loguru.readthedocs.io/).
|
|
29
|
+
|
|
30
|
+
**[Documentation](https://fherb2.github.io/logdruid/)** – installation, usage, configuration and API.
|
|
31
|
+
|
|
32
|
+
## What it is for
|
|
33
|
+
|
|
34
|
+
loguru is a logging library for Python with exactly one global logger. In an
|
|
35
|
+
application with several processes, loguru alone reaches its limits: each
|
|
36
|
+
process started with `spawn` has its own logger with its own outputs; a
|
|
37
|
+
shared output across processes goes through a queue whose lock spans all
|
|
38
|
+
processes, so a process killed hard while holding it can block logging
|
|
39
|
+
everywhere; and messages from libraries that report through the standard
|
|
40
|
+
`logging` module do not appear in loguru's outputs.
|
|
41
|
+
|
|
42
|
+
logdruid is a setup layer around loguru for exactly this case: applications
|
|
43
|
+
– not libraries – with several processes. It does not build a logging API of
|
|
44
|
+
its own: you keep reporting with loguru's logger, unchanged. logdruid only
|
|
45
|
+
sets it up and adds what loguru does not offer: one shared output for all
|
|
46
|
+
processes, ordered by time, without a lock across process boundaries; taking
|
|
47
|
+
over the messages from `logging`; and a configuration in a file of its own.
|
|
48
|
+
|
|
49
|
+
For an application with a single process, loguru alone is enough, as it is
|
|
50
|
+
when each process may write to its own file and the order across processes
|
|
51
|
+
does not matter. Libraries should use neither loguru nor logdruid but report
|
|
52
|
+
through `logging`.
|
|
53
|
+
|
|
54
|
+
## Features
|
|
55
|
+
|
|
56
|
+
| Feature | loguru alone | with logdruid |
|
|
57
|
+
| --- | --- | --- |
|
|
58
|
+
| Reporting with `logger.info(...)` etc., levels `TRACE` to `CRITICAL` including `SUCCESS` | yes | yes, the same logger and the same API |
|
|
59
|
+
| Console and file, rotation and retention of the file, format, colours | yes | yes, set in the configuration file |
|
|
60
|
+
| Exceptions with extended tracebacks, optionally with variable values | yes | yes |
|
|
61
|
+
| All processes write into one shared output | yes, through a shared queue with a lock across all processes | yes, through a pipe of its own per child process, without a shared lock |
|
|
62
|
+
| A process killed hard does not hold up the others | not guaranteed | yes |
|
|
63
|
+
| Messages of all processes ordered by time | no, order of arrival | yes, with an adjustable time window |
|
|
64
|
+
| Minimum level per name of a message (module, logger) | yes, through a filter | yes |
|
|
65
|
+
| Minimum level per process | no | yes |
|
|
66
|
+
| Taking over messages from `logging` | only as a code example in loguru's documentation | yes, with the logger's name and with level translation per logger |
|
|
67
|
+
| Configuration from a file, precedence defaults < application < file < command line | no, only environment variables | yes |
|
|
68
|
+
| Mistakes in the configuration do not stop the application | no | yes, they only produce a warning |
|
|
69
|
+
|
|
70
|
+
## Documentation
|
|
71
|
+
|
|
72
|
+
Installation, usage, configuration, the API reference and the changelog:
|
|
73
|
+
<https://fherb2.github.io/logdruid/>
|
|
74
|
+
|
|
75
|
+
## License
|
|
76
|
+
|
|
77
|
+
MIT, see [LICENSE](https://github.com/fherb2/logdruid/blob/main/LICENSE).
|
|
78
|
+
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
logdruid/__init__.py,sha256=izeK9YrangccqGBbDRvY-2ASgvNbs6W_5l1Oon6nrjg,592
|
|
2
|
+
logdruid/logdruid.py,sha256=XyHL-_GvsI82qi_p-Yxd6Pu93vD1hY4nbwoMuexN8ic,26658
|
|
3
|
+
logdruid-0.1.0.dist-info/METADATA,sha256=c6SODE06K1gdmDMskLa42T0gfOhvrWV-n6e1jXRL4Rk,4088
|
|
4
|
+
logdruid-0.1.0.dist-info/WHEEL,sha256=L-WvLdSBvJWHXv5zrQxDvNRnIgoBzLUpcbuiw19KCuQ,88
|
|
5
|
+
logdruid-0.1.0.dist-info/licenses/LICENSE,sha256=g_J8h1P1M7gvuYF2KZVSNklcVtYDuw15TAf-o78Ngtg,1074
|
|
6
|
+
logdruid-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 github.com/fherb2
|
|
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.
|