Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion mypy/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down
38 changes: 38 additions & 0 deletions mypy/fastparse.py
Original file line number Diff line number Diff line change
Expand Up @@ -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] = []
Expand Down Expand Up @@ -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:
Expand Down
73 changes: 72 additions & 1 deletion mypy/parse.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
from __future__ import annotations

import ast
import re

from librt.internal import ReadBuffer

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

Expand Down Expand Up @@ -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
Expand Down
32 changes: 32 additions & 0 deletions test-data/unit/check-ignore.test
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Loading