Skip to content

Commit 5fa9f67

Browse files
committed
fix: avoid max recursion errors in ast code. #1774
1 parent 34af01d commit 5fa9f67

File tree

4 files changed

+40
-28
lines changed

4 files changed

+40
-28
lines changed

CHANGES.rst

+7
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ Unreleased
2828
on the first line. This closes `issue 754`_. The fix was contributed by
2929
`Daniel Diniz <pull 1773_>`_.
3030

31+
- Fix: very complex source files like `this one <resolvent_lookup_>`_ could
32+
cause a maximum recursion error when creating an HTML report. This is now
33+
fixed, closing `issue 1774`_.
34+
3135
- HTML report improvements:
3236

3337
- Support files (JavaScript and CSS) referenced by the HTML report now have
@@ -41,10 +45,13 @@ Unreleased
4145
- Column sort order is remembered better as you move between the index pages,
4246
fixing `issue 1766`_. Thanks, `Daniel Diniz <pull 1768_>`_.
4347

48+
49+
.. _resolvent_lookup: https://github.com/sympy/sympy/blob/130950f3e6b3f97fcc17f4599ac08f70fdd2e9d4/sympy/polys/numberfields/resolvent_lookup.py
4450
.. _issue 754: https://github.com/nedbat/coveragepy/issues/754
4551
.. _issue 1766: https://github.com/nedbat/coveragepy/issues/1766
4652
.. _pull 1768: https://github.com/nedbat/coveragepy/pull/1768
4753
.. _pull 1773: https://github.com/nedbat/coveragepy/pull/1773
54+
.. _issue 1774: https://github.com/nedbat/coveragepy/issues/1774
4855

4956

5057
.. scriv-start-here

coverage/phystokens.py

+12-20
Original file line numberDiff line numberDiff line change
@@ -78,26 +78,19 @@ def _phys_tokens(toks: TokenInfos) -> TokenInfos:
7878
last_lineno = elineno
7979

8080

81-
class SoftKeywordFinder(ast.NodeVisitor):
81+
def find_soft_key_lines(source: str) -> set[TLineNo]:
8282
"""Helper for finding lines with soft keywords, like match/case lines."""
83-
def __init__(self, source: str) -> None:
84-
# This will be the set of line numbers that start with a soft keyword.
85-
self.soft_key_lines: set[TLineNo] = set()
86-
self.visit(ast.parse(source))
87-
88-
if sys.version_info >= (3, 10):
89-
def visit_Match(self, node: ast.Match) -> None:
90-
"""Invoked by ast.NodeVisitor.visit"""
91-
self.soft_key_lines.add(node.lineno)
83+
soft_key_lines: set[TLineNo] = set()
84+
85+
for node in ast.walk(ast.parse(source)):
86+
if sys.version_info >= (3, 10) and isinstance(node, ast.Match):
87+
soft_key_lines.add(node.lineno)
9288
for case in node.cases:
93-
self.soft_key_lines.add(case.pattern.lineno)
94-
self.generic_visit(node)
89+
soft_key_lines.add(case.pattern.lineno)
90+
elif sys.version_info >= (3, 12) and isinstance(node, ast.TypeAlias):
91+
soft_key_lines.add(node.lineno)
9592

96-
if sys.version_info >= (3, 12):
97-
def visit_TypeAlias(self, node: ast.TypeAlias) -> None:
98-
"""Invoked by ast.NodeVisitor.visit"""
99-
self.soft_key_lines.add(node.lineno)
100-
self.generic_visit(node)
93+
return soft_key_lines
10194

10295

10396
def source_token_lines(source: str) -> TSourceTokenLines:
@@ -124,7 +117,7 @@ def source_token_lines(source: str) -> TSourceTokenLines:
124117
tokgen = generate_tokens(source)
125118

126119
if env.PYBEHAVIOR.soft_keywords:
127-
soft_key_lines = SoftKeywordFinder(source).soft_key_lines
120+
soft_key_lines = find_soft_key_lines(source)
128121

129122
for ttype, ttext, (sline, scol), (_, ecol), _ in _phys_tokens(tokgen):
130123
mark_start = True
@@ -151,8 +144,7 @@ def source_token_lines(source: str) -> TSourceTokenLines:
151144
# Need the version_info check to keep mypy from borking
152145
# on issoftkeyword here.
153146
if env.PYBEHAVIOR.soft_keywords and keyword.issoftkeyword(ttext):
154-
# Soft keywords appear at the start of the line,
155-
# on lines that start match or case statements.
147+
# Soft keywords appear at the start of their line.
156148
if len(line) == 0:
157149
is_start_of_line = True
158150
elif (len(line) == 1) and line[0][0] == "ws":

coverage/regions.py

+20-8
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ class Context:
2121
lines: set[int]
2222

2323

24-
class RegionFinder(ast.NodeVisitor):
24+
class RegionFinder:
2525
"""An ast visitor that will find and track regions of code.
2626
2727
Functions and classes are tracked by name. Results are in the .regions
@@ -34,13 +34,27 @@ def __init__(self) -> None:
3434

3535
def parse_source(self, source: str) -> None:
3636
"""Parse `source` and walk the ast to populate the .regions attribute."""
37-
self.visit(ast.parse(source))
37+
self.handle_node(ast.parse(source))
3838

3939
def fq_node_name(self) -> str:
4040
"""Get the current fully qualified name we're processing."""
4141
return ".".join(c.name for c in self.context)
4242

43-
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
43+
def handle_node(self, node: ast.AST) -> None:
44+
"""Recursively handle any node."""
45+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
46+
self.handle_FunctionDef(node)
47+
elif isinstance(node, ast.ClassDef):
48+
self.handle_ClassDef(node)
49+
else:
50+
self.handle_node_body(node)
51+
52+
def handle_node_body(self, node: ast.AST) -> None:
53+
"""Recursively handle the nodes in this node's body, if any."""
54+
for body_node in getattr(node, "body", ()):
55+
self.handle_node(body_node)
56+
57+
def handle_FunctionDef(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None:
4458
"""Called for `def` or `async def`."""
4559
lines = set(range(node.body[0].lineno, cast(int, node.body[-1].end_lineno) + 1))
4660
if self.context and self.context[-1].kind == "class":
@@ -60,12 +74,10 @@ def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
6074
lines=lines,
6175
)
6276
)
63-
self.generic_visit(node)
77+
self.handle_node_body(node)
6478
self.context.pop()
6579

66-
visit_AsyncFunctionDef = visit_FunctionDef # type: ignore[assignment]
67-
68-
def visit_ClassDef(self, node: ast.ClassDef) -> None:
80+
def handle_ClassDef(self, node: ast.ClassDef) -> None:
6981
"""Called for `class`."""
7082
# The lines for a class are the lines in the methods of the class.
7183
# We start empty, and count on visit_FunctionDef to add the lines it
@@ -80,7 +92,7 @@ def visit_ClassDef(self, node: ast.ClassDef) -> None:
8092
lines=lines,
8193
)
8294
)
83-
self.generic_visit(node)
95+
self.handle_node_body(node)
8496
self.context.pop()
8597
# Class bodies should be excluded from the enclosing classes.
8698
for ancestor in reversed(self.context):

tests/test_phystokens.py

+1
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ def match():
135135
global case
136136
""")
137137
tokens = list(source_token_lines(source))
138+
print(tokens)
138139
assert tokens[0][0] == ("key", "match")
139140
assert tokens[0][4] == ("nam", "match")
140141
assert tokens[1][1] == ("key", "case")

0 commit comments

Comments
 (0)