Skip to content

Commit c2b60a8

Browse files
authored
Distinguish not found vs skipped modules (#20812)
Fixes #20800 We need to distinguish not found modules from skipped modules, because these have a different effect in the caller (unless we are using `--ignore-missing-imports`). I didn't really think about the daemon, I simply fall back to the old semantics (everything is not found) so there should be no change for the daemon, I added a TODO. Implementation is straightforward: I record the suppression reason as part of the suppression hash. I also fix a stupid bug I added earlier when I mutate a list I am iterating over.
1 parent 1102e91 commit c2b60a8

4 files changed

Lines changed: 49 additions & 11 deletions

File tree

mypy/build.py

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -724,7 +724,7 @@ class BuildManager:
724724
Semantic analyzer, pass 2
725725
all_types: Map {Expression: Type} from all modules (enabled by export_types)
726726
options: Build options
727-
missing_modules: Set of modules that could not be imported encountered so far
727+
missing_modules: Modules that could not be imported (or intentionally skipped)
728728
stale_modules: Set of modules that needed to be rechecked (only used by tests)
729729
fg_deps_meta: Metadata for fine-grained dependencies caches associated with modules
730730
fg_deps: A fine-grained dependency map
@@ -785,7 +785,7 @@ def __init__(
785785
self.version_id = version_id
786786
self.modules: dict[str, MypyFile] = {}
787787
self.import_map: dict[str, set[str]] = {}
788-
self.missing_modules: set[str] = set()
788+
self.missing_modules: dict[str, int] = {}
789789
self.fg_deps_meta: dict[str, FgDepMeta] = {}
790790
# fg_deps holds the dependencies of every module that has been
791791
# processed. We store this in BuildManager so that we can compute
@@ -2147,9 +2147,17 @@ def write_cache_meta(meta: CacheMeta, manager: BuildManager, meta_file: str) ->
21472147
"""
21482148

21492149

2150+
class SuppressionReason:
2151+
NOT_FOUND: Final = 1
2152+
SKIPPED: Final = 2
2153+
2154+
21502155
class ModuleNotFound(Exception):
21512156
"""Control flow exception to signal that a module was not found."""
21522157

2158+
def __init__(self, reason: int = SuppressionReason.NOT_FOUND) -> None:
2159+
self.reason = reason
2160+
21532161

21542162
class State:
21552163
"""The state for a module.
@@ -2280,9 +2288,9 @@ def new_state(
22802288
root_source,
22812289
skip_diagnose=temporary,
22822290
)
2283-
except ModuleNotFound:
2291+
except ModuleNotFound as exc:
22842292
if not temporary:
2285-
manager.missing_modules.add(id)
2293+
manager.missing_modules[id] = exc.reason
22862294
raise
22872295
if follow_imports == "silent":
22882296
ignore_all = True
@@ -3040,9 +3048,13 @@ def suppressed_deps_opts(self) -> bytes:
30403048
buf = WriteBuffer()
30413049
import_options = self.manager.import_options
30423050
for dep in sorted(self.suppressed):
3051+
# Using .get() is a bit defensive, but just in case we have a bug elsewhere
3052+
# (e.g. in the daemon), it is better to get a stale cache than a crash.
3053+
reason = self.manager.missing_modules.get(dep, SuppressionReason.NOT_FOUND)
30433054
if self.priorities.get(dep) != PRI_INDIRECT:
30443055
write_str_bare(buf, dep)
30453056
write_bytes_bare(buf, import_options[dep])
3057+
write_int_bare(buf, reason)
30463058
return buf.getvalue()
30473059

30483060
def write_cache(self) -> tuple[CacheMeta, str] | None:
@@ -3229,7 +3241,12 @@ def find_module_and_diagnose(
32293241
skipping_ancestor(manager, id, result, ancestor_for)
32303242
else:
32313243
skipping_module(manager, caller_line, caller_state, id, result)
3232-
raise ModuleNotFound
3244+
reason = SuppressionReason.SKIPPED
3245+
if options.ignore_missing_imports:
3246+
# Performance optimization: when we are ignoring imports, there is no
3247+
# difference for the caller between skipped import and actually missing one.
3248+
reason = SuppressionReason.NOT_FOUND
3249+
raise ModuleNotFound(reason=reason)
32333250
if is_silent_import_module(manager, result) and not root_source:
32343251
follow_imports = "silent"
32353252
return result, follow_imports
@@ -3754,7 +3771,7 @@ def load_graph(
37543771
for dep in st.ancestors + dependencies + st.suppressed:
37553772
ignored = dep in st.suppressed_set and dep not in entry_points
37563773
if ignored and dep not in added:
3757-
manager.missing_modules.add(dep)
3774+
manager.missing_modules[dep] = SuppressionReason.NOT_FOUND
37583775
# TODO: for now we skip this in the daemon as a performance optimization.
37593776
# This however creates a correctness issue, see #7777 and State.is_fresh().
37603777
if not manager.use_fine_grained_cache():
@@ -3810,10 +3827,10 @@ def load_graph(
38103827
# modules that are back in graph. We need to do this after the loop to cover an edge
38113828
# case where a namespace package ancestor is shared by a typed and an untyped package.
38123829
for st in graph.values():
3813-
for dep in st.suppressed:
3830+
for dep in st.suppressed.copy():
38143831
if dep in graph:
38153832
st.add_dependency(dep)
3816-
manager.missing_modules.discard(dep)
3833+
manager.missing_modules.pop(dep, None)
38173834
# Second, in the initial loop we skip indirect dependencies, so to make indirect dependencies
38183835
# behave more consistently with regular ones, we suppress them manually here (when needed).
38193836
for st in graph.values():

mypy/semanal.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -448,7 +448,7 @@ class SemanticAnalyzer(
448448
def __init__(
449449
self,
450450
modules: dict[str, MypyFile],
451-
missing_modules: set[str],
451+
missing_modules: dict[str, int],
452452
incomplete_namespaces: set[str],
453453
errors: Errors,
454454
plugin: Plugin,

mypy/server/update.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@
128128
BuildResult,
129129
Graph,
130130
State,
131+
SuppressionReason,
131132
load_graph,
132133
process_fresh_modules,
133134
)
@@ -591,7 +592,7 @@ def update_module_isolated(
591592
sources = get_sources(manager.fscache, previous_modules, [(module, path)], followed)
592593

593594
if module in manager.missing_modules:
594-
manager.missing_modules.remove(module)
595+
del manager.missing_modules[module]
595596

596597
orig_module = module
597598
orig_state = graph.get(module)
@@ -727,7 +728,8 @@ def delete_module(module_id: str, path: str, graph: Graph, manager: BuildManager
727728
# If the module is removed from the build but still exists, then
728729
# we mark it as missing so that it will get picked up by import from still.
729730
if manager.fscache.isfile(path):
730-
manager.missing_modules.add(module_id)
731+
# TODO: check if there is an equivalent of #20800 for the daemon.
732+
manager.missing_modules[module_id] = SuppressionReason.NOT_FOUND
731733

732734

733735
def dedupe_modules(modules: list[tuple[str, str]]) -> list[tuple[str, str]]:

test-data/unit/check-incremental.test

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7928,3 +7928,22 @@ from a import b # type: ignore[attr-defined]
79287928
[out]
79297929
main:2: error: Unused "type: ignore" comment
79307930
[out2]
7931+
7932+
[case testAddedMissingModuleSkip]
7933+
# flags: --follow-imports=skip
7934+
import mod
7935+
[file mod.py.2]
7936+
[out]
7937+
main:2: error: Cannot find implementation or library stub for module named "mod"
7938+
main:2: note: See https://mypy.readthedocs.io/en/stable/running_mypy.html#missing-imports
7939+
[out2]
7940+
7941+
[case testDeletedModuleSkip]
7942+
# flags: --follow-imports=skip
7943+
import mod
7944+
[file mod.py]
7945+
[delete mod.py.2]
7946+
[out]
7947+
[out2]
7948+
main:2: error: Cannot find implementation or library stub for module named "mod"
7949+
main:2: note: See https://mypy.readthedocs.io/en/stable/running_mypy.html#missing-imports

0 commit comments

Comments
 (0)