excdump 0.1.0__tar.gz
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-0.1.0/.gitignore +13 -0
- excdump-0.1.0/LICENSE +20 -0
- excdump-0.1.0/PKG-INFO +235 -0
- excdump-0.1.0/README.md +213 -0
- excdump-0.1.0/excdump/__init__.py +120 -0
- excdump-0.1.0/excdump/__main__.py +7 -0
- excdump-0.1.0/excdump/capture.py +291 -0
- excdump-0.1.0/excdump/cli.py +365 -0
- excdump-0.1.0/excdump/config.py +237 -0
- excdump-0.1.0/excdump/loading.py +53 -0
- excdump-0.1.0/excdump/model.py +172 -0
- excdump-0.1.0/excdump/paths.py +103 -0
- excdump-0.1.0/excdump/py.typed +0 -0
- excdump-0.1.0/excdump/session.py +258 -0
- excdump-0.1.0/excdump/sources.py +200 -0
- excdump-0.1.0/excdump/store.py +397 -0
- excdump-0.1.0/excdump/tui.py +450 -0
- excdump-0.1.0/excdump/values.py +261 -0
- excdump-0.1.0/pyproject.toml +41 -0
excdump-0.1.0/.gitignore
ADDED
excdump-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Alireza Hariri
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR OTHER DEALINGS IN THE SOFTWARE.
|
excdump-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: excdump
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Capture a full exception snapshot and debug it later, offline.
|
|
5
|
+
Project-URL: Homepage, https://github.com/alireza-hariri/excdump
|
|
6
|
+
Project-URL: Repository, https://github.com/alireza-hariri/excdump
|
|
7
|
+
Project-URL: Issues, https://github.com/alireza-hariri/excdump/issues
|
|
8
|
+
Author-email: Alireza Hariri <ali.r.hariri@gmail.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: debugging,exceptions,pdb,postmortem,traceback
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Software Development :: Debuggers
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Python: >=3.11
|
|
18
|
+
Requires-Dist: dill>=0.4.1
|
|
19
|
+
Requires-Dist: prompt-toolkit>=3.0.53
|
|
20
|
+
Requires-Dist: pydantic>=2.13.4
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# excdump
|
|
24
|
+
|
|
25
|
+
Capture everything about an exception the moment it happens, then debug it later
|
|
26
|
+
on your own machine — with the frames, the locals, related globals, and the source as they were.
|
|
27
|
+
|
|
28
|
+
A traceback tells you where a program failed. It does not tell you what `order`
|
|
29
|
+
held, what `rate` had been computed to, or what the file looked like before you
|
|
30
|
+
edited it. `excdump` writes all of that to a file at the moment of failure, and
|
|
31
|
+
gives you an offline debugger to walk it afterwards.
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from excdump import dump_exception
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
handle_checkout()
|
|
38
|
+
except Exception:
|
|
39
|
+
trace_id = dump_exception() # returns the id -- log it
|
|
40
|
+
raise
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
```console
|
|
44
|
+
$ python -m excdump inspect <trace-id>
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Install
|
|
48
|
+
|
|
49
|
+
Requires Python 3.14+.
|
|
50
|
+
|
|
51
|
+
```console
|
|
52
|
+
$ uv sync # or: pip install -e .
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Try it without writing any code:
|
|
56
|
+
|
|
57
|
+
```console
|
|
58
|
+
$ python -m excdump demo
|
|
59
|
+
$ python -m excdump inspect
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Capturing
|
|
63
|
+
|
|
64
|
+
Two ways in. `dump_exception()` is called from an `except` block and returns the
|
|
65
|
+
trace id, which is what you log:
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
except Exception:
|
|
69
|
+
logger.error("checkout failed", extra={"trace_id": dump_exception()})
|
|
70
|
+
raise
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`@dump_on_exception` wraps a function and captures anything that escapes it,
|
|
74
|
+
then re-raises unchanged. It takes options, or none at all:
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
from excdump import dump_on_exception
|
|
78
|
+
|
|
79
|
+
@dump_on_exception
|
|
80
|
+
def handle_checkout(cart):
|
|
81
|
+
...
|
|
82
|
+
|
|
83
|
+
@dump_on_exception(on_dump=report_to_sentry, n_depth_up=2)
|
|
84
|
+
def calculate_tax(order):
|
|
85
|
+
...
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
The decorated function is the pivot the session opens on, with the decorator's
|
|
89
|
+
own frames left out, so `up` reaches its real caller.
|
|
90
|
+
|
|
91
|
+
Both capture the frames of the traceback plus a configurable number of caller
|
|
92
|
+
frames above it, every exception in a `raise ... from ...` chain, and the source
|
|
93
|
+
of each file involved.
|
|
94
|
+
|
|
95
|
+
## Inspecting
|
|
96
|
+
|
|
97
|
+
```console
|
|
98
|
+
$ python -m excdump list # what the store holds, grouped by failure
|
|
99
|
+
$ python -m excdump inspect # the most recent dump
|
|
100
|
+
$ python -m excdump inspect <trace-id> # a specific one; a unique prefix works
|
|
101
|
+
$ python -m excdump inspect <path-id> # newest dump of one failure
|
|
102
|
+
$ python -m excdump gc # reclaim paths nothing hits any more
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
`inspect` opens a `pdb`-like session over the dump. Frames, locals and globals
|
|
106
|
+
are all there; `--plain` forces the readline prompt instead of the TUI.
|
|
107
|
+
|
|
108
|
+
Nothing about the session touches the process that crashed — it no longer
|
|
109
|
+
exists. That is the point: you get to look around at your leisure, on a
|
|
110
|
+
different machine, days later.
|
|
111
|
+
|
|
112
|
+
## How dumps are filed
|
|
113
|
+
|
|
114
|
+
A dump is filed under its **exception path** — the `(filename, lineno)` list of
|
|
115
|
+
its traceback, hashed. One bug hit a million times shares one path, so retention
|
|
116
|
+
applies per failure and a hot loop cannot push a rarer failure out of the store.
|
|
117
|
+
|
|
118
|
+
```
|
|
119
|
+
.exception_dumps/
|
|
120
|
+
.gc when the last sweep ran
|
|
121
|
+
<path_id>/
|
|
122
|
+
path.json the (filename, lineno) list, for humans
|
|
123
|
+
sources/<hash>.json.gz full text of a captured file
|
|
124
|
+
<trace_id>.dump one capture
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Nothing is ever read to decide where a dump goes, so many processes can write
|
|
128
|
+
into one store with no coordination between them.
|
|
129
|
+
|
|
130
|
+
Three things bound the size:
|
|
131
|
+
|
|
132
|
+
| | bound by | when |
|
|
133
|
+
|---|---|---|
|
|
134
|
+
| dumps within a path | count, `max_dumps_per_path` | every capture |
|
|
135
|
+
| whole paths | age, `max_path_age_days` | hourly during capture, and `gc` |
|
|
136
|
+
| abandoned temporaries | age, one hour | with the sweep above |
|
|
137
|
+
|
|
138
|
+
Paths need collecting because their ids churn: the id hashes line numbers, so a
|
|
139
|
+
deploy that shifts a line puts every traceback through that file under a new id
|
|
140
|
+
and leaves the old one unreachable. Age tells those apart from failures that
|
|
141
|
+
simply have not recurred yet — and needs no deploy hook to do it.
|
|
142
|
+
|
|
143
|
+
Removing a path removes everything it held. No rule here reads anything but file
|
|
144
|
+
names, which is why the sweep can run during capture without loading a thing.
|
|
145
|
+
|
|
146
|
+
## Source is stored, not re-read
|
|
147
|
+
|
|
148
|
+
Each captured file's full text is written into the path's `sources/` directory,
|
|
149
|
+
named by the hash of its contents. The inspector reads *that*, not the file on
|
|
150
|
+
disk.
|
|
151
|
+
|
|
152
|
+
This matters more than it sounds. Read the live file back and every dump written
|
|
153
|
+
before your last edit points its arrow at the wrong line — silently, and most
|
|
154
|
+
confusingly for the old dumps you most want to trust. Content addressing also
|
|
155
|
+
means a file edited between two dumps of one path simply lands in a second blob,
|
|
156
|
+
and two processes writing the same text write the same bytes to the same name.
|
|
157
|
+
|
|
158
|
+
## What gets stored for a value
|
|
159
|
+
|
|
160
|
+
Each captured value is stored the cheapest way that keeps it, decided per value:
|
|
161
|
+
|
|
162
|
+
- **plain pickle** if pickle can take it and the result will resolve wherever
|
|
163
|
+
the dump is read;
|
|
164
|
+
- otherwise **dill**, which handles functions, classes and closures pickle
|
|
165
|
+
refuses. Everything dill takes goes into one shared stream per dump, so ten
|
|
166
|
+
functions from one module carry that module's namespace once between them.
|
|
167
|
+
Both of dill's encodings are tried and the smaller kept: neither wins in
|
|
168
|
+
general, and the one that lost by 8× on a batch of functions from one module
|
|
169
|
+
won by 3× on a single decorated one.
|
|
170
|
+
- **modules** are stored by name and re-imported on load, rather than pickled
|
|
171
|
+
whole;
|
|
172
|
+
- anything left becomes a capped `repr`, visible as a placeholder rather than a
|
|
173
|
+
failed load.
|
|
174
|
+
|
|
175
|
+
A dump that cannot be fully reconstructed still opens. A class this machine
|
|
176
|
+
cannot import shows as `<Unavailable pkg.Thing>` and everything around it still
|
|
177
|
+
reads.
|
|
178
|
+
|
|
179
|
+
Set `serializer` to `"dill"` to send everything through dill (much larger), or
|
|
180
|
+
`"pickle"` to drop whatever pickle will not take.
|
|
181
|
+
|
|
182
|
+
## Configuration
|
|
183
|
+
|
|
184
|
+
`configure()` validates as it sets, so a typo fails at startup rather than
|
|
185
|
+
mid-exception:
|
|
186
|
+
|
|
187
|
+
```python
|
|
188
|
+
from excdump import configure
|
|
189
|
+
|
|
190
|
+
configure(store_dir="/var/log/exception_dumps", max_dumps_per_path=500)
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Every field also reads `EXCDUMP_<NAME>` from the environment, so a deployment
|
|
194
|
+
can tune capture without touching code. A malformed value is ignored rather than
|
|
195
|
+
raised — a bad environment variable must not stop an app from starting.
|
|
196
|
+
|
|
197
|
+
| option | default | meaning |
|
|
198
|
+
|---|---|---|
|
|
199
|
+
| `store_dir` | `./.exception_dumps` | root of the dump store |
|
|
200
|
+
| `max_dumps_per_path` | 200 | dumps kept per failure; 0 disables pruning |
|
|
201
|
+
| `max_path_age_days` | 14 | age at which a whole path is dropped; 0 keeps forever |
|
|
202
|
+
| `gc_interval_seconds` | 3600 | how often capture sweeps; 0 leaves it to `gc` |
|
|
203
|
+
| `n_depth_up` | 5 | caller frames captured above the handling frame |
|
|
204
|
+
| `n_depth_down` | 5 | traceback frames captured below it |
|
|
205
|
+
| `serializer` | `"auto"` | `"auto"`, `"dill"` or `"pickle"` |
|
|
206
|
+
| `source_radius` | 5 | lines kept either side of each captured line |
|
|
207
|
+
| `max_repr_chars` | 2000 | cap on a stored `repr` |
|
|
208
|
+
| `max_dill_bytes` | 65536 | backstop on one dill-serialized value |
|
|
209
|
+
| `max_source_bytes` | 1000000 | cap on a captured file's stored text |
|
|
210
|
+
| `enabled` | `True` | master switch |
|
|
211
|
+
| `on_dump` | `None` | callback given each trace id |
|
|
212
|
+
| `verbose` | `False` | print the dump path to stderr |
|
|
213
|
+
|
|
214
|
+
## Capture never breaks the caller
|
|
215
|
+
|
|
216
|
+
Everything on the capture path is written to fail quietly. A store that cannot
|
|
217
|
+
be written, a value that cannot be serialized, a sweep that cannot run — none of
|
|
218
|
+
them raise, because all of it happens with an exception already in flight and
|
|
219
|
+
losing the original failure is always worse.
|
|
220
|
+
|
|
221
|
+
## Layout
|
|
222
|
+
|
|
223
|
+
| module | holds |
|
|
224
|
+
|---|---|
|
|
225
|
+
| `config.py` | `Config`, `configure()`, environment overrides |
|
|
226
|
+
| `paths.py` | trace and path ids, file-name conventions |
|
|
227
|
+
| `model.py` | `ExceptionDump`, `ExceptionRecord`, `FrameSnapshot` |
|
|
228
|
+
| `sources.py` | captured source and the per-path sidecar |
|
|
229
|
+
| `values.py` | which serializer each value gets |
|
|
230
|
+
| `capture.py` | `dump_exception`, `dump_on_exception` |
|
|
231
|
+
| `store.py` | on-disk layout, retention, collection, lookup |
|
|
232
|
+
| `loading.py` | reading a dump back, tolerantly |
|
|
233
|
+
| `session.py` | the offline debugging session |
|
|
234
|
+
| `cli.py` | commands and the readline inspector |
|
|
235
|
+
| `tui.py` | the full-screen inspector |
|
excdump-0.1.0/README.md
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# excdump
|
|
2
|
+
|
|
3
|
+
Capture everything about an exception the moment it happens, then debug it later
|
|
4
|
+
on your own machine — with the frames, the locals, related globals, and the source as they were.
|
|
5
|
+
|
|
6
|
+
A traceback tells you where a program failed. It does not tell you what `order`
|
|
7
|
+
held, what `rate` had been computed to, or what the file looked like before you
|
|
8
|
+
edited it. `excdump` writes all of that to a file at the moment of failure, and
|
|
9
|
+
gives you an offline debugger to walk it afterwards.
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
from excdump import dump_exception
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
handle_checkout()
|
|
16
|
+
except Exception:
|
|
17
|
+
trace_id = dump_exception() # returns the id -- log it
|
|
18
|
+
raise
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
```console
|
|
22
|
+
$ python -m excdump inspect <trace-id>
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
Requires Python 3.14+.
|
|
28
|
+
|
|
29
|
+
```console
|
|
30
|
+
$ uv sync # or: pip install -e .
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Try it without writing any code:
|
|
34
|
+
|
|
35
|
+
```console
|
|
36
|
+
$ python -m excdump demo
|
|
37
|
+
$ python -m excdump inspect
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Capturing
|
|
41
|
+
|
|
42
|
+
Two ways in. `dump_exception()` is called from an `except` block and returns the
|
|
43
|
+
trace id, which is what you log:
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
except Exception:
|
|
47
|
+
logger.error("checkout failed", extra={"trace_id": dump_exception()})
|
|
48
|
+
raise
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
`@dump_on_exception` wraps a function and captures anything that escapes it,
|
|
52
|
+
then re-raises unchanged. It takes options, or none at all:
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from excdump import dump_on_exception
|
|
56
|
+
|
|
57
|
+
@dump_on_exception
|
|
58
|
+
def handle_checkout(cart):
|
|
59
|
+
...
|
|
60
|
+
|
|
61
|
+
@dump_on_exception(on_dump=report_to_sentry, n_depth_up=2)
|
|
62
|
+
def calculate_tax(order):
|
|
63
|
+
...
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
The decorated function is the pivot the session opens on, with the decorator's
|
|
67
|
+
own frames left out, so `up` reaches its real caller.
|
|
68
|
+
|
|
69
|
+
Both capture the frames of the traceback plus a configurable number of caller
|
|
70
|
+
frames above it, every exception in a `raise ... from ...` chain, and the source
|
|
71
|
+
of each file involved.
|
|
72
|
+
|
|
73
|
+
## Inspecting
|
|
74
|
+
|
|
75
|
+
```console
|
|
76
|
+
$ python -m excdump list # what the store holds, grouped by failure
|
|
77
|
+
$ python -m excdump inspect # the most recent dump
|
|
78
|
+
$ python -m excdump inspect <trace-id> # a specific one; a unique prefix works
|
|
79
|
+
$ python -m excdump inspect <path-id> # newest dump of one failure
|
|
80
|
+
$ python -m excdump gc # reclaim paths nothing hits any more
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
`inspect` opens a `pdb`-like session over the dump. Frames, locals and globals
|
|
84
|
+
are all there; `--plain` forces the readline prompt instead of the TUI.
|
|
85
|
+
|
|
86
|
+
Nothing about the session touches the process that crashed — it no longer
|
|
87
|
+
exists. That is the point: you get to look around at your leisure, on a
|
|
88
|
+
different machine, days later.
|
|
89
|
+
|
|
90
|
+
## How dumps are filed
|
|
91
|
+
|
|
92
|
+
A dump is filed under its **exception path** — the `(filename, lineno)` list of
|
|
93
|
+
its traceback, hashed. One bug hit a million times shares one path, so retention
|
|
94
|
+
applies per failure and a hot loop cannot push a rarer failure out of the store.
|
|
95
|
+
|
|
96
|
+
```
|
|
97
|
+
.exception_dumps/
|
|
98
|
+
.gc when the last sweep ran
|
|
99
|
+
<path_id>/
|
|
100
|
+
path.json the (filename, lineno) list, for humans
|
|
101
|
+
sources/<hash>.json.gz full text of a captured file
|
|
102
|
+
<trace_id>.dump one capture
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Nothing is ever read to decide where a dump goes, so many processes can write
|
|
106
|
+
into one store with no coordination between them.
|
|
107
|
+
|
|
108
|
+
Three things bound the size:
|
|
109
|
+
|
|
110
|
+
| | bound by | when |
|
|
111
|
+
|---|---|---|
|
|
112
|
+
| dumps within a path | count, `max_dumps_per_path` | every capture |
|
|
113
|
+
| whole paths | age, `max_path_age_days` | hourly during capture, and `gc` |
|
|
114
|
+
| abandoned temporaries | age, one hour | with the sweep above |
|
|
115
|
+
|
|
116
|
+
Paths need collecting because their ids churn: the id hashes line numbers, so a
|
|
117
|
+
deploy that shifts a line puts every traceback through that file under a new id
|
|
118
|
+
and leaves the old one unreachable. Age tells those apart from failures that
|
|
119
|
+
simply have not recurred yet — and needs no deploy hook to do it.
|
|
120
|
+
|
|
121
|
+
Removing a path removes everything it held. No rule here reads anything but file
|
|
122
|
+
names, which is why the sweep can run during capture without loading a thing.
|
|
123
|
+
|
|
124
|
+
## Source is stored, not re-read
|
|
125
|
+
|
|
126
|
+
Each captured file's full text is written into the path's `sources/` directory,
|
|
127
|
+
named by the hash of its contents. The inspector reads *that*, not the file on
|
|
128
|
+
disk.
|
|
129
|
+
|
|
130
|
+
This matters more than it sounds. Read the live file back and every dump written
|
|
131
|
+
before your last edit points its arrow at the wrong line — silently, and most
|
|
132
|
+
confusingly for the old dumps you most want to trust. Content addressing also
|
|
133
|
+
means a file edited between two dumps of one path simply lands in a second blob,
|
|
134
|
+
and two processes writing the same text write the same bytes to the same name.
|
|
135
|
+
|
|
136
|
+
## What gets stored for a value
|
|
137
|
+
|
|
138
|
+
Each captured value is stored the cheapest way that keeps it, decided per value:
|
|
139
|
+
|
|
140
|
+
- **plain pickle** if pickle can take it and the result will resolve wherever
|
|
141
|
+
the dump is read;
|
|
142
|
+
- otherwise **dill**, which handles functions, classes and closures pickle
|
|
143
|
+
refuses. Everything dill takes goes into one shared stream per dump, so ten
|
|
144
|
+
functions from one module carry that module's namespace once between them.
|
|
145
|
+
Both of dill's encodings are tried and the smaller kept: neither wins in
|
|
146
|
+
general, and the one that lost by 8× on a batch of functions from one module
|
|
147
|
+
won by 3× on a single decorated one.
|
|
148
|
+
- **modules** are stored by name and re-imported on load, rather than pickled
|
|
149
|
+
whole;
|
|
150
|
+
- anything left becomes a capped `repr`, visible as a placeholder rather than a
|
|
151
|
+
failed load.
|
|
152
|
+
|
|
153
|
+
A dump that cannot be fully reconstructed still opens. A class this machine
|
|
154
|
+
cannot import shows as `<Unavailable pkg.Thing>` and everything around it still
|
|
155
|
+
reads.
|
|
156
|
+
|
|
157
|
+
Set `serializer` to `"dill"` to send everything through dill (much larger), or
|
|
158
|
+
`"pickle"` to drop whatever pickle will not take.
|
|
159
|
+
|
|
160
|
+
## Configuration
|
|
161
|
+
|
|
162
|
+
`configure()` validates as it sets, so a typo fails at startup rather than
|
|
163
|
+
mid-exception:
|
|
164
|
+
|
|
165
|
+
```python
|
|
166
|
+
from excdump import configure
|
|
167
|
+
|
|
168
|
+
configure(store_dir="/var/log/exception_dumps", max_dumps_per_path=500)
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Every field also reads `EXCDUMP_<NAME>` from the environment, so a deployment
|
|
172
|
+
can tune capture without touching code. A malformed value is ignored rather than
|
|
173
|
+
raised — a bad environment variable must not stop an app from starting.
|
|
174
|
+
|
|
175
|
+
| option | default | meaning |
|
|
176
|
+
|---|---|---|
|
|
177
|
+
| `store_dir` | `./.exception_dumps` | root of the dump store |
|
|
178
|
+
| `max_dumps_per_path` | 200 | dumps kept per failure; 0 disables pruning |
|
|
179
|
+
| `max_path_age_days` | 14 | age at which a whole path is dropped; 0 keeps forever |
|
|
180
|
+
| `gc_interval_seconds` | 3600 | how often capture sweeps; 0 leaves it to `gc` |
|
|
181
|
+
| `n_depth_up` | 5 | caller frames captured above the handling frame |
|
|
182
|
+
| `n_depth_down` | 5 | traceback frames captured below it |
|
|
183
|
+
| `serializer` | `"auto"` | `"auto"`, `"dill"` or `"pickle"` |
|
|
184
|
+
| `source_radius` | 5 | lines kept either side of each captured line |
|
|
185
|
+
| `max_repr_chars` | 2000 | cap on a stored `repr` |
|
|
186
|
+
| `max_dill_bytes` | 65536 | backstop on one dill-serialized value |
|
|
187
|
+
| `max_source_bytes` | 1000000 | cap on a captured file's stored text |
|
|
188
|
+
| `enabled` | `True` | master switch |
|
|
189
|
+
| `on_dump` | `None` | callback given each trace id |
|
|
190
|
+
| `verbose` | `False` | print the dump path to stderr |
|
|
191
|
+
|
|
192
|
+
## Capture never breaks the caller
|
|
193
|
+
|
|
194
|
+
Everything on the capture path is written to fail quietly. A store that cannot
|
|
195
|
+
be written, a value that cannot be serialized, a sweep that cannot run — none of
|
|
196
|
+
them raise, because all of it happens with an exception already in flight and
|
|
197
|
+
losing the original failure is always worse.
|
|
198
|
+
|
|
199
|
+
## Layout
|
|
200
|
+
|
|
201
|
+
| module | holds |
|
|
202
|
+
|---|---|
|
|
203
|
+
| `config.py` | `Config`, `configure()`, environment overrides |
|
|
204
|
+
| `paths.py` | trace and path ids, file-name conventions |
|
|
205
|
+
| `model.py` | `ExceptionDump`, `ExceptionRecord`, `FrameSnapshot` |
|
|
206
|
+
| `sources.py` | captured source and the per-path sidecar |
|
|
207
|
+
| `values.py` | which serializer each value gets |
|
|
208
|
+
| `capture.py` | `dump_exception`, `dump_on_exception` |
|
|
209
|
+
| `store.py` | on-disk layout, retention, collection, lookup |
|
|
210
|
+
| `loading.py` | reading a dump back, tolerantly |
|
|
211
|
+
| `session.py` | the offline debugging session |
|
|
212
|
+
| `cli.py` | commands and the readline inspector |
|
|
213
|
+
| `tui.py` | the full-screen inspector |
|
|
@@ -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
|
+
]
|