Skip to content

Commit b645e06

Browse files
Use two-phase type checking in sequential mode (#21973)
Fixes #21348 This means that (unless local partial types are disabled) we should have identical semantics for all three: sequential (in-process) type-checking, parallel type-checking, and in the daemon. When reading the relevant code I noticed a bug, when using `-c` in parallel mode we do not check implementations in the code passed with `-c`, fix is quite simple (just reorder writing cache and flushing the errors), so I simply add it here. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 5a06937 commit b645e06

18 files changed

Lines changed: 324 additions & 171 deletions

‎mypy/build.py‎

Lines changed: 122 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -2055,14 +2055,26 @@ def find_cache_meta(
20552055

20562056
# Ignore cache if (relevant) options aren't the same.
20572057
# Note that it's fine to mutilate cached_options since it's only used here.
2058-
cached_options = m.options
2058+
cached_options = m.options.copy()
20592059
current_options = options_snapshot(id, manager)
20602060
if manager.options.skip_version_check:
20612061
# When we're lax about version we're also lax about platform.
20622062
cached_options["platform"] = current_options["platform"]
20632063
if "debug_cache" in cached_options:
20642064
# Older versions included debug_cache, but it's silly to compare it.
20652065
del cached_options["debug_cache"]
2066+
if "local_partial_types" in cached_options:
2067+
local_partial_types = cached_options["local_partial_types"]
2068+
del cached_options["local_partial_types"]
2069+
is_parallel = manager.options.num_workers > 0
2070+
if not local_partial_types and cached_options["is_parallel"] != is_parallel:
2071+
# If local partial types are disabled, behavior is too different for
2072+
# parallel and sequential runs, see write_cache() for details.
2073+
return None
2074+
del cached_options["is_parallel"]
2075+
else:
2076+
# Cache from an old mypy version.
2077+
return None
20662078
if cached_options != current_options:
20672079
manager.log(f"Metadata abandoned for {id}: options differ")
20682080
if manager.options.verbosity >= 2:
@@ -2226,7 +2238,6 @@ def validate_meta(
22262238
meta.mtime = mtime
22272239
meta.path = path
22282240
meta.size = size
2229-
meta.options = options_snapshot(id, manager)
22302241
meta_file, _, _ = get_cache_names(id, path, manager.options)
22312242
if manager.logging_enabled:
22322243
manager.log(
@@ -2271,6 +2282,8 @@ def write_cache(
22712282
trans_dep_hash: bytes,
22722283
source_hash: str,
22732284
ignore_all: bool,
2285+
local_partial_types: bool,
2286+
is_parallel: bool,
22742287
manager: BuildManager,
22752288
) -> tuple[bytes, tuple[CacheMeta, str] | None]:
22762289
"""Write cache files for a module.
@@ -2370,6 +2383,13 @@ def write_cache(
23702383
# important, or otherwise the options would never match when
23712384
# verifying the cache.
23722385
assert source_hash is not None
2386+
# Local partial types need special handling as they behave differently
2387+
# in sequential and parallel runs:
2388+
# * In sequential run we respected the option, but use different SCC
2389+
# processing logic depending on whether they are enabled or disabled.
2390+
# * In parallel run they are always on, and we give an error if a user
2391+
# tries to disable them.
2392+
extra_options = {"local_partial_types": local_partial_types, "is_parallel": is_parallel}
23732393
meta = CacheMeta(
23742394
id=id,
23752395
path=path,
@@ -2381,7 +2401,7 @@ def write_cache(
23812401
data_file=data_file,
23822402
suppressed=suppressed,
23832403
imports_ignored=imports_ignored,
2384-
options=options_snapshot(id, manager),
2404+
options=options_snapshot(id, manager) | extra_options,
23852405
suppressed_deps_opts=suppressed_deps_opts,
23862406
dep_prios=dep_prios,
23872407
dep_lines=dep_lines,
@@ -2726,6 +2746,11 @@ def new_state(
27262746
meta, meta_ex = meta_pair
27272747
interface_hash = meta.interface_hash
27282748
meta_source_hash = meta.hash
2749+
# Update the local partial types in case they were set by an inline config.
2750+
# So we can select the correct SCC processing logic without reading the file.
2751+
local_partial_types = meta.options["local_partial_types"]
2752+
if options.local_partial_types != local_partial_types:
2753+
options = options.apply_changes({"local_partial_types": local_partial_types})
27292754
if path and source is None and manager.fscache.isdir(path):
27302755
source = ""
27312756

@@ -3636,6 +3661,8 @@ def write_cache(self) -> tuple[CacheMeta, str] | None:
36363661
self.trans_dep_hash,
36373662
self.source_hash,
36383663
self.ignore_all,
3664+
self.options.local_partial_types,
3665+
self.options.num_workers > 0,
36393666
self.manager,
36403667
)
36413668
if new_interface_hash == self.interface_hash:
@@ -4636,18 +4663,7 @@ def process_graph(graph: Graph, manager: BuildManager) -> None:
46364663
# type-checking this is already done and results should be empty here.
46374664
if not manager.workers:
46384665
assert not results
4639-
for id, result in results.items():
4640-
# Interface and implementation results may be mixed in the same batch
4641-
# from different workers, process each one accordingly.
4642-
if result.interface_hash is not None:
4643-
new_hash = bytes.fromhex(result.interface_hash)
4644-
if new_hash != graph[id].interface_hash:
4645-
graph[id].mark_interface_stale()
4646-
graph[id].interface_hash = new_hash
4647-
else:
4648-
manager.flush_errors(
4649-
manager.errors.simplify_path(graph[id].xpath), result.error_lines, False
4650-
)
4666+
process_results(results, graph, manager)
46514667
ready = []
46524668
for done_scc in done:
46534669
for dependent in done_scc.direct_dependents:
@@ -4659,6 +4675,26 @@ def process_graph(graph: Graph, manager: BuildManager) -> None:
46594675
manager.trace(f"Transitive deps cache size: {sys.getsizeof(manager.transitive_deps_cache)}")
46604676

46614677

4678+
def process_results(results: dict[str, ModuleResult], graph: Graph, manager: BuildManager) -> None:
4679+
"""Process results of type-checking given modules.
4680+
4681+
This will update interface hashes and flush type-checking errors (if any).
4682+
Blockers should have been already handled by the caller.
4683+
"""
4684+
for id, result in results.items():
4685+
# Interface and implementation results may be mixed in the same batch
4686+
# from different workers, process each one accordingly.
4687+
if result.interface_hash is not None:
4688+
new_hash = bytes.fromhex(result.interface_hash)
4689+
if new_hash != graph[id].interface_hash:
4690+
graph[id].mark_interface_stale()
4691+
graph[id].interface_hash = new_hash
4692+
else:
4693+
manager.flush_errors(
4694+
manager.errors.simplify_path(graph[id].xpath), result.error_lines, False
4695+
)
4696+
4697+
46624698
def order_ascc(graph: Graph, ascc: AbstractSet[str], pri_max: int = PRI_INDIRECT) -> list[str]:
46634699
"""Come up with the ideal processing order within an SCC.
46644700
@@ -4776,7 +4812,45 @@ def maybe_load_deps(graph: Graph, ascc: SCC, manager: BuildManager) -> None:
47764812

47774813

47784814
def process_stale_scc(graph: Graph, ascc: SCC, manager: BuildManager) -> None:
4779-
"""Process the modules in one SCC from source code."""
4815+
"""Process the modules in one SCC from source code.
4816+
4817+
This will process module interfaces first (when possible). This mirrors
4818+
how things are done in parallel type checking.
4819+
"""
4820+
if not all(graph[id].options.local_partial_types for id in ascc.mod_ids):
4821+
# If local partial types are disabled we must process each file sequentially.
4822+
process_stale_scc_full(graph, ascc, manager)
4823+
return
4824+
manager.parse_all([graph[id] for id in ascc.mod_ids], post_parse=False)
4825+
scc_result = process_stale_scc_interface(
4826+
graph, ascc, manager, from_cache={id for id in ascc.mod_ids if graph[id].meta}
4827+
)
4828+
manager.commit()
4829+
4830+
# Process interface results before starting implementations
4831+
# (to mimic parallel checking 1:1).
4832+
mod_results = {}
4833+
stale = []
4834+
meta_files = []
4835+
for id, mod_result, meta_file in scc_result:
4836+
stale.append(id)
4837+
mod_results[id] = mod_result
4838+
meta_files.append(meta_file)
4839+
process_results(mod_results, graph, manager)
4840+
4841+
mod_results = {}
4842+
for id, meta_file in zip(stale, meta_files):
4843+
mod_results |= process_stale_scc_implementation(graph, [id], manager, [meta_file])
4844+
manager.commit()
4845+
process_results(mod_results, graph, manager)
4846+
4847+
4848+
def process_stale_scc_full(graph: Graph, ascc: SCC, manager: BuildManager) -> None:
4849+
"""Process the modules in one SCC from source code.
4850+
4851+
This is the legacy function that processes each file sequentially (line-by-line),
4852+
thus it may interleave processing interface and implementation parts.
4853+
"""
47804854
# First verify if all transitive dependencies are loaded in the current process.
47814855
t0 = time.time()
47824856
maybe_load_deps(graph, ascc, manager)
@@ -4879,7 +4953,7 @@ def process_stale_scc(graph: Graph, ascc: SCC, manager: BuildManager) -> None:
48794953

48804954
def process_stale_scc_interface(
48814955
graph: Graph, ascc: SCC, manager: BuildManager, from_cache: set[str]
4882-
) -> list[tuple[str, ModuleResult, str]]:
4956+
) -> list[tuple[str, ModuleResult, str | None]]:
48834957
"""Process the modules' interfaces in one SCC from source code."""
48844958
# First verify if all transitive dependencies are loaded in the current process.
48854959
t0 = time.time()
@@ -4925,16 +4999,19 @@ def process_stale_scc_interface(
49254999
for id in stale:
49265000
meta_tuple = meta_tuples[id]
49275001
if meta_tuple is None:
4928-
continue
4929-
meta, meta_file = meta_tuple
5002+
meta = meta_file = None
5003+
else:
5004+
meta, meta_file = meta_tuple
49305005
state = graph[id]
4931-
meta.dep_hashes = [
4932-
graph[dep].interface_hash
4933-
for dep in state.dependencies
4934-
if state.priorities.get(dep) != PRI_INDIRECT
4935-
]
4936-
write_cache_meta(meta, manager, meta_file)
4937-
manager.commit_module(meta_file)
5006+
if meta is not None:
5007+
assert meta_file is not None
5008+
meta.dep_hashes = [
5009+
graph[dep].interface_hash
5010+
for dep in state.dependencies
5011+
if state.priorities.get(dep) != PRI_INDIRECT
5012+
]
5013+
write_cache_meta(meta, manager, meta_file)
5014+
manager.commit_module(meta_file)
49385015
scc_result.append((id, ModuleResult(graph[id].interface_hash.hex(), []), meta_file))
49395016
manager.done_sccs.add(ascc.id)
49405017
manager.add_stats(
@@ -4948,7 +5025,7 @@ def process_stale_scc_interface(
49485025

49495026

49505027
def process_stale_scc_implementation(
4951-
graph: Graph, stale: list[str], manager: BuildManager, meta_files: list[str]
5028+
graph: Graph, stale: list[str], manager: BuildManager, meta_files: list[str | None]
49525029
) -> dict[str, ModuleResult]:
49535030
"""Process implementations (top-level function/method bodies) in an SCC."""
49545031
t0 = time.time()
@@ -4963,7 +5040,10 @@ def process_stale_scc_implementation(
49635040
continue
49645041
# We need to reset deferral count after possibly deferring any methods that
49655042
# are considered part of the top-level (because they define/infer variables).
4966-
checker.pass_num = 0
5043+
# Note we need to add one pass to compensate for function bodies not visited in
5044+
# type_check_first_pass(). So with current DEFAULT_LAST_PASS = 2 each function
5045+
# will be visited at most three times, for both single-phase and two-phase logic.
5046+
checker.pass_num = -1
49675047
checker.deferred_nodes.clear()
49685048
tree = graph[id].tree
49695049
assert tree is not None
@@ -4993,27 +5073,28 @@ def process_stale_scc_implementation(
49935073
scc_result = {}
49945074
for id, meta_file in zip(stale, meta_files):
49955075
state = graph[id]
5076+
# If there are no errors, only write the cache, don't send anything back
5077+
# to the caller (as a micro-optimization).
5078+
if graph[id].xpath not in manager.errors.ignored_files:
5079+
errors = manager.errors.file_messages(graph[id].xpath)
5080+
formatted = manager.errors.format_messages(
5081+
graph[id].xpath, errors, formatter=manager.error_formatter
5082+
)
5083+
scc_result[id] = ModuleResult(None, formatted)
5084+
else:
5085+
errors = []
5086+
if meta_file is None:
5087+
continue
49965088
indirect = [dep for dep in state.dependencies if state.priorities.get(dep) == PRI_INDIRECT]
49975089
meta_ex = CacheMetaEx(
49985090
dependencies=indirect,
49995091
suppressed=[
50005092
dep for dep in state.suppressed if state.priorities.get(dep) == PRI_INDIRECT
50015093
],
50025094
dep_hashes=[graph[dep].interface_hash for dep in indirect],
5003-
error_lines=[],
5095+
error_lines=errors,
50045096
)
5005-
if graph[id].xpath not in manager.errors.ignored_files:
5006-
errors = manager.errors.file_messages(graph[id].xpath)
5007-
formatted = manager.errors.format_messages(
5008-
graph[id].xpath, errors, formatter=manager.error_formatter
5009-
)
5010-
meta_ex.error_lines = errors
5011-
write_cache_meta_ex(meta_file, meta_ex, manager)
5012-
scc_result[id] = ModuleResult(None, formatted)
5013-
else:
5014-
# If there are no errors, only write the cache, don't send anything back
5015-
# to the caller (as a micro-optimization).
5016-
write_cache_meta_ex(meta_file, meta_ex, manager)
5097+
write_cache_meta_ex(meta_file, meta_ex, manager)
50175098
manager.commit_module(meta_file)
50185099

50195100
manager.add_stats(type_check_time_implementation=time.time() - t0)

‎mypy/semanal.py‎

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -719,24 +719,26 @@ def refresh_partial(
719719
self.accept(node)
720720
del self.patches
721721

722+
def ad_hoc_error(self, msg: str) -> None:
723+
n = TempNode(AnyType(TypeOfAny.special_form))
724+
n.line = 1
725+
n.column = 0
726+
n.end_line = 1
727+
n.end_column = 0
728+
self.fail(msg, n)
729+
722730
def refresh_top_level(self, file_node: MypyFile) -> None:
723731
"""Reanalyze a stale module top-level in fine-grained incremental mode."""
724732
if self.options.allow_redefinition and not self.options.local_partial_types:
725-
n = TempNode(AnyType(TypeOfAny.special_form))
726-
n.line = 1
727-
n.column = 0
728-
n.end_line = 1
729-
n.end_column = 0
730-
self.fail("--local-partial-types must be enabled if using --allow-redefinition", n)
733+
self.ad_hoc_error(
734+
"--local-partial-types must be enabled if using --allow-redefinition"
735+
)
731736
if self.options.allow_redefinition and self.options.allow_redefinition_old:
732-
n = TempNode(AnyType(TypeOfAny.special_form))
733-
n.line = 1
734-
n.column = 0
735-
n.end_line = 1
736-
n.end_column = 0
737-
self.fail(
738-
"--allow-redefinition-old and --allow-redefinition should not be used together", n
737+
self.ad_hoc_error(
738+
"--allow-redefinition-old and --allow-redefinition should not be used together"
739739
)
740+
if not self.options.local_partial_types and self.options.num_workers > 0:
741+
self.ad_hoc_error("--local-partial-types must be enabled in parallel mode")
740742
self.recurse_into_functions = False
741743
self.add_implicit_module_attrs(file_node)
742744
for d in file_node.defs:
@@ -4797,6 +4799,10 @@ def analyze_member_lvalue(
47974799
self.type.names[lval.name] = SymbolTableNode(MDEF, v, implicit=True)
47984800
for func in self.scope.functions:
47994801
func.def_or_infer_vars = True
4802+
4803+
if self.is_self_member_ref(lval) or self.is_cls_member_ref(lval):
4804+
assert self.type, "Self or cls member outside a class"
4805+
cur_node = self.type.names.get(lval.name)
48004806
if (
48014807
cur_node
48024808
and isinstance(cur_node.node, Var)
@@ -4814,6 +4820,13 @@ def is_self_member_ref(self, memberexpr: MemberExpr) -> bool:
48144820
node = memberexpr.expr.node
48154821
return isinstance(node, Var) and node.is_self
48164822

4823+
def is_cls_member_ref(self, memberexpr: MemberExpr) -> bool:
4824+
"""Does memberexpr to refer to an attribute of cls?"""
4825+
if not isinstance(memberexpr.expr, NameExpr):
4826+
return False
4827+
node = memberexpr.expr.node
4828+
return isinstance(node, Var) and node.is_cls
4829+
48174830
def check_lvalue_validity(self, node: Expression | SymbolNode | None, ctx: Context) -> None:
48184831
if isinstance(node, TypeVarExpr):
48194832
self.fail("Invalid assignment target", ctx)

‎mypy/test/testcmdline.py‎

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import os
1010
import re
11+
import shlex
1112
import subprocess
1213
import sys
1314
import sysconfig
@@ -57,7 +58,7 @@ def test_python_cmdline(testcase: DataDrivenTestCase, step: int) -> None:
5758
with open(program_path, "w", encoding="utf8") as file:
5859
for s in testcase.input:
5960
file.write(f"{s}\n")
60-
args = parse_args(normalize_devnull(testcase.input[0]))
61+
args = parse_args(normalize_devnull("\n".join(testcase.input)), step)
6162
custom_cwd = parse_cwd(testcase.input[1]) if len(testcase.input) > 1 else None
6263
args.append("--show-traceback")
6364
if "--error-summary" not in args:
@@ -121,21 +122,25 @@ def test_python_cmdline(testcase: DataDrivenTestCase, step: int) -> None:
121122
)
122123

123124

124-
def parse_args(line: str) -> list[str]:
125-
"""Parse the first line of the program for the command line.
125+
def parse_args(text: str, step: int) -> list[str]:
126+
"""Parse the program for the command line.
126127
127128
This should have the form
128129
129-
# cmd: mypy <options>
130+
# cmd[N]: mypy <options>
130131
131132
For example:
132133
133-
# cmd: mypy pkg/
134+
# cmd: mypy pkg/ or # cmd2: mypy pkg/
134135
"""
135-
m = re.match("# cmd: mypy (.*)$", line)
136+
m = re.search("# cmd: mypy (.*)$", text, flags=re.MULTILINE)
137+
if step > 1:
138+
alt_m = re.search(f"# cmd{step}: mypy (.*)$", text, flags=re.MULTILINE)
139+
if alt_m is not None:
140+
m = alt_m
136141
if not m:
137142
return [] # No args; mypy will spit out an error.
138-
return m.group(1).split()
143+
return shlex.split(m.group(1))
139144

140145

141146
def parse_cwd(line: str) -> str | None:

0 commit comments

Comments
 (0)