From c179d299e31128675702cb62f24667752755e768 Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Sat, 12 Sep 2026 19:47:12 +0100 Subject: [PATCH 1/3] Use two-phase type checking in sequential mode --- mypy/build.py | 125 +++++++++++++++++-------- mypy/test/testcmdline.py | 3 +- test-data/unit/check-generics.test | 16 ++-- test-data/unit/check-inference.test | 29 +----- test-data/unit/check-plugin-attrs.test | 8 +- test-data/unit/check-selftype.test | 4 +- test-data/unit/cmdline.test | 5 + 7 files changed, 111 insertions(+), 79 deletions(-) diff --git a/mypy/build.py b/mypy/build.py index 96a67105c816c..25546311801d8 100644 --- a/mypy/build.py +++ b/mypy/build.py @@ -4620,18 +4620,7 @@ def process_graph(graph: Graph, manager: BuildManager) -> None: # type-checking this is already done and results should be empty here. if not manager.workers: assert not results - for id, result in results.items(): - # Interface and implementation results may be mixed in the same batch - # from different workers, process each one accordingly. - if result.interface_hash is not None: - new_hash = bytes.fromhex(result.interface_hash) - if new_hash != graph[id].interface_hash: - graph[id].mark_interface_stale() - graph[id].interface_hash = new_hash - else: - manager.flush_errors( - manager.errors.simplify_path(graph[id].xpath), result.error_lines, False - ) + process_results(results, graph, manager) ready = [] for done_scc in done: for dependent in done_scc.direct_dependents: @@ -4643,6 +4632,26 @@ def process_graph(graph: Graph, manager: BuildManager) -> None: manager.trace(f"Transitive deps cache size: {sys.getsizeof(manager.transitive_deps_cache)}") +def process_results(results: dict[str, ModuleResult], graph: Graph, manager: BuildManager) -> None: + """Process results of type-checking given modules. + + This will update interface hashes and flush type-checking errors (if any). + Blockers should have been already handled by the caller. + """ + for id, result in results.items(): + # Interface and implementation results may be mixed in the same batch + # from different workers, process each one accordingly. + if result.interface_hash is not None: + new_hash = bytes.fromhex(result.interface_hash) + if new_hash != graph[id].interface_hash: + graph[id].mark_interface_stale() + graph[id].interface_hash = new_hash + else: + manager.flush_errors( + manager.errors.simplify_path(graph[id].xpath), result.error_lines, False + ) + + def order_ascc(graph: Graph, ascc: AbstractSet[str], pri_max: int = PRI_INDIRECT) -> list[str]: """Come up with the ideal processing order within an SCC. @@ -4760,7 +4769,45 @@ def maybe_load_deps(graph: Graph, ascc: SCC, manager: BuildManager) -> None: def process_stale_scc(graph: Graph, ascc: SCC, manager: BuildManager) -> None: - """Process the modules in one SCC from source code.""" + """Process the modules in one SCC from source code. + + This will process module interfaces first (when possible). This mirrors + how things are done in parallel type checking. + """ + if not manager.options.local_partial_types: + # If local partial types are disabled we must process each file sequentially. + process_stale_scc_full(graph, ascc, manager) + return + manager.parse_all([graph[id] for id in ascc.mod_ids], post_parse=False) + scc_result = process_stale_scc_interface( + graph, ascc, manager, from_cache={id for id in ascc.mod_ids if graph[id].meta} + ) + manager.commit() + + # Process interface results before starting implementations + # (to mimic parallel checking 1:1). + mod_results = {} + stale = [] + meta_files = [] + for id, mod_result, meta_file in scc_result: + stale.append(id) + mod_results[id] = mod_result + meta_files.append(meta_file) + process_results(mod_results, graph, manager) + + mod_results = {} + for id, meta_file in zip(stale, meta_files): + mod_results |= process_stale_scc_implementation(graph, [id], manager, [meta_file]) + manager.commit() + process_results(mod_results, graph, manager) + + +def process_stale_scc_full(graph: Graph, ascc: SCC, manager: BuildManager) -> None: + """Process the modules in one SCC from source code. + + This is the legacy function that processes each file sequentially (line-by-line), + thus it may interleave processing interface and implementation parts. + """ # First verify if all transitive dependencies are loaded in the current process. t0 = time.time() maybe_load_deps(graph, ascc, manager) @@ -4863,7 +4910,7 @@ def process_stale_scc(graph: Graph, ascc: SCC, manager: BuildManager) -> None: def process_stale_scc_interface( graph: Graph, ascc: SCC, manager: BuildManager, from_cache: set[str] -) -> list[tuple[str, ModuleResult, str]]: +) -> list[tuple[str, ModuleResult, str | None]]: """Process the modules' interfaces in one SCC from source code.""" # First verify if all transitive dependencies are loaded in the current process. t0 = time.time() @@ -4909,16 +4956,19 @@ def process_stale_scc_interface( for id in stale: meta_tuple = meta_tuples[id] if meta_tuple is None: - continue - meta, meta_file = meta_tuple + meta = meta_file = None + else: + meta, meta_file = meta_tuple state = graph[id] - meta.dep_hashes = [ - graph[dep].interface_hash - for dep in state.dependencies - if state.priorities.get(dep) != PRI_INDIRECT - ] - write_cache_meta(meta, manager, meta_file) - manager.commit_module(meta_file) + if meta is not None: + assert meta_file is not None + meta.dep_hashes = [ + graph[dep].interface_hash + for dep in state.dependencies + if state.priorities.get(dep) != PRI_INDIRECT + ] + write_cache_meta(meta, manager, meta_file) + manager.commit_module(meta_file) scc_result.append((id, ModuleResult(graph[id].interface_hash.hex(), []), meta_file)) manager.done_sccs.add(ascc.id) manager.add_stats( @@ -4932,7 +4982,7 @@ def process_stale_scc_interface( def process_stale_scc_implementation( - graph: Graph, stale: list[str], manager: BuildManager, meta_files: list[str] + graph: Graph, stale: list[str], manager: BuildManager, meta_files: list[str | None] ) -> dict[str, ModuleResult]: """Process implementations (top-level function/method bodies) in an SCC.""" t0 = time.time() @@ -4977,6 +5027,18 @@ def process_stale_scc_implementation( scc_result = {} for id, meta_file in zip(stale, meta_files): state = graph[id] + # If there are no errors, only write the cache, don't send anything back + # to the caller (as a micro-optimization). + if graph[id].xpath not in manager.errors.ignored_files: + errors = manager.errors.file_messages(graph[id].xpath) + formatted = manager.errors.format_messages( + graph[id].xpath, errors, formatter=manager.error_formatter + ) + scc_result[id] = ModuleResult(None, formatted) + else: + errors = [] + if meta_file is None: + continue indirect = [dep for dep in state.dependencies if state.priorities.get(dep) == PRI_INDIRECT] meta_ex = CacheMetaEx( dependencies=indirect, @@ -4984,20 +5046,9 @@ def process_stale_scc_implementation( dep for dep in state.suppressed if state.priorities.get(dep) == PRI_INDIRECT ], dep_hashes=[graph[dep].interface_hash for dep in indirect], - error_lines=[], + error_lines=errors, ) - if graph[id].xpath not in manager.errors.ignored_files: - errors = manager.errors.file_messages(graph[id].xpath) - formatted = manager.errors.format_messages( - graph[id].xpath, errors, formatter=manager.error_formatter - ) - meta_ex.error_lines = errors - write_cache_meta_ex(meta_file, meta_ex, manager) - scc_result[id] = ModuleResult(None, formatted) - else: - # If there are no errors, only write the cache, don't send anything back - # to the caller (as a micro-optimization). - write_cache_meta_ex(meta_file, meta_ex, manager) + write_cache_meta_ex(meta_file, meta_ex, manager) manager.commit_module(meta_file) manager.add_stats(type_check_time_implementation=time.time() - t0) diff --git a/mypy/test/testcmdline.py b/mypy/test/testcmdline.py index a482ebbfc5f3f..bc8e474755d23 100644 --- a/mypy/test/testcmdline.py +++ b/mypy/test/testcmdline.py @@ -8,6 +8,7 @@ import os import re +import shlex import subprocess import sys import sysconfig @@ -135,7 +136,7 @@ def parse_args(line: str) -> list[str]: m = re.match("# cmd: mypy (.*)$", line) if not m: return [] # No args; mypy will spit out an error. - return m.group(1).split() + return shlex.split(m.group(1)) def parse_cwd(line: str) -> str | None: diff --git a/test-data/unit/check-generics.test b/test-data/unit/check-generics.test index b8f7a5699e199..48b0beff03d33 100644 --- a/test-data/unit/check-generics.test +++ b/test-data/unit/check-generics.test @@ -2921,8 +2921,8 @@ def mix(fs: List[Callable[[S], T]]) -> Callable[[S], List[T]]: def id(__x: U) -> U: ... fs = [id, id, id] -reveal_type(mix(fs)) # N: Revealed type is "def [S] (S`2) -> builtins.list[S`2]" -reveal_type(mix([id, id, id])) # N: Revealed type is "def [S] (S`4) -> builtins.list[S`4]" +reveal_type(mix(fs)) # N: Revealed type is "def [S] (S`1) -> builtins.list[S`1]" +reveal_type(mix([id, id, id])) # N: Revealed type is "def [S] (S`3) -> builtins.list[S`3]" [builtins fixtures/list.pyi] [case testInferenceAgainstGenericCurry] @@ -3098,14 +3098,14 @@ I = TypeVar("I", bound=int) def dec4_bound(f: Callable[[I], List[T]]) -> Callable[[I], T]: ... -reveal_type(dec1(lambda x: x)) # N: Revealed type is "def [T] (T`3) -> builtins.list[T`3]" -reveal_type(dec2(lambda x: x)) # N: Revealed type is "def [S] (S`5) -> builtins.list[S`5]" -reveal_type(dec3(lambda x: x[0])) # N: Revealed type is "def [S] (S`8) -> S`8" -reveal_type(dec4(lambda x: [x])) # N: Revealed type is "def [S] (S`11) -> S`11" +reveal_type(dec1(lambda x: x)) # N: Revealed type is "def [T] (T`1) -> builtins.list[T`1]" +reveal_type(dec2(lambda x: x)) # N: Revealed type is "def [S] (S`3) -> builtins.list[S`3]" +reveal_type(dec3(lambda x: x[0])) # N: Revealed type is "def [S] (S`6) -> S`6" +reveal_type(dec4(lambda x: [x])) # N: Revealed type is "def [S] (S`9) -> S`9" reveal_type(dec1(lambda x: 1)) # N: Revealed type is "def (builtins.int) -> builtins.list[builtins.int]" reveal_type(dec5(lambda x: x)) # N: Revealed type is "def (builtins.int) -> builtins.list[builtins.int]" -reveal_type(dec3(lambda x: x)) # N: Revealed type is "def [S] (S`19) -> builtins.list[S`19]" -reveal_type(dec4(lambda x: x)) # N: Revealed type is "def [T] (builtins.list[T`23]) -> T`23" +reveal_type(dec3(lambda x: x)) # N: Revealed type is "def [S] (S`17) -> builtins.list[S`17]" +reveal_type(dec4(lambda x: x)) # N: Revealed type is "def [T] (builtins.list[T`21]) -> T`21" dec4_bound(lambda x: x) # E: Value of type variable "I" of "dec4_bound" cannot be "list[T]" [builtins fixtures/list.pyi] diff --git a/test-data/unit/check-inference.test b/test-data/unit/check-inference.test index a5b3ae7238a5a..dad9dd8594916 100644 --- a/test-data/unit/check-inference.test +++ b/test-data/unit/check-inference.test @@ -2782,32 +2782,7 @@ x = '' # E: Incompatible types in assignment (expression has type "str", variab def g() -> None: reveal_type(x) # N: Revealed type is "builtins.int | None" --- TODO: combine 4 tests below back into 2 when possible. -[case testLocalPartialTypesWithGlobalInitializedToNone4_no_parallel] -# flags: --local-partial-types --no-strict-optional -a = None - -def f() -> None: - reveal_type(a) # N: Revealed type is "None" - -reveal_type(a) # N: Revealed type is "None" -a = '' -reveal_type(a) # N: Revealed type is "builtins.str" -[builtins fixtures/list.pyi] - -[case testLocalPartialTypesWithGlobalInitializedToNone5_no_parallel] -# flags: --local-partial-types -a = None - -def f() -> None: - reveal_type(a) # N: Revealed type is "None" - -reveal_type(a) # N: Revealed type is "None" -a = '' -reveal_type(a) # N: Revealed type is "builtins.str" -[builtins fixtures/list.pyi] - -[case testLocalPartialTypesWithGlobalInitializedToNone4_parallel_only] +[case testLocalPartialTypesWithGlobalInitializedToNone4] # flags: --local-partial-types --no-strict-optional a = None @@ -2819,7 +2794,7 @@ a = '' reveal_type(a) # N: Revealed type is "builtins.str" [builtins fixtures/list.pyi] -[case testLocalPartialTypesWithGlobalInitializedToNone5_parallel_only] +[case testLocalPartialTypesWithGlobalInitializedToNone5] # flags: --local-partial-types a = None diff --git a/test-data/unit/check-plugin-attrs.test b/test-data/unit/check-plugin-attrs.test index 5e6dd4d83ce02..7d453dcf84d01 100644 --- a/test-data/unit/check-plugin-attrs.test +++ b/test-data/unit/check-plugin-attrs.test @@ -990,10 +990,10 @@ class C(A, B): pass @attr.s class D(A): pass -reveal_type(A.__lt__) # N: Revealed type is "def [_AT] (self: _AT`29, other: _AT`29) -> builtins.bool" -reveal_type(B.__lt__) # N: Revealed type is "def [_AT] (self: _AT`30, other: _AT`30) -> builtins.bool" -reveal_type(C.__lt__) # N: Revealed type is "def [_AT] (self: _AT`31, other: _AT`31) -> builtins.bool" -reveal_type(D.__lt__) # N: Revealed type is "def [_AT] (self: _AT`32, other: _AT`32) -> builtins.bool" +reveal_type(A.__lt__) # N: Revealed type is "def [_AT] (self: _AT`5, other: _AT`5) -> builtins.bool" +reveal_type(B.__lt__) # N: Revealed type is "def [_AT] (self: _AT`6, other: _AT`6) -> builtins.bool" +reveal_type(C.__lt__) # N: Revealed type is "def [_AT] (self: _AT`7, other: _AT`7) -> builtins.bool" +reveal_type(D.__lt__) # N: Revealed type is "def [_AT] (self: _AT`8, other: _AT`8) -> builtins.bool" A() < A() B() < B() diff --git a/test-data/unit/check-selftype.test b/test-data/unit/check-selftype.test index 34ce4595439fc..6f73df5e05c8e 100644 --- a/test-data/unit/check-selftype.test +++ b/test-data/unit/check-selftype.test @@ -2314,8 +2314,8 @@ class A: @classmethod def other_meth(cls) -> Self: - reveal_type(cls.meth) # N: Revealed type is "def [Self <: __main__.A] (self: Self`1) -> Self`1" - reveal_type(A.meth) # N: Revealed type is "def [Self <: __main__.A] (self: Self`2) -> Self`2" + reveal_type(cls.meth) # N: Revealed type is "def [Self <: __main__.A] (self: Self`2) -> Self`2" + reveal_type(A.meth) # N: Revealed type is "def [Self <: __main__.A] (self: Self`3) -> Self`3" return cls().meth() class B: diff --git a/test-data/unit/cmdline.test b/test-data/unit/cmdline.test index 7066034b3e39c..79378adc10b1c 100644 --- a/test-data/unit/cmdline.test +++ b/test-data/unit/cmdline.test @@ -1314,6 +1314,11 @@ pass error: Cache must be enabled in parallel mode == Return code: 2 +[case testCodeModeInParallelMode] +# cmd: mypy -c 'def foo() -> None: 42 + "no"' --num-workers=2 +[out] +:1: error: Unsupported operand types for + ("int" and "str") + [case testCheckingStubPackagesWorksInParallelMode] # cmd: mypy foo-stubs --num-workers=4 [file foo-stubs/__init__.pyi] From 2a510053933ca40271d0eba18f4ba08547be962c Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Sat, 12 Sep 2026 21:45:07 +0100 Subject: [PATCH 2/3] Extend partial None type exception to classmethods --- mypy/semanal.py | 11 +++++++++++ test-data/unit/check-classes.test | 14 ++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/mypy/semanal.py b/mypy/semanal.py index 8d9b001ae7750..5c365df9743f6 100644 --- a/mypy/semanal.py +++ b/mypy/semanal.py @@ -4782,6 +4782,10 @@ def analyze_member_lvalue( self.type.names[lval.name] = SymbolTableNode(MDEF, v, implicit=True) for func in self.scope.functions: func.def_or_infer_vars = True + + if self.is_self_member_ref(lval) or self.is_cls_member_ref(lval): + assert self.type, "Self or cls member outside a class" + cur_node = self.type.names.get(lval.name) if ( cur_node and isinstance(cur_node.node, Var) @@ -4799,6 +4803,13 @@ def is_self_member_ref(self, memberexpr: MemberExpr) -> bool: node = memberexpr.expr.node return isinstance(node, Var) and node.is_self + def is_cls_member_ref(self, memberexpr: MemberExpr) -> bool: + """Does memberexpr to refer to an attribute of cls?""" + if not isinstance(memberexpr.expr, NameExpr): + return False + node = memberexpr.expr.node + return isinstance(node, Var) and node.is_cls + def check_lvalue_validity(self, node: Expression | SymbolNode | None, ctx: Context) -> None: if isinstance(node, TypeVarExpr): self.fail("Invalid assignment target", ctx) diff --git a/test-data/unit/check-classes.test b/test-data/unit/check-classes.test index dc5d74abc07df..fa742f3571f82 100644 --- a/test-data/unit/check-classes.test +++ b/test-data/unit/check-classes.test @@ -9727,3 +9727,17 @@ def f() -> None: class X: ... undefined # E: Name "undefined" is not defined + +[case testPartialNoneTypeClassMethod] +# flags: --local-partial-types + +class C: + x = None + + @classmethod + def foo(cls) -> None: + if not cls.x: + cls.x = 1 + +reveal_type(C.x) # N: Revealed type is "builtins.int | None" +[builtins fixtures/classmethod.pyi] From 50ce3eaeaa17d88c784a6dcac4b9bfdf69421fb3 Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Sun, 13 Sep 2026 00:18:43 +0100 Subject: [PATCH 3/3] Rectify injustice --- mypy/build.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/mypy/build.py b/mypy/build.py index 25546311801d8..59aa2f8292f24 100644 --- a/mypy/build.py +++ b/mypy/build.py @@ -4997,7 +4997,10 @@ def process_stale_scc_implementation( continue # We need to reset deferral count after possibly deferring any methods that # are considered part of the top-level (because they define/infer variables). - checker.pass_num = 0 + # Note we need to add one pass to compensate for function bodies not visited in + # type_check_first_pass(). So with current DEFAULT_LAST_PASS = 2 each function + # will be visited at most three times, for both single-phase and two-phase logic. + checker.pass_num = -1 checker.deferred_nodes.clear() tree = graph[id].tree assert tree is not None