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 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
@@ -0,0 +1,7 @@
1
+ """``python -m excdump ...`` -- same commands as the CLI."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ raise SystemExit(main(sys.argv))
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)