diff --git a/mypy/build.py b/mypy/build.py index 38615f6ddbc1..6d6eba206bfc 100644 --- a/mypy/build.py +++ b/mypy/build.py @@ -164,7 +164,7 @@ from mypy.modules_state import modules_state from mypy.nodes import Expression from mypy.options import Options -from mypy.parse import load_from_raw, parse +from mypy.parse import _restore_stripped_body_imports, load_from_raw, parse from mypy.plugin import ChainedPlugin, Plugin, ReportConfigContext from mypy.plugins.default import DefaultPlugin from mypy.renaming import LimitedVariableRenameVisitor, VariableRenameVisitor @@ -1071,6 +1071,15 @@ def parse_all(self, states: list[State], post_parse: bool = True) -> None: state.options, imports_only=bool(self.workers), ) + # When function bodies are stripped (ignore_errors mode), imports inside + # those bodies are not serialized into tree.imports by the native parser. + # Re-extract them from source so dependency tracking still works. + ignore_errors = state.ignore_all or state.options.ignore_errors + strip_function_bodies = ignore_errors and not state.options.preserve_asts + if strip_function_bodies: + _restore_stripped_body_imports( + state.xpath, state.source, state.tree.imports + ) if self.errors.is_blockers(): self.log("Bailing due to parse errors") self.errors.raise_error() @@ -3313,6 +3322,13 @@ def parse_file(self, *, temporary: bool = False, raw_data: FileRawData | None = self.options, imports_only=bool(self.manager.workers), ) + # When function bodies are stripped (ignore_errors mode), imports inside + # those bodies are not serialized into tree.imports by the native parser. + # Re-extract them from source so dependency tracking still works. + ignore_errors = self.ignore_all or self.options.ignore_errors + strip_function_bodies = ignore_errors and not self.options.preserve_asts + if strip_function_bodies: + _restore_stripped_body_imports(self.xpath, source, self.tree.imports) if manager.errors.is_blockers(): manager.log("Bailing due to parse errors") manager.errors.raise_error() diff --git a/mypy/fastparse.py b/mypy/fastparse.py index a4aea55386ab..16d2aa3fb185 100644 --- a/mypy/fastparse.py +++ b/mypy/fastparse.py @@ -492,6 +492,7 @@ def translate_stmt_list( and stack[0] == "F" and not is_coroutine ): + self._collect_imports_from_stmts(stmts) return [] res: list[Statement] = [] @@ -528,6 +529,43 @@ def translate_stmt_list( return [] return res + def _collect_imports_from_stmts(self, stmts: Sequence[ast3.stmt]) -> None: + """Collect import statements from raw AST when stripping function bodies. + + When ``strip_function_bodies`` is True, the fast path returns an empty + body without visiting individual statements. This means ``Import`` / + ``ImportFrom`` nodes inside function bodies never get added to + ``self.imports``, and the imported modules are never tracked as + dependencies. This method scans the raw AST statements for imports and + registers them so dependency tracking still works for ignored modules. + """ + for stmt in stmts: + if isinstance(stmt, ast3.Import): + names: list[tuple[str, str | None]] = [] + for alias in stmt.names: + name = self.translate_module_id(alias.name) + asname = alias.asname + if asname is None and name != alias.name: + asname = alias.name + names.append((name, asname)) + self.imports.append(Import(names)) + elif isinstance(stmt, ast3.ImportFrom): + if len(stmt.names) == 1 and stmt.names[0].name == "*": + mod = stmt.module if stmt.module is not None else "" + self.imports.append(ImportAll(mod, stmt.level)) + else: + self.imports.append( + ImportFrom( + ( + self.translate_module_id(stmt.module) + if stmt.module is not None + else "" + ), + stmt.level, + [(a.name, a.asname) for a in stmt.names], + ) + ) + def translate_type_comment( self, n: ast3.stmt | ast3.arg, type_comment: str | None ) -> ProperType | None: diff --git a/mypy/parse.py b/mypy/parse.py index 5ffbd20c674d..c3c38feeafd1 100644 --- a/mypy/parse.py +++ b/mypy/parse.py @@ -1,5 +1,6 @@ from __future__ import annotations +import ast import re from librt.internal import ReadBuffer @@ -7,7 +8,16 @@ from mypy import errorcodes as codes from mypy.cache import read_int from mypy.errors import Errors -from mypy.nodes import FileRawData, MypyFile, ParseError, StrExpr +from mypy.nodes import ( + FileRawData, + Import, + ImportAll, + ImportBase, + ImportFrom, + MypyFile, + ParseError, + StrExpr, +) from mypy.options import Options from mypy.types import ProperType @@ -106,6 +116,67 @@ def load_from_raw( return tree +class ImportCollector(ast.NodeVisitor): + def __init__(self) -> None: + self.collected: list[ImportBase] = [] + self.in_function = 0 + + def visit_FunctionDef(self, node: ast.FunctionDef) -> None: + self.in_function += 1 + self.generic_visit(node) + self.in_function -= 1 + + def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None: + self.in_function += 1 + self.generic_visit(node) + self.in_function -= 1 + + def visit_Import(self, node: ast.Import) -> None: + if self.in_function > 0: + names = [(alias.name, alias.asname) for alias in node.names] + self.collected.append(Import(names)) + self.generic_visit(node) + + def visit_ImportFrom(self, node: ast.ImportFrom) -> None: + if self.in_function > 0: + if len(node.names) == 1 and node.names[0].name == "*": + mod = node.module if node.module is not None else "" + self.collected.append(ImportAll(mod, node.level)) + else: + names = [(a.name, a.asname) for a in node.names] + mod = node.module if node.module is not None else "" + self.collected.append(ImportFrom(mod, node.level, names)) + self.generic_visit(node) + + +def _restore_stripped_body_imports( + fnam: str, source: str | bytes | None, imports: list[ImportBase] +) -> None: + """Collect imports from inside function bodies when the native parser strips them. + + When ``ignore_errors`` is set, the native parser skips function bodies to + speed up type checking. This means ``Import`` / ``ImportFrom`` nodes inside + function bodies never get serialized into ``tree.imports``, and the imported + modules are never tracked as dependencies. This function scans the source + with Python's ``ast`` module and adds any imports found inside function + bodies to the imports list. + """ + if source is None: + try: + with open(fnam, encoding="utf-8") as f: + source = f.read() + except OSError: + return + try: + src_tree = ast.parse(source) + except SyntaxError: + return + + collector = ImportCollector() + collector.visit(src_tree) + imports.extend(collector.collected) + + def report_parse_error(error: ParseError, errors: Errors) -> None: message = error["message"] # Standardize error message by capitalizing the first word diff --git a/test-data/unit/check-ignore.test b/test-data/unit/check-ignore.test index 9a30490e8833..d0a670ca8353 100644 --- a/test-data/unit/check-ignore.test +++ b/test-data/unit/check-ignore.test @@ -418,3 +418,35 @@ y # mypy: ignore[assignment] # type: ignore [out] main:1: error: Unrecognized error code "whatever" main:1: note: Error code "misc" not covered by "type: ignore[whatever]" comment + +[case testIgnoreErrorsFollowsImportInsideStrippedFunctionBody] +# flags: --config-file tmp/mypy.ini +import a +[file mypy.ini] +\[mypy] +\[mypy-a] +ignore_errors = True +[file a.py] +class A: + def f(self) -> None: + import b +[file b.py] +x: int = "moo" +[out] +tmp/b.py:1: error: Incompatible types in assignment (expression has type "str", variable has type "int") + +[case testIgnoreErrorsFollowsImportInsideStrippedFunctionBodyNativeParser] +# flags: --no-native-parser --config-file tmp/mypy.ini +import a +[file mypy.ini] +\[mypy] +\[mypy-a] +ignore_errors = True +[file a.py] +class A: + def f(self) -> None: + import b +[file b.py] +x: int = "moo" +[out] +tmp/b.py:1: error: Incompatible types in assignment (expression has type "str", variable has type "int")