beautiful-traceback 0.4.0__tar.gz → 0.5.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.
- {beautiful_traceback-0.4.0 → beautiful_traceback-0.5.0}/PKG-INFO +26 -1
- {beautiful_traceback-0.4.0 → beautiful_traceback-0.5.0}/README.md +25 -0
- {beautiful_traceback-0.4.0 → beautiful_traceback-0.5.0}/beautiful_traceback/formatting.py +78 -5
- {beautiful_traceback-0.4.0 → beautiful_traceback-0.5.0}/beautiful_traceback/hook.py +16 -3
- {beautiful_traceback-0.4.0 → beautiful_traceback-0.5.0}/beautiful_traceback/json_formatting.py +11 -2
- beautiful_traceback-0.5.0/beautiful_traceback/pytest_assertion.py +94 -0
- beautiful_traceback-0.5.0/beautiful_traceback/pytest_plugin.py +103 -0
- {beautiful_traceback-0.4.0 → beautiful_traceback-0.5.0}/pyproject.toml +1 -1
- beautiful_traceback-0.4.0/beautiful_traceback/pytest_plugin.py +0 -169
- {beautiful_traceback-0.4.0 → beautiful_traceback-0.5.0}/beautiful_traceback/__init__.py +0 -0
- {beautiful_traceback-0.4.0 → beautiful_traceback-0.5.0}/beautiful_traceback/_extension.py +0 -0
- {beautiful_traceback-0.4.0 → beautiful_traceback-0.5.0}/beautiful_traceback/cli.py +0 -0
- {beautiful_traceback-0.4.0 → beautiful_traceback-0.5.0}/beautiful_traceback/common.py +0 -0
- {beautiful_traceback-0.4.0 → beautiful_traceback-0.5.0}/beautiful_traceback/parsing.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: beautiful-traceback
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Beautiful, readable Python tracebacks with colors and formatting
|
|
5
5
|
Keywords: traceback,error,debugging,formatting
|
|
6
6
|
Author: Michael Bianco
|
|
@@ -142,6 +142,9 @@ Customize the plugin in your `pytest.ini` or `pyproject.toml`:
|
|
|
142
142
|
[tool.pytest.ini_options]
|
|
143
143
|
enable_beautiful_traceback = true # Enable/disable the plugin
|
|
144
144
|
enable_beautiful_traceback_local_stack_only = true # Show only local code (filter libraries)
|
|
145
|
+
beautiful_traceback_exclude_patterns = [ # Regex patterns to drop frames
|
|
146
|
+
"click/core\\.py",
|
|
147
|
+
]
|
|
145
148
|
```
|
|
146
149
|
|
|
147
150
|
Or in `pytest.ini`:
|
|
@@ -150,8 +153,29 @@ Or in `pytest.ini`:
|
|
|
150
153
|
[pytest]
|
|
151
154
|
enable_beautiful_traceback = true
|
|
152
155
|
enable_beautiful_traceback_local_stack_only = true
|
|
156
|
+
beautiful_traceback_exclude_patterns =
|
|
157
|
+
click/core\.py
|
|
153
158
|
```
|
|
154
159
|
|
|
160
|
+
Example: filter out pytest, pluggy, and playwright frames from CI tracebacks:
|
|
161
|
+
|
|
162
|
+
```toml
|
|
163
|
+
[tool.pytest.ini_options]
|
|
164
|
+
beautiful_traceback_exclude_patterns = [
|
|
165
|
+
"^_pytest/",
|
|
166
|
+
"^pluggy/",
|
|
167
|
+
"^playwright/",
|
|
168
|
+
]
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
**Pattern Matching:** Patterns are tested against multiple representations of each frame:
|
|
172
|
+
- `_pytest/runner.py` (short module path)
|
|
173
|
+
- `/path/to/site-packages/_pytest/runner.py` (full module path)
|
|
174
|
+
- `<site> _pytest/runner.py:353 from_call result: ...` (formatted line with short path)
|
|
175
|
+
- `<site> /path/to/.../runner.py:353 from_call result: ...` (formatted line with full path)
|
|
176
|
+
|
|
177
|
+
This allows you to write simpler patterns like `^_pytest/` instead of needing to match the full site-packages path.
|
|
178
|
+
|
|
155
179
|
## Examples
|
|
156
180
|
|
|
157
181
|
Check out the [examples/](examples/) directory for detailed usage examples including basic usage, exception chaining, logging integration, and more.
|
|
@@ -176,6 +200,7 @@ beautiful_traceback.install(
|
|
|
176
200
|
only_tty=True, # Only activate for TTY output
|
|
177
201
|
only_hook_if_default_excepthook=True, # Only install if default hook
|
|
178
202
|
local_stack_only=False, # Filter to show only local code
|
|
203
|
+
exclude_patterns=["click/core\\.py"], # Regex patterns to drop frames
|
|
179
204
|
envvar='ENABLE_BEAUTIFUL_TRACEBACK' # Optional environment variable gate
|
|
180
205
|
)
|
|
181
206
|
```
|
|
@@ -130,6 +130,9 @@ Customize the plugin in your `pytest.ini` or `pyproject.toml`:
|
|
|
130
130
|
[tool.pytest.ini_options]
|
|
131
131
|
enable_beautiful_traceback = true # Enable/disable the plugin
|
|
132
132
|
enable_beautiful_traceback_local_stack_only = true # Show only local code (filter libraries)
|
|
133
|
+
beautiful_traceback_exclude_patterns = [ # Regex patterns to drop frames
|
|
134
|
+
"click/core\\.py",
|
|
135
|
+
]
|
|
133
136
|
```
|
|
134
137
|
|
|
135
138
|
Or in `pytest.ini`:
|
|
@@ -138,8 +141,29 @@ Or in `pytest.ini`:
|
|
|
138
141
|
[pytest]
|
|
139
142
|
enable_beautiful_traceback = true
|
|
140
143
|
enable_beautiful_traceback_local_stack_only = true
|
|
144
|
+
beautiful_traceback_exclude_patterns =
|
|
145
|
+
click/core\.py
|
|
141
146
|
```
|
|
142
147
|
|
|
148
|
+
Example: filter out pytest, pluggy, and playwright frames from CI tracebacks:
|
|
149
|
+
|
|
150
|
+
```toml
|
|
151
|
+
[tool.pytest.ini_options]
|
|
152
|
+
beautiful_traceback_exclude_patterns = [
|
|
153
|
+
"^_pytest/",
|
|
154
|
+
"^pluggy/",
|
|
155
|
+
"^playwright/",
|
|
156
|
+
]
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
**Pattern Matching:** Patterns are tested against multiple representations of each frame:
|
|
160
|
+
- `_pytest/runner.py` (short module path)
|
|
161
|
+
- `/path/to/site-packages/_pytest/runner.py` (full module path)
|
|
162
|
+
- `<site> _pytest/runner.py:353 from_call result: ...` (formatted line with short path)
|
|
163
|
+
- `<site> /path/to/.../runner.py:353 from_call result: ...` (formatted line with full path)
|
|
164
|
+
|
|
165
|
+
This allows you to write simpler patterns like `^_pytest/` instead of needing to match the full site-packages path.
|
|
166
|
+
|
|
143
167
|
## Examples
|
|
144
168
|
|
|
145
169
|
Check out the [examples/](examples/) directory for detailed usage examples including basic usage, exception chaining, logging integration, and more.
|
|
@@ -164,6 +188,7 @@ beautiful_traceback.install(
|
|
|
164
188
|
only_tty=True, # Only activate for TTY output
|
|
165
189
|
only_hook_if_default_excepthook=True, # Only install if default hook
|
|
166
190
|
local_stack_only=False, # Filter to show only local code
|
|
191
|
+
exclude_patterns=["click/core\\.py"], # Regex patterns to drop frames
|
|
167
192
|
envvar='ENABLE_BEAUTIFUL_TRACEBACK' # Optional environment variable gate
|
|
168
193
|
)
|
|
169
194
|
```
|
|
@@ -129,6 +129,46 @@ TEST_PATHS: typ.List[str] = []
|
|
|
129
129
|
PWD = os.getcwd()
|
|
130
130
|
|
|
131
131
|
|
|
132
|
+
def _compile_exclude_patterns(
|
|
133
|
+
exclude_patterns: typ.Sequence[str],
|
|
134
|
+
) -> list[re.Pattern[str]]:
|
|
135
|
+
if not exclude_patterns:
|
|
136
|
+
return []
|
|
137
|
+
|
|
138
|
+
return [re.compile(pattern) for pattern in exclude_patterns]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _row_matches_exclude_patterns(
|
|
142
|
+
row: Row, exclude_patterns: typ.Sequence[re.Pattern[str]]
|
|
143
|
+
) -> bool:
|
|
144
|
+
if not exclude_patterns:
|
|
145
|
+
return False
|
|
146
|
+
|
|
147
|
+
# Try multiple representations so patterns can match different parts:
|
|
148
|
+
# 1. short_module: "_pytest/runner.py" (aliased path without prefix)
|
|
149
|
+
# 2. full_module: "/path/to/site-packages/_pytest/runner.py" (absolute path)
|
|
150
|
+
# 3. haystack_full: "<site> /path/to/site-packages/_pytest/runner.py:353 from_call result: ..."
|
|
151
|
+
# 4. haystack_short: "<site> _pytest/runner.py:353 from_call result: ..."
|
|
152
|
+
haystack_full = (
|
|
153
|
+
f"{row.alias} {row.full_module}:{row.lineno} {row.call} {row.context}"
|
|
154
|
+
)
|
|
155
|
+
haystack_short = (
|
|
156
|
+
f"{row.alias} {row.short_module}:{row.lineno} {row.call} {row.context}"
|
|
157
|
+
)
|
|
158
|
+
candidates = [
|
|
159
|
+
row.short_module,
|
|
160
|
+
row.full_module,
|
|
161
|
+
haystack_full,
|
|
162
|
+
haystack_short,
|
|
163
|
+
]
|
|
164
|
+
for pattern in exclude_patterns:
|
|
165
|
+
for candidate in candidates:
|
|
166
|
+
if pattern.search(candidate):
|
|
167
|
+
return True
|
|
168
|
+
|
|
169
|
+
return False
|
|
170
|
+
|
|
171
|
+
|
|
132
172
|
def _py_paths() -> typ.List[str]:
|
|
133
173
|
if TEST_PATHS:
|
|
134
174
|
return TEST_PATHS
|
|
@@ -227,7 +267,9 @@ def _iter_entry_rows(
|
|
|
227
267
|
|
|
228
268
|
|
|
229
269
|
def _init_entries_context(
|
|
230
|
-
entries: StackFrameEntryList,
|
|
270
|
+
entries: StackFrameEntryList,
|
|
271
|
+
term_width: typ.Optional[int] = None,
|
|
272
|
+
exclude_patterns: typ.Sequence[str] = (),
|
|
231
273
|
) -> Context:
|
|
232
274
|
if term_width is None:
|
|
233
275
|
_term_width = _get_terminal_width()
|
|
@@ -246,6 +288,19 @@ def _init_entries_context(
|
|
|
246
288
|
max_row_width = _term_width - 10
|
|
247
289
|
|
|
248
290
|
rows = list(_iter_entry_rows(aliases, entry_paths, entries))
|
|
291
|
+
compiled_exclude_patterns = _compile_exclude_patterns(exclude_patterns)
|
|
292
|
+
if compiled_exclude_patterns:
|
|
293
|
+
rows = [
|
|
294
|
+
row
|
|
295
|
+
for row in rows
|
|
296
|
+
if not _row_matches_exclude_patterns(row, compiled_exclude_patterns)
|
|
297
|
+
]
|
|
298
|
+
|
|
299
|
+
used_aliases = {row.alias for row in rows if row.alias}
|
|
300
|
+
if used_aliases:
|
|
301
|
+
aliases = [alias for alias in aliases if alias[0] in used_aliases]
|
|
302
|
+
else:
|
|
303
|
+
aliases = []
|
|
249
304
|
|
|
250
305
|
if rows:
|
|
251
306
|
max_short_module_len = max(
|
|
@@ -426,9 +481,15 @@ def _format_traceback(
|
|
|
426
481
|
|
|
427
482
|
|
|
428
483
|
def format_traceback(
|
|
429
|
-
traceback: ExceptionTraceback,
|
|
484
|
+
traceback: ExceptionTraceback,
|
|
485
|
+
color: bool = False,
|
|
486
|
+
local_stack_only: bool = False,
|
|
487
|
+
exclude_patterns: typ.Sequence[str] = (),
|
|
430
488
|
) -> str:
|
|
431
|
-
ctx = _init_entries_context(
|
|
489
|
+
ctx = _init_entries_context(
|
|
490
|
+
traceback.stack_frames,
|
|
491
|
+
exclude_patterns=exclude_patterns,
|
|
492
|
+
)
|
|
432
493
|
return _format_traceback(ctx, traceback, color, local_stack_only)
|
|
433
494
|
|
|
434
495
|
|
|
@@ -436,6 +497,7 @@ def format_tracebacks(
|
|
|
436
497
|
tracebacks: typ.List[ExceptionTraceback],
|
|
437
498
|
color: bool = False,
|
|
438
499
|
local_stack_only: bool = False,
|
|
500
|
+
exclude_patterns: typ.Sequence[str] = (),
|
|
439
501
|
) -> str:
|
|
440
502
|
traceback_strs: typ.List[str] = []
|
|
441
503
|
|
|
@@ -447,7 +509,12 @@ def format_tracebacks(
|
|
|
447
509
|
# traceback_strs.append("vvv happend after ^^^ - ")
|
|
448
510
|
traceback_strs.append(CONTEXT_HEAD + os.linesep)
|
|
449
511
|
|
|
450
|
-
traceback_str = format_traceback(
|
|
512
|
+
traceback_str = format_traceback(
|
|
513
|
+
tb_tup,
|
|
514
|
+
color,
|
|
515
|
+
local_stack_only,
|
|
516
|
+
exclude_patterns=exclude_patterns,
|
|
517
|
+
)
|
|
451
518
|
traceback_strs.append(traceback_str)
|
|
452
519
|
|
|
453
520
|
return os.linesep.join(traceback_strs).strip()
|
|
@@ -463,6 +530,7 @@ def exc_to_traceback_str(
|
|
|
463
530
|
color: bool = False,
|
|
464
531
|
local_stack_only: bool = False,
|
|
465
532
|
exc_msg_override: str | None = None,
|
|
533
|
+
exclude_patterns: typ.Sequence[str] = (),
|
|
466
534
|
) -> str:
|
|
467
535
|
# NOTE (mb 2020-08-13): wrt. cause vs context see
|
|
468
536
|
# https://www.python.org/dev/peps/pep-3134/#enhanced-reporting
|
|
@@ -511,7 +579,12 @@ def exc_to_traceback_str(
|
|
|
511
579
|
|
|
512
580
|
tracebacks = list(reversed(tracebacks))
|
|
513
581
|
|
|
514
|
-
return format_tracebacks(
|
|
582
|
+
return format_tracebacks(
|
|
583
|
+
tracebacks,
|
|
584
|
+
color,
|
|
585
|
+
local_stack_only,
|
|
586
|
+
exclude_patterns=exclude_patterns,
|
|
587
|
+
)
|
|
515
588
|
|
|
516
589
|
|
|
517
590
|
class LoggingFormatterMixin:
|
|
@@ -8,7 +8,11 @@ import colorama
|
|
|
8
8
|
from beautiful_traceback import formatting
|
|
9
9
|
|
|
10
10
|
|
|
11
|
-
def init_excepthook(
|
|
11
|
+
def init_excepthook(
|
|
12
|
+
color: bool,
|
|
13
|
+
local_stack_only: bool,
|
|
14
|
+
exclude_patterns: typ.Sequence[str],
|
|
15
|
+
) -> typ.Callable:
|
|
12
16
|
def excepthook(
|
|
13
17
|
exc_type: typ.Type[BaseException],
|
|
14
18
|
exc_value: BaseException,
|
|
@@ -17,7 +21,11 @@ def init_excepthook(color: bool, local_stack_only: bool) -> typ.Callable:
|
|
|
17
21
|
# pylint:disable=unused-argument
|
|
18
22
|
tb_str = (
|
|
19
23
|
formatting.exc_to_traceback_str(
|
|
20
|
-
exc_value,
|
|
24
|
+
exc_value,
|
|
25
|
+
traceback,
|
|
26
|
+
color,
|
|
27
|
+
local_stack_only,
|
|
28
|
+
exclude_patterns=exclude_patterns,
|
|
21
29
|
)
|
|
22
30
|
+ "\n"
|
|
23
31
|
)
|
|
@@ -39,6 +47,7 @@ def install(
|
|
|
39
47
|
only_tty: bool = True,
|
|
40
48
|
only_hook_if_default_excepthook: bool = True,
|
|
41
49
|
local_stack_only: bool = False,
|
|
50
|
+
exclude_patterns: typ.Sequence[str] = (),
|
|
42
51
|
) -> None:
|
|
43
52
|
"""Hook the current excepthook to the beautiful_traceback.
|
|
44
53
|
|
|
@@ -68,7 +77,11 @@ def install(
|
|
|
68
77
|
if only_hook_if_default_excepthook and not is_default_exepthook:
|
|
69
78
|
return
|
|
70
79
|
|
|
71
|
-
sys.excepthook = init_excepthook(
|
|
80
|
+
sys.excepthook = init_excepthook(
|
|
81
|
+
color=color,
|
|
82
|
+
local_stack_only=local_stack_only,
|
|
83
|
+
exclude_patterns=exclude_patterns,
|
|
84
|
+
)
|
|
72
85
|
|
|
73
86
|
|
|
74
87
|
def uninstall() -> None:
|
{beautiful_traceback-0.4.0 → beautiful_traceback-0.5.0}/beautiful_traceback/json_formatting.py
RENAMED
|
@@ -46,6 +46,7 @@ def exc_to_json(
|
|
|
46
46
|
exc_value: BaseException,
|
|
47
47
|
traceback: types.TracebackType | None,
|
|
48
48
|
local_stack_only: bool = False,
|
|
49
|
+
exclude_patterns: typ.Sequence[str] = (),
|
|
49
50
|
) -> dict[str, typ.Any]:
|
|
50
51
|
"""Convert an exception to a JSON-serializable dictionary.
|
|
51
52
|
|
|
@@ -112,7 +113,11 @@ def exc_to_json(
|
|
|
112
113
|
"frames": [],
|
|
113
114
|
}
|
|
114
115
|
else:
|
|
115
|
-
ctx = fmt._init_entries_context(
|
|
116
|
+
ctx = fmt._init_entries_context(
|
|
117
|
+
entries,
|
|
118
|
+
term_width=fmt.DEFAULT_COLUMNS,
|
|
119
|
+
exclude_patterns=exclude_patterns,
|
|
120
|
+
)
|
|
116
121
|
result = _format_traceback_json(
|
|
117
122
|
ctx.rows,
|
|
118
123
|
main_tb.exc_name,
|
|
@@ -132,7 +137,11 @@ def exc_to_json(
|
|
|
132
137
|
"frames": [],
|
|
133
138
|
}
|
|
134
139
|
else:
|
|
135
|
-
ctx = fmt._init_entries_context(
|
|
140
|
+
ctx = fmt._init_entries_context(
|
|
141
|
+
entries,
|
|
142
|
+
term_width=fmt.DEFAULT_COLUMNS,
|
|
143
|
+
exclude_patterns=exclude_patterns,
|
|
144
|
+
)
|
|
136
145
|
chain_item = _format_traceback_json(
|
|
137
146
|
ctx.rows,
|
|
138
147
|
tb.exc_name,
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Helpers for extracting pytest's rewritten assertion details."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def get_exception_message_override(excinfo: pytest.ExceptionInfo) -> str | None:
|
|
9
|
+
"""Return pytest's verbose exception message when rewriting adds detail.
|
|
10
|
+
|
|
11
|
+
The plugin overrides pytest's longrepr rendering, which skips the
|
|
12
|
+
ExceptionInfo repr where pytest stores rewritten assertion diffs. Without
|
|
13
|
+
this, AssertionError messages collapse to str(exc) and omit left/right
|
|
14
|
+
details. Pulling reprcrash.message preserves that verbose message when
|
|
15
|
+
pytest provides one.
|
|
16
|
+
"""
|
|
17
|
+
try:
|
|
18
|
+
repr_info = excinfo.getrepr(style="long")
|
|
19
|
+
except Exception:
|
|
20
|
+
return None
|
|
21
|
+
|
|
22
|
+
reprcrash = getattr(repr_info, "reprcrash", None)
|
|
23
|
+
if reprcrash is None:
|
|
24
|
+
return None
|
|
25
|
+
|
|
26
|
+
message = getattr(reprcrash, "message", None)
|
|
27
|
+
if not message:
|
|
28
|
+
return None
|
|
29
|
+
|
|
30
|
+
exc_name = type(excinfo.value).__name__
|
|
31
|
+
prefix = f"{exc_name}: "
|
|
32
|
+
if message.startswith(prefix):
|
|
33
|
+
message = message.removeprefix(prefix)
|
|
34
|
+
|
|
35
|
+
if not message or message == exc_name:
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
exc_message = str(excinfo.value)
|
|
39
|
+
if message == exc_message:
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
return message
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def get_pytest_assertion_details(excinfo: pytest.ExceptionInfo) -> str | None:
|
|
46
|
+
"""Return pytest's rewritten assertion lines for AssertionError.
|
|
47
|
+
|
|
48
|
+
Pytest only provides left/right diffs when a module is rewritten during
|
|
49
|
+
import. For helper modules, call pytest.register_assert_rewrite before
|
|
50
|
+
importing them, otherwise the assertion message is plain and there are no
|
|
51
|
+
diff lines to extract.
|
|
52
|
+
"""
|
|
53
|
+
if not isinstance(excinfo.value, AssertionError):
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
# pytest stores assertion diffs on its own repr object, not the exception.
|
|
58
|
+
# Reference: https://github.com/pytest-dev/pytest/blob/main/src/_pytest/_code/code.py
|
|
59
|
+
repr_info = excinfo.getrepr(style="long")
|
|
60
|
+
except Exception:
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
reprtraceback = getattr(repr_info, "reprtraceback", None)
|
|
64
|
+
if reprtraceback is None:
|
|
65
|
+
chain = getattr(repr_info, "chain", None)
|
|
66
|
+
if chain:
|
|
67
|
+
reprtraceback = chain[-1][0]
|
|
68
|
+
|
|
69
|
+
if reprtraceback is None:
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
reprentries = getattr(reprtraceback, "reprentries", None)
|
|
73
|
+
if not reprentries:
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
last_entry = reprentries[-1]
|
|
77
|
+
entry_lines = getattr(last_entry, "lines", None)
|
|
78
|
+
if not entry_lines:
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
lines = []
|
|
82
|
+
for line in entry_lines:
|
|
83
|
+
stripped = line.lstrip()
|
|
84
|
+
if stripped.startswith("E"):
|
|
85
|
+
lines.append(stripped)
|
|
86
|
+
continue
|
|
87
|
+
|
|
88
|
+
if stripped.startswith(">"):
|
|
89
|
+
lines.append(stripped)
|
|
90
|
+
|
|
91
|
+
if not lines:
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
return os.linesep.join(lines)
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Pytest plugin that preserves rewritten assertion details in tracebacks.
|
|
2
|
+
|
|
3
|
+
Pytest rewrites assert statements at import time for test modules (and any
|
|
4
|
+
modules registered via pytest.register_assert_rewrite). The rewritten asserts
|
|
5
|
+
raise AssertionError instances that include rich explanation text and left/right
|
|
6
|
+
diffs inside pytest's repr objects, not on the exception itself. This plugin
|
|
7
|
+
extracts those repr details and appends them to beautiful_traceback output.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from typing import Any, Generator
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
from pytest import Config
|
|
15
|
+
|
|
16
|
+
from . import formatting
|
|
17
|
+
from .pytest_assertion import get_exception_message_override
|
|
18
|
+
from .pytest_assertion import get_pytest_assertion_details
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _get_option(config: Config, key: str) -> Any:
|
|
22
|
+
val = None
|
|
23
|
+
|
|
24
|
+
# will throw an exception if option is not set
|
|
25
|
+
try:
|
|
26
|
+
val = config.getoption(key)
|
|
27
|
+
except Exception:
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
if val is None:
|
|
31
|
+
val = config.getini(key)
|
|
32
|
+
|
|
33
|
+
return val
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _format_traceback(excinfo: pytest.ExceptionInfo, config: Config) -> str:
|
|
37
|
+
"""Format a traceback with beautiful_traceback styling and pytest details."""
|
|
38
|
+
message_override = get_exception_message_override(excinfo)
|
|
39
|
+
assertion_details = get_pytest_assertion_details(excinfo)
|
|
40
|
+
exclude_patterns = _get_option(config, "beautiful_traceback_exclude_patterns")
|
|
41
|
+
|
|
42
|
+
formatted_traceback = formatting.exc_to_traceback_str(
|
|
43
|
+
excinfo.value,
|
|
44
|
+
excinfo.tb,
|
|
45
|
+
color=True,
|
|
46
|
+
local_stack_only=_get_option(
|
|
47
|
+
config, "enable_beautiful_traceback_local_stack_only"
|
|
48
|
+
),
|
|
49
|
+
exc_msg_override=message_override,
|
|
50
|
+
exclude_patterns=exclude_patterns,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
if assertion_details:
|
|
54
|
+
formatted_traceback += os.linesep + assertion_details + os.linesep
|
|
55
|
+
|
|
56
|
+
return formatted_traceback
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def pytest_addoption(parser) -> None:
|
|
60
|
+
parser.addini(
|
|
61
|
+
"enable_beautiful_traceback",
|
|
62
|
+
"Enable the beautiful traceback plugin",
|
|
63
|
+
type="bool",
|
|
64
|
+
default=True,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
parser.addini(
|
|
68
|
+
"enable_beautiful_traceback_local_stack_only",
|
|
69
|
+
"Show only local code (filter out library/framework internals)",
|
|
70
|
+
type="bool",
|
|
71
|
+
default=True,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
parser.addini(
|
|
75
|
+
"beautiful_traceback_exclude_patterns",
|
|
76
|
+
"Exclude traceback frames that match regex patterns",
|
|
77
|
+
type="linelist",
|
|
78
|
+
default=[],
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@pytest.hookimpl(hookwrapper=True)
|
|
83
|
+
def pytest_runtest_makereport(item, call) -> Generator[None, None, None]:
|
|
84
|
+
"""Format test execution tracebacks with beautiful_traceback.
|
|
85
|
+
|
|
86
|
+
This hook runs during the test execution phase and replaces pytest's
|
|
87
|
+
default traceback formatting with beautiful_traceback's output.
|
|
88
|
+
"""
|
|
89
|
+
outcome = yield # type: ignore[misc]
|
|
90
|
+
report = outcome.get_result() # type: ignore[attr-defined]
|
|
91
|
+
|
|
92
|
+
if _get_option(item.config, "enable_beautiful_traceback") and report.failed:
|
|
93
|
+
report.longrepr = _format_traceback(call.excinfo, item.config)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def pytest_exception_interact(node, call, report) -> None:
|
|
97
|
+
"""Format collection-phase tracebacks with beautiful_traceback.
|
|
98
|
+
|
|
99
|
+
This hook runs during collection (e.g., import errors, fixture errors)
|
|
100
|
+
and ensures those errors also use beautiful_traceback formatting.
|
|
101
|
+
"""
|
|
102
|
+
if _get_option(node.config, "enable_beautiful_traceback") and report.failed:
|
|
103
|
+
report.longrepr = _format_traceback(call.excinfo, node.config)
|
|
@@ -1,169 +0,0 @@
|
|
|
1
|
-
import os
|
|
2
|
-
from typing import Any, Generator
|
|
3
|
-
|
|
4
|
-
from . import formatting
|
|
5
|
-
import pytest
|
|
6
|
-
|
|
7
|
-
from pytest import Config
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
def _get_option(config: Config, key: str) -> Any:
|
|
11
|
-
val = None
|
|
12
|
-
|
|
13
|
-
# will throw an exception if option is not set
|
|
14
|
-
try:
|
|
15
|
-
val = config.getoption(key)
|
|
16
|
-
except Exception:
|
|
17
|
-
pass
|
|
18
|
-
|
|
19
|
-
if val is None:
|
|
20
|
-
val = config.getini(key)
|
|
21
|
-
|
|
22
|
-
return val
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
def _get_exception_message_override(excinfo: pytest.ExceptionInfo) -> str | None:
|
|
26
|
-
"""Return pytest's verbose exception message when rewriting adds detail.
|
|
27
|
-
|
|
28
|
-
The plugin overrides pytest's longrepr rendering, which skips the
|
|
29
|
-
ExceptionInfo repr where pytest stores rewritten assertion diffs. Without
|
|
30
|
-
this, AssertionError messages collapse to str(exc) and omit left/right
|
|
31
|
-
details. Pulling reprcrash.message preserves that verbose message when
|
|
32
|
-
pytest provides one.
|
|
33
|
-
"""
|
|
34
|
-
try:
|
|
35
|
-
repr_info = excinfo.getrepr(style="long")
|
|
36
|
-
except Exception:
|
|
37
|
-
return None
|
|
38
|
-
|
|
39
|
-
reprcrash = getattr(repr_info, "reprcrash", None)
|
|
40
|
-
if reprcrash is None:
|
|
41
|
-
return None
|
|
42
|
-
|
|
43
|
-
message = getattr(reprcrash, "message", None)
|
|
44
|
-
if not message:
|
|
45
|
-
return None
|
|
46
|
-
|
|
47
|
-
exc_name = type(excinfo.value).__name__
|
|
48
|
-
prefix = f"{exc_name}: "
|
|
49
|
-
if message.startswith(prefix):
|
|
50
|
-
message = message.removeprefix(prefix)
|
|
51
|
-
|
|
52
|
-
if not message or message == exc_name:
|
|
53
|
-
return None
|
|
54
|
-
|
|
55
|
-
exc_message = str(excinfo.value)
|
|
56
|
-
if message == exc_message:
|
|
57
|
-
return None
|
|
58
|
-
|
|
59
|
-
return message
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
def _get_pytest_assertion_details(excinfo: pytest.ExceptionInfo) -> str | None:
|
|
63
|
-
"""Return the pytest assertion diff lines for AssertionError."""
|
|
64
|
-
if not isinstance(excinfo.value, AssertionError):
|
|
65
|
-
return None
|
|
66
|
-
|
|
67
|
-
try:
|
|
68
|
-
# pytest stores assertion diffs on its own repr object, not the exception.
|
|
69
|
-
# Reference: https://github.com/pytest-dev/pytest/blob/main/src/_pytest/_code/code.py
|
|
70
|
-
repr_info = excinfo.getrepr(style="long")
|
|
71
|
-
except Exception:
|
|
72
|
-
return None
|
|
73
|
-
|
|
74
|
-
reprtraceback = getattr(repr_info, "reprtraceback", None)
|
|
75
|
-
if reprtraceback is None:
|
|
76
|
-
chain = getattr(repr_info, "chain", None)
|
|
77
|
-
if chain:
|
|
78
|
-
reprtraceback = chain[-1][0]
|
|
79
|
-
|
|
80
|
-
if reprtraceback is None:
|
|
81
|
-
return None
|
|
82
|
-
|
|
83
|
-
reprentries = getattr(reprtraceback, "reprentries", None)
|
|
84
|
-
if not reprentries:
|
|
85
|
-
return None
|
|
86
|
-
|
|
87
|
-
last_entry = reprentries[-1]
|
|
88
|
-
entry_lines = getattr(last_entry, "lines", None)
|
|
89
|
-
if not entry_lines:
|
|
90
|
-
return None
|
|
91
|
-
|
|
92
|
-
# Keep only the assertion diff lines for concise appending.
|
|
93
|
-
lines = []
|
|
94
|
-
for line in entry_lines:
|
|
95
|
-
# Keep pytest's assertion diff lines and the failing expression.
|
|
96
|
-
stripped = line.lstrip()
|
|
97
|
-
if stripped.startswith("E"):
|
|
98
|
-
lines.append(stripped)
|
|
99
|
-
continue
|
|
100
|
-
|
|
101
|
-
# Include the source line marker when present.
|
|
102
|
-
if stripped.startswith(">"):
|
|
103
|
-
lines.append(stripped)
|
|
104
|
-
|
|
105
|
-
if not lines:
|
|
106
|
-
return None
|
|
107
|
-
|
|
108
|
-
return os.linesep.join(lines)
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
def _format_traceback(excinfo: pytest.ExceptionInfo, config: Config) -> str:
|
|
112
|
-
"""Format a traceback with beautiful_traceback styling and pytest assertion details."""
|
|
113
|
-
message_override = _get_exception_message_override(excinfo)
|
|
114
|
-
assertion_details = _get_pytest_assertion_details(excinfo)
|
|
115
|
-
|
|
116
|
-
formatted_traceback = formatting.exc_to_traceback_str(
|
|
117
|
-
excinfo.value,
|
|
118
|
-
excinfo.tb,
|
|
119
|
-
color=True,
|
|
120
|
-
local_stack_only=_get_option(
|
|
121
|
-
config, "enable_beautiful_traceback_local_stack_only"
|
|
122
|
-
),
|
|
123
|
-
exc_msg_override=message_override,
|
|
124
|
-
)
|
|
125
|
-
|
|
126
|
-
if assertion_details:
|
|
127
|
-
formatted_traceback += os.linesep + assertion_details + os.linesep
|
|
128
|
-
|
|
129
|
-
return formatted_traceback
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
def pytest_addoption(parser) -> None:
|
|
133
|
-
parser.addini(
|
|
134
|
-
"enable_beautiful_traceback",
|
|
135
|
-
"Enable the beautiful traceback plugin",
|
|
136
|
-
type="bool",
|
|
137
|
-
default=True,
|
|
138
|
-
)
|
|
139
|
-
|
|
140
|
-
parser.addini(
|
|
141
|
-
"enable_beautiful_traceback_local_stack_only",
|
|
142
|
-
"Show only local code (filter out library/framework internals)",
|
|
143
|
-
type="bool",
|
|
144
|
-
default=True,
|
|
145
|
-
)
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
@pytest.hookimpl(hookwrapper=True)
|
|
149
|
-
def pytest_runtest_makereport(item, call) -> Generator[None, None, None]:
|
|
150
|
-
"""Format test execution tracebacks with beautiful_traceback.
|
|
151
|
-
|
|
152
|
-
This hook runs during the test execution phase and replaces pytest's
|
|
153
|
-
default traceback formatting with beautiful_traceback's output.
|
|
154
|
-
"""
|
|
155
|
-
outcome = yield # type: ignore[misc]
|
|
156
|
-
report = outcome.get_result() # type: ignore[attr-defined]
|
|
157
|
-
|
|
158
|
-
if _get_option(item.config, "enable_beautiful_traceback") and report.failed:
|
|
159
|
-
report.longrepr = _format_traceback(call.excinfo, item.config)
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
def pytest_exception_interact(node, call, report) -> None:
|
|
163
|
-
"""Format collection-phase tracebacks with beautiful_traceback.
|
|
164
|
-
|
|
165
|
-
This hook runs during collection (e.g., import errors, fixture errors)
|
|
166
|
-
and ensures those errors also use beautiful_traceback formatting.
|
|
167
|
-
"""
|
|
168
|
-
if _get_option(node.config, "enable_beautiful_traceback") and report.failed:
|
|
169
|
-
report.longrepr = _format_traceback(call.excinfo, node.config)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|