diff --git a/mypy/build.py b/mypy/build.py index a03a6eb8972c..c342ed578658 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,20 @@ 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( - # 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 +3535,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 4cf391d312df..b6c0ffb3a440 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