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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: beautiful-traceback
3
- Version: 0.4.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, term_width: typ.Optional[int] = None
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, color: bool = False, local_stack_only: bool = False
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(traceback.stack_frames)
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(tb_tup, color, local_stack_only)
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(tracebacks, color, local_stack_only)
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(color: bool, local_stack_only: bool) -> typ.Callable:
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, traceback, color, local_stack_only
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(color=color, local_stack_only=local_stack_only)
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:
@@ -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(entries, term_width=fmt.DEFAULT_COLUMNS)
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(entries, term_width=fmt.DEFAULT_COLUMNS)
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,6 +1,6 @@
1
1
  [project]
2
2
  name = "beautiful-traceback"
3
- version = "0.4.0"
3
+ version = "0.5.0"
4
4
  description = "Beautiful, readable Python tracebacks with colors and formatting"
5
5
  keywords = ["traceback", "error", "debugging", "formatting"]
6
6
  readme = "README.md"
@@ -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)