From c3b4e51622f1b1c38ed5250fda83f469eb166617 Mon Sep 17 00:00:00 2001 From: geooo109 Date: Fri, 17 Jul 2026 12:40:38 +0300 Subject: [PATCH 1/4] [mypyc] Record MRO ancestor modules as deps of subclass-defining modules Fixes #21742 Mypy's incremental system only recompiles a module when a recorded dependency changes, and a dependency edge to an ancestor's module is created in cases such as an explicit import or a checked expression whose type references that ancestor. A bare subclass definition produces neither, so the transitive ancestors never enter the module's dependency graph. The goal of this PR is to make the dependency graph reflect what the generated C actually depends on. When compiling with mypyc, a module that defines a class now records the modules defining every ancestor in that class's MRO as indirect dependencies, so an ancestor's interface change reaches the subclass-defining module directly. --- mypy/build.py | 50 ++++++++++--- mypyc/test-data/run-multimodule.test | 104 +++++++++++++++++++++++++++ 2 files changed, 145 insertions(+), 9 deletions(-) diff --git a/mypy/build.py b/mypy/build.py index a03a6eb8972cc..d5c78feb446e1 100644 --- a/mypy/build.py +++ b/mypy/build.py @@ -118,6 +118,7 @@ MypyFile, OverloadedFuncDef, SymbolTable, + TypeInfo, ) from mypy.options import OPTIONS_AFFECTING_CACHE_NO_PLATFORM from mypy.partially_defined import PossiblyUndefinedVariableVisitor @@ -3479,17 +3480,27 @@ def finish_passes(self) -> None: if options.export_types: manager.all_types.update(self.type_map()) + # Indirect dependencies: modules this one never imports directly but + # whose interface changes must still invalidate it. Three sources: + # * module_refs: modules reached via attribute access or re-exports + # (mostly recorded during semantic analysis). + # * The type of every checked expression, resolved to the modules + # defining the type's components — for a class instance, its whole + # MRO (TypeIndirectionVisitor, inside patch_indirect_dependencies). + # * For mypyc-compiled modules: the ancestors of every class + # *defined* here. Generated C embeds each ancestor's method and + # attribute layout (vtables, object struct), so the defining module + # must be recompiled when any ancestor's interface changes. A bare + # `class B(A): pass` produces no expression types, so the + # sources above record nothing for it, and staleness propagation + # would stop at intermediate modules whose own interfaces are + # unchanged. + indirect_refs = self.tree.module_refs | self.type_checker().module_refs + if self.options.mypyc: + indirect_refs |= self.compiled_class_ancestor_refs() # We should always patch indirect dependencies, even in full (non-incremental) builds, # because the cache still may be written, and it must be correct. - self.patch_indirect_dependencies( - # Two possible sources of indirect dependencies: - # * Symbols not directly imported in this module but accessed via an attribute - # or via a re-export (vast majority of these recorded in semantic analysis). - # * For each expression type we need to record definitions of type components - # since "meaning" of the type may be updated when definitions are updated. - self.tree.module_refs | self.type_checker().module_refs, - set(self.type_map().values()), - ) + self.patch_indirect_dependencies(indirect_refs, set(self.type_map().values())) if self.options.dump_inference_stats: dump_type_stats( @@ -3531,6 +3542,27 @@ def patch_indirect_dependencies(self, module_refs: set[str], types: set[Type]) - self.add_dependency(dep) self.priorities[dep] = PRI_INDIRECT + def compiled_class_ancestor_refs(self) -> set[str]: + """Modules defining MRO ancestors of classes defined in this module. + + Only used for mypyc-compiled modules: the generated C for a class + (vtable arrays, getter/setter tables, object struct) references + every inherited method/attribute of every ancestor, including + ancestors defined in modules this module does not import directly. + Recording them as indirect dependencies makes an ancestor's + interface change re-trigger type checking (and hence C regeneration) + of this module. Only top-level classes are scanned: mypyc rejects + nested class definitions. + """ + assert self.tree is not None + mods: set[str] = set() + for sym in self.tree.names.values(): + node = sym.node + if isinstance(node, TypeInfo) and node.module_name == self.id: + for ancestor in node.mro[1:]: + mods.add(ancestor.module_name) + return mods + def compute_fine_grained_deps(self) -> dict[str, set[str]]: assert self.tree is not None if self.id in ("builtins", "typing", "types", "sys", "_typeshed"): diff --git a/mypyc/test-data/run-multimodule.test b/mypyc/test-data/run-multimodule.test index 4cf391d312dff..b6c0ffb3a440e 100644 --- a/mypyc/test-data/run-multimodule.test +++ b/mypyc/test-data/run-multimodule.test @@ -1900,6 +1900,110 @@ def _force_recompile() -> int: from native import test test() +[case testIncrementalCrossGroupInheritedMethodRemoved] +# Regression: under separate=True, a module whose only content is a subclass +# definition (`class Leaf(Mid): pass`) produces no expression types, so it +# recorded no dependency on the modules defining its transitive bases. When a +# Base method two hops up the inheritance chain was removed, staleness +# propagation stopped at other_mid (its own interface is unchanged) and +# other_leaf stayed fresh: its stale generated C still referenced the method +# in Leaf's vtable via exports_other_base.CPyDef_..., which no longer exists +# in the regenerated export table, and the build failed. +# compiled_class_ancestor_refs must record the MRO ancestors' modules as +# indirect deps so an ancestor's interface change recompiles the defining +# module too. (removed_method is declared after existing_method so the +# surviving call site's vtable slot is unaffected.) +import other_leaf + +def test() -> int: + c = other_leaf.Leaf() + return c.existing_method(5) + +[file other_base.py] +class Base: + def existing_method(self, x: int) -> int: + return x + 1 + + def removed_method(self, x: int) -> int: + return x + 100 + +[file other_base.py.2] +class Base: + def existing_method(self, x: int) -> int: + return x + 1 + +[file other_mid.py] +from other_base import Base + +class Mid(Base): + def mid_method(self, x: int) -> int: + return x + 2 + +[file other_leaf.py] +from other_mid import Mid + +class Leaf(Mid): + pass + +[file driver.py] +from native import test +print(test()) + +[out] +6 +[out2] +6 + +[case testIncrementalCrossGroupInheritedMethodRemovedNonEmptyLeaf] +# Same regression as testIncrementalCrossGroupInheritedMethodRemoved, but the +# subclass body is NOT empty: a class constant and a method that never uses +# self. Neither produces an expression whose type reaches Leaf's MRO, so the +# pre-existing dependency sources still record nothing for other_leaf, while +# its generated vtable references the ancestors regardless of body shape. +# Guards against narrowing compiled_class_ancestor_refs to pass-only bodies. +import other_leaf + +def test() -> int: + return other_leaf.Leaf().existing_method(5) + +[file other_base.py] +class Base: + def existing_method(self, x: int) -> int: + return x + 1 + + def removed_method(self, x: int) -> int: + return x + 100 + +[file other_base.py.2] +class Base: + def existing_method(self, x: int) -> int: + return x + 1 + +[file other_mid.py] +from other_base import Base + +class Mid(Base): + def mid_method(self, x: int) -> int: + return x + 2 + +[file other_leaf.py] +from other_mid import Mid + +class Leaf(Mid): + FLAG = True + + def foo(self) -> str: + return "foo" + +[file driver.py] +from native import test +print(test()) + +[out] +6 +[out2] +6 + [case testCrossModuleInheritedAttrDefaultsSameGroup] # separate: [(["native.py"], "grp1"), (["other_a.py", "other_b.py"], "grp2")] # Regression: with the subclass (other_a) and base (other_b) in the same From 1f638cc026619f1be0b9815e812274986aaab898 Mon Sep 17 00:00:00 2001 From: geooo109 Date: Fri, 17 Jul 2026 15:17:02 +0300 Subject: [PATCH 2/4] refactor comment --- mypy/build.py | 23 ++++++++--------------- 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/mypy/build.py b/mypy/build.py index d5c78feb446e1..c342ed5786588 100644 --- a/mypy/build.py +++ b/mypy/build.py @@ -3480,21 +3480,14 @@ def finish_passes(self) -> None: if options.export_types: manager.all_types.update(self.type_map()) - # Indirect dependencies: modules this one never imports directly but - # whose interface changes must still invalidate it. Three sources: - # * module_refs: modules reached via attribute access or re-exports - # (mostly recorded during semantic analysis). - # * The type of every checked expression, resolved to the modules - # defining the type's components — for a class instance, its whole - # MRO (TypeIndirectionVisitor, inside patch_indirect_dependencies). - # * For mypyc-compiled modules: the ancestors of every class - # *defined* here. Generated C embeds each ancestor's method and - # attribute layout (vtables, object struct), so the defining module - # must be recompiled when any ancestor's interface changes. A bare - # `class B(A): pass` produces no expression types, so the - # sources above record nothing for it, and staleness propagation - # would stop at intermediate modules whose own interfaces are - # unchanged. + # Possible sources of indirect dependencies: + # * Symbols not directly imported in this module but accessed via an attribute + # or via a re-export (vast majority of these recorded in semantic analysis). + # * For each expression type we need to record definitions of type components + # since "meaning" of the type may be updated when definitions are updated. + # * For mypyc-compiled modules only: modules defining MRO ancestors of + # classes defined here, since the generated C embeds each ancestor's + # method/attribute layout. indirect_refs = self.tree.module_refs | self.type_checker().module_refs if self.options.mypyc: indirect_refs |= self.compiled_class_ancestor_refs() From eba2e259f9fe29beee297b63a6b1a665739628ba Mon Sep 17 00:00:00 2001 From: geooo109 Date: Mon, 20 Jul 2026 17:56:27 +0300 Subject: [PATCH 3/4] PR feedback 1 (p-sawicki) --- mypy/build.py | 47 ++++++++-------------------- mypy/plugin.py | 17 ++++++++++ mypyc/codegen/emitmodule.py | 24 +++++++++++++- mypyc/test-data/run-multimodule.test | 22 +++---------- 4 files changed, 57 insertions(+), 53 deletions(-) diff --git a/mypy/build.py b/mypy/build.py index c342ed5786588..96a67105c816c 100644 --- a/mypy/build.py +++ b/mypy/build.py @@ -118,7 +118,6 @@ MypyFile, OverloadedFuncDef, SymbolTable, - TypeInfo, ) from mypy.options import OPTIONS_AFFECTING_CACHE_NO_PLATFORM from mypy.partially_defined import PossiblyUndefinedVariableVisitor @@ -3480,20 +3479,21 @@ def finish_passes(self) -> None: if options.export_types: manager.all_types.update(self.type_map()) - # Possible sources of indirect dependencies: - # * Symbols not directly imported in this module but accessed via an attribute - # or via a re-export (vast majority of these recorded in semantic analysis). - # * For each expression type we need to record definitions of type components - # since "meaning" of the type may be updated when definitions are updated. - # * For mypyc-compiled modules only: modules defining MRO ancestors of - # classes defined here, since the generated C embeds each ancestor's - # method/attribute layout. - indirect_refs = self.tree.module_refs | self.type_checker().module_refs - if self.options.mypyc: - indirect_refs |= self.compiled_class_ancestor_refs() # We should always patch indirect dependencies, even in full (non-incremental) builds, # because the cache still may be written, and it must be correct. - self.patch_indirect_dependencies(indirect_refs, set(self.type_map().values())) + self.patch_indirect_dependencies( + # Three possible sources of indirect dependencies: + # * Symbols not directly imported in this module but accessed via an attribute + # or via a re-export (vast majority of these recorded in semantic analysis). + # * For each expression type we need to record definitions of type components + # since "meaning" of the type may be updated when definitions are updated. + # * Additional dependencies reported by plugins (e.g. mypyc, see + # MypycPlugin.get_additional_indirect_deps). + self.tree.module_refs + | self.type_checker().module_refs + | manager.plugin.get_additional_indirect_deps(self.tree), + set(self.type_map().values()), + ) if self.options.dump_inference_stats: dump_type_stats( @@ -3535,27 +3535,6 @@ def patch_indirect_dependencies(self, module_refs: set[str], types: set[Type]) - self.add_dependency(dep) self.priorities[dep] = PRI_INDIRECT - def compiled_class_ancestor_refs(self) -> set[str]: - """Modules defining MRO ancestors of classes defined in this module. - - Only used for mypyc-compiled modules: the generated C for a class - (vtable arrays, getter/setter tables, object struct) references - every inherited method/attribute of every ancestor, including - ancestors defined in modules this module does not import directly. - Recording them as indirect dependencies makes an ancestor's - interface change re-trigger type checking (and hence C regeneration) - of this module. Only top-level classes are scanned: mypyc rejects - nested class definitions. - """ - assert self.tree is not None - mods: set[str] = set() - for sym in self.tree.names.values(): - node = sym.node - if isinstance(node, TypeInfo) and node.module_name == self.id: - for ancestor in node.mro[1:]: - mods.add(ancestor.module_name) - return mods - def compute_fine_grained_deps(self) -> dict[str, set[str]]: assert self.tree is not None if self.id in ("builtins", "typing", "types", "sys", "_typeshed"): diff --git a/mypy/plugin.py b/mypy/plugin.py index 383b07af87c0d..eb5c2512732a0 100644 --- a/mypy/plugin.py +++ b/mypy/plugin.py @@ -587,6 +587,17 @@ def get_additional_deps(self, file: MypyFile) -> list[tuple[int, str, int]]: """ return [] + def get_additional_indirect_deps(self, file: MypyFile) -> set[str]: + """Customize indirect dependencies for a module. + + Unlike get_additional_deps(), this hook is called after the module + has been type checked, so analyzed information (such as class MROs) + is available. The returned module names are recorded as indirect + dependencies: a change to their interfaces will invalidate this + module's cache, but they are not treated as imports. + """ + return set() + def get_type_analyze_hook(self, fullname: str) -> Callable[[AnalyzeTypeContext], Type] | None: """Customize behaviour of the type analyzer for given full names. @@ -846,6 +857,12 @@ def get_additional_deps(self, file: MypyFile) -> list[tuple[int, str, int]]: deps.extend(plugin.get_additional_deps(file)) return deps + def get_additional_indirect_deps(self, file: MypyFile) -> set[str]: + deps: set[str] = set() + for plugin in self._plugins: + deps |= plugin.get_additional_indirect_deps(file) + return deps + def get_type_analyze_hook(self, fullname: str) -> Callable[[AnalyzeTypeContext], Type] | None: # Micro-optimization: Inline iteration over plugins for plugin in self._plugins: diff --git a/mypyc/codegen/emitmodule.py b/mypyc/codegen/emitmodule.py index 3b320b5321232..9130007e3f6ef 100644 --- a/mypyc/codegen/emitmodule.py +++ b/mypyc/codegen/emitmodule.py @@ -23,7 +23,7 @@ ) from mypy.errors import CompileError from mypy.fscache import FileSystemCache -from mypy.nodes import MypyFile +from mypy.nodes import MypyFile, TypeInfo from mypy.options import Options from mypy.plugin import Plugin, ReportConfigContext from mypy.util import hash_digest, json_dumps @@ -200,6 +200,28 @@ def get_additional_deps(self, file: MypyFile) -> list[tuple[int, str, int]]: # Report dependency on modules in the module's group return [(10, id, -1) for id in self.group_map.get(file.fullname, (None, []))[1]] + def get_additional_indirect_deps(self, file: MypyFile) -> set[str]: + """Modules defining MRO ancestors of classes defined in this module. + + The generated C for a class (vtable arrays, getter/setter tables, + object struct) references every inherited method/attribute of every + ancestor, including ancestors defined in modules this module does + not import directly. Recording them as indirect dependencies makes + an ancestor's interface change re-trigger type checking (and hence + C regeneration) of this module. Only top-level classes are scanned: + mypyc rejects nested class definitions. + """ + if file.fullname not in self.group_map: + return set() + + mods: set[str] = set() + for sym in file.names.values(): + node = sym.node + if isinstance(node, TypeInfo) and node.module_name == file.fullname: + for ancestor in node.mro[1:]: + mods.add(ancestor.module_name) + return mods + def parse_and_typecheck( sources: list[BuildSource], diff --git a/mypyc/test-data/run-multimodule.test b/mypyc/test-data/run-multimodule.test index b6c0ffb3a440e..672a726ae985c 100644 --- a/mypyc/test-data/run-multimodule.test +++ b/mypyc/test-data/run-multimodule.test @@ -1901,18 +1901,8 @@ from native import test test() [case testIncrementalCrossGroupInheritedMethodRemoved] -# Regression: under separate=True, a module whose only content is a subclass -# definition (`class Leaf(Mid): pass`) produces no expression types, so it -# recorded no dependency on the modules defining its transitive bases. When a -# Base method two hops up the inheritance chain was removed, staleness -# propagation stopped at other_mid (its own interface is unchanged) and -# other_leaf stayed fresh: its stale generated C still referenced the method -# in Leaf's vtable via exports_other_base.CPyDef_..., which no longer exists -# in the regenerated export table, and the build failed. -# compiled_class_ancestor_refs must record the MRO ancestors' modules as -# indirect deps so an ancestor's interface change recompiles the defining -# module too. (removed_method is declared after existing_method so the -# surviving call site's vtable slot is unaffected.) +# Regression: updating a transitive base class triggers recompilation of the +# subclass-defining module (mypy#21742). import other_leaf def test() -> int: @@ -1955,12 +1945,8 @@ print(test()) 6 [case testIncrementalCrossGroupInheritedMethodRemovedNonEmptyLeaf] -# Same regression as testIncrementalCrossGroupInheritedMethodRemoved, but the -# subclass body is NOT empty: a class constant and a method that never uses -# self. Neither produces an expression whose type reaches Leaf's MRO, so the -# pre-existing dependency sources still record nothing for other_leaf, while -# its generated vtable references the ancestors regardless of body shape. -# Guards against narrowing compiled_class_ancestor_refs to pass-only bodies. +# Regression: updating a transitive base class triggers recompilation of the +# module defining a subclass with a non-empty body (mypy#21742). import other_leaf def test() -> int: From 2308e6b7771d6fc8f88bc054680426fcb2b4ea7f Mon Sep 17 00:00:00 2001 From: geooo109 Date: Mon, 20 Jul 2026 18:03:25 +0300 Subject: [PATCH 4/4] refactor get_additional_indirect_deps comment --- mypy/plugin.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/mypy/plugin.py b/mypy/plugin.py index eb5c2512732a0..d0e5add71a5b5 100644 --- a/mypy/plugin.py +++ b/mypy/plugin.py @@ -590,11 +590,11 @@ def get_additional_deps(self, file: MypyFile) -> list[tuple[int, str, int]]: def get_additional_indirect_deps(self, file: MypyFile) -> set[str]: """Customize indirect dependencies for a module. - Unlike get_additional_deps(), this hook is called after the module - has been type checked, so analyzed information (such as class MROs) - is available. The returned module names are recorded as indirect - dependencies: a change to their interfaces will invalidate this - module's cache, but they are not treated as imports. + This hook is called after the module has been type checked, so + analyzed information (such as class MROs) is available. The + returned module names are recorded as indirect dependencies: + a change to their interfaces will invalidate this module's + cache, but they are not treated as imports. """ return set()