beautiful-traceback 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.
- beautiful_traceback/__init__.py +22 -0
- beautiful_traceback/_extension.py +14 -0
- beautiful_traceback/common.py +28 -0
- beautiful_traceback/formatting.py +512 -0
- beautiful_traceback/hook.py +76 -0
- beautiful_traceback/parsing.py +116 -0
- beautiful_traceback/pytest_plugin.py +83 -0
- beautiful_traceback-0.1.0.dist-info/METADATA +252 -0
- beautiful_traceback-0.1.0.dist-info/RECORD +11 -0
- beautiful_traceback-0.1.0.dist-info/WHEEL +4 -0
- beautiful_traceback-0.1.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from .hook import install
|
|
2
|
+
from .hook import uninstall
|
|
3
|
+
from .formatting import LoggingFormatter
|
|
4
|
+
from .formatting import LoggingFormatterMixin
|
|
5
|
+
|
|
6
|
+
from ._extension import load_ipython_extension # noqa: F401
|
|
7
|
+
|
|
8
|
+
__version__ = "0.1.0"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# retain typo for backward compatibility
|
|
12
|
+
LoggingFormaterMixin = LoggingFormatterMixin
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"install",
|
|
17
|
+
"uninstall",
|
|
18
|
+
"__version__",
|
|
19
|
+
"LoggingFormatter",
|
|
20
|
+
"LoggingFormatterMixin",
|
|
21
|
+
"LoggingFormaterMixin",
|
|
22
|
+
]
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def load_ipython_extension(ip: Any) -> None: # pragma: no cover
|
|
5
|
+
# prevent circular import
|
|
6
|
+
from beautiful_traceback import install
|
|
7
|
+
|
|
8
|
+
install()
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def unload_ipython_extension(ip: Any) -> None: # pragma: no cover
|
|
12
|
+
from beautiful_traceback import uninstall
|
|
13
|
+
|
|
14
|
+
uninstall()
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import typing as typ
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Entry(typ.NamedTuple):
|
|
5
|
+
module: str
|
|
6
|
+
call: str
|
|
7
|
+
lineno: str
|
|
8
|
+
src_ctx: str
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
Entries = typ.List[Entry]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Traceback(typ.NamedTuple):
|
|
15
|
+
exc_name: str
|
|
16
|
+
exc_msg: str
|
|
17
|
+
entries: Entries
|
|
18
|
+
|
|
19
|
+
is_caused: bool
|
|
20
|
+
is_context: bool
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
Tracebacks = typ.List[Traceback]
|
|
24
|
+
|
|
25
|
+
ALIASES_HEAD = "Aliases for entries in sys.path:"
|
|
26
|
+
TRACEBACK_HEAD = "Traceback (most recent call last):"
|
|
27
|
+
CAUSE_HEAD = "The above exception was the direct cause of the following exception:"
|
|
28
|
+
CONTEXT_HEAD = "During handling of the above exception, another exception occurred:"
|
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
import sys
|
|
4
|
+
import types
|
|
5
|
+
import typing as typ
|
|
6
|
+
import logging
|
|
7
|
+
import traceback as tb
|
|
8
|
+
import subprocess as sp
|
|
9
|
+
import collections
|
|
10
|
+
|
|
11
|
+
import colorama
|
|
12
|
+
|
|
13
|
+
import beautiful_traceback.common as com
|
|
14
|
+
|
|
15
|
+
DEFAULT_COLUMNS = 80
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _get_terminal_width() -> int:
|
|
19
|
+
try:
|
|
20
|
+
columns = int(os.environ["COLUMNS"])
|
|
21
|
+
# lines = int(os.environ['LINES' ])
|
|
22
|
+
return columns
|
|
23
|
+
except (KeyError, ValueError):
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
if not sys.stdout.isatty():
|
|
27
|
+
return DEFAULT_COLUMNS
|
|
28
|
+
|
|
29
|
+
if hasattr(os, "get_terminal_size"):
|
|
30
|
+
try:
|
|
31
|
+
size = os.get_terminal_size(0)
|
|
32
|
+
return size.columns
|
|
33
|
+
except OSError:
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
size_output = sp.check_output(["stty", "size"]).decode()
|
|
38
|
+
_, columns = [int(val) for val in size_output.strip().split()]
|
|
39
|
+
return columns
|
|
40
|
+
except sp.CalledProcessError:
|
|
41
|
+
pass
|
|
42
|
+
except IOError:
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
return DEFAULT_COLUMNS
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
FMT_MODULE: str = (
|
|
49
|
+
colorama.Fore.CYAN + colorama.Style.NORMAL + "{0}" + colorama.Style.RESET_ALL
|
|
50
|
+
)
|
|
51
|
+
FMT_CALL: str = (
|
|
52
|
+
colorama.Fore.YELLOW + colorama.Style.NORMAL + "{0}" + colorama.Style.RESET_ALL
|
|
53
|
+
)
|
|
54
|
+
FMT_LINENO: str = (
|
|
55
|
+
colorama.Fore.MAGENTA + colorama.Style.NORMAL + "{0}" + colorama.Style.RESET_ALL
|
|
56
|
+
)
|
|
57
|
+
FMT_CONTEXT: str = "{0}"
|
|
58
|
+
|
|
59
|
+
FMT_ERROR_NAME: str = (
|
|
60
|
+
colorama.Fore.RED + colorama.Style.BRIGHT + "{0}" + colorama.Style.RESET_ALL
|
|
61
|
+
)
|
|
62
|
+
FMT_ERROR_MSG: str = colorama.Style.BRIGHT + "{0}" + colorama.Style.RESET_ALL
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class Row(typ.NamedTuple):
|
|
66
|
+
alias: str
|
|
67
|
+
short_module: str
|
|
68
|
+
full_module: str
|
|
69
|
+
call: str
|
|
70
|
+
lineno: str
|
|
71
|
+
context: str
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class PaddedRow(typ.NamedTuple):
|
|
75
|
+
alias: str
|
|
76
|
+
short_module: str
|
|
77
|
+
full_module: str
|
|
78
|
+
call: str
|
|
79
|
+
lineno: str
|
|
80
|
+
context: str
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
Alias = str
|
|
84
|
+
Prefix = str
|
|
85
|
+
|
|
86
|
+
AliasPrefix = typ.Tuple[Alias, Prefix]
|
|
87
|
+
AliasPrefixes = typ.List[AliasPrefix]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class Context(typ.NamedTuple):
|
|
91
|
+
rows: typ.List[Row]
|
|
92
|
+
aliases: AliasPrefixes
|
|
93
|
+
|
|
94
|
+
max_row_width: int
|
|
95
|
+
is_wide_mode: bool
|
|
96
|
+
|
|
97
|
+
# for paddings
|
|
98
|
+
max_short_module_len: int
|
|
99
|
+
max_full_module_len: int
|
|
100
|
+
|
|
101
|
+
max_lineno_len: int
|
|
102
|
+
max_call_len: int
|
|
103
|
+
max_context_len: int
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _iter_entry_paths(entries: com.Entries) -> typ.Iterable[str]:
|
|
107
|
+
for entry in entries:
|
|
108
|
+
module_abspath = os.path.abspath(entry.module)
|
|
109
|
+
is_valid_abspath = module_abspath != entry.module and os.path.exists(
|
|
110
|
+
module_abspath
|
|
111
|
+
)
|
|
112
|
+
if is_valid_abspath:
|
|
113
|
+
yield module_abspath
|
|
114
|
+
else:
|
|
115
|
+
yield entry.module
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# used by unit tests to override paths
|
|
119
|
+
TEST_PATHS: typ.List[str] = []
|
|
120
|
+
|
|
121
|
+
PWD = os.getcwd()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _py_paths() -> typ.List[str]:
|
|
125
|
+
if TEST_PATHS:
|
|
126
|
+
return TEST_PATHS
|
|
127
|
+
|
|
128
|
+
# NOTE (mb 2020-08-16): We don't know which path entry
|
|
129
|
+
# was used to import a module. I guess we could figure it
|
|
130
|
+
# out, but the preference here is to make the shortest
|
|
131
|
+
# path possible.
|
|
132
|
+
|
|
133
|
+
paths = list(sys.path)
|
|
134
|
+
# NOTE (mb 2020-10-04): aliases must be sorted from longest to
|
|
135
|
+
# shortest, so that the longer matches are used first.
|
|
136
|
+
paths.sort(key=len, reverse=True)
|
|
137
|
+
|
|
138
|
+
if "" in paths:
|
|
139
|
+
paths.remove("")
|
|
140
|
+
|
|
141
|
+
return paths
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _iter_used_py_paths(entry_paths: typ.List[str]) -> typ.Iterable[str]:
|
|
145
|
+
_uniq_entry_paths = set(entry_paths)
|
|
146
|
+
|
|
147
|
+
for py_path in _py_paths():
|
|
148
|
+
is_path_used = False
|
|
149
|
+
for entry_path in list(_uniq_entry_paths):
|
|
150
|
+
if entry_path.startswith(py_path):
|
|
151
|
+
is_path_used = True
|
|
152
|
+
_uniq_entry_paths.remove(entry_path)
|
|
153
|
+
|
|
154
|
+
if is_path_used:
|
|
155
|
+
yield py_path
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _iter_alias_prefixes(entry_paths: typ.List[str]) -> typ.Iterable[AliasPrefix]:
|
|
159
|
+
alias_index = 0
|
|
160
|
+
|
|
161
|
+
for py_path in _iter_used_py_paths(entry_paths):
|
|
162
|
+
if py_path.endswith("site-packages"):
|
|
163
|
+
alias = "<site>"
|
|
164
|
+
elif py_path.endswith("dist-packages"):
|
|
165
|
+
alias = "<dist>"
|
|
166
|
+
elif re.search(r"lib/python\d.\d+$", py_path):
|
|
167
|
+
alias = "<py>"
|
|
168
|
+
elif re.search(r"lib/Python\d.\d+\\lib$", py_path):
|
|
169
|
+
alias = "<py>"
|
|
170
|
+
elif py_path.startswith(PWD):
|
|
171
|
+
alias = "<pwd>"
|
|
172
|
+
py_path = PWD
|
|
173
|
+
else:
|
|
174
|
+
alias = f"<p{alias_index}>"
|
|
175
|
+
alias_index += 1
|
|
176
|
+
|
|
177
|
+
# Always end paths with a slash. This way relative paths don't
|
|
178
|
+
# start with a / and tooling can open files (e.g. Ctrl+Click),
|
|
179
|
+
# which would otherwise be parsed as absolute paths.
|
|
180
|
+
if not py_path.endswith("/"):
|
|
181
|
+
py_path = py_path + "/"
|
|
182
|
+
|
|
183
|
+
yield (alias, py_path)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _iter_entry_rows(
|
|
187
|
+
aliases: AliasPrefixes, entry_paths: typ.List[str], entries: com.Entries
|
|
188
|
+
) -> typ.Iterable[Row]:
|
|
189
|
+
for abs_module, entry in zip(entry_paths, entries):
|
|
190
|
+
used_alias = ""
|
|
191
|
+
module_full = abs_module
|
|
192
|
+
module_short = abs_module
|
|
193
|
+
|
|
194
|
+
module = entry.module
|
|
195
|
+
if module.startswith("." + os.sep):
|
|
196
|
+
module = module[2:]
|
|
197
|
+
|
|
198
|
+
# NOTE (mb 2020-08-18): module may not be an absolute path,
|
|
199
|
+
# but it's not shortened using an alias yet either.
|
|
200
|
+
if abs_module.endswith(module):
|
|
201
|
+
for alias, alias_path in aliases:
|
|
202
|
+
if abs_module.startswith(alias_path):
|
|
203
|
+
new_module_short = abs_module[len(alias_path) :]
|
|
204
|
+
|
|
205
|
+
new_len = len(new_module_short) + len(alias)
|
|
206
|
+
old_len = len(module_short) + len(used_alias)
|
|
207
|
+
if new_len < old_len:
|
|
208
|
+
used_alias = alias
|
|
209
|
+
module_short = new_module_short
|
|
210
|
+
|
|
211
|
+
yield Row(
|
|
212
|
+
used_alias,
|
|
213
|
+
module_short,
|
|
214
|
+
module_full,
|
|
215
|
+
entry.call or "",
|
|
216
|
+
entry.lineno or "",
|
|
217
|
+
entry.src_ctx or "",
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _init_entries_context(
|
|
222
|
+
entries: com.Entries, term_width: typ.Optional[int] = None
|
|
223
|
+
) -> Context:
|
|
224
|
+
if term_width is None:
|
|
225
|
+
_term_width = _get_terminal_width()
|
|
226
|
+
else:
|
|
227
|
+
_term_width = term_width
|
|
228
|
+
|
|
229
|
+
entry_paths = list(_iter_entry_paths(entries))
|
|
230
|
+
aliases = list(_iter_alias_prefixes(entry_paths))
|
|
231
|
+
|
|
232
|
+
# NOTE (mb 2020-10-04): When calculating widths of a column, we care more
|
|
233
|
+
# about alignment than staying below the max_row_width. The limits are
|
|
234
|
+
# only a best effort and padding will be added even if that means
|
|
235
|
+
# wrapping. We rely on the aliases to reduce the wrapping.
|
|
236
|
+
|
|
237
|
+
# indent (4 spaces) + 3 x sep (2 spaces each)
|
|
238
|
+
max_row_width = _term_width - 10
|
|
239
|
+
|
|
240
|
+
rows = list(_iter_entry_rows(aliases, entry_paths, entries))
|
|
241
|
+
|
|
242
|
+
if rows:
|
|
243
|
+
max_short_module_len = max(
|
|
244
|
+
len(row.alias) + len(row.short_module) for row in rows
|
|
245
|
+
)
|
|
246
|
+
max_full_module_len = max(len(row.full_module) for row in rows)
|
|
247
|
+
|
|
248
|
+
max_lineno_len = max(len(row.lineno) for row in rows)
|
|
249
|
+
max_call_len = max(len(row.call) for row in rows)
|
|
250
|
+
max_context_len = max(len(row.context) for row in rows)
|
|
251
|
+
else:
|
|
252
|
+
max_short_module_len = 0
|
|
253
|
+
max_full_module_len = 0
|
|
254
|
+
|
|
255
|
+
max_lineno_len = 0
|
|
256
|
+
max_call_len = 0
|
|
257
|
+
max_context_len = 0
|
|
258
|
+
|
|
259
|
+
max_total_len = (
|
|
260
|
+
max_full_module_len + max_lineno_len + max_call_len + max_context_len
|
|
261
|
+
)
|
|
262
|
+
is_wide_mode = max_total_len < max_row_width
|
|
263
|
+
|
|
264
|
+
return Context(
|
|
265
|
+
rows,
|
|
266
|
+
aliases,
|
|
267
|
+
max_row_width,
|
|
268
|
+
is_wide_mode,
|
|
269
|
+
max_short_module_len,
|
|
270
|
+
max_full_module_len,
|
|
271
|
+
max_lineno_len,
|
|
272
|
+
max_call_len,
|
|
273
|
+
max_context_len,
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _padded_rows(ctx: Context) -> typ.Iterable[PaddedRow]:
|
|
278
|
+
# Expand padding from left to right.
|
|
279
|
+
# This will mutate rows (updating strings with added padding)
|
|
280
|
+
|
|
281
|
+
for row in ctx.rows:
|
|
282
|
+
if ctx.is_wide_mode:
|
|
283
|
+
short_module = ""
|
|
284
|
+
full_module = row.full_module.ljust(ctx.max_full_module_len)
|
|
285
|
+
else:
|
|
286
|
+
short_module = row.short_module.ljust(
|
|
287
|
+
ctx.max_short_module_len - len(row.alias)
|
|
288
|
+
)
|
|
289
|
+
full_module = ""
|
|
290
|
+
|
|
291
|
+
# the max lengths are calculated upstream in `_init_entries_context`
|
|
292
|
+
padded_call = row.call.ljust(ctx.max_call_len)
|
|
293
|
+
padded_lineno = row.lineno.ljust(ctx.max_lineno_len)
|
|
294
|
+
|
|
295
|
+
yield PaddedRow(
|
|
296
|
+
row.alias,
|
|
297
|
+
short_module,
|
|
298
|
+
full_module,
|
|
299
|
+
padded_call,
|
|
300
|
+
padded_lineno,
|
|
301
|
+
row.context,
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _aliases_to_lines(ctx: Context, color: bool = False) -> typ.Iterable[str]:
|
|
306
|
+
fmt_module = FMT_MODULE if color else "{0}"
|
|
307
|
+
if ctx.aliases:
|
|
308
|
+
alias_padding = max(len(alias) for alias, _ in ctx.aliases)
|
|
309
|
+
for alias, path in ctx.aliases:
|
|
310
|
+
yield " " + alias.ljust(alias_padding) + ": " + fmt_module.format(path)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _rows_to_lines(
|
|
314
|
+
rows: typ.List[PaddedRow], color: bool = False, local_stack_only: bool = False
|
|
315
|
+
) -> typ.Iterable[str]:
|
|
316
|
+
# apply colors and additional separators/ spacing
|
|
317
|
+
fmt_module = FMT_MODULE if color else "{0}"
|
|
318
|
+
fmt_call = FMT_CALL if color else "{0}"
|
|
319
|
+
fmt_lineno = FMT_LINENO if color else "{0}"
|
|
320
|
+
fmt_context = FMT_CONTEXT if color else "{0}"
|
|
321
|
+
|
|
322
|
+
# padding has already been added to the components at this point
|
|
323
|
+
for alias, short_module, full_module, call, lineno, context in rows:
|
|
324
|
+
if short_module:
|
|
325
|
+
_alias = alias
|
|
326
|
+
module = short_module
|
|
327
|
+
else:
|
|
328
|
+
_alias = ""
|
|
329
|
+
module = full_module
|
|
330
|
+
|
|
331
|
+
# append line number to file so editors can jump to the line
|
|
332
|
+
bare_module = module.strip()
|
|
333
|
+
bare_lineno = lineno.strip()
|
|
334
|
+
module_padding = " " * (
|
|
335
|
+
len(module) - len(bare_module) + len(lineno) - len(bare_lineno)
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
parts = (
|
|
339
|
+
" ",
|
|
340
|
+
_alias,
|
|
341
|
+
" ",
|
|
342
|
+
fmt_module.format(bare_module),
|
|
343
|
+
":",
|
|
344
|
+
fmt_lineno.format(bare_lineno),
|
|
345
|
+
module_padding,
|
|
346
|
+
" ",
|
|
347
|
+
fmt_call.format(call),
|
|
348
|
+
" ",
|
|
349
|
+
fmt_context.format(context),
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
line = "".join(parts)
|
|
353
|
+
|
|
354
|
+
if local_stack_only and not alias == "<pwd>":
|
|
355
|
+
continue
|
|
356
|
+
|
|
357
|
+
# bold any entries which are the current working directory
|
|
358
|
+
if alias == "<pwd>":
|
|
359
|
+
yield line.replace(colorama.Style.NORMAL, colorama.Style.BRIGHT)
|
|
360
|
+
else:
|
|
361
|
+
yield line
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _traceback_to_entries(traceback: types.TracebackType) -> typ.Iterable[com.Entry]:
|
|
365
|
+
summary = tb.extract_tb(traceback)
|
|
366
|
+
for entry in summary:
|
|
367
|
+
module = entry[0]
|
|
368
|
+
call = entry[2]
|
|
369
|
+
lineno = str(entry[1])
|
|
370
|
+
context = entry[3] or ""
|
|
371
|
+
yield com.Entry(module, call, lineno, context)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _format_traceback(
|
|
375
|
+
ctx: Context,
|
|
376
|
+
traceback: com.Traceback,
|
|
377
|
+
color: bool = False,
|
|
378
|
+
local_stack_only: bool = False,
|
|
379
|
+
) -> str:
|
|
380
|
+
padded_rows = list(_padded_rows(ctx))
|
|
381
|
+
|
|
382
|
+
lines = []
|
|
383
|
+
if ctx.aliases and not ctx.is_wide_mode:
|
|
384
|
+
lines.append(com.ALIASES_HEAD)
|
|
385
|
+
lines.extend(_aliases_to_lines(ctx, color))
|
|
386
|
+
|
|
387
|
+
lines.append(com.TRACEBACK_HEAD)
|
|
388
|
+
lines.extend(_rows_to_lines(padded_rows, color, local_stack_only))
|
|
389
|
+
|
|
390
|
+
if traceback.exc_name == "RecursionError" and len(lines) > 100:
|
|
391
|
+
prelude_index = 0
|
|
392
|
+
|
|
393
|
+
line_counts: typ.Dict[str, int] = collections.defaultdict(int)
|
|
394
|
+
for i, line in enumerate(lines):
|
|
395
|
+
line_counts[line] += 1
|
|
396
|
+
if line_counts[line] == 3:
|
|
397
|
+
prelude_index = i
|
|
398
|
+
break
|
|
399
|
+
|
|
400
|
+
if prelude_index > 0:
|
|
401
|
+
num_omitted = len(lines) - prelude_index - 2
|
|
402
|
+
lines = (
|
|
403
|
+
lines[:prelude_index]
|
|
404
|
+
+ [f" ... {num_omitted} omitted lines"]
|
|
405
|
+
+ lines[-2:]
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
fmt_error_name = FMT_ERROR_NAME if color else "{0}"
|
|
409
|
+
error_line = fmt_error_name.format(traceback.exc_name)
|
|
410
|
+
if traceback.exc_msg:
|
|
411
|
+
fmt_error_msg = FMT_ERROR_MSG if color else "{0}"
|
|
412
|
+
error_line += ": " + fmt_error_msg.format(traceback.exc_msg)
|
|
413
|
+
|
|
414
|
+
lines.append(error_line)
|
|
415
|
+
return os.linesep.join(lines) + os.linesep
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def format_traceback(
|
|
419
|
+
traceback: com.Traceback, color: bool = False, local_stack_only: bool = False
|
|
420
|
+
) -> str:
|
|
421
|
+
ctx = _init_entries_context(traceback.entries)
|
|
422
|
+
return _format_traceback(ctx, traceback, color, local_stack_only)
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def format_tracebacks(
|
|
426
|
+
tracebacks: typ.List[com.Traceback],
|
|
427
|
+
color: bool = False,
|
|
428
|
+
local_stack_only: bool = False,
|
|
429
|
+
) -> str:
|
|
430
|
+
traceback_strs: typ.List[str] = []
|
|
431
|
+
|
|
432
|
+
for tb_tup in tracebacks:
|
|
433
|
+
if tb_tup.is_caused:
|
|
434
|
+
# traceback_strs.append("vvv caused by ^^^ - ")
|
|
435
|
+
traceback_strs.append(com.CAUSE_HEAD + os.linesep)
|
|
436
|
+
elif tb_tup.is_context:
|
|
437
|
+
# traceback_strs.append("vvv happend after ^^^ - ")
|
|
438
|
+
traceback_strs.append(com.CONTEXT_HEAD + os.linesep)
|
|
439
|
+
|
|
440
|
+
traceback_str = format_traceback(tb_tup, color, local_stack_only)
|
|
441
|
+
traceback_strs.append(traceback_str)
|
|
442
|
+
|
|
443
|
+
return os.linesep.join(traceback_strs).strip()
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def get_tb_attr(ex: BaseException) -> types.TracebackType:
|
|
447
|
+
return typ.cast(types.TracebackType, getattr(ex, "__traceback__", None))
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def exc_to_traceback_str(
|
|
451
|
+
exc_value: BaseException,
|
|
452
|
+
traceback: types.TracebackType,
|
|
453
|
+
color: bool = False,
|
|
454
|
+
local_stack_only: bool = False,
|
|
455
|
+
) -> str:
|
|
456
|
+
# NOTE (mb 2020-08-13): wrt. cause vs context see
|
|
457
|
+
# https://www.python.org/dev/peps/pep-3134/#enhanced-reporting
|
|
458
|
+
# https://stackoverflow.com/questions/11235932/
|
|
459
|
+
tracebacks: typ.List[com.Traceback] = []
|
|
460
|
+
|
|
461
|
+
cur_exc_value: BaseException = exc_value
|
|
462
|
+
cur_traceback: types.TracebackType = traceback
|
|
463
|
+
|
|
464
|
+
# Track seen exceptions to prevent infinite loops from circular references
|
|
465
|
+
seen_exceptions: typ.Set[int] = set()
|
|
466
|
+
|
|
467
|
+
while cur_exc_value:
|
|
468
|
+
# Check if we've seen this exception before (circular reference)
|
|
469
|
+
exc_id = id(cur_exc_value)
|
|
470
|
+
if exc_id in seen_exceptions:
|
|
471
|
+
# Circular reference detected, break the loop
|
|
472
|
+
break
|
|
473
|
+
seen_exceptions.add(exc_id)
|
|
474
|
+
|
|
475
|
+
next_cause = getattr(cur_exc_value, "__cause__", None)
|
|
476
|
+
next_context = getattr(cur_exc_value, "__context__", None)
|
|
477
|
+
|
|
478
|
+
tb_tup = com.Traceback(
|
|
479
|
+
exc_name=type(cur_exc_value).__name__,
|
|
480
|
+
exc_msg=str(cur_exc_value),
|
|
481
|
+
entries=list(_traceback_to_entries(cur_traceback)),
|
|
482
|
+
is_caused=bool(next_cause),
|
|
483
|
+
is_context=bool(next_context),
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
tracebacks.append(tb_tup)
|
|
487
|
+
|
|
488
|
+
if next_cause:
|
|
489
|
+
cur_exc_value = next_cause
|
|
490
|
+
cur_traceback = get_tb_attr(next_cause)
|
|
491
|
+
elif next_context:
|
|
492
|
+
cur_exc_value = next_context
|
|
493
|
+
cur_traceback = get_tb_attr(next_context)
|
|
494
|
+
else:
|
|
495
|
+
break
|
|
496
|
+
|
|
497
|
+
tracebacks = list(reversed(tracebacks))
|
|
498
|
+
|
|
499
|
+
return format_tracebacks(tracebacks, color, local_stack_only)
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
class LoggingFormatterMixin:
|
|
503
|
+
# pylint:disable=invalid-name # logging module naming convention
|
|
504
|
+
# pylint:disable=no-self-use # because mixin
|
|
505
|
+
|
|
506
|
+
def formatException(self, ei) -> str:
|
|
507
|
+
_, exc_value, traceback = ei
|
|
508
|
+
return exc_to_traceback_str(exc_value, traceback, color=True)
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
class LoggingFormatter(LoggingFormatterMixin, logging.Formatter):
|
|
512
|
+
pass
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
import types
|
|
4
|
+
import typing as typ
|
|
5
|
+
|
|
6
|
+
import colorama
|
|
7
|
+
|
|
8
|
+
from beautiful_traceback import formatting
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def init_excepthook(color: bool, local_stack_only: bool) -> typ.Callable:
|
|
12
|
+
def excepthook(
|
|
13
|
+
exc_type: typ.Type[BaseException],
|
|
14
|
+
exc_value: BaseException,
|
|
15
|
+
traceback: types.TracebackType,
|
|
16
|
+
) -> None:
|
|
17
|
+
# pylint:disable=unused-argument
|
|
18
|
+
tb_str = (
|
|
19
|
+
formatting.exc_to_traceback_str(
|
|
20
|
+
exc_value, traceback, color, local_stack_only
|
|
21
|
+
)
|
|
22
|
+
+ "\n"
|
|
23
|
+
)
|
|
24
|
+
if color:
|
|
25
|
+
colorama.init()
|
|
26
|
+
try:
|
|
27
|
+
sys.stderr.write(tb_str)
|
|
28
|
+
finally:
|
|
29
|
+
colorama.deinit()
|
|
30
|
+
else:
|
|
31
|
+
sys.stderr.write(tb_str)
|
|
32
|
+
|
|
33
|
+
return excepthook
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def install(
|
|
37
|
+
envvar: typ.Optional[str] = None,
|
|
38
|
+
color: bool = True,
|
|
39
|
+
only_tty: bool = True,
|
|
40
|
+
only_hook_if_default_excepthook: bool = True,
|
|
41
|
+
local_stack_only: bool = False,
|
|
42
|
+
) -> None:
|
|
43
|
+
"""Hook the current excepthook to the beautiful_traceback.
|
|
44
|
+
|
|
45
|
+
If you set `only_tty=False`, beautiful_traceback will always
|
|
46
|
+
be active even when stdout is piped or redirected.
|
|
47
|
+
|
|
48
|
+
Color output respects the NO_COLOR environment variable
|
|
49
|
+
(https://no-color.org/). If NO_COLOR is set (regardless of
|
|
50
|
+
its value), color output will be disabled.
|
|
51
|
+
"""
|
|
52
|
+
if envvar and os.environ.get(envvar, "0") == "0":
|
|
53
|
+
return
|
|
54
|
+
|
|
55
|
+
# Respect NO_COLOR environment variable
|
|
56
|
+
if "NO_COLOR" in os.environ:
|
|
57
|
+
color = False
|
|
58
|
+
|
|
59
|
+
isatty = getattr(sys.stderr, "isatty", lambda: False)
|
|
60
|
+
if only_tty and not isatty():
|
|
61
|
+
return
|
|
62
|
+
|
|
63
|
+
if not isatty():
|
|
64
|
+
color = False
|
|
65
|
+
|
|
66
|
+
# pylint:disable=comparison-with-callable ; intentional
|
|
67
|
+
is_default_exepthook = sys.excepthook == sys.__excepthook__
|
|
68
|
+
if only_hook_if_default_excepthook and not is_default_exepthook:
|
|
69
|
+
return
|
|
70
|
+
|
|
71
|
+
sys.excepthook = init_excepthook(color=color, local_stack_only=local_stack_only)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def uninstall() -> None:
|
|
75
|
+
"""Restore the default excepthook."""
|
|
76
|
+
sys.excepthook = sys.__excepthook__
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import typing as typ
|
|
3
|
+
|
|
4
|
+
import beautiful_traceback.common as com
|
|
5
|
+
|
|
6
|
+
# TODO (mb 2020-08-12): path/module with doublequotes in them.
|
|
7
|
+
# Not even sure what python does with that.
|
|
8
|
+
|
|
9
|
+
# https://regex101.com/r/GpKtqR/1
|
|
10
|
+
LOCATION_PATTERN = r"""
|
|
11
|
+
\s\s
|
|
12
|
+
File
|
|
13
|
+
\s
|
|
14
|
+
\"(?P<module>[^\"]+)\"
|
|
15
|
+
,\sline\s
|
|
16
|
+
(?P<lineno>\d+)
|
|
17
|
+
,\sin\s
|
|
18
|
+
(?P<call>.*)
|
|
19
|
+
"""
|
|
20
|
+
LOCATION_RE = re.compile(LOCATION_PATTERN, flags=re.VERBOSE)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _parse_entries(entry_lines: typ.List[str]) -> typ.Iterable[com.Entry]:
|
|
24
|
+
i = 0
|
|
25
|
+
while i < len(entry_lines):
|
|
26
|
+
line = entry_lines[i]
|
|
27
|
+
i += 1
|
|
28
|
+
loc_match = LOCATION_RE.match(line)
|
|
29
|
+
if loc_match is None:
|
|
30
|
+
continue
|
|
31
|
+
|
|
32
|
+
if i < len(entry_lines):
|
|
33
|
+
maybe_src_ctx = entry_lines[i]
|
|
34
|
+
else:
|
|
35
|
+
maybe_src_ctx = ""
|
|
36
|
+
|
|
37
|
+
is_src_ctx = maybe_src_ctx.startswith(" ")
|
|
38
|
+
if is_src_ctx:
|
|
39
|
+
src_ctx = maybe_src_ctx.strip()
|
|
40
|
+
i += 1
|
|
41
|
+
else:
|
|
42
|
+
src_ctx = ""
|
|
43
|
+
|
|
44
|
+
module, lineno, call = loc_match.groups()
|
|
45
|
+
|
|
46
|
+
yield com.Entry(module, call, lineno, src_ctx)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
TRACE_HEADERS = {com.TRACEBACK_HEAD, com.CAUSE_HEAD, com.CONTEXT_HEAD}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _iter_tracebacks(trace: str) -> typ.Iterable[com.Traceback]:
|
|
53
|
+
lines = trace.strip().splitlines()
|
|
54
|
+
|
|
55
|
+
i = 0
|
|
56
|
+
while i < len(lines):
|
|
57
|
+
line = lines[i].strip()
|
|
58
|
+
|
|
59
|
+
# skip empty lines
|
|
60
|
+
if not line:
|
|
61
|
+
i += 1
|
|
62
|
+
continue
|
|
63
|
+
|
|
64
|
+
is_caused = False
|
|
65
|
+
is_context = False
|
|
66
|
+
|
|
67
|
+
if line.startswith(com.CAUSE_HEAD):
|
|
68
|
+
is_caused = True
|
|
69
|
+
i += 1
|
|
70
|
+
elif line.startswith(com.CONTEXT_HEAD):
|
|
71
|
+
is_context = True
|
|
72
|
+
i += 1
|
|
73
|
+
|
|
74
|
+
# skip empty lines and tb head
|
|
75
|
+
while i < len(lines):
|
|
76
|
+
line = lines[i].strip()
|
|
77
|
+
if not line or line.startswith(com.TRACEBACK_HEAD):
|
|
78
|
+
i += 1
|
|
79
|
+
else:
|
|
80
|
+
break
|
|
81
|
+
|
|
82
|
+
# accumulate entry lines
|
|
83
|
+
entry_lines: typ.List[str] = []
|
|
84
|
+
while i < len(lines) and lines[i].startswith(" "):
|
|
85
|
+
entry_lines.append(lines[i])
|
|
86
|
+
i += 1
|
|
87
|
+
|
|
88
|
+
exc_line = lines[i]
|
|
89
|
+
if ": " in exc_line:
|
|
90
|
+
exc_name, exc_msg = exc_line.split(": ", 1)
|
|
91
|
+
else:
|
|
92
|
+
exc_name = exc_line
|
|
93
|
+
exc_msg = ""
|
|
94
|
+
|
|
95
|
+
entries = list(_parse_entries(entry_lines))
|
|
96
|
+
yield com.Traceback(
|
|
97
|
+
exc_name=exc_name,
|
|
98
|
+
exc_msg=exc_msg,
|
|
99
|
+
entries=entries,
|
|
100
|
+
is_caused=is_caused,
|
|
101
|
+
is_context=is_context,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
i += 1
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def parse_tracebacks(trace: str) -> com.Tracebacks:
|
|
108
|
+
"""Parses a chain of tracebacks.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
trace: The traceback in the default python format, starting with
|
|
112
|
+
"Traceback (most recent call last):"
|
|
113
|
+
ending with the last line in the chain, e.g.
|
|
114
|
+
"FileNotFoundError: [Errno 2] No such ..."
|
|
115
|
+
"""
|
|
116
|
+
return list(_iter_tracebacks(trace))
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
from . import formatting
|
|
2
|
+
import pytest
|
|
3
|
+
|
|
4
|
+
from pytest import Config
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _get_option(config: Config, key: str):
|
|
8
|
+
val = None
|
|
9
|
+
|
|
10
|
+
# will throw an exception if option is not set
|
|
11
|
+
try:
|
|
12
|
+
val = config.getoption(key)
|
|
13
|
+
except Exception:
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
if val is None:
|
|
17
|
+
val = config.getini(key)
|
|
18
|
+
|
|
19
|
+
return val
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def pytest_addoption(parser):
|
|
23
|
+
parser.addini(
|
|
24
|
+
"enable_beautiful_traceback",
|
|
25
|
+
"Enable the beautiful traceback plugin",
|
|
26
|
+
type="bool",
|
|
27
|
+
default=True,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
parser.addini(
|
|
31
|
+
"enable_beautiful_traceback_local_stack_only",
|
|
32
|
+
"Show only local code (filter out library/framework internals)",
|
|
33
|
+
type="bool",
|
|
34
|
+
default=True,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@pytest.hookimpl(hookwrapper=True)
|
|
39
|
+
def pytest_runtest_makereport(item, call):
|
|
40
|
+
"""
|
|
41
|
+
Pytest stack traces are challenging to work with by default. This plugin allows beautiful_traceback to be used instead.
|
|
42
|
+
|
|
43
|
+
This little piece of code was hard-won:
|
|
44
|
+
|
|
45
|
+
https://grok.com/share/bGVnYWN5_951be3b1-6811-4fda-b220-c1dd72dedc31
|
|
46
|
+
"""
|
|
47
|
+
outcome = yield
|
|
48
|
+
report = outcome.get_result() # Get the generated TestReport object
|
|
49
|
+
|
|
50
|
+
# Check if the report is for the 'call' phase (test execution) and if it failed
|
|
51
|
+
if _get_option(item.config, "enable_beautiful_traceback") and report.failed:
|
|
52
|
+
value = call.excinfo.value
|
|
53
|
+
tb = call.excinfo.tb
|
|
54
|
+
|
|
55
|
+
formatted_traceback = formatting.exc_to_traceback_str(
|
|
56
|
+
value,
|
|
57
|
+
tb,
|
|
58
|
+
color=True,
|
|
59
|
+
local_stack_only=_get_option(
|
|
60
|
+
item.config, "enable_beautiful_traceback_local_stack_only"
|
|
61
|
+
),
|
|
62
|
+
)
|
|
63
|
+
report.longrepr = formatted_traceback
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def pytest_exception_interact(node, call, report):
|
|
67
|
+
"""
|
|
68
|
+
This can run during collection, not just test execution.
|
|
69
|
+
|
|
70
|
+
So, if there's an import or other pre-run error in pytest, this will apply the correct formatting.
|
|
71
|
+
"""
|
|
72
|
+
if report.failed:
|
|
73
|
+
value = call.excinfo.value
|
|
74
|
+
tb = call.excinfo.tb
|
|
75
|
+
formatted_traceback = formatting.exc_to_traceback_str(
|
|
76
|
+
value,
|
|
77
|
+
tb,
|
|
78
|
+
color=True,
|
|
79
|
+
local_stack_only=_get_option(
|
|
80
|
+
node.config, "enable_beautiful_traceback_local_stack_only"
|
|
81
|
+
),
|
|
82
|
+
)
|
|
83
|
+
report.longrepr = formatted_traceback
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: beautiful-traceback
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Beautiful, readable Python tracebacks with colors and formatting
|
|
5
|
+
Keywords: traceback,error,debugging,formatting
|
|
6
|
+
Author: Michael Bianco
|
|
7
|
+
Author-email: Michael Bianco <mike@mikebian.co>
|
|
8
|
+
Requires-Dist: colorama>=0.4.6
|
|
9
|
+
Requires-Python: >=3.9
|
|
10
|
+
Project-URL: Repository, https://github.com/iloveitaly/beautiful-traceback
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# Beautiful Traceback
|
|
14
|
+
|
|
15
|
+
> **Note:** This is a fork of the [pretty-traceback](https://github.com/mbarkhau/pretty-traceback) repo with simplified development and improvements for better integration with FastAPI, [structlog](https://github.com/iloveitaly/structlog-config), IPython, pytest, and more. This project is used in [python-starter-template](https://github.com/iloveitaly/python-starter-template) to provide better debugging experience in production environments.
|
|
16
|
+
|
|
17
|
+
Human readable stacktraces for Python.
|
|
18
|
+
|
|
19
|
+
[](LICENSE.md)
|
|
20
|
+
[](https://www.python.org/downloads/)
|
|
21
|
+
|
|
22
|
+
## Quick Start
|
|
23
|
+
|
|
24
|
+
The fastest way to see it in action:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
# Clone and run an example
|
|
28
|
+
git clone https://github.com/iloveitaly/beautiful-traceback
|
|
29
|
+
cd beautiful-traceback
|
|
30
|
+
uv run examples/simple.py
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Overview
|
|
34
|
+
|
|
35
|
+
Beautiful Traceback groups together what belongs together, adds coloring and alignment. All of this makes it easier for you to see patterns and filter out the signal from the noise. This tabular format is best viewed in a wide terminal.
|
|
36
|
+
|
|
37
|
+

|
|
38
|
+
|
|
39
|
+
## Installation
|
|
40
|
+
|
|
41
|
+
### From PyPI (when published)
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
# Using uv (recommended)
|
|
45
|
+
uv add beautiful-traceback
|
|
46
|
+
|
|
47
|
+
# Using pip
|
|
48
|
+
pip install beautiful-traceback
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Development Installation
|
|
52
|
+
|
|
53
|
+
To install from source:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
git clone https://github.com/iloveitaly/beautiful-traceback
|
|
57
|
+
cd beautiful-traceback
|
|
58
|
+
uv sync
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Run examples:
|
|
62
|
+
```bash
|
|
63
|
+
uv run examples/simple.py
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Run tests:
|
|
67
|
+
```bash
|
|
68
|
+
uv run pytest
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Usage
|
|
72
|
+
|
|
73
|
+
Add the following to your `__main__.py` or the equivalent module which is your entry point.
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
try:
|
|
77
|
+
import beautiful_traceback
|
|
78
|
+
beautiful_traceback.install()
|
|
79
|
+
except ImportError:
|
|
80
|
+
pass # no need to fail because of missing dev dependency
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Please do not add this code e.g. to your `__init__.py` or any other module that your users may import. They may not want you to mess with how their tracebacks are printed.
|
|
84
|
+
|
|
85
|
+
If you do feel the overwhelming desire to import the `beautiful_traceback` in code that others might import, consider using the `envvar` argument, which will cause the install function to effectively be a noop unless you set `ENABLE_BEAUTIFUL_TRACEBACK=1`.
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
try:
|
|
89
|
+
import beautiful_traceback
|
|
90
|
+
beautiful_traceback.install(envvar='ENABLE_BEAUTIFUL_TRACEBACK')
|
|
91
|
+
except ImportError:
|
|
92
|
+
pass # no need to fail because of missing dev dependency
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Note, that the hook is only installed if the existing hook is the default. Any existing hooks that were installed before the call of `beautiful_traceback.install` will be left in place.
|
|
96
|
+
|
|
97
|
+
## LoggingFormatter
|
|
98
|
+
|
|
99
|
+
A `logging.Formatter` subclass is also available (e.g. for integration with Flask, FastAPI, etc).
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
import os
|
|
103
|
+
from flask.logging import default_handler
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
if os.getenv('FLASK_DEBUG') == "1":
|
|
107
|
+
import beautiful_traceback
|
|
108
|
+
default_handler.setFormatter(beautiful_traceback.LoggingFormatter())
|
|
109
|
+
except ImportError:
|
|
110
|
+
pass # no need to fail because of missing dev dependency
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## IPython and Jupyter Integration
|
|
114
|
+
|
|
115
|
+
Beautiful Traceback works seamlessly in IPython and Jupyter notebooks:
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
# Load the extension
|
|
119
|
+
%load_ext beautiful_traceback
|
|
120
|
+
|
|
121
|
+
# Unload if needed
|
|
122
|
+
%unload_ext beautiful_traceback
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
The extension automatically installs beautiful tracebacks for your interactive session.
|
|
126
|
+
|
|
127
|
+
## Pytest Integration
|
|
128
|
+
|
|
129
|
+
Beautiful Traceback includes a pytest plugin that automatically enhances test failure output.
|
|
130
|
+
|
|
131
|
+
### Automatic Activation
|
|
132
|
+
|
|
133
|
+
The plugin activates automatically when `beautiful-traceback` is installed. No configuration needed!
|
|
134
|
+
|
|
135
|
+
### Configuration Options
|
|
136
|
+
|
|
137
|
+
Customize the plugin in your `pytest.ini` or `pyproject.toml`:
|
|
138
|
+
|
|
139
|
+
```toml
|
|
140
|
+
[tool.pytest.ini_options]
|
|
141
|
+
enable_beautiful_traceback = true # Enable/disable the plugin
|
|
142
|
+
enable_beautiful_traceback_local_stack_only = true # Show only local code (filter libraries)
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Or in `pytest.ini`:
|
|
146
|
+
|
|
147
|
+
```ini
|
|
148
|
+
[pytest]
|
|
149
|
+
enable_beautiful_traceback = true
|
|
150
|
+
enable_beautiful_traceback_local_stack_only = true
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
## Examples
|
|
154
|
+
|
|
155
|
+
Check out the [examples/](examples/) directory for detailed usage examples including basic usage, exception chaining, logging integration, and more.
|
|
156
|
+
|
|
157
|
+
```bash
|
|
158
|
+
# Quick single-exception example
|
|
159
|
+
uv run examples/simple.py
|
|
160
|
+
|
|
161
|
+
# Interactive demo with multiple exception types
|
|
162
|
+
uv run examples/demo.py
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
## Configuration
|
|
166
|
+
|
|
167
|
+
### Installation Options
|
|
168
|
+
|
|
169
|
+
Beautiful Traceback supports several configuration options:
|
|
170
|
+
|
|
171
|
+
```python
|
|
172
|
+
beautiful_traceback.install(
|
|
173
|
+
color=True, # Enable colored output
|
|
174
|
+
only_tty=True, # Only activate for TTY output
|
|
175
|
+
only_hook_if_default_excepthook=True, # Only install if default hook
|
|
176
|
+
local_stack_only=False, # Filter to show only local code
|
|
177
|
+
envvar='ENABLE_BEAUTIFUL_TRACEBACK' # Optional environment variable gate
|
|
178
|
+
)
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
### Environment Variables
|
|
182
|
+
|
|
183
|
+
- **`NO_COLOR`** - Disables colored output when set (respects [no-color.org](https://no-color.org) standard)
|
|
184
|
+
- **`ENABLE_BEAUTIFUL_TRACEBACK`** - Controls activation when using the `envvar` parameter (set to `1` to enable)
|
|
185
|
+
|
|
186
|
+
### LoggingFormatterMixin
|
|
187
|
+
|
|
188
|
+
For more advanced logging integration, you can use `LoggingFormatterMixin` as a base class:
|
|
189
|
+
|
|
190
|
+
```python
|
|
191
|
+
import logging
|
|
192
|
+
import beautiful_traceback
|
|
193
|
+
|
|
194
|
+
class MyFormatter(beautiful_traceback.LoggingFormatterMixin, logging.Formatter):
|
|
195
|
+
def __init__(self):
|
|
196
|
+
super().__init__(fmt='%(levelname)s: %(message)s')
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
This gives you full control over the log format while adding beautiful traceback support.
|
|
200
|
+
|
|
201
|
+
## Global Installation via PTH File
|
|
202
|
+
|
|
203
|
+
You can enable beautiful-traceback across all Python projects without modifying any source code by using a `.pth` file. Python automatically executes import statements in `.pth` files during interpreter startup, making this perfect for development environments.
|
|
204
|
+
|
|
205
|
+
Add this function to your `.zshrc` or `.bashrc`:
|
|
206
|
+
|
|
207
|
+
```bash
|
|
208
|
+
# Create a file to automatically import beautiful-traceback on startup
|
|
209
|
+
python-inject-beautiful-traceback() {
|
|
210
|
+
local site_packages=$(python -c "import site; print(site.getsitepackages()[0])")
|
|
211
|
+
|
|
212
|
+
local pth_file=$site_packages/beautiful_traceback_injection.pth
|
|
213
|
+
local py_file=$site_packages/_beautiful_traceback_injection.py
|
|
214
|
+
|
|
215
|
+
cat <<'EOF' >"$py_file"
|
|
216
|
+
def run_startup_script():
|
|
217
|
+
try:
|
|
218
|
+
import beautiful_traceback
|
|
219
|
+
beautiful_traceback.install()
|
|
220
|
+
except ImportError:
|
|
221
|
+
pass
|
|
222
|
+
|
|
223
|
+
run_startup_script()
|
|
224
|
+
EOF
|
|
225
|
+
|
|
226
|
+
echo "import _beautiful_traceback_injection" >"$pth_file"
|
|
227
|
+
echo "Beautiful traceback injection created: $pth_file"
|
|
228
|
+
}
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
After sourcing your shell config, run `python-inject-beautiful-traceback` to enable beautiful tracebacks globally for that Python environment.
|
|
232
|
+
|
|
233
|
+
## Alternatives
|
|
234
|
+
|
|
235
|
+
Beautiful Traceback is heavily inspired by the backtrace module by [nir0s](https://github.com/nir0s/backtrace) but there are many others (sorted by github stars):
|
|
236
|
+
|
|
237
|
+
- https://github.com/qix-/better-exceptions
|
|
238
|
+
- https://github.com/cknd/stackprinter
|
|
239
|
+
- https://github.com/onelivesleft/PrettyErrors
|
|
240
|
+
- https://github.com/skorokithakis/tbvaccine
|
|
241
|
+
- https://github.com/aroberge/friendly-traceback
|
|
242
|
+
- https://github.com/HallerPatrick/frosch
|
|
243
|
+
- https://github.com/nir0s/backtrace
|
|
244
|
+
- https://github.com/mbarkhau/pretty-traceback
|
|
245
|
+
- https://github.com/staticshock/colored-traceback.py
|
|
246
|
+
- https://github.com/chillaranand/ptb
|
|
247
|
+
- https://github.com/laurb9/rich-traceback
|
|
248
|
+
- https://github.com/willmcgugan/rich#tracebacks
|
|
249
|
+
|
|
250
|
+
## License
|
|
251
|
+
|
|
252
|
+
MIT License - see [LICENSE.md](LICENSE.md) for details.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
beautiful_traceback/__init__.py,sha256=XW0PVUTOPa6qFDhvE8oPWikOcL8Xbfgv_BLuFdkVhCI,462
|
|
2
|
+
beautiful_traceback/_extension.py,sha256=klyo3XL4q3-Wdy4Lt6JYdh-Cfh_SkRCI7jCcQCwfWTM,311
|
|
3
|
+
beautiful_traceback/common.py,sha256=IIg46wUk5e0syCrkI2HfLPwv8zi8mQ5AIPgb1998lAY,585
|
|
4
|
+
beautiful_traceback/formatting.py,sha256=FilxU_holFyBvVKd2VHTtqQ4sRfUlfxSIYwHLtadR3M,15105
|
|
5
|
+
beautiful_traceback/hook.py,sha256=6vYpqA-mD4G32HkX5WSyCMzkvMwB4RXP6wbVEIU6oaY,2078
|
|
6
|
+
beautiful_traceback/parsing.py,sha256=lVqOty6X9MqTfo-lTIQjI_zVxUqRhYEVjyGhkW8wps8,2954
|
|
7
|
+
beautiful_traceback/pytest_plugin.py,sha256=vdRgvycWeG9wwh9tK4921ugW9aHx0Zib1Or7mU9Khw0,2315
|
|
8
|
+
beautiful_traceback-0.1.0.dist-info/WHEEL,sha256=Pi5uDq5Fdo_Rr-HD5h9BiPn9Et29Y9Sh8NhcJNnFU1c,79
|
|
9
|
+
beautiful_traceback-0.1.0.dist-info/entry_points.txt,sha256=m99Hs6ia_rvyRu-ruwKwVCjob50f-wvLVtztWyi47a8,68
|
|
10
|
+
beautiful_traceback-0.1.0.dist-info/METADATA,sha256=w0nVy_HVr836OK_I1jY1fosVLAXM_3MOgm5992CvmQc,8020
|
|
11
|
+
beautiful_traceback-0.1.0.dist-info/RECORD,,
|