yapl-kit 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.
- yapl/__init__.py +97 -0
- yapl/config.py +266 -0
- yapl/extensions/buffering/__init__.py +24 -0
- yapl/extensions/buffering/capture.py +66 -0
- yapl/extensions/buffering/context.py +162 -0
- yapl/extensions/buffering/flush.py +71 -0
- yapl/extensions/buffering/models.py +65 -0
- yapl/extensions/coloring/__init__.py +45 -0
- yapl/extensions/coloring/colors.py +86 -0
- yapl/extensions/coloring/models.py +121 -0
- yapl/extensions/coloring/plain.py +113 -0
- yapl/extensions/coloring/policy.py +31 -0
- yapl/extensions/coloring/registry.py +119 -0
- yapl/extensions/coloring/structured.py +100 -0
- yapl/extensions/coloring/text.py +36 -0
- yapl/extensions/coloring/themes.py +111 -0
- yapl/extensions/formatting/__init__.py +54 -0
- yapl/extensions/formatting/_helpers.py +53 -0
- yapl/extensions/formatting/_types.py +21 -0
- yapl/extensions/formatting/formatters.py +115 -0
- yapl/extensions/formatting/json.py +6 -0
- yapl/extensions/formatting/logfmt.py +6 -0
- yapl/extensions/formatting/plain.py +103 -0
- yapl/extensions/formatting/processors.py +391 -0
- yapl/extensions/formatting/structured.py +656 -0
- yapl/extensions/formatting/text.py +6 -0
- yapl/extensions/queueing/__init__.py +25 -0
- yapl/extensions/queueing/handler.py +276 -0
- yapl/extensions/queueing/models.py +126 -0
- yapl/extensions/queueing/policy.py +22 -0
- yapl/extensions/queueing/queue.py +101 -0
- yapl/extensions/queueing/worker.py +50 -0
- yapl/extensions/webhook/__init__.py +54 -0
- yapl/extensions/webhook/client.py +159 -0
- yapl/extensions/webhook/handler.py +244 -0
- yapl/extensions/webhook/models.py +210 -0
- yapl/extensions/webhook/payloads.py +252 -0
- yapl/factory.py +367 -0
- yapl/filters/__init__.py +22 -0
- yapl/filters/enrichment.py +116 -0
- yapl/filters/suppression.py +69 -0
- yapl/levels.py +134 -0
- yapl/logger.py +247 -0
- yapl/py.typed +1 -0
- yapl/stack/__init__.py +14 -0
- yapl/stack/callsite.py +75 -0
- yapl/stack/tracer.py +157 -0
- yapl/types.py +41 -0
- yapl_kit/__init__.py +2 -0
- yapl_kit/py.typed +1 -0
- yapl_kit-0.1.0.dist-info/METADATA +345 -0
- yapl_kit-0.1.0.dist-info/RECORD +53 -0
- yapl_kit-0.1.0.dist-info/WHEEL +4 -0
yapl/__init__.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""YAPL's small, typed public logging setup API.
|
|
2
|
+
|
|
3
|
+
Typical use is ``setup_logging(...)`` once during application startup followed
|
|
4
|
+
by ``get_logger(__name__)`` in modules that emit records. Queue and webhook
|
|
5
|
+
configuration classes are re-exported here for ergonomic setup; specialised
|
|
6
|
+
handlers and formatters remain in ``yapl.extensions``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from .config import (
|
|
12
|
+
ConsoleConfig,
|
|
13
|
+
LoggerOverrides,
|
|
14
|
+
OutputConfig,
|
|
15
|
+
StackConfig,
|
|
16
|
+
SuppressionRule,
|
|
17
|
+
SuppressionRules,
|
|
18
|
+
YaplConfig,
|
|
19
|
+
)
|
|
20
|
+
from .extensions.queueing.models import QueueDiagnostics, QueueingConfig
|
|
21
|
+
from .extensions.webhook.models import (
|
|
22
|
+
WebhookAuthConfig,
|
|
23
|
+
WebhookConfig,
|
|
24
|
+
WebhookDiagnostics,
|
|
25
|
+
WebhookFailurePolicy,
|
|
26
|
+
WebhookMethod,
|
|
27
|
+
WebhookPayloadMode,
|
|
28
|
+
WebhookProvider,
|
|
29
|
+
WebhookRetryConfig,
|
|
30
|
+
)
|
|
31
|
+
from .factory import get_logger, setup_logging, shutdown_logging
|
|
32
|
+
from .levels import (
|
|
33
|
+
level_name,
|
|
34
|
+
register_default_levels,
|
|
35
|
+
register_level,
|
|
36
|
+
registered_levels,
|
|
37
|
+
resolve_level,
|
|
38
|
+
)
|
|
39
|
+
from .logger import (
|
|
40
|
+
RecordFlagPolicy,
|
|
41
|
+
YaplLogger,
|
|
42
|
+
register_level_method,
|
|
43
|
+
set_as_default_logger_class,
|
|
44
|
+
)
|
|
45
|
+
from .types import (
|
|
46
|
+
ColorMode,
|
|
47
|
+
ConsoleStream,
|
|
48
|
+
ContextEnrichValue,
|
|
49
|
+
LevelLike,
|
|
50
|
+
LevelMode,
|
|
51
|
+
OutputDestination,
|
|
52
|
+
OutputFormat,
|
|
53
|
+
QueueOverflowPolicy,
|
|
54
|
+
StackMode,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
__all__ = [
|
|
59
|
+
"ColorMode",
|
|
60
|
+
"ConsoleConfig",
|
|
61
|
+
"ConsoleStream",
|
|
62
|
+
"ContextEnrichValue",
|
|
63
|
+
"LevelLike",
|
|
64
|
+
"LevelMode",
|
|
65
|
+
"LoggerOverrides",
|
|
66
|
+
"OutputConfig",
|
|
67
|
+
"OutputDestination",
|
|
68
|
+
"OutputFormat",
|
|
69
|
+
"QueueDiagnostics",
|
|
70
|
+
"QueueOverflowPolicy",
|
|
71
|
+
"QueueingConfig",
|
|
72
|
+
"RecordFlagPolicy",
|
|
73
|
+
"StackConfig",
|
|
74
|
+
"StackMode",
|
|
75
|
+
"SuppressionRule",
|
|
76
|
+
"SuppressionRules",
|
|
77
|
+
"WebhookAuthConfig",
|
|
78
|
+
"WebhookConfig",
|
|
79
|
+
"WebhookDiagnostics",
|
|
80
|
+
"WebhookFailurePolicy",
|
|
81
|
+
"WebhookMethod",
|
|
82
|
+
"WebhookPayloadMode",
|
|
83
|
+
"WebhookProvider",
|
|
84
|
+
"WebhookRetryConfig",
|
|
85
|
+
"YaplConfig",
|
|
86
|
+
"YaplLogger",
|
|
87
|
+
"get_logger",
|
|
88
|
+
"level_name",
|
|
89
|
+
"register_default_levels",
|
|
90
|
+
"register_level",
|
|
91
|
+
"register_level_method",
|
|
92
|
+
"registered_levels",
|
|
93
|
+
"resolve_level",
|
|
94
|
+
"set_as_default_logger_class",
|
|
95
|
+
"setup_logging",
|
|
96
|
+
"shutdown_logging",
|
|
97
|
+
]
|
yapl/config.py
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterable, Mapping
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
from .levels import resolve_level
|
|
8
|
+
from .types import (
|
|
9
|
+
ColorMode,
|
|
10
|
+
ConsoleStream,
|
|
11
|
+
LevelLike,
|
|
12
|
+
OutputDestination,
|
|
13
|
+
OutputFormat,
|
|
14
|
+
QueueOverflowPolicy,
|
|
15
|
+
StackMode,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from .extensions.formatting.processors import LogEventProcessor
|
|
21
|
+
from .extensions.webhook.models import WebhookConfig
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True, slots=True)
|
|
25
|
+
class SuppressionRule:
|
|
26
|
+
"""Match records that should be suppressed before they reach a sink.
|
|
27
|
+
|
|
28
|
+
Every non-``None`` field must match. ``logger_prefix`` changes ``logger``
|
|
29
|
+
from an exact logger name to a prefix match.
|
|
30
|
+
|
|
31
|
+
Attributes:
|
|
32
|
+
logger: Logger name to match.
|
|
33
|
+
logger_prefix: Whether ``logger`` matches descendant logger names.
|
|
34
|
+
filename: Source filename to match.
|
|
35
|
+
function: Source function name to match.
|
|
36
|
+
line: Source line number to match.
|
|
37
|
+
message_contains: Text that must occur in the rendered message.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
logger: str | None = None
|
|
41
|
+
logger_prefix: bool = False
|
|
42
|
+
|
|
43
|
+
filename: str | None = None
|
|
44
|
+
function: str | None = None
|
|
45
|
+
line: int | None = None
|
|
46
|
+
message_contains: str | None = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(slots=True)
|
|
50
|
+
class SuppressionRules:
|
|
51
|
+
"""Mutable collection of :class:`SuppressionRule` instances.
|
|
52
|
+
|
|
53
|
+
The fluent helpers return this collection so rules can be assembled while
|
|
54
|
+
building a :class:`YaplConfig`.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
rules: list[SuppressionRule] = field(default_factory=list)
|
|
58
|
+
|
|
59
|
+
def drop(
|
|
60
|
+
self,
|
|
61
|
+
*,
|
|
62
|
+
logger: str | None = None,
|
|
63
|
+
logger_prefix: bool = False,
|
|
64
|
+
filename: str | None = None,
|
|
65
|
+
function: str | None = None,
|
|
66
|
+
line: int | None = None,
|
|
67
|
+
message_contains: str | None = None,
|
|
68
|
+
) -> SuppressionRules:
|
|
69
|
+
"""Append one suppression rule and return this collection.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
logger: Logger name to match.
|
|
73
|
+
logger_prefix: Match descendant logger names when ``True``.
|
|
74
|
+
filename: Source filename to match.
|
|
75
|
+
function: Source function name to match.
|
|
76
|
+
line: Source line number to match.
|
|
77
|
+
message_contains: Text that must occur in the rendered message.
|
|
78
|
+
"""
|
|
79
|
+
self.rules.append(
|
|
80
|
+
SuppressionRule(
|
|
81
|
+
logger=logger,
|
|
82
|
+
logger_prefix=logger_prefix,
|
|
83
|
+
filename=filename,
|
|
84
|
+
function=function,
|
|
85
|
+
line=line,
|
|
86
|
+
message_contains=message_contains,
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
return self
|
|
90
|
+
|
|
91
|
+
def extend(self, items: Iterable[SuppressionRule]) -> SuppressionRules:
|
|
92
|
+
"""Append ``items`` and return this collection."""
|
|
93
|
+
self.rules.extend(items)
|
|
94
|
+
return self
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass(slots=True)
|
|
98
|
+
class LoggerOverrides:
|
|
99
|
+
"""Per-logger level overrides applied by :func:`yapl.setup_logging`."""
|
|
100
|
+
|
|
101
|
+
levels: dict[str, int] = field(default_factory=dict)
|
|
102
|
+
|
|
103
|
+
def set(self, logger_name: str, level: LevelLike) -> LoggerOverrides:
|
|
104
|
+
"""Set ``logger_name`` to ``level`` and return this collection.
|
|
105
|
+
|
|
106
|
+
Raises:
|
|
107
|
+
ValueError: If a string level is not registered.
|
|
108
|
+
"""
|
|
109
|
+
self.levels[logger_name] = resolve_level(level)
|
|
110
|
+
return self
|
|
111
|
+
|
|
112
|
+
def update(self, mapping: Mapping[str, LevelLike]) -> LoggerOverrides:
|
|
113
|
+
"""Apply several logger-level overrides and return this collection."""
|
|
114
|
+
for k, v in mapping.items():
|
|
115
|
+
self.set(k, v)
|
|
116
|
+
return self
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass(frozen=True, slots=True)
|
|
120
|
+
class ConsoleConfig:
|
|
121
|
+
"""Configuration for YAPL's default console output.
|
|
122
|
+
|
|
123
|
+
Attributes:
|
|
124
|
+
enabled: Whether a console sink is created when no explicit outputs exist.
|
|
125
|
+
stream: Standard stream used by the console sink.
|
|
126
|
+
level: Optional sink-level override; the root level is used by default.
|
|
127
|
+
color: Colour policy for text output.
|
|
128
|
+
theme_name: Optional registered colour theme name.
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
enabled: bool = True
|
|
132
|
+
stream: ConsoleStream = "stdout"
|
|
133
|
+
level: LevelLike | None = None
|
|
134
|
+
color: ColorMode = "never"
|
|
135
|
+
theme_name: str | None = None
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@dataclass(frozen=True, slots=True)
|
|
139
|
+
class OutputConfig:
|
|
140
|
+
"""Describe one built-in output sink.
|
|
141
|
+
|
|
142
|
+
Attributes:
|
|
143
|
+
destination: ``"stdout"``, ``"stderr"``, ``"file"``, or ``"webhook"``.
|
|
144
|
+
format: Built-in formatter used by the sink.
|
|
145
|
+
level: Optional minimum level for this sink.
|
|
146
|
+
color: Colour policy; supported only by text console outputs.
|
|
147
|
+
file_path: UTF-8 file path required for ``destination="file"``.
|
|
148
|
+
theme_name: Optional registered colour theme name.
|
|
149
|
+
|
|
150
|
+
Raises:
|
|
151
|
+
ValueError: If colour is requested for an unsupported sink or format.
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
destination: OutputDestination
|
|
155
|
+
format: OutputFormat = "text"
|
|
156
|
+
level: LevelLike | None = None
|
|
157
|
+
color: ColorMode = "never"
|
|
158
|
+
file_path: str | None = None
|
|
159
|
+
theme_name: str | None = None
|
|
160
|
+
|
|
161
|
+
def __post_init__(self) -> None:
|
|
162
|
+
if self.destination not in {"stdout", "stderr"} and self.color != "never":
|
|
163
|
+
raise ValueError("color is only supported for stdout and stderr outputs")
|
|
164
|
+
if self.color != "never" and self.format not in {"text", "plain"}:
|
|
165
|
+
raise ValueError("color is only supported with text or plain output")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@dataclass(frozen=True, slots=True)
|
|
169
|
+
class StackConfig:
|
|
170
|
+
"""Control the stack metadata collected by YAPL formatters.
|
|
171
|
+
|
|
172
|
+
``"record"`` preserves the record callsite; ``"full"`` additionally
|
|
173
|
+
captures a rendered stack when requested by the configured stack helpers.
|
|
174
|
+
"""
|
|
175
|
+
|
|
176
|
+
mode: StackMode = "record"
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@dataclass(slots=True)
|
|
180
|
+
class YaplConfig:
|
|
181
|
+
"""Top-level configuration accepted by :func:`yapl.setup_logging`.
|
|
182
|
+
|
|
183
|
+
Most applications only need ``level`` and the default console settings.
|
|
184
|
+
When queueing is enabled (the default), built-in sinks run in a background
|
|
185
|
+
worker and queue insertion does not intentionally wait. Queue capacity is
|
|
186
|
+
measured in records, not bytes, so large arguments or ``extra`` values can
|
|
187
|
+
still retain substantial memory while queued.
|
|
188
|
+
|
|
189
|
+
Attributes:
|
|
190
|
+
level: Root logging level; defaults to ``logging.INFO`` (20).
|
|
191
|
+
console: Default console-sink settings.
|
|
192
|
+
stack: Stack metadata policy.
|
|
193
|
+
outputs: Legacy shorthand output destinations used when ``sinks`` is empty.
|
|
194
|
+
format: Formatter for shorthand outputs.
|
|
195
|
+
sinks: Explicit built-in output definitions.
|
|
196
|
+
queue_enabled: Put configured sinks behind a bounded queue.
|
|
197
|
+
queue_max_size: Maximum queued records, not a byte limit.
|
|
198
|
+
queue_overflow_policy: Action on a full queue; ``"block"`` may wait.
|
|
199
|
+
processors: Structured-event processors run by compatible formatters.
|
|
200
|
+
resource: Static fields added to structured log events.
|
|
201
|
+
webhook: Optional configuration for a ``"webhook"`` output.
|
|
202
|
+
file_path: File used by shorthand ``"file"`` output.
|
|
203
|
+
overrides: Per-logger level overrides.
|
|
204
|
+
suppression: Rules applied to configured handlers.
|
|
205
|
+
"""
|
|
206
|
+
|
|
207
|
+
level: int = 20
|
|
208
|
+
console: ConsoleConfig = field(default_factory=ConsoleConfig)
|
|
209
|
+
stack: StackConfig = field(default_factory=StackConfig)
|
|
210
|
+
|
|
211
|
+
outputs: tuple[OutputDestination, ...] = ()
|
|
212
|
+
format: OutputFormat = "text"
|
|
213
|
+
sinks: tuple[OutputConfig, ...] = ()
|
|
214
|
+
queue_enabled: bool = True
|
|
215
|
+
queue_max_size: int = 10_000
|
|
216
|
+
queue_overflow_policy: QueueOverflowPolicy = "drop_debug_only"
|
|
217
|
+
processors: tuple[LogEventProcessor, ...] = field(default_factory=tuple)
|
|
218
|
+
resource: Mapping[str, Any] = field(default_factory=dict)
|
|
219
|
+
webhook: WebhookConfig | None = None
|
|
220
|
+
file_path: str | None = None
|
|
221
|
+
|
|
222
|
+
overrides: LoggerOverrides = field(default_factory=LoggerOverrides)
|
|
223
|
+
suppression: SuppressionRules = field(default_factory=SuppressionRules)
|
|
224
|
+
|
|
225
|
+
def with_level(self, level: LevelLike) -> YaplConfig:
|
|
226
|
+
"""Set the root level and return this configuration.
|
|
227
|
+
|
|
228
|
+
Raises:
|
|
229
|
+
ValueError: If a string level is not registered.
|
|
230
|
+
"""
|
|
231
|
+
self.level = resolve_level(level)
|
|
232
|
+
return self
|
|
233
|
+
|
|
234
|
+
def with_console_level(self, level: LevelLike | None) -> YaplConfig:
|
|
235
|
+
"""Set the default console sink level and return this configuration."""
|
|
236
|
+
self.console = ConsoleConfig(
|
|
237
|
+
enabled=self.console.enabled,
|
|
238
|
+
stream=self.console.stream,
|
|
239
|
+
level=level,
|
|
240
|
+
color=self.console.color,
|
|
241
|
+
theme_name=self.console.theme_name,
|
|
242
|
+
)
|
|
243
|
+
return self
|
|
244
|
+
|
|
245
|
+
def with_console(
|
|
246
|
+
self,
|
|
247
|
+
*,
|
|
248
|
+
enabled: bool | None = None,
|
|
249
|
+
stream: ConsoleStream | None = None,
|
|
250
|
+
color: ColorMode | None = None,
|
|
251
|
+
theme_name: str | None = None,
|
|
252
|
+
) -> YaplConfig:
|
|
253
|
+
"""Update selected default console options and return this configuration."""
|
|
254
|
+
self.console = ConsoleConfig(
|
|
255
|
+
enabled=self.console.enabled if enabled is None else enabled,
|
|
256
|
+
stream=self.console.stream if stream is None else stream,
|
|
257
|
+
level=self.console.level,
|
|
258
|
+
color=self.console.color if color is None else color,
|
|
259
|
+
theme_name=self.console.theme_name if theme_name is None else theme_name,
|
|
260
|
+
)
|
|
261
|
+
return self
|
|
262
|
+
|
|
263
|
+
def with_stack(self, mode: StackMode) -> YaplConfig:
|
|
264
|
+
"""Set the stack metadata policy and return this configuration."""
|
|
265
|
+
self.stack = StackConfig(mode=mode)
|
|
266
|
+
return self
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Context-local capture and replay of standard logging records."""
|
|
2
|
+
|
|
3
|
+
from .capture import BufferCaptureFilter
|
|
4
|
+
from .context import BufferedLogs, BufferingFilter, install_buffering
|
|
5
|
+
from .models import (
|
|
6
|
+
BufferedLogEntry,
|
|
7
|
+
BufferedLogStore,
|
|
8
|
+
BufferExitBehavior,
|
|
9
|
+
BufferFlushMode,
|
|
10
|
+
BufferingConfig,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"BufferCaptureFilter",
|
|
16
|
+
"BufferExitBehavior",
|
|
17
|
+
"BufferFlushMode",
|
|
18
|
+
"BufferedLogEntry",
|
|
19
|
+
"BufferedLogStore",
|
|
20
|
+
"BufferedLogs",
|
|
21
|
+
"BufferingConfig",
|
|
22
|
+
"BufferingFilter",
|
|
23
|
+
"install_buffering",
|
|
24
|
+
]
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from contextvars import ContextVar, Token
|
|
4
|
+
from copy import copy
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
from .models import BufferedLogEntry, BufferingState
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
_active_buffer_state: ContextVar[BufferingState | None] = ContextVar(
|
|
11
|
+
"yapl_buffering_active_state",
|
|
12
|
+
default=None,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
_capture_enabled: ContextVar[bool] = ContextVar(
|
|
16
|
+
"yapl_buffering_capture_enabled",
|
|
17
|
+
default=True,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_active_buffer_state() -> BufferingState | None:
|
|
22
|
+
return _active_buffer_state.get()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def set_active_buffer_state(state: BufferingState | None) -> Token[BufferingState | None]:
|
|
26
|
+
return _active_buffer_state.set(state)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def reset_active_buffer_state(token: Token[BufferingState | None]) -> None:
|
|
30
|
+
_active_buffer_state.reset(token)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def is_capture_enabled() -> bool:
|
|
34
|
+
return _capture_enabled.get()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def set_capture_enabled(enabled: bool) -> Token[bool]:
|
|
38
|
+
return _capture_enabled.set(enabled)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def reset_capture_enabled(token: Token[bool]) -> None:
|
|
42
|
+
_capture_enabled.reset(token)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class BufferCaptureFilter(logging.Filter):
|
|
46
|
+
"""Copy records into the active context-local buffer exactly once."""
|
|
47
|
+
|
|
48
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
49
|
+
"""Capture ``record`` when buffering is active, then allow it onward."""
|
|
50
|
+
if not is_capture_enabled():
|
|
51
|
+
return True
|
|
52
|
+
|
|
53
|
+
if getattr(record, "_yapl_buffer_capture_seen", False):
|
|
54
|
+
return True
|
|
55
|
+
|
|
56
|
+
state = get_active_buffer_state()
|
|
57
|
+
if state is None:
|
|
58
|
+
return True
|
|
59
|
+
|
|
60
|
+
buffered_record = copy(record)
|
|
61
|
+
state.store.add(
|
|
62
|
+
BufferedLogEntry(record=buffered_record),
|
|
63
|
+
max_entries=state.config.max_entries,
|
|
64
|
+
)
|
|
65
|
+
record._yapl_buffer_capture_seen = True
|
|
66
|
+
return True
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterator
|
|
4
|
+
import logging
|
|
5
|
+
from types import TracebackType
|
|
6
|
+
from typing import Self
|
|
7
|
+
|
|
8
|
+
from .capture import (
|
|
9
|
+
BufferCaptureFilter,
|
|
10
|
+
get_active_buffer_state,
|
|
11
|
+
is_capture_enabled,
|
|
12
|
+
reset_active_buffer_state,
|
|
13
|
+
reset_capture_enabled,
|
|
14
|
+
set_active_buffer_state,
|
|
15
|
+
set_capture_enabled,
|
|
16
|
+
)
|
|
17
|
+
from .flush import discard_buffer_store, flush_buffer_store
|
|
18
|
+
from .models import BufferedLogStore, BufferingConfig, BufferingState
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class BufferedLogs:
|
|
22
|
+
"""Capture records in a context and replay or discard them on exit.
|
|
23
|
+
|
|
24
|
+
Context state uses :mod:`contextvars`, so independent asyncio tasks and
|
|
25
|
+
threads do not share buffers accidentally. Nested contexts share the outer
|
|
26
|
+
buffer by default; set ``include_nested_contexts=False`` for isolation.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, config: BufferingConfig | None = None) -> None:
|
|
30
|
+
"""Create an inactive context with the supplied buffering policy."""
|
|
31
|
+
self._cfg = config or BufferingConfig()
|
|
32
|
+
self._store = BufferedLogStore()
|
|
33
|
+
self._state = BufferingState(store=self._store, config=self._cfg)
|
|
34
|
+
|
|
35
|
+
self._token = None
|
|
36
|
+
self._previous_state: BufferingState | None = None
|
|
37
|
+
self._owns_state: bool = False
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def store(self) -> BufferedLogStore:
|
|
41
|
+
"""Return the mutable store owned by this context."""
|
|
42
|
+
return self._store
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def config(self) -> BufferingConfig:
|
|
46
|
+
"""Return the active buffering configuration."""
|
|
47
|
+
return self._cfg
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def records(self) -> list[logging.LogRecord]:
|
|
51
|
+
"""Return a snapshot list of records currently retained by the context."""
|
|
52
|
+
return [entry.record for entry in self._store.entries]
|
|
53
|
+
|
|
54
|
+
def __len__(self) -> int:
|
|
55
|
+
return len(self._store.entries)
|
|
56
|
+
|
|
57
|
+
def __iter__(self) -> Iterator[logging.LogRecord]:
|
|
58
|
+
"""Iterate over currently buffered records in capture order."""
|
|
59
|
+
for entry in self._store.entries:
|
|
60
|
+
yield entry.record
|
|
61
|
+
|
|
62
|
+
def __enter__(self) -> Self:
|
|
63
|
+
"""Activate this buffer for the current context."""
|
|
64
|
+
return self._enter()
|
|
65
|
+
|
|
66
|
+
def __exit__(
|
|
67
|
+
self,
|
|
68
|
+
exc_type: type[BaseException] | None,
|
|
69
|
+
exc: BaseException | None,
|
|
70
|
+
tb: TracebackType | None,
|
|
71
|
+
) -> bool:
|
|
72
|
+
"""Flush or discard records according to the configured exit policy."""
|
|
73
|
+
return self._exit(exc_type)
|
|
74
|
+
|
|
75
|
+
async def __aenter__(self) -> Self:
|
|
76
|
+
"""Activate this buffer for an asynchronous context manager block."""
|
|
77
|
+
return self._enter()
|
|
78
|
+
|
|
79
|
+
async def __aexit__(
|
|
80
|
+
self,
|
|
81
|
+
exc_type: type[BaseException] | None,
|
|
82
|
+
exc: BaseException | None,
|
|
83
|
+
tb: TracebackType | None,
|
|
84
|
+
) -> bool:
|
|
85
|
+
"""Flush or discard records when leaving an async context manager block."""
|
|
86
|
+
return self._exit(exc_type)
|
|
87
|
+
|
|
88
|
+
def _enter(self) -> Self:
|
|
89
|
+
self._previous_state = get_active_buffer_state()
|
|
90
|
+
|
|
91
|
+
if self._previous_state is not None and self._cfg.include_nested_contexts:
|
|
92
|
+
self._state = self._previous_state
|
|
93
|
+
self._store = self._state.store
|
|
94
|
+
self._cfg = self._state.config
|
|
95
|
+
self._token = None
|
|
96
|
+
self._owns_state = False
|
|
97
|
+
return self
|
|
98
|
+
|
|
99
|
+
self._token = set_active_buffer_state(self._state)
|
|
100
|
+
self._owns_state = True
|
|
101
|
+
return self
|
|
102
|
+
|
|
103
|
+
def _exit(self, exc_type: type[BaseException] | None) -> bool:
|
|
104
|
+
if not self._owns_state:
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
capture_token = set_capture_enabled(False)
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
if self._should_flush(exc_type):
|
|
111
|
+
flush_buffer_store(
|
|
112
|
+
self._store,
|
|
113
|
+
flush_mode=self._cfg.flush_mode,
|
|
114
|
+
)
|
|
115
|
+
else:
|
|
116
|
+
discard_buffer_store(self._store)
|
|
117
|
+
finally:
|
|
118
|
+
if capture_token is not None:
|
|
119
|
+
reset_capture_enabled(capture_token)
|
|
120
|
+
|
|
121
|
+
if self._token is not None:
|
|
122
|
+
reset_active_buffer_state(self._token)
|
|
123
|
+
|
|
124
|
+
return False
|
|
125
|
+
|
|
126
|
+
def _should_flush(self, exc_type: type[BaseException] | None) -> bool:
|
|
127
|
+
if exc_type is None:
|
|
128
|
+
return self._cfg.on_success == "flush"
|
|
129
|
+
return self._cfg.on_error == "flush"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class BufferingFilter(logging.Filter):
|
|
133
|
+
"""Suppress direct sink delivery while records are being captured."""
|
|
134
|
+
|
|
135
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
136
|
+
"""Allow records only when capture is inactive for this context."""
|
|
137
|
+
return (not is_capture_enabled()) or get_active_buffer_state() is None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def install_buffering(*, stream_handlers_only: bool = False) -> int:
|
|
141
|
+
"""Install buffering filters on root handlers and return the count updated.
|
|
142
|
+
|
|
143
|
+
Install once after configuring handlers. Repeated installation adds another
|
|
144
|
+
filter, so retain the result of setup rather than calling it per request.
|
|
145
|
+
"""
|
|
146
|
+
root = logging.getLogger()
|
|
147
|
+
|
|
148
|
+
capture_filter = BufferCaptureFilter()
|
|
149
|
+
root.addFilter(capture_filter)
|
|
150
|
+
|
|
151
|
+
buffering_filter = BufferingFilter()
|
|
152
|
+
updated = 0
|
|
153
|
+
|
|
154
|
+
for handler in root.handlers:
|
|
155
|
+
if stream_handlers_only and not isinstance(handler, logging.StreamHandler):
|
|
156
|
+
continue
|
|
157
|
+
|
|
158
|
+
handler.addFilter(capture_filter)
|
|
159
|
+
handler.addFilter(buffering_filter)
|
|
160
|
+
updated += 1
|
|
161
|
+
|
|
162
|
+
return updated
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
from .models import BufferedLogEntry, BufferedLogStore, BufferFlushMode
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _replay_record(record: logging.LogRecord) -> None:
|
|
9
|
+
logger = logging.getLogger(record.name)
|
|
10
|
+
logger.handle(record)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def flush_buffer_store(
|
|
14
|
+
store: BufferedLogStore,
|
|
15
|
+
*,
|
|
16
|
+
flush_mode: BufferFlushMode = "ordered",
|
|
17
|
+
) -> int:
|
|
18
|
+
"""Replay retained records through their named stdlib loggers.
|
|
19
|
+
|
|
20
|
+
Returns the number of replayed records. Replaying may invoke configured
|
|
21
|
+
handlers synchronously unless those handlers are queue-backed.
|
|
22
|
+
"""
|
|
23
|
+
if store.is_empty():
|
|
24
|
+
return 0
|
|
25
|
+
|
|
26
|
+
entries = _prepare_entries_for_flush(store.entries, flush_mode=flush_mode)
|
|
27
|
+
|
|
28
|
+
for entry in entries:
|
|
29
|
+
_replay_record(entry.record)
|
|
30
|
+
|
|
31
|
+
flushed = len(entries)
|
|
32
|
+
store.clear()
|
|
33
|
+
return flushed
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def discard_buffer_store(store: BufferedLogStore) -> int:
|
|
37
|
+
"""Discard all retained records and return their count."""
|
|
38
|
+
discarded = len(store)
|
|
39
|
+
store.clear()
|
|
40
|
+
return discarded
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _prepare_entries_for_flush(
|
|
44
|
+
entries: list[BufferedLogEntry],
|
|
45
|
+
*,
|
|
46
|
+
flush_mode: BufferFlushMode,
|
|
47
|
+
) -> list[BufferedLogEntry]:
|
|
48
|
+
if flush_mode == "ordered":
|
|
49
|
+
return list(entries)
|
|
50
|
+
|
|
51
|
+
if flush_mode == "grouped":
|
|
52
|
+
return _group_entries(entries)
|
|
53
|
+
|
|
54
|
+
return list(entries)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _group_entries(entries: list[BufferedLogEntry]) -> list[BufferedLogEntry]:
|
|
58
|
+
grouped: dict[int, list[BufferedLogEntry]] = {}
|
|
59
|
+
|
|
60
|
+
for entry in entries:
|
|
61
|
+
level = entry.record.levelno
|
|
62
|
+
grouped.setdefault(level, []).append(entry)
|
|
63
|
+
|
|
64
|
+
ordered_levels = sorted(grouped.keys(), reverse=True)
|
|
65
|
+
|
|
66
|
+
result: list[BufferedLogEntry] = []
|
|
67
|
+
|
|
68
|
+
for level in ordered_levels:
|
|
69
|
+
result.extend(grouped[level])
|
|
70
|
+
|
|
71
|
+
return result
|