Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit fe6fe1d

Browse files
authored
Fix staleness on changed follow imports options (#20773)
This fixes the coarse grained part of #7777. I do not change semantics for daemon for two reasons: * Performance penalty from this fix will be minor for regular incremental, but may be well visible for the daemon. * And (more importantly) and just don't want to open this another can of worms (yet). Some notes: * The general logic is straightforward, invalidate dependent modules, if import options for their dependencies changed. * It looks like the problem only affects suppressed dependencies, in all other situations we either already invalidate because something gets added to/removed from dependencies (e.g. when `follow_imports = skip <-> normal`), or there is no effect on the caller/importer (e.g. when `follow_imports = normal <-> silent`). * This requires another entry for `CacheMeta` but good new is I have an idea how to get rid of another entry I added in one of recent PRs. Essentially I will need to record early (import) errors anyway to send to parallel workers after we switch to parallel parsing, and if we do this we won't need the `imports_ignored` entry.
1 parent ba2374f commit fe6fe1d

5 files changed

Lines changed: 178 additions & 11 deletions

File tree

mypy/build.py

Lines changed: 38 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -895,6 +895,9 @@ def __init__(
895895
# raw parsed trees not analyzed with mypy. We use these to find absolute
896896
# location of a symbol used as a location for an error message.
897897
self.extra_trees: dict[str, MypyFile] = {}
898+
# Snapshot of import-related options per module. We record these even for
899+
# suppressed imports, since they can affect errors in the callers.
900+
self.import_options: dict[str, dict[str, object]] = {}
898901
# Cache for transitive dependency check (expensive).
899902
self.transitive_deps_cache: dict[tuple[int, int], bool] = {}
900903

@@ -1871,6 +1874,7 @@ def write_cache(
18711874
tree: MypyFile,
18721875
dependencies: list[str],
18731876
suppressed: list[str],
1877+
suppressed_deps_opts: bytes,
18741878
imports_ignored: dict[int, list[str]],
18751879
dep_prios: list[int],
18761880
dep_lines: list[int],
@@ -1989,6 +1993,7 @@ def write_cache(
19891993
suppressed=suppressed,
19901994
imports_ignored=imports_ignored,
19911995
options=options_snapshot(id, manager),
1996+
suppressed_deps_opts=suppressed_deps_opts,
19921997
dep_prios=dep_prios,
19931998
dep_lines=dep_lines,
19941999
interface_hash=interface_hash,
@@ -2274,6 +2279,7 @@ def new_state(
22742279
import_context = []
22752280
id = id or "__main__"
22762281
options = manager.options.clone_for_module(id)
2282+
manager.import_options[id] = options.dep_import_options()
22772283

22782284
ignore_all = False
22792285
if not path and source is None:
@@ -2571,7 +2577,16 @@ def is_fresh(self) -> bool:
25712577
# self.meta.dependencies when a dependency is dropped due to
25722578
# suppression by silent mode. However, when a suppressed
25732579
# dependency is added back we find out later in the process.
2574-
return self.meta is not None and self.dependencies == self.meta.dependencies
2580+
# Additionally, we need to verify that import following options are
2581+
# same for suppressed dependencies, even if the first check is OK.
2582+
return (
2583+
self.meta is not None
2584+
and self.dependencies == self.meta.dependencies
2585+
and (
2586+
self.options.fine_grained_incremental
2587+
or self.meta.suppressed_deps_opts == self.suppressed_deps_opts()
2588+
)
2589+
)
25752590

25762591
def mark_as_rechecked(self) -> None:
25772592
"""Marks this module as having been fully re-analyzed by the type-checker."""
@@ -3020,6 +3035,15 @@ def update_fine_grained_deps(self, deps: dict[str, set[str]]) -> None:
30203035
merge_dependencies(self.compute_fine_grained_deps(), deps)
30213036
type_state.update_protocol_deps(deps)
30223037

3038+
def suppressed_deps_opts(self) -> bytes:
3039+
return json_dumps(
3040+
{
3041+
dep: self.manager.import_options[dep]
3042+
for dep in self.suppressed
3043+
if self.priorities.get(dep) != PRI_INDIRECT
3044+
}
3045+
)
3046+
30233047
def write_cache(self) -> tuple[CacheMeta, str] | None:
30243048
assert self.tree is not None, "Internal error: method must be called on parsed file only"
30253049
# We don't support writing cache files in fine-grained incremental mode.
@@ -3051,6 +3075,7 @@ def write_cache(self) -> tuple[CacheMeta, str] | None:
30513075
self.tree,
30523076
list(self.dependencies),
30533077
list(self.suppressed),
3078+
self.suppressed_deps_opts(),
30543079
self.imports_ignored,
30553080
dep_prios,
30563081
dep_lines,
@@ -3126,10 +3151,8 @@ def generate_unused_ignore_notes(self) -> None:
31263151
self.options.warn_unused_ignores
31273152
or codes.UNUSED_IGNORE in self.options.enabled_error_codes
31283153
) and codes.UNUSED_IGNORE not in self.options.disabled_error_codes:
3129-
# If this file was initially loaded from the cache, it may have suppressed
3130-
# dependencies due to imports with ignores on them. We need to generate
3131-
# those errors to avoid spuriously flagging them as unused ignores.
3132-
if self.meta:
3154+
# We only need this for the daemon, regular incremental does this unconditionally.
3155+
if self.meta and self.options.fine_grained_incremental:
31333156
self.verify_dependencies(suppressed_only=True)
31343157
self.manager.errors.generate_unused_ignore_errors(self.xpath)
31353158

@@ -3710,20 +3733,22 @@ def load_graph(
37103733
# but A's cached *indirect* dependency on C is wrong.
37113734
dependencies = [dep for dep in st.dependencies if st.priorities.get(dep) != PRI_INDIRECT]
37123735
if not manager.use_fine_grained_cache():
3713-
# TODO: Ideally we could skip here modules that appeared in st.suppressed
3714-
# because they are not in build with `follow-imports=skip`.
3715-
# This way we could avoid overhead of cloning options in `State.__init__()`
3716-
# below to get the option value. This is quite minor performance loss however.
37173736
added = [dep for dep in st.suppressed if find_module_simple(dep, manager)]
37183737
else:
37193738
# During initial loading we don't care about newly added modules,
37203739
# they will be taken care of during fine-grained update. See also
3721-
# comment about this in `State.__init__()`.
3740+
# comment about this in `State.new_state()`.
37223741
added = []
37233742
for dep in st.ancestors + dependencies + st.suppressed:
37243743
ignored = dep in st.suppressed_set and dep not in entry_points
37253744
if ignored and dep not in added:
37263745
manager.missing_modules.add(dep)
3746+
# TODO: for now we skip this in the daemon as a performance optimization.
3747+
# This however creates a correctness issue, see #7777 and State.is_fresh().
3748+
if not manager.use_fine_grained_cache():
3749+
manager.import_options[dep] = manager.options.clone_for_module(
3750+
dep
3751+
).dep_import_options()
37273752
elif dep not in graph:
37283753
try:
37293754
if dep in st.ancestors:
@@ -4129,6 +4154,9 @@ def process_stale_scc(
41294154
t2 = time.time()
41304155
stale = scc
41314156
for id in stale:
4157+
# Re-generate import errors in case this module was loaded from the cache.
4158+
if graph[id].meta:
4159+
graph[id].verify_dependencies(suppressed_only=True)
41324160
# We may already have parsed the module, or not.
41334161
# If the former, parse_file() is a no-op.
41344162
graph[id].parse_file()

mypy/cache.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@
6969
from mypy_extensions import u8
7070

7171
# High-level cache layout format
72-
CACHE_VERSION: Final = 3
72+
CACHE_VERSION: Final = 4
7373

7474
SerializedError: _TypeAlias = tuple[str | None, int | str, int, int, int, str, str, str | None]
7575

@@ -91,6 +91,7 @@ def __init__(
9191
suppressed: list[str],
9292
imports_ignored: dict[int, list[str]],
9393
options: dict[str, object],
94+
suppressed_deps_opts: bytes,
9495
dep_prios: list[int],
9596
dep_lines: list[int],
9697
dep_hashes: list[bytes],
@@ -112,6 +113,7 @@ def __init__(
112113
self.suppressed = suppressed # dependencies that weren't imported
113114
self.imports_ignored = imports_ignored # type ignore codes by line
114115
self.options = options # build options snapshot
116+
self.suppressed_deps_opts = suppressed_deps_opts # hash of import-related options
115117
# dep_prios and dep_lines are both aligned with dependencies + suppressed
116118
self.dep_prios = dep_prios
117119
self.dep_lines = dep_lines
@@ -136,6 +138,7 @@ def serialize(self) -> dict[str, Any]:
136138
"suppressed": self.suppressed,
137139
"imports_ignored": {str(line): codes for line, codes in self.imports_ignored.items()},
138140
"options": self.options,
141+
"suppressed_deps_opts": self.suppressed_deps_opts.hex(),
139142
"dep_prios": self.dep_prios,
140143
"dep_lines": self.dep_lines,
141144
"dep_hashes": [dep.hex() for dep in self.dep_hashes],
@@ -164,6 +167,7 @@ def deserialize(cls, meta: dict[str, Any], data_file: str) -> CacheMeta | None:
164167
int(line): codes for line, codes in meta["imports_ignored"].items()
165168
},
166169
options=meta["options"],
170+
suppressed_deps_opts=bytes.fromhex(meta["suppressed_deps_opts"]),
167171
dep_prios=meta["dep_prios"],
168172
dep_lines=meta["dep_lines"],
169173
dep_hashes=[bytes.fromhex(dep) for dep in meta["dep_hashes"]],
@@ -191,6 +195,7 @@ def write(self, data: WriteBuffer) -> None:
191195
write_int(data, line)
192196
write_str_list(data, codes)
193197
write_json(data, self.options)
198+
write_bytes(data, self.suppressed_deps_opts)
194199
write_int_list(data, self.dep_prios)
195200
write_int_list(data, self.dep_lines)
196201
write_bytes_list(data, self.dep_hashes)
@@ -220,6 +225,7 @@ def read(cls, data: ReadBuffer, data_file: str) -> CacheMeta | None:
220225
read_int(data): read_str_list(data) for _ in range(read_int_bare(data))
221226
},
222227
options=read_json(data),
228+
suppressed_deps_opts=read_bytes(data),
223229
dep_prios=read_int_list(data),
224230
dep_lines=read_int_list(data),
225231
dep_hashes=read_bytes_list(data),

mypy/options.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -616,3 +616,11 @@ def select_options_affecting_cache(self) -> dict[str, object]:
616616
val = sorted([code.code for code in val])
617617
result[opt] = val
618618
return result
619+
620+
def dep_import_options(self) -> dict[str, object]:
621+
# These are options that can affect dependent modules as well.
622+
return {
623+
"ignore_missing_imports": self.ignore_missing_imports,
624+
"follow_imports": self.follow_imports,
625+
"follow_imports_for_stubs": self.follow_imports_for_stubs,
626+
}

test-data/unit/check-incremental.test

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7797,6 +7797,104 @@ tmp/b.py:5: note: "lol" of "BB" defined here
77977797
tmp/a.py:2: error: Unexpected keyword argument "uhhhh" for "lol" of "BB"
77987798
tmp/b.py:5: note: "lol" of "BB" defined here
77997799

7800+
[case testMissingImportUnIgnoredInConfig]
7801+
# flags: --config-file tmp/mypy.ini
7802+
from foo import bar
7803+
[file mypy.ini]
7804+
\[mypy]
7805+
\[mypy-foo]
7806+
ignore_missing_imports = True
7807+
[file mypy.ini.2]
7808+
\[mypy]
7809+
\[mypy-foo]
7810+
ignore_missing_imports = False
7811+
[out]
7812+
[out2]
7813+
main:2: error: Cannot find implementation or library stub for module named "foo"
7814+
main:2: note: See https://mypy.readthedocs.io/en/stable/running_mypy.html#missing-imports
7815+
7816+
[case testMissingImportUnIgnoredInConfig2]
7817+
# flags: --config-file tmp/mypy.ini
7818+
from foo import bar
7819+
[file mypy.ini]
7820+
\[mypy]
7821+
\[mypy-foo]
7822+
ignore_missing_imports = False
7823+
[file mypy.ini.2]
7824+
\[mypy]
7825+
\[mypy-foo]
7826+
ignore_missing_imports = True
7827+
[out]
7828+
main:2: error: Cannot find implementation or library stub for module named "foo"
7829+
main:2: note: See https://mypy.readthedocs.io/en/stable/running_mypy.html#missing-imports
7830+
[out2]
7831+
7832+
[case testMissingImportUnIgnoredInConfig3]
7833+
# flags: --config-file tmp/mypy.ini
7834+
from foo import bar
7835+
[file foo.py]
7836+
bar = 1
7837+
[file mypy.ini]
7838+
\[mypy]
7839+
\[mypy-foo]
7840+
follow_imports = skip
7841+
[file mypy.ini.2]
7842+
\[mypy]
7843+
\[mypy-foo]
7844+
follow_imports = error
7845+
[out]
7846+
[out2]
7847+
main:2: error: Import of "foo" ignored
7848+
main:2: note: (Using --follow-imports=error, module not passed on command line)
7849+
7850+
[case testMissingImportUnIgnoredInConfig4]
7851+
# flags: --config-file tmp/mypy.ini
7852+
from foo import bar
7853+
[file foo.py]
7854+
bar = 1
7855+
[file mypy.ini]
7856+
\[mypy]
7857+
\[mypy-foo]
7858+
follow_imports = error
7859+
[file mypy.ini.2]
7860+
\[mypy]
7861+
\[mypy-foo]
7862+
follow_imports = skip
7863+
[out]
7864+
main:2: error: Import of "foo" ignored
7865+
main:2: note: (Using --follow-imports=error, module not passed on command line)
7866+
[out2]
7867+
7868+
[case testMissingImportUnIgnoredInConfig5]
7869+
# flags: --config-file tmp/mypy.ini --warn-unused-ignores
7870+
from foo import bar # type: ignore[import-not-found]
7871+
[file mypy.ini]
7872+
\[mypy]
7873+
\[mypy-foo]
7874+
ignore_missing_imports = True
7875+
[file mypy.ini.2]
7876+
\[mypy]
7877+
\[mypy-foo]
7878+
ignore_missing_imports = False
7879+
[out]
7880+
main:2: error: Unused "type: ignore" comment
7881+
[out2]
7882+
7883+
[case testMissingImportUnIgnoredInConfig6]
7884+
# flags: --config-file tmp/mypy.ini --warn-unused-ignores
7885+
from foo import bar # type: ignore[import-not-found]
7886+
[file mypy.ini]
7887+
\[mypy]
7888+
\[mypy-foo]
7889+
ignore_missing_imports = False
7890+
[file mypy.ini.2]
7891+
\[mypy]
7892+
\[mypy-foo]
7893+
ignore_missing_imports = True
7894+
[out]
7895+
[out2]
7896+
main:2: error: Unused "type: ignore" comment
7897+
78007898
[case testIndirectDependencyReorderModulesNoCrash]
78017899
import a
78027900
[file a.py]

test-data/unit/check-modules.test

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3262,3 +3262,30 @@ import b # type: ignore[import-not-found]
32623262
[out]
32633263
[out2]
32643264
tmp/a.py:1: error: Unused "type: ignore" comment
3265+
3266+
[case testTypeIgnoredImportsWorkWithCacheIncremental3]
3267+
# flags: --warn-unused-ignores
3268+
import a
3269+
[file a.py]
3270+
import b # type: ignore[import-not-found]
3271+
[file b.py]
3272+
[delete b.py.2]
3273+
[out]
3274+
tmp/a.py:1: error: Unused "type: ignore" comment
3275+
[out2]
3276+
3277+
[case testImportErrorStaleLoadedFromCacheIncremental]
3278+
import a
3279+
[file a.py]
3280+
import b
3281+
import c
3282+
[file c.py]
3283+
x: int
3284+
[file c.py.2]
3285+
x: str
3286+
[out]
3287+
tmp/a.py:1: error: Cannot find implementation or library stub for module named "b"
3288+
tmp/a.py:1: note: See https://mypy.readthedocs.io/en/stable/running_mypy.html#missing-imports
3289+
[out2]
3290+
tmp/a.py:1: error: Cannot find implementation or library stub for module named "b"
3291+
tmp/a.py:1: note: See https://mypy.readthedocs.io/en/stable/running_mypy.html#missing-imports

0 commit comments

Comments
 (0)