Skip to content

Commit aee983e

Browse files
JukkaLhauntsaninja
andauthored
Don't type check most function bodies if ignoring errors (#14150)
If errors are ignored, type checking function bodies often can have no effect. Remove function bodies after parsing to speed up type checking. Methods that define attributes have an externally visible effect even if errors are ignored. The body of any method that assigns to any attribute is preserved to deal with this (even if it doesn't actually define a new attribute). Most methods don't assign to an attribute, so stripping bodies is still effective for methods. There are a couple of additional interesting things in the implementation: 1. We need to know whether an abstract method has a trivial body (e.g. just `...`) to check `super()` method calls. The approach here is to preserve such trivial bodies and treat them differently from no body at all. 2. Stubgen analyzes the bodies of functions to e.g. infer some return types. As a workaround, explicitly preserve full ASTs when using stubgen. The main benefit is faster type checking when using installed packages with inline type information (PEP 561). Errors are ignored in this case, and it's common to have a large number of third-party code to type check. For example, a self check (code under `mypy/`) is now about **20% faster**, with a compiled mypy on Python 3.11. Another, more subtle benefit is improved reliability. A third-party library may have some code that triggers a mypy crash or an invalid blocking error. If bodies are stripped, often the error will no longer be triggered, since the amount code to type check is much lower. --------- Co-authored-by: Shantanu <12621235+hauntsaninja@users.noreply.github.com>
1 parent bdac4bc commit aee983e

File tree

9 files changed

+692
-26
lines changed

9 files changed

+692
-26
lines changed

mypy/build.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -835,6 +835,8 @@ def parse_file(
835835
Raise CompileError if there is a parse error.
836836
"""
837837
t0 = time.time()
838+
if ignore_errors:
839+
self.errors.ignored_files.add(path)
838840
tree = parse(source, path, id, self.errors, options=options)
839841
tree._fullname = id
840842
self.add_stats(

mypy/config_parser.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -538,10 +538,7 @@ def split_directive(s: str) -> tuple[list[str], list[str]]:
538538

539539

540540
def mypy_comments_to_config_map(line: str, template: Options) -> tuple[dict[str, str], list[str]]:
541-
"""Rewrite the mypy comment syntax into ini file syntax.
542-
543-
Returns
544-
"""
541+
"""Rewrite the mypy comment syntax into ini file syntax."""
545542
options = {}
546543
entries, errors = split_directive(line)
547544
for entry in entries:

mypy/fastparse.py

Lines changed: 166 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@
9999
)
100100
from mypy.reachability import infer_reachability_of_if_statement, mark_block_unreachable
101101
from mypy.sharedparse import argument_elide_name, special_function_elide_names
102+
from mypy.traverser import TraverserVisitor
102103
from mypy.types import (
103104
AnyType,
104105
CallableArgument,
@@ -260,6 +261,11 @@ def parse(
260261
Return the parse tree. If errors is not provided, raise ParseError
261262
on failure. Otherwise, use the errors object to report parse errors.
262263
"""
264+
ignore_errors = (options is not None and options.ignore_errors) or (
265+
errors is not None and fnam in errors.ignored_files
266+
)
267+
# If errors are ignored, we can drop many function bodies to speed up type checking.
268+
strip_function_bodies = ignore_errors and (options is None or not options.preserve_asts)
263269
raise_on_error = False
264270
if options is None:
265271
options = Options()
@@ -281,7 +287,13 @@ def parse(
281287
warnings.filterwarnings("ignore", category=DeprecationWarning)
282288
ast = ast3_parse(source, fnam, "exec", feature_version=feature_version)
283289

284-
tree = ASTConverter(options=options, is_stub=is_stub_file, errors=errors).visit(ast)
290+
tree = ASTConverter(
291+
options=options,
292+
is_stub=is_stub_file,
293+
errors=errors,
294+
ignore_errors=ignore_errors,
295+
strip_function_bodies=strip_function_bodies,
296+
).visit(ast)
285297
tree.path = fnam
286298
tree.is_stub = is_stub_file
287299
except SyntaxError as e:
@@ -400,14 +412,24 @@ def is_no_type_check_decorator(expr: ast3.expr) -> bool:
400412

401413

402414
class ASTConverter:
403-
def __init__(self, options: Options, is_stub: bool, errors: Errors) -> None:
404-
# 'C' for class, 'F' for function
405-
self.class_and_function_stack: list[Literal["C", "F"]] = []
415+
def __init__(
416+
self,
417+
options: Options,
418+
is_stub: bool,
419+
errors: Errors,
420+
*,
421+
ignore_errors: bool,
422+
strip_function_bodies: bool,
423+
) -> None:
424+
# 'C' for class, 'D' for function signature, 'F' for function, 'L' for lambda
425+
self.class_and_function_stack: list[Literal["C", "D", "F", "L"]] = []
406426
self.imports: list[ImportBase] = []
407427

408428
self.options = options
409429
self.is_stub = is_stub
410430
self.errors = errors
431+
self.ignore_errors = ignore_errors
432+
self.strip_function_bodies = strip_function_bodies
411433

412434
self.type_ignores: dict[int, list[str]] = {}
413435

@@ -475,7 +497,12 @@ def get_lineno(self, node: ast3.expr | ast3.stmt) -> int:
475497
return node.lineno
476498

477499
def translate_stmt_list(
478-
self, stmts: Sequence[ast3.stmt], ismodule: bool = False
500+
self,
501+
stmts: Sequence[ast3.stmt],
502+
*,
503+
ismodule: bool = False,
504+
can_strip: bool = False,
505+
is_coroutine: bool = False,
479506
) -> list[Statement]:
480507
# A "# type: ignore" comment before the first statement of a module
481508
# ignores the whole module:
@@ -504,11 +531,41 @@ def translate_stmt_list(
504531
mark_block_unreachable(block)
505532
return [block]
506533

534+
stack = self.class_and_function_stack
535+
if self.strip_function_bodies and len(stack) == 1 and stack[0] == "F":
536+
return []
537+
507538
res: list[Statement] = []
508539
for stmt in stmts:
509540
node = self.visit(stmt)
510541
res.append(node)
511542

543+
if (
544+
self.strip_function_bodies
545+
and can_strip
546+
and stack[-2:] == ["C", "F"]
547+
and not is_possible_trivial_body(res)
548+
):
549+
# We only strip method bodies if they don't assign to an attribute, as
550+
# this may define an attribute which has an externally visible effect.
551+
visitor = FindAttributeAssign()
552+
for s in res:
553+
s.accept(visitor)
554+
if visitor.found:
555+
break
556+
else:
557+
if is_coroutine:
558+
# Yields inside an async function affect the return type and should not
559+
# be stripped.
560+
yield_visitor = FindYield()
561+
for s in res:
562+
s.accept(yield_visitor)
563+
if yield_visitor.found:
564+
break
565+
else:
566+
return []
567+
else:
568+
return []
512569
return res
513570

514571
def translate_type_comment(
@@ -573,9 +630,20 @@ def as_block(self, stmts: list[ast3.stmt], lineno: int) -> Block | None:
573630
b.set_line(lineno)
574631
return b
575632

576-
def as_required_block(self, stmts: list[ast3.stmt], lineno: int) -> Block:
633+
def as_required_block(
634+
self,
635+
stmts: list[ast3.stmt],
636+
lineno: int,
637+
*,
638+
can_strip: bool = False,
639+
is_coroutine: bool = False,
640+
) -> Block:
577641
assert stmts # must be non-empty
578-
b = Block(self.fix_function_overloads(self.translate_stmt_list(stmts)))
642+
b = Block(
643+
self.fix_function_overloads(
644+
self.translate_stmt_list(stmts, can_strip=can_strip, is_coroutine=is_coroutine)
645+
)
646+
)
579647
# TODO: in most call sites line is wrong (includes first line of enclosing statement)
580648
# TODO: also we need to set the column, and the end position here.
581649
b.set_line(lineno)
@@ -831,9 +899,6 @@ def _is_stripped_if_stmt(self, stmt: Statement) -> bool:
831899
# For elif, IfStmt are stored recursively in else_body
832900
return self._is_stripped_if_stmt(stmt.else_body.body[0])
833901

834-
def in_method_scope(self) -> bool:
835-
return self.class_and_function_stack[-2:] == ["C", "F"]
836-
837902
def translate_module_id(self, id: str) -> str:
838903
"""Return the actual, internal module id for a source text id."""
839904
if id == self.options.custom_typing_module:
@@ -868,7 +933,7 @@ def do_func_def(
868933
self, n: ast3.FunctionDef | ast3.AsyncFunctionDef, is_coroutine: bool = False
869934
) -> FuncDef | Decorator:
870935
"""Helper shared between visit_FunctionDef and visit_AsyncFunctionDef."""
871-
self.class_and_function_stack.append("F")
936+
self.class_and_function_stack.append("D")
872937
no_type_check = bool(
873938
n.decorator_list and any(is_no_type_check_decorator(d) for d in n.decorator_list)
874939
)
@@ -915,7 +980,8 @@ def do_func_def(
915980
return_type = TypeConverter(self.errors, line=lineno).visit(func_type_ast.returns)
916981

917982
# add implicit self type
918-
if self.in_method_scope() and len(arg_types) < len(args):
983+
in_method_scope = self.class_and_function_stack[-2:] == ["C", "D"]
984+
if in_method_scope and len(arg_types) < len(args):
919985
arg_types.insert(0, AnyType(TypeOfAny.special_form))
920986
except SyntaxError:
921987
stripped_type = n.type_comment.split("#", 2)[0].strip()
@@ -965,7 +1031,10 @@ def do_func_def(
9651031
end_line = getattr(n, "end_lineno", None)
9661032
end_column = getattr(n, "end_col_offset", None)
9671033

968-
func_def = FuncDef(n.name, args, self.as_required_block(n.body, lineno), func_type)
1034+
self.class_and_function_stack.pop()
1035+
self.class_and_function_stack.append("F")
1036+
body = self.as_required_block(n.body, lineno, can_strip=True, is_coroutine=is_coroutine)
1037+
func_def = FuncDef(n.name, args, body, func_type)
9691038
if isinstance(func_def.type, CallableType):
9701039
# semanal.py does some in-place modifications we want to avoid
9711040
func_def.unanalyzed_type = func_def.type.copy_modified()
@@ -1409,9 +1478,11 @@ def visit_Lambda(self, n: ast3.Lambda) -> LambdaExpr:
14091478
body.lineno = n.body.lineno
14101479
body.col_offset = n.body.col_offset
14111480

1481+
self.class_and_function_stack.append("L")
14121482
e = LambdaExpr(
14131483
self.transform_args(n.args, n.lineno), self.as_required_block([body], n.lineno)
14141484
)
1485+
self.class_and_function_stack.pop()
14151486
e.set_line(n.lineno, n.col_offset) # Overrides set_line -- can't use self.set_line
14161487
return e
14171488

@@ -2081,3 +2152,85 @@ def stringify_name(n: AST) -> str | None:
20812152
if sv is not None:
20822153
return f"{sv}.{n.attr}"
20832154
return None # Can't do it.
2155+
2156+
2157+
class FindAttributeAssign(TraverserVisitor):
2158+
"""Check if an AST contains attribute assignments (e.g. self.x = 0)."""
2159+
2160+
def __init__(self) -> None:
2161+
self.lvalue = False
2162+
self.found = False
2163+
2164+
def visit_assignment_stmt(self, s: AssignmentStmt) -> None:
2165+
self.lvalue = True
2166+
for lv in s.lvalues:
2167+
lv.accept(self)
2168+
self.lvalue = False
2169+
2170+
def visit_with_stmt(self, s: WithStmt) -> None:
2171+
self.lvalue = True
2172+
for lv in s.target:
2173+
if lv is not None:
2174+
lv.accept(self)
2175+
self.lvalue = False
2176+
s.body.accept(self)
2177+
2178+
def visit_for_stmt(self, s: ForStmt) -> None:
2179+
self.lvalue = True
2180+
s.index.accept(self)
2181+
self.lvalue = False
2182+
s.body.accept(self)
2183+
if s.else_body:
2184+
s.else_body.accept(self)
2185+
2186+
def visit_expression_stmt(self, s: ExpressionStmt) -> None:
2187+
# No need to look inside these
2188+
pass
2189+
2190+
def visit_call_expr(self, e: CallExpr) -> None:
2191+
# No need to look inside these
2192+
pass
2193+
2194+
def visit_index_expr(self, e: IndexExpr) -> None:
2195+
# No need to look inside these
2196+
pass
2197+
2198+
def visit_member_expr(self, e: MemberExpr) -> None:
2199+
if self.lvalue:
2200+
self.found = True
2201+
2202+
2203+
class FindYield(TraverserVisitor):
2204+
"""Check if an AST contains yields or yield froms."""
2205+
2206+
def __init__(self) -> None:
2207+
self.found = False
2208+
2209+
def visit_yield_expr(self, e: YieldExpr) -> None:
2210+
self.found = True
2211+
2212+
def visit_yield_from_expr(self, e: YieldFromExpr) -> None:
2213+
self.found = True
2214+
2215+
2216+
def is_possible_trivial_body(s: list[Statement]) -> bool:
2217+
"""Could the statements form a "trivial" function body, such as 'pass'?
2218+
2219+
This mimics mypy.semanal.is_trivial_body, but this runs before
2220+
semantic analysis so some checks must be conservative.
2221+
"""
2222+
l = len(s)
2223+
if l == 0:
2224+
return False
2225+
i = 0
2226+
if isinstance(s[0], ExpressionStmt) and isinstance(s[0].expr, StrExpr):
2227+
# Skip docstring
2228+
i += 1
2229+
if i == l:
2230+
return True
2231+
if l > i + 1:
2232+
return False
2233+
stmt = s[i]
2234+
return isinstance(stmt, (PassStmt, RaiseStmt)) or (
2235+
isinstance(stmt, ExpressionStmt) and isinstance(stmt.expr, EllipsisExpr)
2236+
)

mypy/semanal.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6684,7 +6684,7 @@ def is_trivial_body(block: Block) -> bool:
66846684
"..." (ellipsis), or "raise NotImplementedError()". A trivial body may also
66856685
start with a statement containing just a string (e.g. a docstring).
66866686
6687-
Note: functions that raise other kinds of exceptions do not count as
6687+
Note: Functions that raise other kinds of exceptions do not count as
66886688
"trivial". We use this function to help us determine when it's ok to
66896689
relax certain checks on body, but functions that raise arbitrary exceptions
66906690
are more likely to do non-trivial work. For example:
@@ -6694,11 +6694,18 @@ def halt(self, reason: str = ...) -> NoReturn:
66946694
66956695
A function that raises just NotImplementedError is much less likely to be
66966696
this complex.
6697+
6698+
Note: If you update this, you may also need to update
6699+
mypy.fastparse.is_possible_trivial_body!
66976700
"""
66986701
body = block.body
6702+
if not body:
6703+
# Functions have empty bodies only if the body is stripped or the function is
6704+
# generated or deserialized. In these cases the body is unknown.
6705+
return False
66996706

67006707
# Skip a docstring
6701-
if body and isinstance(body[0], ExpressionStmt) and isinstance(body[0].expr, StrExpr):
6708+
if isinstance(body[0], ExpressionStmt) and isinstance(body[0].expr, StrExpr):
67026709
body = block.body[1:]
67036710

67046711
if len(body) == 0:

mypy/stubgen.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1588,6 +1588,7 @@ def mypy_options(stubgen_options: Options) -> MypyOptions:
15881588
options.python_version = stubgen_options.pyversion
15891589
options.show_traceback = True
15901590
options.transform_source = remove_misplaced_type_comments
1591+
options.preserve_asts = True
15911592
return options
15921593

15931594

mypy/test/testparse.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,13 @@
77
from pytest import skip
88

99
from mypy import defaults
10+
from mypy.config_parser import parse_mypy_comments
1011
from mypy.errors import CompileError
1112
from mypy.options import Options
1213
from mypy.parse import parse
1314
from mypy.test.data import DataDrivenTestCase, DataSuite
1415
from mypy.test.helpers import assert_string_arrays_equal, find_test_files, parse_options
16+
from mypy.util import get_mypy_comments
1517

1618

1719
class ParserSuite(DataSuite):
@@ -40,13 +42,16 @@ def test_parser(testcase: DataDrivenTestCase) -> None:
4042
else:
4143
options.python_version = defaults.PYTHON3_VERSION
4244

45+
source = "\n".join(testcase.input)
46+
47+
# Apply mypy: comments to options.
48+
comments = get_mypy_comments(source)
49+
changes, _ = parse_mypy_comments(comments, options)
50+
options = options.apply_changes(changes)
51+
4352
try:
4453
n = parse(
45-
bytes("\n".join(testcase.input), "ascii"),
46-
fnam="main",
47-
module="__main__",
48-
errors=None,
49-
options=options,
54+
bytes(source, "ascii"), fnam="main", module="__main__", errors=None, options=options
5055
)
5156
a = n.str_with_options(options).split("\n")
5257
except CompileError as e:

0 commit comments

Comments
 (0)