lucidlog 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.
- lucidlog/__init__.py +37 -0
- lucidlog/api.py +151 -0
- lucidlog/capture.py +197 -0
- lucidlog/config.py +89 -0
- lucidlog/diagnosis.py +86 -0
- lucidlog/integrations/__init__.py +1 -0
- lucidlog/integrations/asyncio.py +72 -0
- lucidlog/integrations/logging.py +27 -0
- lucidlog/integrations/threading.py +51 -0
- lucidlog/models.py +173 -0
- lucidlog/redaction.py +56 -0
- lucidlog/render.py +146 -0
- lucidlog/rules.py +355 -0
- lucidlog/serializers.py +11 -0
- lucidlog/source.py +59 -0
- lucidlog-0.1.0.dist-info/METADATA +112 -0
- lucidlog-0.1.0.dist-info/RECORD +19 -0
- lucidlog-0.1.0.dist-info/WHEEL +5 -0
- lucidlog-0.1.0.dist-info/top_level.txt +1 -0
lucidlog/__init__.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""LucidLog public API."""
|
|
2
|
+
|
|
3
|
+
from .api import (
|
|
4
|
+
clear_diagnostic_rules,
|
|
5
|
+
diagnose,
|
|
6
|
+
exception,
|
|
7
|
+
get_registered_rules,
|
|
8
|
+
install,
|
|
9
|
+
log_exception,
|
|
10
|
+
register_diagnostic_rule,
|
|
11
|
+
render_diagnostic,
|
|
12
|
+
render_exception,
|
|
13
|
+
uninstall,
|
|
14
|
+
)
|
|
15
|
+
from .config import Config, default_config
|
|
16
|
+
from .rules import DiagnosticRule, clear_rules, list_rules, register_rule
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"Config",
|
|
20
|
+
"DiagnosticRule",
|
|
21
|
+
"clear_diagnostic_rules",
|
|
22
|
+
"clear_rules",
|
|
23
|
+
"default_config",
|
|
24
|
+
"diagnose",
|
|
25
|
+
"exception",
|
|
26
|
+
"get_registered_rules",
|
|
27
|
+
"install",
|
|
28
|
+
"list_rules",
|
|
29
|
+
"log_exception",
|
|
30
|
+
"register_diagnostic_rule",
|
|
31
|
+
"register_rule",
|
|
32
|
+
"render_diagnostic",
|
|
33
|
+
"render_exception",
|
|
34
|
+
"uninstall",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
__version__ = "0.1.0"
|
lucidlog/api.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import sys
|
|
5
|
+
import traceback
|
|
6
|
+
from typing import TextIO
|
|
7
|
+
|
|
8
|
+
from .config import Config, default_config
|
|
9
|
+
from .diagnosis import build_diagnostic
|
|
10
|
+
from .models import Diagnostic
|
|
11
|
+
from .redaction import sanitize_text
|
|
12
|
+
from .render import render_diagnostic as _render_diagnostic
|
|
13
|
+
from .rules import DiagnosticRule, clear_rules, list_rules, register_rule
|
|
14
|
+
|
|
15
|
+
_previous_excepthook = None
|
|
16
|
+
_installed = False
|
|
17
|
+
_installed_config = default_config()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def diagnose(exc: BaseException, *, config: Config | None = None) -> Diagnostic:
|
|
21
|
+
return build_diagnostic(exc, config=config)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def register_diagnostic_rule(rule: DiagnosticRule) -> None:
|
|
25
|
+
register_rule(rule)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def clear_diagnostic_rules() -> None:
|
|
29
|
+
clear_rules()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def get_registered_rules() -> tuple[DiagnosticRule, ...]:
|
|
33
|
+
return list_rules()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _handle_uncaught(exc_type, exc_value, exc_traceback) -> None:
|
|
37
|
+
global _previous_excepthook
|
|
38
|
+
try:
|
|
39
|
+
diagnostic = build_diagnostic(exc_value, config=_installed_config)
|
|
40
|
+
_render_diagnostic(
|
|
41
|
+
diagnostic,
|
|
42
|
+
full=_installed_config.include_full_traceback,
|
|
43
|
+
stream=_installed_config.output_stream,
|
|
44
|
+
color=_installed_config.color,
|
|
45
|
+
)
|
|
46
|
+
except BaseException:
|
|
47
|
+
if _previous_excepthook is not None:
|
|
48
|
+
_previous_excepthook(exc_type, exc_value, exc_traceback)
|
|
49
|
+
return
|
|
50
|
+
traceback.print_exception(exc_type, exc_value, exc_traceback)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def install(*, config: Config | None = None) -> None:
|
|
54
|
+
global _previous_excepthook, _installed, _installed_config
|
|
55
|
+
if _installed:
|
|
56
|
+
return
|
|
57
|
+
_installed_config = config or default_config()
|
|
58
|
+
_previous_excepthook = sys.excepthook
|
|
59
|
+
sys.excepthook = _handle_uncaught
|
|
60
|
+
_installed = True
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def uninstall() -> None:
|
|
64
|
+
global _previous_excepthook, _installed, _installed_config
|
|
65
|
+
if not _installed:
|
|
66
|
+
return
|
|
67
|
+
if sys.excepthook is _handle_uncaught:
|
|
68
|
+
sys.excepthook = _previous_excepthook
|
|
69
|
+
_previous_excepthook = None
|
|
70
|
+
_installed = False
|
|
71
|
+
_installed_config = default_config()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def exception(
|
|
75
|
+
message: str | None = None,
|
|
76
|
+
*,
|
|
77
|
+
stream: TextIO | None = None,
|
|
78
|
+
full: bool = False,
|
|
79
|
+
config: Config | None = None,
|
|
80
|
+
) -> Diagnostic:
|
|
81
|
+
exc = sys.exc_info()[1]
|
|
82
|
+
if exc is None:
|
|
83
|
+
raise RuntimeError("lucidlog.exception() must be called from within an active exception handler.")
|
|
84
|
+
selected_config = config or default_config()
|
|
85
|
+
diagnostic = build_diagnostic(exc, config=selected_config)
|
|
86
|
+
_render_diagnostic(
|
|
87
|
+
diagnostic,
|
|
88
|
+
full=full,
|
|
89
|
+
stream=stream if stream is not None else selected_config.output_stream,
|
|
90
|
+
color=selected_config.color,
|
|
91
|
+
)
|
|
92
|
+
if message:
|
|
93
|
+
target = stream if stream is not None else selected_config.output_stream
|
|
94
|
+
if target is None:
|
|
95
|
+
target = sys.stderr
|
|
96
|
+
target.write(sanitize_text(message, selected_config) + "\n")
|
|
97
|
+
return diagnostic
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def log_exception(
|
|
101
|
+
logger: logging.Logger,
|
|
102
|
+
message: str,
|
|
103
|
+
*,
|
|
104
|
+
full: bool = False,
|
|
105
|
+
config: Config | None = None,
|
|
106
|
+
) -> Diagnostic:
|
|
107
|
+
exc = sys.exc_info()[1]
|
|
108
|
+
if exc is None:
|
|
109
|
+
raise RuntimeError("No active exception to log.")
|
|
110
|
+
selected_config = config or default_config()
|
|
111
|
+
diagnostic = build_diagnostic(exc, config=selected_config)
|
|
112
|
+
level = {
|
|
113
|
+
"critical": logging.CRITICAL,
|
|
114
|
+
"error": logging.ERROR,
|
|
115
|
+
"warning": logging.WARNING,
|
|
116
|
+
"info": logging.INFO,
|
|
117
|
+
}[diagnostic.severity]
|
|
118
|
+
logger.log(
|
|
119
|
+
level,
|
|
120
|
+
sanitize_text(message, selected_config),
|
|
121
|
+
extra={"lucidlog": diagnostic.to_dict(), "lucidlog_full": full},
|
|
122
|
+
)
|
|
123
|
+
return diagnostic
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def render_exception(
|
|
127
|
+
exc: BaseException,
|
|
128
|
+
*,
|
|
129
|
+
full: bool = False,
|
|
130
|
+
stream: TextIO | None = None,
|
|
131
|
+
color: bool | None = None,
|
|
132
|
+
config: Config | None = None,
|
|
133
|
+
) -> str:
|
|
134
|
+
selected_config = config or default_config()
|
|
135
|
+
diagnostic = build_diagnostic(exc, config=selected_config)
|
|
136
|
+
return _render_diagnostic(
|
|
137
|
+
diagnostic,
|
|
138
|
+
full=full,
|
|
139
|
+
stream=stream if stream is not None else selected_config.output_stream,
|
|
140
|
+
color=selected_config.color if color is None else color,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def render_diagnostic(
|
|
145
|
+
diag: Diagnostic,
|
|
146
|
+
*,
|
|
147
|
+
full: bool = False,
|
|
148
|
+
stream: TextIO | None = None,
|
|
149
|
+
color: bool | None = None,
|
|
150
|
+
) -> str:
|
|
151
|
+
return _render_diagnostic(diag, full=full, stream=stream, color=color)
|
lucidlog/capture.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import traceback
|
|
4
|
+
import builtins
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .config import Config, default_config
|
|
8
|
+
from .models import DiagnosticCause, ExceptionContext, FrameInfo, SourceLine
|
|
9
|
+
from .redaction import redact_path, sanitize_text
|
|
10
|
+
from .source import read_source_context
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _safe_message(exc: BaseException, config: Config) -> str:
|
|
14
|
+
try:
|
|
15
|
+
message = str(exc)
|
|
16
|
+
except BaseException:
|
|
17
|
+
message = "<exception message could not be rendered>"
|
|
18
|
+
return sanitize_text(message, config)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _build_frame(frame: traceback.FrameSummary, config: Config) -> FrameInfo:
|
|
22
|
+
source_line, context = read_source_context(
|
|
23
|
+
frame.filename,
|
|
24
|
+
frame.lineno,
|
|
25
|
+
context_lines=config.max_source_context,
|
|
26
|
+
max_file_bytes=config.max_source_file_bytes,
|
|
27
|
+
)
|
|
28
|
+
safe_context = tuple(
|
|
29
|
+
SourceLine(
|
|
30
|
+
line_number=line.line_number,
|
|
31
|
+
text=sanitize_text(line.text, config, limit=config.max_context_line_length),
|
|
32
|
+
is_error_line=line.is_error_line,
|
|
33
|
+
start_column=line.start_column,
|
|
34
|
+
end_column=line.end_column,
|
|
35
|
+
)
|
|
36
|
+
for line in context
|
|
37
|
+
)
|
|
38
|
+
return FrameInfo(
|
|
39
|
+
filename=sanitize_text(
|
|
40
|
+
redact_path(frame.filename, config),
|
|
41
|
+
config,
|
|
42
|
+
limit=config.max_message_length,
|
|
43
|
+
),
|
|
44
|
+
line_number=frame.lineno,
|
|
45
|
+
function=sanitize_text(frame.name, config, limit=256),
|
|
46
|
+
source_line=(
|
|
47
|
+
sanitize_text(source_line, config, limit=config.max_context_line_length)
|
|
48
|
+
if source_line is not None
|
|
49
|
+
else None
|
|
50
|
+
),
|
|
51
|
+
context=safe_context,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _group_members(exc: BaseException) -> tuple[BaseException, ...]:
|
|
56
|
+
group_type: Any = getattr(builtins, "BaseExceptionGroup", None)
|
|
57
|
+
if group_type is None or not isinstance(exc, group_type):
|
|
58
|
+
return ()
|
|
59
|
+
return tuple(exc.exceptions)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _capture_relationships(
|
|
63
|
+
exc: BaseException,
|
|
64
|
+
*,
|
|
65
|
+
config: Config,
|
|
66
|
+
seen: set[int],
|
|
67
|
+
budget: list[int],
|
|
68
|
+
depth: int = 0,
|
|
69
|
+
) -> tuple[tuple[DiagnosticCause, ...], bool]:
|
|
70
|
+
if not config.include_exception_chain:
|
|
71
|
+
return (), False
|
|
72
|
+
if depth >= config.max_chain_depth:
|
|
73
|
+
return (), bool(exc.__cause__ or (exc.__context__ and not exc.__suppress_context__) or _group_members(exc))
|
|
74
|
+
|
|
75
|
+
related: list[DiagnosticCause] = []
|
|
76
|
+
truncated = False
|
|
77
|
+
next_exc = exc.__cause__
|
|
78
|
+
relation = "cause"
|
|
79
|
+
if next_exc is None and not exc.__suppress_context__:
|
|
80
|
+
next_exc = exc.__context__
|
|
81
|
+
relation = "context"
|
|
82
|
+
def related_exceptions():
|
|
83
|
+
if next_exc is not None:
|
|
84
|
+
yield next_exc, relation
|
|
85
|
+
for member in _group_members(exc):
|
|
86
|
+
yield member, "group_member"
|
|
87
|
+
|
|
88
|
+
for candidate_count, (child, child_relation) in enumerate(related_exceptions()):
|
|
89
|
+
if candidate_count >= config.max_group_exceptions:
|
|
90
|
+
truncated = True
|
|
91
|
+
break
|
|
92
|
+
child_id = id(child)
|
|
93
|
+
if child_id in seen or budget[0] >= config.max_group_exceptions:
|
|
94
|
+
truncated = True
|
|
95
|
+
continue
|
|
96
|
+
seen.add(child_id)
|
|
97
|
+
budget[0] += 1
|
|
98
|
+
tb = child.__traceback__
|
|
99
|
+
child_frames: tuple[FrameInfo, ...] = ()
|
|
100
|
+
location = "<unknown>"
|
|
101
|
+
if tb is not None:
|
|
102
|
+
extracted = traceback.extract_tb(tb, limit=-config.max_frames)
|
|
103
|
+
child_frames = tuple(_build_frame(frame, config) for frame in extracted)
|
|
104
|
+
if child_frames:
|
|
105
|
+
location = child_frames[-1].location
|
|
106
|
+
children, child_truncated = _capture_relationships(
|
|
107
|
+
child,
|
|
108
|
+
config=config,
|
|
109
|
+
seen=seen,
|
|
110
|
+
budget=budget,
|
|
111
|
+
depth=depth + 1,
|
|
112
|
+
)
|
|
113
|
+
related.append(
|
|
114
|
+
DiagnosticCause(
|
|
115
|
+
exception_type=type(child).__name__,
|
|
116
|
+
message=_safe_message(child, config),
|
|
117
|
+
location=location,
|
|
118
|
+
summary="Nested exception" if child_relation != "group_member" else "Exception group member",
|
|
119
|
+
relation=child_relation,
|
|
120
|
+
children=children,
|
|
121
|
+
frames=child_frames,
|
|
122
|
+
)
|
|
123
|
+
)
|
|
124
|
+
truncated = truncated or child_truncated
|
|
125
|
+
return tuple(related), truncated
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _format_frame(frame: FrameInfo) -> list[str]:
|
|
129
|
+
lines = [f' File "{frame.filename}", line {frame.line_number}, in {frame.function}']
|
|
130
|
+
if frame.source_line:
|
|
131
|
+
lines.append(f" {frame.source_line.strip()}")
|
|
132
|
+
return lines
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _format_cause(cause: DiagnosticCause, depth: int = 0) -> list[str]:
|
|
136
|
+
lines: list[str] = []
|
|
137
|
+
relation = {
|
|
138
|
+
"cause": "Caused by",
|
|
139
|
+
"context": "During handling of",
|
|
140
|
+
"group_member": "Exception group member",
|
|
141
|
+
}.get(cause.relation, "Related exception")
|
|
142
|
+
lines.append(f"{' ' * depth}{relation}:")
|
|
143
|
+
for frame in cause.frames:
|
|
144
|
+
lines.extend(f"{' ' * depth}{line}" for line in _format_frame(frame))
|
|
145
|
+
lines.append(f"{' ' * depth}{cause.exception_type}: {cause.message}")
|
|
146
|
+
for child in cause.children:
|
|
147
|
+
lines.extend(_format_cause(child, depth + 1))
|
|
148
|
+
return lines
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _format_traceback(
|
|
152
|
+
exception_type: str,
|
|
153
|
+
message: str,
|
|
154
|
+
frames: tuple[FrameInfo, ...],
|
|
155
|
+
causes: tuple[DiagnosticCause, ...],
|
|
156
|
+
config: Config,
|
|
157
|
+
) -> str:
|
|
158
|
+
lines = ["Traceback (most recent call last):"]
|
|
159
|
+
for frame in frames:
|
|
160
|
+
lines.extend(_format_frame(frame))
|
|
161
|
+
lines.append(f"{exception_type}: {message}")
|
|
162
|
+
for cause in causes:
|
|
163
|
+
lines.extend(_format_cause(cause))
|
|
164
|
+
return sanitize_text("\n".join(lines), config, limit=config.max_traceback_chars)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def capture_exception(exc: BaseException, *, config: Config | None = None) -> ExceptionContext:
|
|
168
|
+
config = config or default_config()
|
|
169
|
+
frames: list[FrameInfo] = []
|
|
170
|
+
if exc.__traceback__ is not None:
|
|
171
|
+
extracted = traceback.extract_tb(exc.__traceback__, limit=-config.max_frames)
|
|
172
|
+
frames = [_build_frame(frame, config) for frame in extracted]
|
|
173
|
+
primary_frame = frames[-1] if frames else None
|
|
174
|
+
causes, causes_truncated = _capture_relationships(
|
|
175
|
+
exc,
|
|
176
|
+
config=config,
|
|
177
|
+
seen={id(exc)},
|
|
178
|
+
budget=[0],
|
|
179
|
+
)
|
|
180
|
+
message = _safe_message(exc, config)
|
|
181
|
+
formatted_traceback = _format_traceback(
|
|
182
|
+
type(exc).__name__,
|
|
183
|
+
message,
|
|
184
|
+
tuple(frames),
|
|
185
|
+
causes,
|
|
186
|
+
config,
|
|
187
|
+
)
|
|
188
|
+
return ExceptionContext(
|
|
189
|
+
exception_type=type(exc).__name__,
|
|
190
|
+
message=message,
|
|
191
|
+
exception_mro=tuple(base.__name__ for base in type(exc).__mro__[1:]),
|
|
192
|
+
frames=tuple(frames),
|
|
193
|
+
primary_frame=primary_frame,
|
|
194
|
+
formatted_traceback=formatted_traceback,
|
|
195
|
+
causes=causes,
|
|
196
|
+
causes_truncated=causes_truncated,
|
|
197
|
+
)
|
lucidlog/config.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
import re
|
|
5
|
+
from typing import TextIO
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
DEFAULT_REDACTION_PATTERNS = (
|
|
9
|
+
r"(?im)(authorization|cookie)\s*[:=]\s*[^\r\n]+",
|
|
10
|
+
r"""(?i)(password|passwd|pwd|secret|token|api[_-]?key|access[_-]?key)["']?\s*[:=]\s*(?:"[^"]*"|'[^']*'|[^\s,;}\]]+)""",
|
|
11
|
+
r"(?i)\b(bearer|basic)\s+[A-Za-z0-9._~+/-]+=*",
|
|
12
|
+
r"\b(?:gh[pousr]_[A-Za-z0-9_]{20,}|sk[-_](?:live|test)?[A-Za-z0-9_-]{16,}|xox[baprs]-[A-Za-z0-9-]{16,}|AIza[0-9A-Za-z_-]{20,})\b",
|
|
13
|
+
r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |OPENSSH )?PRIVATE KEY-----",
|
|
14
|
+
r"(?i)\bAKIA[0-9A-Z]{16}\b",
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class Config:
|
|
20
|
+
max_chain_depth: int = 4
|
|
21
|
+
max_source_context: int = 3
|
|
22
|
+
max_frames: int = 20
|
|
23
|
+
shorten_paths: bool = True
|
|
24
|
+
output_stream: TextIO | None = None
|
|
25
|
+
redact_patterns: tuple[str, ...] = DEFAULT_REDACTION_PATTERNS
|
|
26
|
+
redact_paths: bool = True
|
|
27
|
+
include_full_traceback: bool = False
|
|
28
|
+
max_message_length: int = 4096
|
|
29
|
+
include_exception_chain: bool = True
|
|
30
|
+
include_local_variables: bool = False
|
|
31
|
+
allow_network: bool = False
|
|
32
|
+
root_path: str | None = None
|
|
33
|
+
max_traceback_chars: int = 20000
|
|
34
|
+
max_group_exceptions: int = 20
|
|
35
|
+
max_context_line_length: int = 1000
|
|
36
|
+
max_source_file_bytes: int = 5000000
|
|
37
|
+
max_diagnosis_evidence: int = 20
|
|
38
|
+
color: bool | None = None
|
|
39
|
+
|
|
40
|
+
def __post_init__(self) -> None:
|
|
41
|
+
for name in (
|
|
42
|
+
"max_chain_depth",
|
|
43
|
+
"max_frames",
|
|
44
|
+
"max_message_length",
|
|
45
|
+
"max_traceback_chars",
|
|
46
|
+
"max_group_exceptions",
|
|
47
|
+
"max_context_line_length",
|
|
48
|
+
"max_source_file_bytes",
|
|
49
|
+
"max_diagnosis_evidence",
|
|
50
|
+
):
|
|
51
|
+
value = getattr(self, name)
|
|
52
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
53
|
+
raise TypeError(f"{name} must be an integer")
|
|
54
|
+
if value < 1:
|
|
55
|
+
raise ValueError(f"{name} must be at least 1")
|
|
56
|
+
if isinstance(self.max_source_context, bool) or not isinstance(self.max_source_context, int):
|
|
57
|
+
raise TypeError("max_source_context must be an integer")
|
|
58
|
+
if self.max_source_context < 0:
|
|
59
|
+
raise ValueError("max_source_context cannot be negative")
|
|
60
|
+
if not isinstance(self.redact_patterns, tuple) or not all(
|
|
61
|
+
isinstance(pattern, str) for pattern in self.redact_patterns
|
|
62
|
+
):
|
|
63
|
+
raise TypeError("redact_patterns must be a tuple of strings")
|
|
64
|
+
for pattern in self.redact_patterns:
|
|
65
|
+
re.compile(pattern)
|
|
66
|
+
if self.color is not None and not isinstance(self.color, bool):
|
|
67
|
+
raise TypeError("color must be True, False, or None")
|
|
68
|
+
for name in (
|
|
69
|
+
"shorten_paths",
|
|
70
|
+
"redact_paths",
|
|
71
|
+
"include_full_traceback",
|
|
72
|
+
"include_exception_chain",
|
|
73
|
+
"include_local_variables",
|
|
74
|
+
"allow_network",
|
|
75
|
+
):
|
|
76
|
+
if not isinstance(getattr(self, name), bool):
|
|
77
|
+
raise TypeError(f"{name} must be a boolean")
|
|
78
|
+
if self.include_local_variables:
|
|
79
|
+
raise ValueError("LucidLog does not capture local variables.")
|
|
80
|
+
if self.allow_network:
|
|
81
|
+
raise ValueError("The LucidLog core does not make network requests.")
|
|
82
|
+
if self.root_path is not None and not isinstance(self.root_path, str):
|
|
83
|
+
raise TypeError("root_path must be a string or None")
|
|
84
|
+
if self.output_stream is not None and not callable(getattr(self.output_stream, "write", None)):
|
|
85
|
+
raise TypeError("output_stream must provide a write() method")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def default_config() -> Config:
|
|
89
|
+
return Config()
|
lucidlog/diagnosis.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
from dataclasses import replace
|
|
5
|
+
from itertools import islice
|
|
6
|
+
|
|
7
|
+
from .capture import capture_exception
|
|
8
|
+
from .config import Config, default_config
|
|
9
|
+
from .models import Diagnostic, Diagnosis
|
|
10
|
+
from .redaction import sanitize_text
|
|
11
|
+
from .rules import diagnose_with_rules
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def severity_for_exception(exc: BaseException) -> str:
|
|
15
|
+
if isinstance(exc, Warning):
|
|
16
|
+
return "warning"
|
|
17
|
+
if isinstance(exc, (KeyboardInterrupt, SystemExit)) or type(exc).__name__ == "CancelledError":
|
|
18
|
+
return "info"
|
|
19
|
+
if isinstance(exc, (MemoryError, SystemError)):
|
|
20
|
+
return "critical"
|
|
21
|
+
members = getattr(exc, "exceptions", ())
|
|
22
|
+
if members:
|
|
23
|
+
if any(severity_for_exception(member) == "critical" for member in islice(members, 20)):
|
|
24
|
+
return "critical"
|
|
25
|
+
return "error"
|
|
26
|
+
return "error"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def build_diagnostic(exc: BaseException, *, config: Config | None = None) -> Diagnostic:
|
|
30
|
+
config = config or default_config()
|
|
31
|
+
context = capture_exception(exc, config=config)
|
|
32
|
+
diagnosis = diagnose_with_rules(context)
|
|
33
|
+
if not isinstance(diagnosis, Diagnosis):
|
|
34
|
+
raise TypeError("A diagnostic rule must return a Diagnosis instance.")
|
|
35
|
+
if not all(
|
|
36
|
+
isinstance(value, str)
|
|
37
|
+
for value in (diagnosis.summary, diagnosis.explanation, diagnosis.confidence, diagnosis.origin)
|
|
38
|
+
):
|
|
39
|
+
raise TypeError("A Diagnosis must use strings for its text fields.")
|
|
40
|
+
if diagnosis.suggestion is not None and not isinstance(diagnosis.suggestion, str):
|
|
41
|
+
raise TypeError("A Diagnosis suggestion must be a string or None.")
|
|
42
|
+
if not isinstance(diagnosis.evidence, tuple) or not all(
|
|
43
|
+
isinstance(item, str) for item in diagnosis.evidence
|
|
44
|
+
):
|
|
45
|
+
raise TypeError("A Diagnosis evidence field must be a tuple of strings.")
|
|
46
|
+
if diagnosis.confidence not in {"low", "medium", "high"}:
|
|
47
|
+
raise ValueError("A Diagnosis confidence must be low, medium, or high.")
|
|
48
|
+
evidence = list(diagnosis.evidence[: config.max_diagnosis_evidence])
|
|
49
|
+
if len(diagnosis.evidence) > config.max_diagnosis_evidence:
|
|
50
|
+
evidence[-1] = "Additional rule evidence omitted due to configured limits."
|
|
51
|
+
diagnosis = replace(
|
|
52
|
+
diagnosis,
|
|
53
|
+
summary=sanitize_text(diagnosis.summary, config),
|
|
54
|
+
explanation=sanitize_text(diagnosis.explanation, config),
|
|
55
|
+
suggestion=(
|
|
56
|
+
sanitize_text(diagnosis.suggestion, config)
|
|
57
|
+
if diagnosis.suggestion is not None
|
|
58
|
+
else None
|
|
59
|
+
),
|
|
60
|
+
confidence=sanitize_text(diagnosis.confidence, config, limit=64),
|
|
61
|
+
origin=sanitize_text(diagnosis.origin, config, limit=128),
|
|
62
|
+
evidence=tuple(sanitize_text(item, config) for item in evidence),
|
|
63
|
+
)
|
|
64
|
+
digest = hashlib.sha256()
|
|
65
|
+
location = context.primary_frame
|
|
66
|
+
fingerprint_parts = (
|
|
67
|
+
type(exc).__name__,
|
|
68
|
+
location.filename if location else "",
|
|
69
|
+
str(location.line_number) if location else "",
|
|
70
|
+
location.function if location else "",
|
|
71
|
+
)
|
|
72
|
+
digest.update(":".join(fingerprint_parts).encode("utf-8", errors="replace"))
|
|
73
|
+
diagnostic_id = digest.hexdigest()[:16]
|
|
74
|
+
return Diagnostic(
|
|
75
|
+
schema_version=1,
|
|
76
|
+
diagnostic_id=diagnostic_id,
|
|
77
|
+
exception_type=context.exception_type,
|
|
78
|
+
message=context.message,
|
|
79
|
+
frames=context.frames,
|
|
80
|
+
primary_frame=context.primary_frame,
|
|
81
|
+
causes=context.causes,
|
|
82
|
+
diagnosis=diagnosis,
|
|
83
|
+
formatted_traceback=context.formatted_traceback,
|
|
84
|
+
severity=severity_for_exception(exc),
|
|
85
|
+
causes_truncated=context.causes_truncated,
|
|
86
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Runtime integrations for LucidLog."""
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from typing import Any
|
|
6
|
+
from weakref import WeakKeyDictionary
|
|
7
|
+
|
|
8
|
+
from ..api import render_exception
|
|
9
|
+
from ..config import Config, default_config
|
|
10
|
+
|
|
11
|
+
_Handler = Callable[[asyncio.AbstractEventLoop, dict[str, Any]], None]
|
|
12
|
+
_installed_handlers: WeakKeyDictionary[asyncio.AbstractEventLoop, tuple[_Handler | None, _Handler]] = (
|
|
13
|
+
WeakKeyDictionary()
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def install_asyncio_exception_handler(
|
|
18
|
+
loop: asyncio.AbstractEventLoop | None = None,
|
|
19
|
+
*,
|
|
20
|
+
config: Config | None = None,
|
|
21
|
+
chain_previous: bool = False,
|
|
22
|
+
) -> None:
|
|
23
|
+
loop = loop or asyncio.get_running_loop()
|
|
24
|
+
if loop in _installed_handlers:
|
|
25
|
+
return
|
|
26
|
+
previous = loop.get_exception_handler()
|
|
27
|
+
selected_config = config or default_config()
|
|
28
|
+
|
|
29
|
+
def handler(current_loop: asyncio.AbstractEventLoop, context: dict[str, Any]) -> None:
|
|
30
|
+
exception = context.get("exception")
|
|
31
|
+
if not isinstance(exception, BaseException):
|
|
32
|
+
_delegate(current_loop, context, previous)
|
|
33
|
+
return
|
|
34
|
+
if previous is not None and not chain_previous:
|
|
35
|
+
previous(current_loop, context)
|
|
36
|
+
return
|
|
37
|
+
try:
|
|
38
|
+
render_exception(
|
|
39
|
+
exception,
|
|
40
|
+
full=selected_config.include_full_traceback,
|
|
41
|
+
stream=selected_config.output_stream,
|
|
42
|
+
config=selected_config,
|
|
43
|
+
)
|
|
44
|
+
except BaseException:
|
|
45
|
+
_delegate(current_loop, context, previous)
|
|
46
|
+
return
|
|
47
|
+
if previous is not None:
|
|
48
|
+
previous(current_loop, context)
|
|
49
|
+
|
|
50
|
+
_installed_handlers[loop] = (previous, handler)
|
|
51
|
+
loop.set_exception_handler(handler)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _delegate(
|
|
55
|
+
loop: asyncio.AbstractEventLoop,
|
|
56
|
+
context: dict[str, Any],
|
|
57
|
+
previous: _Handler | None,
|
|
58
|
+
) -> None:
|
|
59
|
+
if previous is not None:
|
|
60
|
+
previous(loop, context)
|
|
61
|
+
else:
|
|
62
|
+
loop.default_exception_handler(context)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def uninstall_asyncio_exception_handler(loop: asyncio.AbstractEventLoop | None = None) -> None:
|
|
66
|
+
loop = loop or asyncio.get_running_loop()
|
|
67
|
+
installed = _installed_handlers.pop(loop, None)
|
|
68
|
+
if installed is None:
|
|
69
|
+
return
|
|
70
|
+
previous, handler = installed
|
|
71
|
+
if loop.get_exception_handler() is handler:
|
|
72
|
+
loop.set_exception_handler(previous)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
from ..api import log_exception as _log_exception
|
|
6
|
+
from ..config import Config
|
|
7
|
+
from ..models import Diagnostic
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def log_exception_with_diagnostic(
|
|
11
|
+
logger: logging.Logger,
|
|
12
|
+
message: str,
|
|
13
|
+
*,
|
|
14
|
+
full: bool = False,
|
|
15
|
+
config: Config | None = None,
|
|
16
|
+
) -> Diagnostic:
|
|
17
|
+
return _log_exception(logger, message, full=full, config=config)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def log_exception(
|
|
21
|
+
logger: logging.Logger,
|
|
22
|
+
message: str,
|
|
23
|
+
*,
|
|
24
|
+
full: bool = False,
|
|
25
|
+
config: Config | None = None,
|
|
26
|
+
) -> Diagnostic:
|
|
27
|
+
return log_exception_with_diagnostic(logger, message, full=full, config=config)
|