From 135a306aa3db9649e9cf1edfeee360045189fdf3 Mon Sep 17 00:00:00 2001 From: Niladri Das <125604915+bniladridas@users.noreply.github.com> Date: Mon, 26 Oct 2026 04:51:58 +0530 Subject: [PATCH 1/4] Track imports in stripped function bodies When ignore_errors is set, mypy strips function bodies to speed up type checking. However, Import/ImportFrom nodes inside those bodies were never registered in tree.imports, so the imported modules were never tracked as dependencies and never type-checked. The native parser deserializes tree.imports directly from raw data in load_from_raw(), which replaces the MypyFile object entirely. Any imports collected before deserialization are therefore lost. The fix restores stripped-body imports after load_from_raw() in both the threaded (BuildManager.parse_all) and non-threaded (State.parse_file) build paths, using Python's ast module to scan the source for imports inside function bodies. A matching fix was already applied to the Python (fastparse) parser path via _collect_imports_from_stmts(). Adds regression tests for both parser configurations, verifying that a module imported inside a stripped function body is still type-checked. Fixes #22280 --- mypy/build.py | 16 ++++++- mypy/fastparse.py | 36 ++++++++++++++++ mypy/parse.py | 72 +++++++++++++++++++++++++++++++- test-data/unit/check-ignore.test | 32 ++++++++++++++ 4 files changed, 154 insertions(+), 2 deletions(-) diff --git a/mypy/build.py b/mypy/build.py index 38615f6ddbc14..5aa53932cae48 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,13 @@ 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 +3320,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 a4aea55386abb..ab878883536f9 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,41 @@ def translate_stmt_list( return [] return res + def _collect_imports_from_stmts(self, stmts: list[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 5ffbd20c674df..fb493693c392f 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,66 @@ def load_from_raw( return tree +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 + + 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) + + 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 9a30490e88334..d0a670ca83532 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") From 2d142e870939300bbeb0f578d23666aede46898e Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 25 Oct 2026 23:50:37 +0000 Subject: [PATCH 2/4] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- mypy/build.py | 4 +++- mypy/fastparse.py | 8 +++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/mypy/build.py b/mypy/build.py index 5aa53932cae48..6d6eba206bfc3 100644 --- a/mypy/build.py +++ b/mypy/build.py @@ -1077,7 +1077,9 @@ def parse_all(self, states: list[State], post_parse: bool = True) -> None: 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) + _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() diff --git a/mypy/fastparse.py b/mypy/fastparse.py index ab878883536f9..2667a6884e4a3 100644 --- a/mypy/fastparse.py +++ b/mypy/fastparse.py @@ -556,9 +556,11 @@ def _collect_imports_from_stmts(self, stmts: list[ast3.stmt]) -> None: else: self.imports.append( ImportFrom( - self.translate_module_id(stmt.module) - if stmt.module is not None - else "", + ( + self.translate_module_id(stmt.module) + if stmt.module is not None + else "" + ), stmt.level, [(a.name, a.asname) for a in stmt.names], ) From 91c089735c9efd9252be367efd881b453c1437a2 Mon Sep 17 00:00:00 2001 From: Niladri Das <125604915+bniladridas@users.noreply.github.com> Date: Mon, 26 Oct 2026 05:56:15 +0530 Subject: [PATCH 3/4] Fix type annotation in _collect_imports_from_stmts Change the stmts parameter from list[ast3.stmt] to Sequence[ast3.stmt] to match the caller's type. The function only reads the sequence and never mutates it, so Sequence is the correct type. This fixes a mypy self-check failure reported in CI for PR #22298. Fixes #22298 Signed-off-by: Niladri Das <125604915+bniladridas@users.noreply.github.com> --- mypy/fastparse.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mypy/fastparse.py b/mypy/fastparse.py index 2667a6884e4a3..16d2aa3fb1852 100644 --- a/mypy/fastparse.py +++ b/mypy/fastparse.py @@ -529,7 +529,7 @@ def translate_stmt_list( return [] return res - def _collect_imports_from_stmts(self, stmts: list[ast3.stmt]) -> None: + 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 From 76481d91405e33f41d510f2c4dd49ba37cc5cc7c Mon Sep 17 00:00:00 2001 From: Niladri Das <125604915+bniladridas@users.noreply.github.com> Date: Mon, 26 Oct 2026 06:08:01 +0530 Subject: [PATCH 4/4] Move ImportCollector to module scope mypyc cannot compile nested class definitions, so defining ImportCollector inside _restore_stripped_body_imports fails the compiled build. The class uses no closure state, so hoisting it to module scope is behavior preserving. Signed-off-by: Niladri Das <125604915+bniladridas@users.noreply.github.com> --- mypy/parse.py | 65 ++++++++++++++++++++++++++------------------------- 1 file changed, 33 insertions(+), 32 deletions(-) diff --git a/mypy/parse.py b/mypy/parse.py index fb493693c392f..c3c38feeafd18 100644 --- a/mypy/parse.py +++ b/mypy/parse.py @@ -116,6 +116,39 @@ 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: @@ -139,38 +172,6 @@ def _restore_stripped_body_imports( except SyntaxError: return - 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) - collector = ImportCollector() collector.visit(src_tree) imports.extend(collector.collected)