From c3b4e51622f1b1c38ed5250fda83f469eb166617 Mon Sep 17 00:00:00 2001 From: geooo109 Date: Fri, 17 Jul 2026 12:40:38 +0300 Subject: [PATCH 1/2] [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/2] 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()