From dc8242a7326e15aa131e8bdadeac4a2148b40654 Mon Sep 17 00:00:00 2001 From: ShahabAhmed01 <192491541+ShahabAhmed01@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:07:00 +0500 Subject: [PATCH] Follow modules imported inside skipped function bodies in the native parser Modules whose errors are ignored get their function bodies stripped during parsing to speed up type checking. With the native parser, stripping also drops import statements inside those bodies from the serialized import metadata, so modules imported only from within such bodies (e.g. lazy imports inside methods) can be missing from the dependency graph and are never type checked or reported by mypy. The old parser visits import statements before discarding stripped bodies, so it does not have this problem. Restore the old behavior by parsing the file a second time without stripping function bodies and using its complete import metadata when the native parser would strip function bodies. This fixes mypy#22284. --- mypy/nativeparse.py | 71 ++++++++++++++++++------- test-data/unit/check-inline-config.test | 27 ++++++++++ 2 files changed, 79 insertions(+), 19 deletions(-) diff --git a/mypy/nativeparse.py b/mypy/nativeparse.py index 43b3fd6fbeec9..7ac8f87ecc303 100644 --- a/mypy/nativeparse.py +++ b/mypy/nativeparse.py @@ -19,7 +19,7 @@ from __future__ import annotations import os -from typing import Final, cast +from typing import Any, Final, cast import ast_serialize from librt.internal import ( @@ -394,6 +394,32 @@ def read_statements(state: State, data: ReadBuffer, n: int) -> list[Statement]: return defs +def _ast_serialize_parse( + filename: str, + options: Options, + source: str | bytes | None, + skip_function_bodies: bool, + cache_version: int, +) -> tuple[bytes, list[ParseError], TypeIgnores, bytes, dict[str, Any]]: + """Call the native parser and return its results for a single parse. + + Raise CompileError if the file cannot be read or parsed at all. + """ + return cast( + "tuple[bytes, list[ParseError], TypeIgnores, bytes, dict[str, Any]]", + ast_serialize.parse( + filename, + source, + skip_function_bodies=skip_function_bodies, + python_version=options.python_version, + platform=options.platform, + always_true=options.always_true, + always_false=options.always_false, + cache_version=cache_version, + ), + ) + + def parse_to_binary_ast( filename: str, options: Options, @@ -411,15 +437,8 @@ def parse_to_binary_ast( list[tuple[int, str]], ]: try: - ast_bytes, errors, ignores, import_bytes, ast_data = ast_serialize.parse( - filename, - source, - skip_function_bodies=skip_function_bodies, - python_version=options.python_version, - platform=options.platform, - always_true=options.always_true, - always_false=options.always_false, - cache_version=7, + ast_bytes, errors, ignores, import_bytes, ast_data = _ast_serialize_parse( + filename, options, source, skip_function_bodies, cache_version=7 ) except ValueError as exc: if source is None: @@ -428,15 +447,8 @@ def parse_to_binary_ast( with open(filename, "rb") as f: source_bytes = f.read() source = decode_python_encoding(source_bytes) - ast_bytes, errors, ignores, import_bytes, ast_data = ast_serialize.parse( - filename, - source, - skip_function_bodies=skip_function_bodies, - python_version=options.python_version, - platform=options.platform, - always_true=options.always_true, - always_false=options.always_false, - cache_version=5, + ast_bytes, errors, ignores, import_bytes, ast_data = _ast_serialize_parse( + filename, options, source, skip_function_bodies, cache_version=5 ) # Compute hash using original non-UTF-8 bytes, so that # incremental mode works correctly. @@ -444,6 +456,27 @@ def parse_to_binary_ast( else: # Convert everything unexpected to a standard-looking blocker. raise CompileError([f"{filename}: error: Cannot parse file: {exc}"]) from exc + if skip_function_bodies: + # Stripping function bodies also drops import statements inside those + # bodies from the serialized import metadata. A module imported only + # from within a skipped body (e.g. a lazy import inside a method of a + # module where errors are ignored) would then be missing from mypy's + # dependency graph and never get checked (see mypy#22284). The legacy + # parser visits statements before it drops stripped bodies, so it + # records these imports. To keep behavior consistent, parse the file + # again without stripping and use the complete serialized import + # metadata for the dependency graph. + try: + _, _, _, full_import_bytes, _ = _ast_serialize_parse( + filename, options, source, False, cache_version=7 + ) + except (ValueError, CompileError): + # Can't get the complete import metadata (e.g. the file changed or + # can't be read again); retain the imports collected during the + # stripped parse. + pass + else: + import_bytes = full_import_bytes return ( ast_bytes, errors, diff --git a/test-data/unit/check-inline-config.test b/test-data/unit/check-inline-config.test index f12a94be86752..3a23eafc1fdf1 100644 --- a/test-data/unit/check-inline-config.test +++ b/test-data/unit/check-inline-config.test @@ -323,6 +323,33 @@ class C: def f(self) -> None: self.x = 1 +[case testIgnoreErrorsMethodImportFollowed] +# Regression test for mypy#22284: a module imported inside a method of a +# module whose errors are ignored (via a config file) must still be +# followed and type checked. +# flags: --config-file tmp/mypy.ini --show-error-codes +import pkg.a + +[file pkg/__init__.py] + +[file pkg/a.py] +class A: + def f(self) -> None: + import pkg.b + +[file pkg/b.py] +x: int = "moo" + +[file mypy.ini] +\[mypy] +show_error_codes = True + +\[mypy-pkg.a] +ignore_errors = True + +[out] +tmp/pkg/b.py:1: error: Incompatible types in assignment (expression has type "str", variable has type "int") [assignment] + [case testIgnoreErrorsWithLambda] # mypy: ignore-errors=True