diff --git a/mypy/build.py b/mypy/build.py index a03a6eb8972c..96a67105c816 100644 --- a/mypy/build.py +++ b/mypy/build.py @@ -3482,12 +3482,16 @@ def finish_passes(self) -> None: # 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: + # 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. - self.tree.module_refs | self.type_checker().module_refs, + # * 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()), ) diff --git a/mypy/plugin.py b/mypy/plugin.py index 383b07af87c0..d0e5add71a5b 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. + + 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 3b320b532123..9130007e3f6e 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 4cf391d312df..672a726ae985 100644 --- a/mypyc/test-data/run-multimodule.test +++ b/mypyc/test-data/run-multimodule.test @@ -1900,6 +1900,96 @@ def _force_recompile() -> int: from native import test test() +[case testIncrementalCrossGroupInheritedMethodRemoved] +# Regression: updating a transitive base class triggers recompilation of the +# subclass-defining module (mypy#21742). +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] +# 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: + 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