logleaf 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.
logleaf/__init__.py ADDED
@@ -0,0 +1,24 @@
1
+ """Background logging. Stable entry points: log, Logger, BoundLogger and statistics."""
2
+
3
+ from typing import TYPE_CHECKING, Any
4
+
5
+ from ._models import LoggerStats, OutputStats
6
+ from .logger import BoundLogger, Logger
7
+ from .shared import log
8
+
9
+ # Keep old explicit/star imports working without loading the prototype for
10
+ # ordinary `from logleaf import log` users.
11
+ if TYPE_CHECKING:
12
+ from .experimental import BatchLogger, Stats
13
+
14
+ __all__ = ["log", "Logger", "BoundLogger", "LoggerStats", "OutputStats", "BatchLogger", "Stats"]
15
+
16
+
17
+ def __getattr__(name: str) -> Any:
18
+ if name in ("BatchLogger", "Stats"):
19
+ from . import experimental
20
+
21
+ value = getattr(experimental, name)
22
+ globals()[name] = value
23
+ return value
24
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
logleaf/_core.py ADDED
@@ -0,0 +1,344 @@
1
+ """Queue admission, writer lifecycle, output failure and loss accounting."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import os
7
+ import threading
8
+ import time
9
+ from collections import deque
10
+ from time import time_ns as clock_ns
11
+ from typing import Any
12
+
13
+ from ._models import LoggerStats, OutputStats, _Event, _ExceptionEvent, _Output
14
+ from .formatting import LEVELS, Formatter
15
+
16
+
17
+ class _Core:
18
+ def __init__(
19
+ self,
20
+ outputs: list[_Output],
21
+ formatter: Formatter,
22
+ timestamp_mode: str,
23
+ capacity: int,
24
+ batch_size: int,
25
+ flush_interval: float,
26
+ drop_report_interval: float,
27
+ poll_interval: float = 0.005,
28
+ ) -> None:
29
+ self.outputs = outputs
30
+ self.formatter = formatter
31
+ self.timestamp_mode = timestamp_mode
32
+ self.capacity = capacity
33
+ self.batch_size = batch_size
34
+ self.flush_interval = flush_interval
35
+ self.drop_report_interval = drop_report_interval
36
+ self.poll_interval = poll_interval
37
+ self.pid = os.getpid()
38
+ self.minimum = min((output.level for output in outputs), default=100)
39
+ self.available_minimum = self.minimum
40
+ self.condition = threading.Condition(threading.Lock())
41
+ self.queue: deque[_Event] = deque()
42
+ self.closing = False
43
+ self.worker_error: str | None = None
44
+ self.format_errors = 0
45
+ self.last_format_error: str | None = None
46
+ self.accepted = self.written = self.failed = self.partial = self.processed = 0
47
+ self.high = self.rejected = 0
48
+ self.drops: dict[int, int] = dict.fromkeys(LEVELS, 0)
49
+ started_ns = clock_ns()
50
+ for output in outputs:
51
+ output.report_since_ns = started_ns
52
+ self.thread = threading.Thread(target=self._run, name="logleaf-writer", daemon=True)
53
+ try:
54
+ self.thread.start()
55
+ except BaseException:
56
+ for output in outputs:
57
+ output.target.close()
58
+ raise
59
+
60
+ def check_process(self) -> None:
61
+ # Check BEFORE locks: a forked child may inherit a permanently locked condition.
62
+ if os.getpid() != self.pid:
63
+ raise RuntimeError(
64
+ "create a new Logger inside each child process; inherited Logger unsafe"
65
+ )
66
+
67
+ def submit(self, event: _Event) -> bool:
68
+ with self.condition:
69
+ if self.closing:
70
+ raise RuntimeError("logger is closed")
71
+ if self.worker_error or event.level < self.available_minimum:
72
+ self.rejected += 1
73
+ return False
74
+ pending = self.accepted - self.processed
75
+ if pending >= self.capacity:
76
+ self.drops[event.level] += 1
77
+ return False
78
+ self.queue.append(event)
79
+ self.accepted += 1
80
+ if pending >= self.high:
81
+ self.high = pending + 1
82
+ return True
83
+
84
+ def snapshot(self) -> LoggerStats:
85
+ self.check_process()
86
+ with self.condition:
87
+ dropped = sum(self.drops.values())
88
+ return LoggerStats(
89
+ accepted=self.accepted,
90
+ written=self.written,
91
+ failed=self.failed,
92
+ partial=self.partial,
93
+ pending=self.accepted - self.processed,
94
+ dropped=dropped,
95
+ dropped_by_level={LEVELS[k]: v for k, v in self.drops.items()},
96
+ rejected_unavailable=self.rejected,
97
+ high_watermark=self.high,
98
+ outputs={
99
+ output.name: OutputStats(
100
+ output.written,
101
+ output.failed,
102
+ sum(output.reported.values()),
103
+ dropped - sum(output.reported.values()),
104
+ output.error,
105
+ )
106
+ for output in self.outputs
107
+ },
108
+ worker_error=self.worker_error,
109
+ format_errors=self.format_errors,
110
+ last_format_error=self.last_format_error,
111
+ )
112
+
113
+ def can_capture_exception(self) -> bool:
114
+ # Exception-only preflight avoids expensive snapshots on an already full
115
+ # queue. submit() rechecks admission after capture; no capacity is reserved.
116
+ with self.condition:
117
+ if self.closing:
118
+ raise RuntimeError("logger is closed")
119
+ if self.worker_error or 40 < self.available_minimum:
120
+ self.rejected += 1
121
+ return False
122
+ if self.accepted - self.processed >= self.capacity:
123
+ self.drops[40] += 1
124
+ return False
125
+ return True
126
+
127
+ def close(self, timeout: float | None) -> None:
128
+ self.check_process()
129
+ if timeout is not None and (
130
+ type(timeout) not in (int, float) or not math.isfinite(timeout) or timeout < 0
131
+ ):
132
+ raise ValueError("timeout must be nonnegative and finite, or None")
133
+ with self.condition:
134
+ self.closing = True
135
+ self.condition.notify_all()
136
+ self.thread.join(timeout)
137
+ if self.thread.is_alive():
138
+ raise TimeoutError("writer is still draining; close() may be retried")
139
+ errors = [f"{o.name}: {o.error}" for o in self.outputs if o.error]
140
+ if self.worker_error:
141
+ errors.append(self.worker_error)
142
+ if self.last_format_error:
143
+ errors.append("format: " + self.last_format_error)
144
+ if errors:
145
+ raise RuntimeError("log output failed; inspect stats: " + "; ".join(errors))
146
+
147
+ def _fail_output(self, output: _Output, error: Exception) -> None:
148
+ with self.condition:
149
+ if output.error is None:
150
+ output.error = f"{type(error).__name__}: {error}"
151
+ self.available_minimum = min(
152
+ (target.level for target in self.outputs if target.error is None), default=100
153
+ )
154
+
155
+ def _flush(self) -> None:
156
+ for output in self.outputs:
157
+ if output.error is None:
158
+ try:
159
+ output.target.flush()
160
+ except Exception as error:
161
+ self._fail_output(output, error)
162
+
163
+ def _record(self, event: _Event) -> dict[str, Any]:
164
+ stamp = event.timestamp_ns if event.timestamp_ns is not None else clock_ns()
165
+ record = {
166
+ "timestamp": self.formatter.timestamp(stamp),
167
+ "timestamp_mode": self.timestamp_mode,
168
+ "level": LEVELS[event.level],
169
+ "event": event.message,
170
+ **(event.context or {}),
171
+ **event.fields,
172
+ }
173
+ if isinstance(event, _ExceptionEvent) and event.exception is not None:
174
+ snapshot = event.exception
175
+ record["_log_hub"] = {
176
+ "kind": "exception",
177
+ "type": snapshot.type_name,
178
+ "message": snapshot.message,
179
+ "traceback": snapshot.render(),
180
+ }
181
+ return record
182
+
183
+ def _format_failure(self, error: Exception) -> None:
184
+ with self.condition:
185
+ self.format_errors += 1
186
+ self.last_format_error = f"{type(error).__name__}: {error}"
187
+
188
+ def _write_single(self, batch: list[_Event] | deque[_Event]) -> None:
189
+ # Admission already applied this only output's level. Avoid constructing
190
+ # per-output index and delivery arrays for the common file-only case.
191
+ output = self.outputs[0]
192
+ written = 0
193
+ if output.error is None:
194
+ lines = []
195
+ for event in batch:
196
+ try:
197
+ lines.append(self.formatter.render(self._record(event), output.style))
198
+ except (TypeError, ValueError, OverflowError) as error:
199
+ self._format_failure(error)
200
+ try:
201
+ if lines:
202
+ output.target.write(lines)
203
+ written = len(lines)
204
+ except Exception as error:
205
+ self._fail_output(output, error)
206
+ failed = len(batch) - written
207
+ with self.condition:
208
+ output.written += written
209
+ output.failed += failed
210
+ self.written += written
211
+ self.failed += failed
212
+ self.processed += len(batch)
213
+
214
+ def _write_batch(self, batch: list[_Event] | deque[_Event]) -> None:
215
+ if len(self.outputs) == 1:
216
+ self._write_single(batch)
217
+ return
218
+ records = []
219
+ for event in batch:
220
+ records.append(self._record(event))
221
+ delivered = [0] * len(batch)
222
+ targets = [0] * len(batch)
223
+ for output in self.outputs:
224
+ indices = [i for i, event in enumerate(batch) if event.level >= output.level]
225
+ if not indices:
226
+ continue
227
+ for i in indices:
228
+ targets[i] += 1
229
+ ok = False
230
+ valid_indices = []
231
+ if output.error is None:
232
+ lines = []
233
+ for i in indices:
234
+ try:
235
+ lines.append(self.formatter.render(records[i], output.style))
236
+ valid_indices.append(i)
237
+ except (TypeError, ValueError, OverflowError) as error:
238
+ self._format_failure(error)
239
+ try:
240
+ if lines:
241
+ output.target.write(lines)
242
+ ok = True
243
+ except Exception as error:
244
+ self._fail_output(output, error)
245
+ with self.condition:
246
+ if ok:
247
+ output.written += len(valid_indices)
248
+ output.failed += len(indices) - len(valid_indices)
249
+ else:
250
+ output.failed += len(indices)
251
+ if ok:
252
+ for i in valid_indices:
253
+ delivered[i] += 1
254
+ # Aggregate outside the admission lock so a batch cannot lengthen the
255
+ # producer's critical section by hundreds of Python loop iterations.
256
+ written = sum(count > 0 for count in delivered)
257
+ failed = len(batch) - written
258
+ partial = sum(0 < count < target for count, target in zip(delivered, targets))
259
+ with self.condition:
260
+ self.written += written
261
+ self.failed += failed
262
+ self.partial += partial
263
+ self.processed += len(batch)
264
+
265
+ def _report_drops(self) -> None:
266
+ # No producer-side clock reads in worker mode, including the overflow path.
267
+ # These timestamps bound accounting windows, not individual drop times.
268
+ with self.condition:
269
+ totals = self.drops.copy()
270
+ until_ns = clock_ns()
271
+ for output in self.outputs:
272
+ if output.error is not None:
273
+ continue
274
+ counts = {LEVELS[k]: totals[k] - output.reported[k] for k in LEVELS}
275
+ count = sum(counts.values())
276
+ if not count:
277
+ continue
278
+ record = {
279
+ "timestamp": self.formatter.timestamp(until_ns),
280
+ "timestamp_mode": "worker",
281
+ "level": "WARNING",
282
+ "event": "日志队列溢出",
283
+ "_log_hub": {
284
+ "kind": "queue_overflow",
285
+ "dropped": count,
286
+ "dropped_by_level": counts,
287
+ "window_start": self.formatter.timestamp(output.report_since_ns),
288
+ "window_end": self.formatter.timestamp(until_ns),
289
+ },
290
+ }
291
+ try:
292
+ # Bypasses queue and severity filter; flush before marking reported.
293
+ output.target.write([self.formatter.render(record, output.style)])
294
+ output.target.flush()
295
+ except Exception as error:
296
+ self._fail_output(output, error)
297
+ else:
298
+ with self.condition:
299
+ output.reported = totals.copy()
300
+ output.report_since_ns = until_ns
301
+
302
+ def _run(self) -> None:
303
+ next_flush = time.monotonic() + self.flush_interval
304
+ next_report = time.monotonic() + self.drop_report_interval
305
+ try:
306
+ while True:
307
+ with self.condition:
308
+ self.condition.wait_for(
309
+ lambda: bool(self.queue) or self.closing,
310
+ timeout=max(
311
+ 0,
312
+ min(
313
+ self.poll_interval, min(next_flush, next_report) - time.monotonic()
314
+ ),
315
+ ),
316
+ )
317
+ if len(self.queue) <= self.batch_size:
318
+ batch, self.queue = self.queue, deque()
319
+ else:
320
+ batch = [self.queue.popleft() for _ in range(self.batch_size)]
321
+ closing = self.closing and not self.queue
322
+ if batch:
323
+ self._write_batch(batch)
324
+ now = time.monotonic()
325
+ if closing or now >= next_report:
326
+ self._report_drops()
327
+ next_report = time.monotonic() + self.drop_report_interval
328
+ if closing or now >= next_flush:
329
+ self._flush()
330
+ next_flush = time.monotonic() + self.flush_interval
331
+ if closing:
332
+ break
333
+ except BaseException as error:
334
+ with self.condition:
335
+ self.worker_error = f"{type(error).__name__}: {error}"
336
+ self.failed += self.accepted - self.processed
337
+ self.processed = self.accepted
338
+ self.queue.clear()
339
+ finally:
340
+ for output in self.outputs:
341
+ try:
342
+ output.target.close()
343
+ except Exception as error:
344
+ self._fail_output(output, error)
logleaf/_models.py ADDED
@@ -0,0 +1,64 @@
1
+ """Shared event envelopes and statistics; no queue or I/O behavior."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+ from .exceptions import ExceptionSnapshot
8
+ from .formatting import LEVELS
9
+ from .sinks import ConsoleOutput, FileOutput
10
+
11
+ Scalar = str | int | float | bool | None
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class OutputStats:
16
+ written: int
17
+ failed: int
18
+ dropped_reported: int
19
+ dropped_unreported: int
20
+ error: str | None
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class LoggerStats:
25
+ accepted: int
26
+ written: int
27
+ failed: int
28
+ partial: int
29
+ pending: int
30
+ dropped: int
31
+ dropped_by_level: dict[str, int]
32
+ rejected_unavailable: int
33
+ high_watermark: int
34
+ outputs: dict[str, OutputStats]
35
+ worker_error: str | None
36
+ format_errors: int
37
+ last_format_error: str | None
38
+
39
+
40
+ @dataclass(slots=True)
41
+ class _Event:
42
+ level: int
43
+ message: str
44
+ fields: dict[str, Scalar]
45
+ timestamp_ns: int | None
46
+ context: dict[str, Scalar] | None = None
47
+
48
+
49
+ @dataclass(slots=True)
50
+ class _ExceptionEvent(_Event):
51
+ exception: ExceptionSnapshot | None = None
52
+
53
+
54
+ @dataclass
55
+ class _Output:
56
+ name: str
57
+ target: FileOutput | ConsoleOutput
58
+ level: int
59
+ style: str
60
+ written: int = 0
61
+ failed: int = 0
62
+ error: str | None = None
63
+ reported: dict[int, int] = field(default_factory=lambda: dict.fromkeys(LEVELS, 0))
64
+ report_since_ns: int = 0
logleaf/exceptions.py ADDED
@@ -0,0 +1,134 @@
1
+ """Detached exception snapshots: no traceback frames, exception objects or locals."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import deque
6
+ from dataclasses import dataclass
7
+
8
+ MAX_FRAMES = 64
9
+ MAX_NODES = 64
10
+ MAX_DEPTH = 8
11
+
12
+
13
+ def _text(value: object) -> str:
14
+ try:
15
+ return str.__str__(str(value)) # Discard attributes on a returned str subclass.
16
+ except Exception:
17
+ return "<exception text unavailable>"
18
+
19
+
20
+ @dataclass(frozen=True, slots=True)
21
+ class Frame:
22
+ filename: str
23
+ line: int
24
+ function: str
25
+
26
+
27
+ @dataclass(frozen=True, slots=True)
28
+ class ExceptionSnapshot:
29
+ type_name: str
30
+ message: str
31
+ frames: tuple[Frame, ...]
32
+ notes: tuple[str, ...]
33
+ previous: ExceptionSnapshot | None
34
+ relation: str | None
35
+ children: tuple[ExceptionSnapshot, ...]
36
+ omitted_frames: int = 0
37
+ omitted_children: int = 0
38
+ truncated_chain: bool = False
39
+
40
+ def render(self) -> str:
41
+ """Worker-only string assembly. Source files are never read."""
42
+ lines: list[str] = []
43
+ if self.previous is not None:
44
+ lines.append(self.previous.render())
45
+ lines.append(
46
+ "The above exception was the direct cause of the following exception:"
47
+ if self.relation == "cause"
48
+ else "During handling of the above exception, another exception occurred:"
49
+ )
50
+ if self.truncated_chain:
51
+ lines.append("[exception chain truncated: cycle or snapshot limit]")
52
+ if self.frames:
53
+ lines.append("Traceback (most recent call last):")
54
+ if self.omitted_frames:
55
+ lines.append(f" [... {self.omitted_frames} earlier frames omitted]")
56
+ for frame in self.frames:
57
+ lines.append(f' File "{frame.filename}", line {frame.line}, in {frame.function}')
58
+ lines.append(f"{self.type_name}: {self.message}")
59
+ lines.extend(self.notes)
60
+ for index, child in enumerate(self.children, 1):
61
+ lines.append(f" + Exception group member {index}:")
62
+ lines.extend(" " + line for line in child.render().splitlines())
63
+ if self.omitted_children:
64
+ lines.append(f" [... {self.omitted_children} exception group members omitted]")
65
+ return "\n".join(lines)
66
+
67
+
68
+ def capture_exception(error: BaseException) -> ExceptionSnapshot:
69
+ """Capture descriptions now without retaining application-owned objects.
70
+
71
+ Limits bound stored frame/node counts, not string sizes or capture time.
72
+ str(error) necessarily executes on the caller; ordinary logs never use this.
73
+ """
74
+ seen: set[int] = set()
75
+
76
+ def capture(current: BaseException, depth: int) -> ExceptionSnapshot:
77
+ seen.add(id(current))
78
+ frames: deque[Frame] = deque(maxlen=MAX_FRAMES)
79
+ count = 0
80
+ tb = current.__traceback__
81
+ while tb is not None:
82
+ code = tb.tb_frame.f_code
83
+ frames.append(Frame(str(code.co_filename), tb.tb_lineno, str(code.co_name)))
84
+ count += 1
85
+ tb = tb.tb_next
86
+ if isinstance(current, SyntaxError):
87
+ if isinstance(current.filename, str) and type(current.lineno) is int:
88
+ frames.append(Frame(str(current.filename), current.lineno, "<syntax>"))
89
+ count += 1
90
+ kind = type(current)
91
+ type_name = f"{_text(kind.__module__)}.{_text(kind.__qualname__)}"
92
+ message = _text(current)
93
+ try:
94
+ notes = current.__notes__
95
+ except Exception:
96
+ notes = ()
97
+ # Freeze notes too: callers may mutate their original list after logging.
98
+ notes = tuple(_text(note) for note in notes) if isinstance(notes, (list, tuple)) else ()
99
+ previous = None
100
+ relation = None
101
+ truncated = False
102
+ candidate = current.__cause__
103
+ if candidate is not None:
104
+ relation = "cause"
105
+ elif not current.__suppress_context__:
106
+ candidate = current.__context__
107
+ relation = "context" if candidate is not None else None
108
+ if candidate is not None:
109
+ if id(candidate) in seen or depth >= MAX_DEPTH or len(seen) >= MAX_NODES:
110
+ truncated = True
111
+ else:
112
+ previous = capture(candidate, depth + 1)
113
+ children = []
114
+ omitted = 0
115
+ if isinstance(current, BaseExceptionGroup):
116
+ for child in current.exceptions:
117
+ if id(child) in seen or depth >= MAX_DEPTH or len(seen) >= MAX_NODES:
118
+ omitted += 1
119
+ else:
120
+ children.append(capture(child, depth + 1))
121
+ return ExceptionSnapshot(
122
+ type_name,
123
+ message,
124
+ tuple(frames),
125
+ notes,
126
+ previous,
127
+ relation,
128
+ tuple(children),
129
+ max(0, count - MAX_FRAMES),
130
+ omitted,
131
+ truncated,
132
+ )
133
+
134
+ return capture(error, 0)
@@ -0,0 +1,5 @@
1
+ """Historical performance prototypes, outside the stable Logger API."""
2
+
3
+ from .prototype import BatchLogger, Stats
4
+
5
+ __all__ = ["BatchLogger", "Stats"]