From 3736b96ccb0c8dffa6f2f6117b73f1a596d3e8f4 Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Thu, 12 Feb 2026 10:47:38 +0000 Subject: [PATCH 1/2] Fix undetected submodule deletion on warm run --- mypy/build.py | 40 +++++++++++++++++++++++---- test-data/unit/check-incremental.test | 31 +++++++++++++++++++++ 2 files changed, 66 insertions(+), 5 deletions(-) diff --git a/mypy/build.py b/mypy/build.py index 93180e1eed5e9..0669742d1189c 100644 --- a/mypy/build.py +++ b/mypy/build.py @@ -2373,9 +2373,19 @@ def new_state( # suppressed dependencies. Therefore, when the package with module is added, # we need to re-calculate dependencies. # NOTE: see comment below for why we skip this in fine-grained mode. - if exist_added_packages(suppressed, manager, options): + if exist_added_packages(suppressed, manager): state.parse_file() # This is safe because the cache is anyway stale. state.compute_dependencies() + # This is an inverse to the situation above. If we had an import like this: + # from pkg import mod + # and then mod was deleted, we need to force recompute dependencies, to + # decide whether we should still depend on a missing pkg.mod. Otherwise, + # the above import is indistinguishable from something like this: + # import pkg + # import pkg.mod + if exist_removed_submodules(dependencies, manager): + state.parse_file() # Same as above, the current state is stale anyway. + state.compute_dependencies() state.size_hint = meta.size else: # When doing a fine-grained cache load, pretend we only @@ -3242,7 +3252,7 @@ def find_module_and_diagnose( raise ModuleNotFound -def exist_added_packages(suppressed: list[str], manager: BuildManager, options: Options) -> bool: +def exist_added_packages(suppressed: list[str], manager: BuildManager) -> bool: """Find if there are any newly added packages that were previously suppressed. Exclude everything not in build for follow-imports=skip. @@ -3255,13 +3265,32 @@ def exist_added_packages(suppressed: list[str], manager: BuildManager, options: path = find_module_simple(dep, manager) if not path: continue + options = manager.options.clone_for_module(dep) if options.follow_imports == "skip" and ( not path.endswith(".pyi") or options.follow_imports_for_stubs ): continue - if "__init__.py" in path: - # It is better to have a bit lenient test, this will only slightly reduce - # performance, while having a too strict test may affect correctness. + if os.path.basename(path) in ("__init__.py", "__init__.pyi"): + return True + return False + + +def exist_removed_submodules(dependencies: list[str], manager: BuildManager) -> bool: + """Find if there are any submodules of packages that are now missing. + + This is conceptually an inverse of exist_added_packages(). + """ + dependencies_set = set(dependencies) + for dep in dependencies: + if "." not in dep: + continue + if dep in manager.source_set.source_modules: + # We still know it is definitely a module. + continue + direct_ancestor, _ = dep.rsplit(".", maxsplit=1) + if direct_ancestor not in dependencies_set: + continue + if find_module_simple(dep, manager) is None: return True return False @@ -3784,6 +3813,7 @@ def load_graph( for dep in st.suppressed: if dep in graph: st.add_dependency(dep) + manager.missing_modules.discard(dep) # Second, in the initial loop we skip indirect dependencies, so to make indirect dependencies # behave more consistently with regular ones, we suppress them manually here (when needed). for st in graph.values(): diff --git a/test-data/unit/check-incremental.test b/test-data/unit/check-incremental.test index 2506e5ba83916..e3812ed3c4612 100644 --- a/test-data/unit/check-incremental.test +++ b/test-data/unit/check-incremental.test @@ -7864,3 +7864,34 @@ import n3 # touch [out] [out2] tmp/a.py:2: error: Name "b.c.d.C" is not defined + +[case testDeletedModuleFromImport] +# flags: --ignore-missing-imports +from a import b +[file a/__init__.py] +[file a/b.py] +[delete a/b.py.2] +[out] +[out2] +main:2: error: Module "a" has no attribute "b" + +[case testDeletedModuleFromImport2] +# flags: --ignore-missing-imports +import a +import a.b +from a import b # no error here +[file a/__init__.py] +[file a/b.py] +[delete a/b.py.2] +[out] +[out2] + +[case testDeletedModuleFromImport3] +# flags: --warn-unused-ignores +from a import b # type: ignore[attr-defined] +[file a/__init__.py] +[file a/b.py] +[delete a/b.py.2] +[out] +main:2: error: Unused "type: ignore" comment +[out2] From d9c88e861c9c8cef07704175fc086a41c74b71e4 Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Thu, 12 Feb 2026 11:04:06 +0000 Subject: [PATCH 2/2] One more tiny fix and comment --- mypy/build.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/mypy/build.py b/mypy/build.py index 0669742d1189c..55953087a9976 100644 --- a/mypy/build.py +++ b/mypy/build.py @@ -3266,7 +3266,16 @@ def exist_added_packages(suppressed: list[str], manager: BuildManager) -> bool: if not path: continue options = manager.options.clone_for_module(dep) - if options.follow_imports == "skip" and ( + # Technically this is not 100% correct, since we can have: + # from pkg import mod + # with + # [mypy-pkg] + # follow-import = silent + # [mypy-pkg.mod] + # follow-imports = normal + # But such cases are extremely rare, and this allows us to avoid + # massive performance impact in much more common situations. + if options.follow_imports in ("skip", "error") and ( not path.endswith(".pyi") or options.follow_imports_for_stubs ): continue