excdump 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.
- excdump/__init__.py +120 -0
- excdump/__main__.py +7 -0
- excdump/capture.py +291 -0
- excdump/cli.py +365 -0
- excdump/config.py +237 -0
- excdump/loading.py +53 -0
- excdump/model.py +172 -0
- excdump/paths.py +103 -0
- excdump/py.typed +0 -0
- excdump/session.py +258 -0
- excdump/sources.py +200 -0
- excdump/store.py +397 -0
- excdump/tui.py +450 -0
- excdump/values.py +261 -0
- excdump-0.1.0.dist-info/METADATA +235 -0
- excdump-0.1.0.dist-info/RECORD +18 -0
- excdump-0.1.0.dist-info/WHEEL +4 -0
- excdump-0.1.0.dist-info/licenses/LICENSE +20 -0
excdump/__init__.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Capture rich exception snapshots and inspect them offline.
|
|
2
|
+
|
|
3
|
+
The dump stores the whole exception chain (``__cause__`` / ``__context__``), so
|
|
4
|
+
the inspector can walk between chained exceptions the way modern pdb does with
|
|
5
|
+
its ``exceptions`` command, in addition to walking frames within one exception.
|
|
6
|
+
|
|
7
|
+
Dumps are kept small: source is stored once per file (as merged line windows
|
|
8
|
+
keyed by a path relative to the capture root, not per frame), each object is
|
|
9
|
+
serialized once no matter how many frames reference it, the whole file is
|
|
10
|
+
gzipped, and each value is stored with the cheapest serializer that can hold
|
|
11
|
+
it -- plain pickle for almost everything, dill only where pickle fails.
|
|
12
|
+
The full text of every captured file is written once per exception
|
|
13
|
+
path, beside the dumps, and referenced by content hash -- the inspector reads
|
|
14
|
+
that instead of the file on disk, so line numbers still line up after the code
|
|
15
|
+
has moved on. :func:`set_serializer` overrides that per-value choice with strict ``dill``
|
|
16
|
+
or strict ``pickle``.
|
|
17
|
+
|
|
18
|
+
In production, capture is configured once and then needs no arguments::
|
|
19
|
+
|
|
20
|
+
configure(store_dir="/var/log/exception_dumps", max_dumps_per_path=1000,
|
|
21
|
+
on_dump=lambda trace_id: log.error("dump %s", trace_id))
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
...
|
|
25
|
+
except Exception:
|
|
26
|
+
trace_id = dump_exception() # returns the id to log
|
|
27
|
+
|
|
28
|
+
Dumps are filed by *exception path* -- the ``(filename, lineno)`` list of the
|
|
29
|
+
traceback -- and each path keeps only its most recent
|
|
30
|
+
``CONFIG.max_dumps_per_path`` dumps, so a hot failure loop cannot fill the disk
|
|
31
|
+
and cannot push other, rarer failures out of the store. Paths themselves are
|
|
32
|
+
reclaimed by age -- see ``python -m excdump gc``.
|
|
33
|
+
|
|
34
|
+
The implementation is split by responsibility -- :mod:`~excdump.config`,
|
|
35
|
+
:mod:`~excdump.paths`, :mod:`~excdump.model`, :mod:`~excdump.sources`,
|
|
36
|
+
:mod:`~excdump.values`, :mod:`~excdump.capture`, :mod:`~excdump.store`,
|
|
37
|
+
:mod:`~excdump.loading`, :mod:`~excdump.session`, :mod:`~excdump.cli` --
|
|
38
|
+
and everything a caller needs is re-exported here.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
from .capture import dump_exception, dump_on_exception
|
|
42
|
+
from .cli import (
|
|
43
|
+
COMMANDS,
|
|
44
|
+
OfflinePdb,
|
|
45
|
+
dispatch,
|
|
46
|
+
gc_command,
|
|
47
|
+
help_text,
|
|
48
|
+
list_command,
|
|
49
|
+
load_and_debug,
|
|
50
|
+
main,
|
|
51
|
+
plain_loop,
|
|
52
|
+
)
|
|
53
|
+
from .config import (
|
|
54
|
+
CONFIG,
|
|
55
|
+
SERIALIZERS,
|
|
56
|
+
Config,
|
|
57
|
+
SerializerName,
|
|
58
|
+
Unset,
|
|
59
|
+
UNSET,
|
|
60
|
+
configure,
|
|
61
|
+
get_serializer,
|
|
62
|
+
logger,
|
|
63
|
+
set_serializer,
|
|
64
|
+
)
|
|
65
|
+
from .loading import load_dump
|
|
66
|
+
from .model import (
|
|
67
|
+
ExceptionDump,
|
|
68
|
+
ExceptionRecord,
|
|
69
|
+
FrameSnapshot,
|
|
70
|
+
MissingRef,
|
|
71
|
+
)
|
|
72
|
+
from .paths import (
|
|
73
|
+
DUMP_SUFFIX,
|
|
74
|
+
PATH_META,
|
|
75
|
+
SOURCE_DIR,
|
|
76
|
+
SOURCE_SUFFIX,
|
|
77
|
+
exception_path,
|
|
78
|
+
path_id,
|
|
79
|
+
relative_path,
|
|
80
|
+
trace_path_id,
|
|
81
|
+
)
|
|
82
|
+
from .session import DebuggerSession
|
|
83
|
+
from .sources import SourceFile, SourceStore
|
|
84
|
+
from .store import DumpStore, default_store, resolve_dump
|
|
85
|
+
from .values import _DillRef, _ModuleRef, _ValueFilter
|
|
86
|
+
|
|
87
|
+
__all__ = [
|
|
88
|
+
"CONFIG",
|
|
89
|
+
"COMMANDS",
|
|
90
|
+
"Config",
|
|
91
|
+
"DebuggerSession",
|
|
92
|
+
"DumpStore",
|
|
93
|
+
"ExceptionDump",
|
|
94
|
+
"ExceptionRecord",
|
|
95
|
+
"FrameSnapshot",
|
|
96
|
+
"MissingRef",
|
|
97
|
+
"OfflinePdb",
|
|
98
|
+
"SerializerName",
|
|
99
|
+
"SourceFile",
|
|
100
|
+
"SourceStore",
|
|
101
|
+
"configure",
|
|
102
|
+
"default_store",
|
|
103
|
+
"dispatch",
|
|
104
|
+
"dump_exception",
|
|
105
|
+
"dump_on_exception",
|
|
106
|
+
"exception_path",
|
|
107
|
+
"gc_command",
|
|
108
|
+
"get_serializer",
|
|
109
|
+
"help_text",
|
|
110
|
+
"list_command",
|
|
111
|
+
"load_and_debug",
|
|
112
|
+
"load_dump",
|
|
113
|
+
"main",
|
|
114
|
+
"path_id",
|
|
115
|
+
"plain_loop",
|
|
116
|
+
"relative_path",
|
|
117
|
+
"resolve_dump",
|
|
118
|
+
"set_serializer",
|
|
119
|
+
"trace_path_id",
|
|
120
|
+
]
|
excdump/__main__.py
ADDED
excdump/capture.py
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""Taking the snapshot: walking frames and the exception chain.
|
|
2
|
+
|
|
3
|
+
This is the only part that runs inside a failing application, so it stays
|
|
4
|
+
defensive -- a capture that raises would replace the user's exception with its
|
|
5
|
+
own.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import inspect
|
|
9
|
+
import sys
|
|
10
|
+
import time
|
|
11
|
+
import traceback
|
|
12
|
+
from functools import wraps
|
|
13
|
+
from types import FrameType
|
|
14
|
+
from typing import Any, Callable, Dict, Iterable, List, Optional, ParamSpec, Tuple, TypeVar
|
|
15
|
+
|
|
16
|
+
from .config import CONFIG, logger, _serializer_module, _serializer_name
|
|
17
|
+
from .model import ExceptionDump, ExceptionRecord, FrameSnapshot
|
|
18
|
+
from .paths import _new_trace_id, _tb_frames, exception_path, path_id
|
|
19
|
+
from .sources import SourceStore
|
|
20
|
+
from .store import DumpStore
|
|
21
|
+
from .values import _ValueFilter
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
P = ParamSpec("P")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
R = TypeVar("R")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _snapshot_frames(
|
|
31
|
+
frames: Iterable[Tuple[FrameType, int]],
|
|
32
|
+
store: SourceStore,
|
|
33
|
+
values: _ValueFilter,
|
|
34
|
+
) -> List[FrameSnapshot]:
|
|
35
|
+
"""Snapshot live frames while they are still reachable."""
|
|
36
|
+
snapshots: List[FrameSnapshot] = []
|
|
37
|
+
for frame, lineno in frames:
|
|
38
|
+
code = frame.f_code
|
|
39
|
+
file_id = store.capture(code.co_filename, lineno, code=code)
|
|
40
|
+
|
|
41
|
+
# Expressions only need globals referenced by this code object. Saving
|
|
42
|
+
# the whole module also pulls functions, imports, and decorator state
|
|
43
|
+
# into what should be a small crash snapshot.
|
|
44
|
+
referenced = {
|
|
45
|
+
name: frame.f_globals[name] for name in code.co_names if name in frame.f_globals
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
snapshots.append(
|
|
49
|
+
FrameSnapshot(
|
|
50
|
+
filename=sys.intern(code.co_filename),
|
|
51
|
+
lineno=lineno,
|
|
52
|
+
name=sys.intern(code.co_name),
|
|
53
|
+
locals_dict=values.filter(frame.f_locals),
|
|
54
|
+
globals_dict=values.filter(referenced),
|
|
55
|
+
file_id=file_id,
|
|
56
|
+
store=store,
|
|
57
|
+
)
|
|
58
|
+
)
|
|
59
|
+
return snapshots
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _chain_links(exc_value: BaseException) -> List[Tuple[BaseException, Optional[str]]]:
|
|
63
|
+
"""Walk a chain newest-first, pairing each exception with its older link."""
|
|
64
|
+
links: List[Tuple[BaseException, Optional[str]]] = []
|
|
65
|
+
seen: set = set()
|
|
66
|
+
current: Optional[BaseException] = exc_value
|
|
67
|
+
while current is not None and id(current) not in seen:
|
|
68
|
+
seen.add(id(current))
|
|
69
|
+
if current.__cause__ is not None:
|
|
70
|
+
older, relation = current.__cause__, "cause"
|
|
71
|
+
elif current.__context__ is not None and not current.__suppress_context__:
|
|
72
|
+
older, relation = current.__context__, "context"
|
|
73
|
+
else:
|
|
74
|
+
older, relation = None, None
|
|
75
|
+
links.append((current, relation))
|
|
76
|
+
current = older
|
|
77
|
+
return links
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def dump_exception(
|
|
81
|
+
exc_info=None,
|
|
82
|
+
*,
|
|
83
|
+
n_depth_up: Optional[int] = None,
|
|
84
|
+
n_depth_down: Optional[int] = None,
|
|
85
|
+
serializer: Optional[str] = None,
|
|
86
|
+
store: Optional["DumpStore"] = None,
|
|
87
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
88
|
+
_anchor_frame: Optional[FrameType] = None,
|
|
89
|
+
_first_up_frame: Optional[FrameType] = None,
|
|
90
|
+
) -> Optional[str]:
|
|
91
|
+
"""Capture the exception being handled and return its trace id.
|
|
92
|
+
|
|
93
|
+
Called with no arguments inside an ``except`` block, this captures
|
|
94
|
+
:func:`sys.exc_info` using the deployment's :data:`CONFIG` defaults. There
|
|
95
|
+
is no file name to choose: the dump is filed under its *exception path* --
|
|
96
|
+
the ``(filename, lineno)`` list of the traceback -- and named after the
|
|
97
|
+
returned trace id, which is what you log and later pass to ``inspect``.
|
|
98
|
+
|
|
99
|
+
``n_depth_up`` captures callers above the handling frame and
|
|
100
|
+
``n_depth_down`` captures traceback frames below it. The handling frame is
|
|
101
|
+
always included and is the initial frame selected by the inspector.
|
|
102
|
+
|
|
103
|
+
Chained exceptions (``raise ... from ...`` or exceptions raised while
|
|
104
|
+
handling another) are captured too, each with its own traceback frames,
|
|
105
|
+
capped at ``n_depth_up + n_depth_down + 1`` innermost frames.
|
|
106
|
+
|
|
107
|
+
Returns ``None`` when capture is disabled via ``CONFIG.enabled``.
|
|
108
|
+
|
|
109
|
+
The two private frame arguments are used by :func:`dump_on_exception` to
|
|
110
|
+
hide its wrapper frame from captured application frames.
|
|
111
|
+
"""
|
|
112
|
+
if isinstance(exc_info, str):
|
|
113
|
+
raise TypeError(
|
|
114
|
+
"dump_exception no longer takes a file path; dumps are named by trace id "
|
|
115
|
+
"under CONFIG.store_dir (see configure(store_dir=...))"
|
|
116
|
+
)
|
|
117
|
+
if not CONFIG.enabled:
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
n_depth_up = CONFIG.n_depth_up if n_depth_up is None else n_depth_up
|
|
121
|
+
n_depth_down = CONFIG.n_depth_down if n_depth_down is None else n_depth_down
|
|
122
|
+
if n_depth_up < 0 or n_depth_down < 0:
|
|
123
|
+
raise ValueError("depth values must be non-negative")
|
|
124
|
+
|
|
125
|
+
chosen = _serializer_name(serializer)
|
|
126
|
+
module = _serializer_module(chosen)
|
|
127
|
+
dump_store = store or DumpStore()
|
|
128
|
+
sources = SourceStore()
|
|
129
|
+
values = _ValueFilter(chosen)
|
|
130
|
+
|
|
131
|
+
if exc_info is None:
|
|
132
|
+
exc_info = sys.exc_info()
|
|
133
|
+
|
|
134
|
+
exc_type, exc_value, tb = exc_info
|
|
135
|
+
if tb is None:
|
|
136
|
+
raise ValueError("No active exception traceback found.")
|
|
137
|
+
|
|
138
|
+
caller_frame = inspect.currentframe().f_back
|
|
139
|
+
anchor_frame = _anchor_frame or caller_frame
|
|
140
|
+
|
|
141
|
+
# Traceback order is outermost to innermost. Locate the handling frame so
|
|
142
|
+
# "up" and "down" are measured from that frame, not the exception site.
|
|
143
|
+
tb_frames = _tb_frames(tb)
|
|
144
|
+
anchor_index = next(
|
|
145
|
+
(index for index, (frame, _) in enumerate(tb_frames) if frame is anchor_frame),
|
|
146
|
+
None,
|
|
147
|
+
)
|
|
148
|
+
if anchor_index is None:
|
|
149
|
+
# This can happen when dump_exception is called by a separate handler.
|
|
150
|
+
# In that case, use the first traceback frame as the navigation pivot.
|
|
151
|
+
anchor_index = 0
|
|
152
|
+
anchor_frame = tb_frames[0][0]
|
|
153
|
+
|
|
154
|
+
anchor_lineno = tb_frames[anchor_index][1]
|
|
155
|
+
down_frames = tb_frames[anchor_index + 1 : anchor_index + 1 + n_depth_down]
|
|
156
|
+
|
|
157
|
+
up_frames = []
|
|
158
|
+
frame = _first_up_frame if _first_up_frame is not None else anchor_frame.f_back
|
|
159
|
+
while frame is not None and len(up_frames) < n_depth_up:
|
|
160
|
+
up_frames.append((frame, frame.f_lineno))
|
|
161
|
+
frame = frame.f_back
|
|
162
|
+
up_frames.reverse()
|
|
163
|
+
|
|
164
|
+
primary = ExceptionRecord(
|
|
165
|
+
exc_type=str(exc_type.__name__ if exc_type else "Unknown"),
|
|
166
|
+
exc_value=str(exc_value),
|
|
167
|
+
formatted_tb="".join(traceback.format_exception(exc_type, exc_value, tb)),
|
|
168
|
+
frames=_snapshot_frames(
|
|
169
|
+
up_frames + [(anchor_frame, anchor_lineno)] + down_frames, sources, values
|
|
170
|
+
),
|
|
171
|
+
target_frame_index=len(up_frames),
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
links = _chain_links(exc_value) if exc_value is not None else [(None, None)]
|
|
175
|
+
primary.relation = links[0][1] if links else None
|
|
176
|
+
|
|
177
|
+
chain_limit = n_depth_up + n_depth_down + 1
|
|
178
|
+
records = [primary]
|
|
179
|
+
for older, relation in links[1:]:
|
|
180
|
+
older_frames = _tb_frames(older.__traceback__)[-chain_limit:]
|
|
181
|
+
records.append(
|
|
182
|
+
ExceptionRecord(
|
|
183
|
+
exc_type=type(older).__name__,
|
|
184
|
+
exc_value=str(older),
|
|
185
|
+
formatted_tb="".join(
|
|
186
|
+
traceback.format_exception_only(type(older), older)
|
|
187
|
+
),
|
|
188
|
+
frames=_snapshot_frames(older_frames, sources, values),
|
|
189
|
+
target_frame_index=max(0, len(older_frames) - 1),
|
|
190
|
+
relation=relation,
|
|
191
|
+
)
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
records.reverse() # oldest first, handled exception last
|
|
195
|
+
|
|
196
|
+
path = exception_path(tb, sources.root)
|
|
197
|
+
trace_id = _new_trace_id(path_id(path))
|
|
198
|
+
dump_data = ExceptionDump(
|
|
199
|
+
records,
|
|
200
|
+
sources=sources,
|
|
201
|
+
trace_id=trace_id,
|
|
202
|
+
path=path,
|
|
203
|
+
created_at=time.time(),
|
|
204
|
+
metadata=dict(metadata) if metadata else None,
|
|
205
|
+
# Written last: every frame has been filtered by now, so this is the
|
|
206
|
+
# complete set of values dill had to take, serialized together.
|
|
207
|
+
dill_blob=values.dill_blob(),
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
filepath = dump_store.write(dump_data, module)
|
|
211
|
+
|
|
212
|
+
if CONFIG.verbose:
|
|
213
|
+
print(f"[+] Exception context saved to: {filepath}", file=sys.stderr)
|
|
214
|
+
logger.info("captured %s as %s (%s)", dump_data.exc_type, trace_id, filepath)
|
|
215
|
+
_notify(CONFIG.on_dump, trace_id)
|
|
216
|
+
return trace_id
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _notify(callback: Optional[Callable[[str], None]], trace_id: str) -> None:
|
|
220
|
+
"""Hand a trace id to a user callback without letting it break capture."""
|
|
221
|
+
if callback is None:
|
|
222
|
+
return
|
|
223
|
+
try:
|
|
224
|
+
callback(trace_id)
|
|
225
|
+
except Exception:
|
|
226
|
+
logger.exception("on_dump callback failed for %s", trace_id)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def dump_on_exception(
|
|
230
|
+
func: Optional[Callable[P, R]] = None,
|
|
231
|
+
*,
|
|
232
|
+
on_dump: Optional[Callable[[str], None]] = None,
|
|
233
|
+
n_depth_up: Optional[int] = None,
|
|
234
|
+
n_depth_down: Optional[int] = None,
|
|
235
|
+
serializer: Optional[str] = None,
|
|
236
|
+
store: Optional[DumpStore] = None,
|
|
237
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
238
|
+
) -> Any:
|
|
239
|
+
"""Dump exceptions leaving a function, then re-raise them unchanged.
|
|
240
|
+
|
|
241
|
+
Usable bare (``@dump_on_exception``) or with options
|
|
242
|
+
(``@dump_on_exception(on_dump=report)``). ``on_dump`` receives the trace id
|
|
243
|
+
of each dump, which is how a service ties a user-visible error to the dump
|
|
244
|
+
it can inspect later; it runs in addition to ``CONFIG.on_dump``.
|
|
245
|
+
|
|
246
|
+
The decorated function is the navigation pivot. Decorator implementation
|
|
247
|
+
frames are omitted, so ``up`` reaches its real caller and ``down`` reaches
|
|
248
|
+
functions called by it. Capture never changes the program's behaviour: the
|
|
249
|
+
original exception propagates even if writing the dump fails.
|
|
250
|
+
"""
|
|
251
|
+
if isinstance(func, str):
|
|
252
|
+
raise TypeError(
|
|
253
|
+
"dump_on_exception no longer takes a file path; dumps are named by trace id "
|
|
254
|
+
"under CONFIG.store_dir (see configure(store_dir=...))"
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
def decorate(target: Callable[P, R]) -> Callable[P, R]:
|
|
258
|
+
@wraps(target)
|
|
259
|
+
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
|
|
260
|
+
try:
|
|
261
|
+
return target(*args, **kwargs)
|
|
262
|
+
except Exception:
|
|
263
|
+
exc_info = sys.exc_info()
|
|
264
|
+
tb = exc_info[2]
|
|
265
|
+
# The traceback starts at this wrapper; the next frame is the
|
|
266
|
+
# decorated function and is the correct up/down pivot.
|
|
267
|
+
decorated_tb = tb.tb_next if tb is not None else None
|
|
268
|
+
if decorated_tb is None:
|
|
269
|
+
raise
|
|
270
|
+
try:
|
|
271
|
+
trace_id = dump_exception(
|
|
272
|
+
exc_info,
|
|
273
|
+
n_depth_up=n_depth_up,
|
|
274
|
+
n_depth_down=n_depth_down,
|
|
275
|
+
serializer=serializer,
|
|
276
|
+
store=store,
|
|
277
|
+
metadata=metadata,
|
|
278
|
+
_anchor_frame=decorated_tb.tb_frame,
|
|
279
|
+
_first_up_frame=inspect.currentframe().f_back,
|
|
280
|
+
)
|
|
281
|
+
except Exception:
|
|
282
|
+
# A debugging aid must never take down the application.
|
|
283
|
+
logger.exception("failed to capture exception from %s", target.__qualname__)
|
|
284
|
+
else:
|
|
285
|
+
if trace_id is not None:
|
|
286
|
+
_notify(on_dump, trace_id)
|
|
287
|
+
raise
|
|
288
|
+
|
|
289
|
+
return wrapper
|
|
290
|
+
|
|
291
|
+
return decorate if func is None else decorate(func)
|