From c179d299e31128675702cb62f24667752755e768 Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Sat, 12 Sep 2026 19:47:12 +0100 Subject: [PATCH 1/9] 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/9] 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/9] 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 From fe456910162c3733ff278e06739a95a1e0177fc9 Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Fri, 18 Sep 2026 20:39:12 +0100 Subject: [PATCH 4/9] Be more conservative about respecting no-local-partial-types --- mypy/build.py | 2 +- test-data/unit/check-inference.test | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/mypy/build.py b/mypy/build.py index aa93c43c4cb15..d09d0deea55ca 100644 --- a/mypy/build.py +++ b/mypy/build.py @@ -4790,7 +4790,7 @@ def process_stale_scc(graph: Graph, ascc: SCC, manager: BuildManager) -> None: 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 not all(graph[id].options.local_partial_types for id in ascc.mod_ids): # If local partial types are disabled we must process each file sequentially. process_stale_scc_full(graph, ascc, manager) return diff --git a/test-data/unit/check-inference.test b/test-data/unit/check-inference.test index dad9dd8594916..fd956104b0684 100644 --- a/test-data/unit/check-inference.test +++ b/test-data/unit/check-inference.test @@ -4363,3 +4363,14 @@ x = [] # E: Need type annotation for "x" (hint: "x: list[] = ...") def f() -> None: global x x + +[case testPerFileNoLocalPartialTypesForcesSinglePhase] +# mypy: no-local-partial-types + +x = [] + +def func() -> None: + x.append(1) + +reveal_type(x) # N: Revealed type is "builtins.list[builtins.int]" +[builtins fixtures/list.pyi] From 058f4ca43a8eeab9ee3aaf56cb61bd5fcccdf07d Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Fri, 18 Sep 2026 22:48:18 +0100 Subject: [PATCH 5/9] Actually error on --no-local-parial-types in parallel mode --- mypy/build.py | 1 + mypy/semanal.py | 31 +++++++++------ test-data/unit/check-bound.test | 4 +- test-data/unit/check-columns.test | 1 - test-data/unit/check-custom-plugin.test | 2 +- test-data/unit/check-errorcodes.test | 2 +- test-data/unit/check-functions.test | 2 +- test-data/unit/check-incremental.test | 33 ++++++++++++++- test-data/unit/check-inference.test | 53 ++++++------------------- test-data/unit/check-protocols.test | 24 +---------- test-data/unit/check-redefine2.test | 22 +++++++++- 11 files changed, 92 insertions(+), 83 deletions(-) diff --git a/mypy/build.py b/mypy/build.py index d09d0deea55ca..3c83759abd7fc 100644 --- a/mypy/build.py +++ b/mypy/build.py @@ -898,6 +898,7 @@ def __init__( self.errors, self.plugin, self.import_map, + parallel_worker, ) self.all_types: dict[Expression, Type] = {} # Enabled by export_types self.indirection_detector = TypeIndirectionVisitor() diff --git a/mypy/semanal.py b/mypy/semanal.py index d6b0e72eb4aab..5e29f6cb95b55 100644 --- a/mypy/semanal.py +++ b/mypy/semanal.py @@ -461,6 +461,7 @@ def __init__( errors: Errors, plugin: Plugin, import_map: dict[str, set[str]], + parallel_worker: bool, ) -> None: """Construct semantic analyzer. @@ -494,6 +495,8 @@ def __init__( self.errors = errors self.modules = modules self.import_map = import_map + # True if this analysis is run in a parallel worker process. + self.parallel_worker = parallel_worker self.msg = MessageBuilder(errors, modules) self.missing_modules = missing_modules self.missing_names = [set()] @@ -719,24 +722,26 @@ def refresh_partial( self.accept(node) del self.patches + def ad_hoc_error(self, msg: str) -> None: + n = TempNode(AnyType(TypeOfAny.special_form)) + n.line = 1 + n.column = 0 + n.end_line = 1 + n.end_column = 0 + self.fail(msg, n) + def refresh_top_level(self, file_node: MypyFile) -> None: """Reanalyze a stale module top-level in fine-grained incremental mode.""" if self.options.allow_redefinition and not self.options.local_partial_types: - n = TempNode(AnyType(TypeOfAny.special_form)) - n.line = 1 - n.column = 0 - n.end_line = 1 - n.end_column = 0 - self.fail("--local-partial-types must be enabled if using --allow-redefinition", n) + self.ad_hoc_error( + "--local-partial-types must be enabled if using --allow-redefinition" + ) if self.options.allow_redefinition and self.options.allow_redefinition_old: - n = TempNode(AnyType(TypeOfAny.special_form)) - n.line = 1 - n.column = 0 - n.end_line = 1 - n.end_column = 0 - self.fail( - "--allow-redefinition-old and --allow-redefinition should not be used together", n + self.ad_hoc_error( + "--allow-redefinition-old and --allow-redefinition should not be used together" ) + if not self.options.local_partial_types and self.parallel_worker: + self.ad_hoc_error("--local-partial-types must be enabled in parallel mode") self.recurse_into_functions = False self.add_implicit_module_attrs(file_node) for d in file_node.defs: diff --git a/test-data/unit/check-bound.test b/test-data/unit/check-bound.test index 1f9eba6120201..1c713fd77c38d 100644 --- a/test-data/unit/check-bound.test +++ b/test-data/unit/check-bound.test @@ -46,7 +46,7 @@ z = G(B()) [case testBoundVoid] -# flags: --no-strict-optional --no-local-partial-types +# flags: --no-strict-optional from typing import TypeVar, Generic T = TypeVar('T', bound=int) class C(Generic[T]): @@ -75,7 +75,7 @@ z: C [case testBoundHigherOrderWithVoid] -# flags: --no-strict-optional --no-local-partial-types +# flags: --no-strict-optional from typing import TypeVar, Callable class A: pass T = TypeVar('T', bound=A) diff --git a/test-data/unit/check-columns.test b/test-data/unit/check-columns.test index ea88eedd37fcc..839d13d36678d 100644 --- a/test-data/unit/check-columns.test +++ b/test-data/unit/check-columns.test @@ -209,7 +209,6 @@ y: Dict[int, int] = { [builtins fixtures/dict.pyi] [case testColumnCannotDetermineType] -# flags: --no-local-partial-types (x) # E:2: Cannot determine type of "x" # E:2: Name "x" is used before definition x = None diff --git a/test-data/unit/check-custom-plugin.test b/test-data/unit/check-custom-plugin.test index dd1de1265b599..504c153145537 100644 --- a/test-data/unit/check-custom-plugin.test +++ b/test-data/unit/check-custom-plugin.test @@ -1007,7 +1007,7 @@ reveal_type(Cls.attr) # N: Revealed type is "builtins.int" plugins=/test-data/unit/plugins/class_attr_hook.py [case testClassAttrPluginPartialType] -# flags: --config-file tmp/mypy.ini --no-local-partial-types +# flags: --config-file tmp/mypy.ini class Cls: attr = None diff --git a/test-data/unit/check-errorcodes.test b/test-data/unit/check-errorcodes.test index 35e63bca7ee46..c82ef61d01404 100644 --- a/test-data/unit/check-errorcodes.test +++ b/test-data/unit/check-errorcodes.test @@ -981,7 +981,7 @@ Foo = TypedDict("Bar", {}) # E: First argument "Bar" to TypedDict() does not ma [typing fixtures/typing-typeddict.pyi] [case testTruthyBool] -# flags: --enable-error-code truthy-bool --no-local-partial-types +# flags: --enable-error-code truthy-bool from typing import List, Union, Any class Foo: diff --git a/test-data/unit/check-functions.test b/test-data/unit/check-functions.test index 4c8fec2e5a959..4cb58820d5bff 100644 --- a/test-data/unit/check-functions.test +++ b/test-data/unit/check-functions.test @@ -2664,7 +2664,7 @@ reveal_type(bar(None)) # N: Revealed type is "None" [out] [case testNoComplainInferredNone] -# flags: --no-strict-optional --no-local-partial-types +# flags: --no-strict-optional from typing import TypeVar, Optional T = TypeVar('T') def X(val: T) -> T: ... diff --git a/test-data/unit/check-incremental.test b/test-data/unit/check-incremental.test index 0db89c0abd237..e4b5a43407e8e 100644 --- a/test-data/unit/check-incremental.test +++ b/test-data/unit/check-incremental.test @@ -6639,7 +6639,7 @@ class C: ... [out2] [out3] -[case testNoCrashOnPartialLambdaInference] +[case testNoCrashOnPartialLambdaInference_no_parallel] # flags: --no-local-partial-types import m [file m.py] @@ -6666,6 +6666,37 @@ reveal_type(xs) [out2] tmp/m.py:9: note: Revealed type is "builtins.list[builtins.int]" +[case testNoCrashOnPartialLambdaInference_parallel_only] +# flags: --local-partial-types +import m +[file m.py] +from typing import TypeVar, Callable + +V = TypeVar("V") +def apply(val: V, func: Callable[[V], None]) -> None: + return func(val) + +xs = [] +apply(0, lambda a: xs.append(a)) +[file m.py.2] +from typing import TypeVar, Callable + +V = TypeVar("V") +def apply(val: V, func: Callable[[V], None]) -> None: + return func(val) + +xs = [] +apply(0, lambda a: xs.append(a)) +reveal_type(xs) +[builtins fixtures/list.pyi] +[out] +tmp/m.py:7: error: Need type annotation for "xs" (hint: "xs: list[] = ...") +tmp/m.py:8: error: Cannot determine type of "xs" +[out2] +tmp/m.py:7: error: Need type annotation for "xs" (hint: "xs: list[] = ...") +tmp/m.py:8: error: Cannot determine type of "xs" +tmp/m.py:9: note: Revealed type is "builtins.list[Any]" + [case testTypingSelfCoarse] import m [file lib.py] diff --git a/test-data/unit/check-inference.test b/test-data/unit/check-inference.test index fd956104b0684..88de271dfa129 100644 --- a/test-data/unit/check-inference.test +++ b/test-data/unit/check-inference.test @@ -1925,7 +1925,6 @@ reveal_type(C().a) # N: Revealed type is "builtins.dict[Any, Any]" [builtins fixtures/dict.pyi] [case testInferAttributeInitializedToNoneAndAssignedClassBody] -# flags: --no-local-partial-types class C: a = None def __init__(self) -> None: @@ -2099,7 +2098,6 @@ x = 1 [out] [case testPartiallyInitializedVariableDoesNotEscapeScope2] -# flags: --no-local-partial-types x = None def f() -> None: x = None @@ -2127,22 +2125,6 @@ main:6: error: Incompatible types in assignment (expression has type "int", vari main:7: error: "None" not callable [case testGlobalInitializedToNoneSetFromFunction] -# flags: --no-local-partial-types -a = None -def f() -> None: - global a - a = 42 - reveal_type(a) # N: Revealed type is "builtins.int" -reveal_type(a) # N: Revealed type is "builtins.int | None" - -b = None -def unchecked(): - global b - b = 42 -reveal_type(b) # N: Revealed type is "Any | None" - -[case testGlobalInitializedToNoneSetFromFunctionLocalPartialTypes] -# flags: --local-partial-types a = None def f() -> None: global a @@ -2157,23 +2139,6 @@ def unchecked(): reveal_type(b) # N: Revealed type is "Any | None" [case testGlobalInitializedToNoneSetFromMethod] -# flags: --no-local-partial-types -a = None -class C: - def m(self) -> None: - global a - a = 42 -reveal_type(a) # N: Revealed type is "builtins.int | None" - -b = None -class CC: - def unchecked(self): - global b - b = 42 -reveal_type(b) # N: Revealed type is "Any | None" - -[case testGlobalInitializedToNoneSetFromMethodLocalPartialTypes] -# flags: --local-partial-types a = None class C: def m(self) -> None: @@ -2189,7 +2154,6 @@ class CC: reveal_type(b) # N: Revealed type is "Any | None" [case testPartialTypeErrorSpecialCase1] -# flags: --no-local-partial-types # This used to crash. class A: x = None @@ -2208,7 +2172,6 @@ class A: [builtins fixtures/for.pyi] [case testPartialTypeErrorSpecialCase3] -# flags: --no-local-partial-types class A: x = None def f(self) -> None: @@ -2561,7 +2524,6 @@ main:4: error: Unsupported target for indexed assignment ("type[C[T]]") main:4: error: Invalid type: try using Literal[0] instead? [case testNoCrashOnPartialMember] -# flags: --no-local-partial-types class C: x = None def __init__(self) -> None: @@ -2580,7 +2542,6 @@ reveal_type(x) # N: Revealed type is "builtins.str" [builtins fixtures/tuple.pyi] [case testNoCrashOnPartialVariable2] -# flags: --no-local-partial-types from typing import Tuple, TypeVar T = TypeVar('T', bound=str) @@ -4364,7 +4325,7 @@ def f() -> None: global x x -[case testPerFileNoLocalPartialTypesForcesSinglePhase] +[case testPerFileNoLocalPartialTypesForcesSinglePhase_no_parallel] # mypy: no-local-partial-types x = [] @@ -4374,3 +4335,15 @@ def func() -> None: reveal_type(x) # N: Revealed type is "builtins.list[builtins.int]" [builtins fixtures/list.pyi] + +[case testPerFileNoLocalPartialTypesForcesSinglePhase_parallel_only] +# mypy: no-local-partial-types + +x = [] + +def func() -> None: + x.append(1) +[builtins fixtures/list.pyi] +[out] +main:1: error: --local-partial-types must be enabled in parallel mode +main:3: error: Need type annotation for "x" (hint: "x: list[] = ...") diff --git a/test-data/unit/check-protocols.test b/test-data/unit/check-protocols.test index 7d1eec6cc9cb8..f9adfa51688d0 100644 --- a/test-data/unit/check-protocols.test +++ b/test-data/unit/check-protocols.test @@ -2920,7 +2920,6 @@ hs(None) [case testPartialTypeProtocol] -# flags: --no-local-partial-types from typing import Protocol class Flapper(Protocol): @@ -2941,25 +2940,7 @@ class Gleemer: [case testPartialTypeProtocolHashable] -# flags: --no-strict-optional --no-local-partial-types -from typing import Protocol - -class Hashable(Protocol): - def __hash__(self) -> int: ... - -class ObjectHashable: - def __hash__(self) -> int: ... - -class DataArray(ObjectHashable): - __hash__ = None - - def f(self, x: Hashable) -> None: - reveal_type([self, x]) # N: Revealed type is "builtins.list[builtins.object]" -[builtins fixtures/tuple.pyi] - - -[case testPartialTypeProtocolHashableLocalPartialTypes] -# flags: --no-strict-optional --local-partial-types +# flags: --no-strict-optional from typing import Protocol class Hashable(Protocol): @@ -2977,7 +2958,7 @@ class DataArray(ObjectHashable): [case testPartialAttributeNoneType] -# flags: --no-strict-optional --no-local-partial-types +# flags: --no-strict-optional from typing import Optional, Protocol, runtime_checkable @runtime_checkable @@ -2995,7 +2976,6 @@ class MyClass: [case testPartialAttributeNoneTypeStrictOptional] -# flags: --no-local-partial-types from typing import Optional, Protocol, runtime_checkable @runtime_checkable diff --git a/test-data/unit/check-redefine2.test b/test-data/unit/check-redefine2.test index b7c4e17183c15..964a1d02db12b 100644 --- a/test-data/unit/check-redefine2.test +++ b/test-data/unit/check-redefine2.test @@ -1160,7 +1160,7 @@ else: x = "" # E: Incompatible types in assignment (expression has type "str", variable has type "int") reveal_type(x) # N: Revealed type is "builtins.int" -[case testNewRedefineWithoutLocalPartialTypes] +[case testNewRedefineWithoutLocalPartialTypes_no_parallel] import a import b @@ -1179,6 +1179,26 @@ if int(): [out] tmp/b.py:1: error: --local-partial-types must be enabled if using --allow-redefinition +[case testNewRedefineWithoutLocalPartialTypes_parallel_only] +import a +import b + +[file a.py] +# mypy: local-partial-types, allow-redefinition +x = 0 +if int(): + x = "" + +[file b.py] +# mypy: local-partial-types=false, allow-redefinition +x = 0 +if int(): + x = "" + +[out] +tmp/b.py:1: error: --local-partial-types must be enabled if using --allow-redefinition +tmp/b.py:1: error: --local-partial-types must be enabled in parallel mode + [case testNewRedefineNestedLoopInfiniteExpansion] # flags: --allow-redefinition def a(): ... From 3dc8d57e7e1f31c23c151e7b88bf9105996a3172 Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Tue, 22 Sep 2026 00:32:30 +0100 Subject: [PATCH 6/9] Be more careful with caching local partial types --- mypy/build.py | 37 ++++++++++++++++++++++++--- mypy/semanal.py | 5 +--- mypy/test/testcmdline.py | 7 ++--- test-data/unit/check-incremental.test | 24 +++++++++++++++++ test-data/unit/cmdline.test | 20 +++++++++++++++ test-data/unit/exportjson.test | 2 ++ 6 files changed, 84 insertions(+), 11 deletions(-) diff --git a/mypy/build.py b/mypy/build.py index 3c83759abd7fc..926573ba67aff 100644 --- a/mypy/build.py +++ b/mypy/build.py @@ -898,7 +898,6 @@ def __init__( self.errors, self.plugin, self.import_map, - parallel_worker, ) self.all_types: dict[Expression, Type] = {} # Enabled by export_types self.indirection_detector = TypeIndirectionVisitor() @@ -2056,7 +2055,7 @@ def find_cache_meta( # Ignore cache if (relevant) options aren't the same. # Note that it's fine to mutilate cached_options since it's only used here. - cached_options = m.options + cached_options = m.options.copy() current_options = options_snapshot(id, manager) if manager.options.skip_version_check: # When we're lax about version we're also lax about platform. @@ -2064,6 +2063,18 @@ def find_cache_meta( if "debug_cache" in cached_options: # Older versions included debug_cache, but it's silly to compare it. del cached_options["debug_cache"] + if "local_partial_types" in cached_options: + local_partial_types = cached_options["local_partial_types"] + del cached_options["local_partial_types"] + is_parallel = manager.options.num_workers > 0 + if not local_partial_types and cached_options["is_parallel"] != is_parallel: + # If local partial types are disabled, behavior is too different for + # parallel and sequential runs, see write_cache for details. + return None + del cached_options["is_parallel"] + else: + # Cache from an old mypy version. + return None if cached_options != current_options: manager.log(f"Metadata abandoned for {id}: options differ") if manager.options.verbosity >= 2: @@ -2227,7 +2238,6 @@ def validate_meta( meta.mtime = mtime meta.path = path meta.size = size - meta.options = options_snapshot(id, manager) meta_file, _, _ = get_cache_names(id, path, manager.options) if manager.logging_enabled: manager.log( @@ -2272,6 +2282,8 @@ def write_cache( trans_dep_hash: bytes, source_hash: str, ignore_all: bool, + local_partial_types: bool, + is_parallel: bool, manager: BuildManager, ) -> tuple[bytes, tuple[CacheMeta, str] | None]: """Write cache files for a module. @@ -2371,6 +2383,16 @@ def write_cache( # important, or otherwise the options would never match when # verifying the cache. assert source_hash is not None + # Local partial types need special handling as they behave differently + # in sequential and parallel runs: + # * In sequential run we respected the option, but use different SCC + # processing logic depending on whether they are enabled or disabled. + # * In parallel run they are always on, and we give an error if a user + # tries to disable them. + extra_options = { + "local_partial_types": local_partial_types, + "is_parallel": is_parallel, + } meta = CacheMeta( id=id, path=path, @@ -2382,7 +2404,7 @@ def write_cache( data_file=data_file, suppressed=suppressed, imports_ignored=imports_ignored, - options=options_snapshot(id, manager), + options=options_snapshot(id, manager) | extra_options, suppressed_deps_opts=suppressed_deps_opts, dep_prios=dep_prios, dep_lines=dep_lines, @@ -2727,6 +2749,11 @@ def new_state( meta, meta_ex = meta_pair interface_hash = meta.interface_hash meta_source_hash = meta.hash + # Update the local partial types in case they were set by an inline config. + # So we can select the correct SCC processing logic without reading the file. + local_partial_types = meta.options["local_partial_types"] + if options.local_partial_types != local_partial_types: + options = options.apply_changes({"local_partial_types": local_partial_types}) if path and source is None and manager.fscache.isdir(path): source = "" @@ -3637,6 +3664,8 @@ def write_cache(self) -> tuple[CacheMeta, str] | None: self.trans_dep_hash, self.source_hash, self.ignore_all, + self.options.local_partial_types, + self.options.num_workers > 0, self.manager, ) if new_interface_hash == self.interface_hash: diff --git a/mypy/semanal.py b/mypy/semanal.py index 5e29f6cb95b55..57de5c4de8507 100644 --- a/mypy/semanal.py +++ b/mypy/semanal.py @@ -461,7 +461,6 @@ def __init__( errors: Errors, plugin: Plugin, import_map: dict[str, set[str]], - parallel_worker: bool, ) -> None: """Construct semantic analyzer. @@ -495,8 +494,6 @@ def __init__( self.errors = errors self.modules = modules self.import_map = import_map - # True if this analysis is run in a parallel worker process. - self.parallel_worker = parallel_worker self.msg = MessageBuilder(errors, modules) self.missing_modules = missing_modules self.missing_names = [set()] @@ -740,7 +737,7 @@ def refresh_top_level(self, file_node: MypyFile) -> None: self.ad_hoc_error( "--allow-redefinition-old and --allow-redefinition should not be used together" ) - if not self.options.local_partial_types and self.parallel_worker: + if not self.options.local_partial_types and self.options.num_workers > 0: self.ad_hoc_error("--local-partial-types must be enabled in parallel mode") self.recurse_into_functions = False self.add_implicit_module_attrs(file_node) diff --git a/mypy/test/testcmdline.py b/mypy/test/testcmdline.py index bc8e474755d23..421a9a9332e02 100644 --- a/mypy/test/testcmdline.py +++ b/mypy/test/testcmdline.py @@ -58,7 +58,7 @@ def test_python_cmdline(testcase: DataDrivenTestCase, step: int) -> None: with open(program_path, "w", encoding="utf8") as file: for s in testcase.input: file.write(f"{s}\n") - args = parse_args(normalize_devnull(testcase.input[0])) + args = parse_args(normalize_devnull(testcase.input[step - 1]), step) custom_cwd = parse_cwd(testcase.input[1]) if len(testcase.input) > 1 else None args.append("--show-traceback") if "--error-summary" not in args: @@ -122,7 +122,7 @@ def test_python_cmdline(testcase: DataDrivenTestCase, step: int) -> None: ) -def parse_args(line: str) -> list[str]: +def parse_args(line: str, step: int) -> list[str]: """Parse the first line of the program for the command line. This should have the form @@ -133,7 +133,8 @@ def parse_args(line: str) -> list[str]: # cmd: mypy pkg/ """ - m = re.match("# cmd: mypy (.*)$", line) + step_str = "" if step == 1 else str(step) + m = re.match(f"# cmd{step_str}: mypy (.*)$", line) if not m: return [] # No args; mypy will spit out an error. return shlex.split(m.group(1)) diff --git a/test-data/unit/check-incremental.test b/test-data/unit/check-incremental.test index e4b5a43407e8e..cb66c05fb5c8c 100644 --- a/test-data/unit/check-incremental.test +++ b/test-data/unit/check-incremental.test @@ -8251,3 +8251,27 @@ y = 2 [stale] [out2] [out3] + +[case testNoLocalPartialTypesInlineRespectedIncremental_no_parallel] +import a +[file a.py] +# mypy: no-local-partial-types +import dep + +items = [] + +def add() -> None: + items.append(1) + +reveal_type(items) +[file dep.py] +value: int = 1 +[file dep.py.2] +value: str = "x" +[builtins fixtures/list.pyi] +[rechecked a, dep] +[stale dep] +[out] +tmp/a.py:9: note: Revealed type is "builtins.list[builtins.int]" +[out2] +tmp/a.py:9: note: Revealed type is "builtins.list[builtins.int]" diff --git a/test-data/unit/cmdline.test b/test-data/unit/cmdline.test index fd4ed3dbb1509..9928f64acd116 100644 --- a/test-data/unit/cmdline.test +++ b/test-data/unit/cmdline.test @@ -1330,3 +1330,23 @@ error: Cache must be enabled in parallel mode from foo.api import bar as bar [file foo-stubs/api/bar.pyi] [out] + +[case testCacheInvalidatedForNoLocalPartialTypesParallelVsSequential] +# cmd: mypy a.py +# cmd2: mypy a.py --num-workers=2 +[file a.py] +# mypy: no-local-partial-types + +items = [] + +def add() -> None: + items.append(1) + +reveal_type(items) +[out] +a.py:8: note: Revealed type is "list[int]" +== Return code: 0 +[out2] +a.py:1: error: --local-partial-types must be enabled in parallel mode +a.py:3: error: Need type annotation for "items" (hint: "items: list[] = ...") +a.py:8: note: Revealed type is "list[Any]" diff --git a/test-data/unit/exportjson.test b/test-data/unit/exportjson.test index 2d6a8c56b20f7..f1f977c6ea2ca 100644 --- a/test-data/unit/exportjson.test +++ b/test-data/unit/exportjson.test @@ -298,6 +298,8 @@ from typing_extensions import Final ], "suppressed": [], "options": { + "is_parallel": false, + "local_partial_types": true, "other_options": "", "platform": ... }, From 26fb69f93defbc798ed6271929d97d59e7fbf868 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 23:34:21 +0000 Subject: [PATCH 7/9] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- mypy/build.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/mypy/build.py b/mypy/build.py index 926573ba67aff..9e79c4f7b3c72 100644 --- a/mypy/build.py +++ b/mypy/build.py @@ -2389,10 +2389,7 @@ def write_cache( # processing logic depending on whether they are enabled or disabled. # * In parallel run they are always on, and we give an error if a user # tries to disable them. - extra_options = { - "local_partial_types": local_partial_types, - "is_parallel": is_parallel, - } + extra_options = {"local_partial_types": local_partial_types, "is_parallel": is_parallel} meta = CacheMeta( id=id, path=path, From 8257f435fc64e8f69fc59080094175b865781b17 Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Tue, 22 Sep 2026 00:35:53 +0100 Subject: [PATCH 8/9] Style --- mypy/build.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mypy/build.py b/mypy/build.py index 9e79c4f7b3c72..e4b1ec2565e8b 100644 --- a/mypy/build.py +++ b/mypy/build.py @@ -2069,7 +2069,7 @@ def find_cache_meta( is_parallel = manager.options.num_workers > 0 if not local_partial_types and cached_options["is_parallel"] != is_parallel: # If local partial types are disabled, behavior is too different for - # parallel and sequential runs, see write_cache for details. + # parallel and sequential runs, see write_cache() for details. return None del cached_options["is_parallel"] else: From 834e207d213429fcfe70d04106ff18f42aff6752 Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Tue, 22 Sep 2026 01:36:40 +0100 Subject: [PATCH 9/9] Match testcheck logic --- mypy/test/testcmdline.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/mypy/test/testcmdline.py b/mypy/test/testcmdline.py index 421a9a9332e02..b558127d31dc0 100644 --- a/mypy/test/testcmdline.py +++ b/mypy/test/testcmdline.py @@ -58,7 +58,7 @@ def test_python_cmdline(testcase: DataDrivenTestCase, step: int) -> None: with open(program_path, "w", encoding="utf8") as file: for s in testcase.input: file.write(f"{s}\n") - args = parse_args(normalize_devnull(testcase.input[step - 1]), step) + args = parse_args(normalize_devnull("\n".join(testcase.input)), step) custom_cwd = parse_cwd(testcase.input[1]) if len(testcase.input) > 1 else None args.append("--show-traceback") if "--error-summary" not in args: @@ -122,19 +122,22 @@ def test_python_cmdline(testcase: DataDrivenTestCase, step: int) -> None: ) -def parse_args(line: str, step: int) -> list[str]: - """Parse the first line of the program for the command line. +def parse_args(text: str, step: int) -> list[str]: + """Parse the program for the command line. This should have the form - # cmd: mypy + # cmd[N]: mypy For example: - # cmd: mypy pkg/ + # cmd: mypy pkg/ or # cmd2: mypy pkg/ """ - step_str = "" if step == 1 else str(step) - m = re.match(f"# cmd{step_str}: mypy (.*)$", line) + m = re.search("# cmd: mypy (.*)$", text, flags=re.MULTILINE) + if step > 1: + alt_m = re.search(f"# cmd{step}: mypy (.*)$", text, flags=re.MULTILINE) + if alt_m is not None: + m = alt_m if not m: return [] # No args; mypy will spit out an error. return shlex.split(m.group(1))