Skip to content

Commit c0ab40c

Browse files
Merge pull request #14903 from RonnyPfannschmidt/fix-10745-cheaper-recursion-tracebacks
Make rendering deep and recursive tracebacks cheap
2 parents d6f66d4 + 7eee10c commit c0ab40c

6 files changed

Lines changed: 100 additions & 3 deletions

File tree

changelog/10745.improvement.1.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
The line numbers of a module's statements are now computed once per traceback
2+
instead of once per traceback entry, which noticeably speeds up rendering
3+
failures raised from large test modules.

changelog/10745.improvement.2.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
A ``RecursionError`` whose recursion origin cannot be located now shows only the
2+
first and last 10 stack frames, matching what pytest already did when locating
3+
the origin failed outright. Previously the full traceback -- often around a
4+
thousand near-identical frames -- was rendered, which was both slow and
5+
unreadable. Pass ``--full-trace`` to see every frame.

src/_pytest/_code/code.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1167,10 +1167,10 @@ def _truncate_recursive_traceback(
11671167
TypeError), in which case we do our best to warn the user of the
11681168
error and show a limited traceback.
11691169
"""
1170+
max_frames = 10
11701171
try:
11711172
recursionindex = traceback.recursionindex()
11721173
except Exception as e:
1173-
max_frames = 10
11741174
extraline: str | None = (
11751175
"!!! Recursion error detected, but an error occurred locating the origin of recursion.\n"
11761176
" The following exception happened when comparing locals in the stack frame:\n"
@@ -1184,6 +1184,18 @@ def _truncate_recursive_traceback(
11841184
if recursionindex is not None:
11851185
extraline = "!!! Recursion detected (same locals & position)"
11861186
traceback = traceback[: recursionindex + 1]
1187+
elif self.tbfilter is not False and len(traceback) > 2 * max_frames:
1188+
# The origin of the recursion could not be pinned down (the frames
1189+
# differ), but rendering hundreds of near-identical entries is both
1190+
# slow and unreadable -- show the ends only, as above.
1191+
extraline = (
1192+
"!!! Recursion error detected, but the origin of the recursion "
1193+
"could not be located.\n"
1194+
f" Displaying first and last {max_frames} stack frames out of {len(traceback)}.\n"
1195+
" Pass `--full-trace` to see all frames."
1196+
)
1197+
# Type ignored for the same reason as above.
1198+
traceback = traceback[:max_frames] + traceback[-max_frames:] # type: ignore
11871199
else:
11881200
extraline = None
11891201

src/_pytest/_code/source.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -166,10 +166,19 @@ def deindent(lines: Iterable[str]) -> list[str]:
166166
return textwrap.dedent("\n".join(lines)).splitlines()
167167

168168

169-
def get_statement_startend2(lineno: int, node: ast.AST) -> tuple[int, int | None]:
169+
def _statement_linenos(node: ast.AST) -> list[int]:
170+
"""Sorted 0-based line numbers of all statements below ``node``.
171+
172+
Walking the tree is proportional to the size of the whole module, while a
173+
traceback asks for many line numbers of the same module -- so the result is
174+
memoized on the node, which shares the lifetime of the caller's ast cache.
175+
"""
176+
values: list[int] | None = getattr(node, "_pytest_statement_linenos", None)
177+
if values is not None:
178+
return values
170179
# Flatten all statements and except handlers into one lineno-list.
171180
# AST's line numbers start indexing at 1.
172-
values: list[int] = []
181+
values = []
173182
for x in ast.walk(node):
174183
if isinstance(x, ast.stmt | ast.ExceptHandler):
175184
# The lineno points to the class/def, so need to include the decorators.
@@ -183,6 +192,15 @@ def get_statement_startend2(lineno: int, node: ast.AST) -> tuple[int, int | None
183192
# Treat the finally/orelse part as its own statement.
184193
values.append(val[0].lineno - 1 - 1)
185194
values.sort()
195+
try:
196+
node._pytest_statement_linenos = values # type: ignore[attr-defined]
197+
except AttributeError: # pragma: no cover - defensive
198+
pass
199+
return values
200+
201+
202+
def get_statement_startend2(lineno: int, node: ast.AST) -> tuple[int, int | None]:
203+
values = _statement_linenos(node)
186204
insert_index = bisect_right(values, lineno)
187205
if insert_index == 0:
188206
return 0, None

testing/code/test_excinfo.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1876,6 +1876,47 @@ def b(x):
18761876
)
18771877

18781878

1879+
@pytest.mark.usefixtures("limited_recursion_depth")
1880+
def test_undetectable_recursion_is_truncated() -> None:
1881+
"""A recursion whose origin cannot be located is shown ends-only (#10745)."""
1882+
1883+
def f(x):
1884+
# The locals differ in every frame, so recursionindex() finds nothing.
1885+
f(x + 1)
1886+
1887+
with pytest.raises(RecursionError) as excinfo:
1888+
f(0)
1889+
1890+
p = ExceptionInfoFormatter(style="long", tbfilter=True)
1891+
traceback, extraline = p._truncate_recursive_traceback(excinfo.traceback)
1892+
assert len(traceback) == 20
1893+
assert extraline is not None
1894+
matcher = LineMatcher(extraline.splitlines())
1895+
matcher.fnmatch_lines(
1896+
[
1897+
"!!! Recursion error detected, but the origin of the recursion could not be located.",
1898+
"*Displaying first and last 10 stack frames out of *",
1899+
"*--full-trace*",
1900+
]
1901+
)
1902+
1903+
1904+
@pytest.mark.usefixtures("limited_recursion_depth")
1905+
def test_undetectable_recursion_kept_with_full_trace() -> None:
1906+
"""``--full-trace`` (tbfilter=False) still renders every frame (#10745)."""
1907+
1908+
def f(x):
1909+
f(x + 1)
1910+
1911+
with pytest.raises(RecursionError) as excinfo:
1912+
f(0)
1913+
1914+
p = ExceptionInfoFormatter(style="long", tbfilter=False)
1915+
traceback, extraline = p._truncate_recursive_traceback(excinfo.traceback)
1916+
assert len(traceback) == len(excinfo.traceback)
1917+
assert extraline is None
1918+
1919+
18791920
@pytest.mark.usefixtures("limited_recursion_depth")
18801921
def test_no_recursion_index_on_recursion_error():
18811922
"""

testing/code/test_source.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# mypy: allow-untyped-defs
22
from __future__ import annotations
33

4+
import ast
45
import inspect
56
import linecache
67
from pathlib import Path
@@ -706,3 +707,20 @@ def patched_compile2(_, *args, **kwargs):
706707

707708
with patch("builtins.compile", new=patched_compile2):
708709
Source(patched_compile2).getstatement(1)
710+
711+
712+
def test_statement_linenos_are_memoized_per_node() -> None:
713+
"""The statement index of a module is reused across traceback entries (#10745)."""
714+
from _pytest._code.source import _statement_linenos
715+
from _pytest._code.source import get_statement_startend2
716+
717+
node = ast.parse("x = 1\nif x:\n y = 2\n")
718+
values = _statement_linenos(node)
719+
assert values == [0, 1, 2]
720+
# A second call reuses the very same list instead of walking the tree again.
721+
assert _statement_linenos(node) is values
722+
assert get_statement_startend2(1, node) == (1, 2)
723+
724+
other = ast.parse("z = 3\n")
725+
assert _statement_linenos(other) == [0]
726+
assert _statement_linenos(node) is values

0 commit comments

Comments
 (0)