From e799bd11ba4e705cdd637a388d03d06c07dbb4ad Mon Sep 17 00:00:00 2001 From: Ryan Heard Date: Wed, 7 Oct 2026 17:45:16 -0400 Subject: [PATCH] [mypyc] Recompile dependents when a class's native_class or @trait changes --- mypyc/codegen/emitmodule.py | 48 ++++++---- mypyc/test-data/run-multimodule.test | 128 +++++++++++++++++++++++++++ 2 files changed, 161 insertions(+), 15 deletions(-) diff --git a/mypyc/codegen/emitmodule.py b/mypyc/codegen/emitmodule.py index 38632b400458..6ebc74a96fb8 100644 --- a/mypyc/codegen/emitmodule.py +++ b/mypyc/codegen/emitmodule.py @@ -82,7 +82,7 @@ from mypyc.irbuild.main import build_ir from mypyc.irbuild.mapper import Mapper from mypyc.irbuild.prepare import load_type_map -from mypyc.irbuild.util import is_decorated_class +from mypyc.irbuild.util import is_decorated_class, is_extension_class, is_trait from mypyc.namegen import NameGenerator, exported_name from mypyc.options import CompilerOptions from mypyc.transform.borrow_generator_attrs import borrow_generator_attrs @@ -140,10 +140,12 @@ class MypycPlugin(Plugin): * If the IR metadata is missing or stale or any of the generated C source files associated missing or stale, then we need to recompile the module so we mark it as stale. - * Mypy ignores class decorators, but a decorator that can replace - a class changes how other modules refer to the class (see - ClassIR.is_decorated), so we make such decorated classes part - of the module's interface. + * Mypy doesn't record class decorators, but some of them change how + other modules use a class: @mypyc_attr(native_class=False) and + @trait decide what kind of class mypyc generates, and a decorator + that can replace the class changes what its name refers to (see + ClassIR.is_decorated). So we make the kinds of a module's classes + part of its interface. """ def __init__( @@ -161,9 +163,9 @@ def __init__( def report_config_data( self, ctx: ReportConfigContext - ) -> tuple[str | None, list[str], list[str]] | None: + ) -> tuple[str | None, list[str], list[tuple[str, bool, bool, bool]]] | None: # The config data we report is the group map entry for the module, - # followed by the names of its decorated classes. + # followed by the kinds of its classes (see class_kinds). # If the data is being used to check validity, we do additional checks # that the IR cache exists and matches the metadata cache and all # output source files exist and are up to date. @@ -176,7 +178,7 @@ def report_config_data( # If we aren't doing validity checks, just return the cache data if not is_check: assert self._modules is not None - return (*self.group_map[id], decorated_class_names(self._modules[id])) + return (*self.group_map[id], class_kinds(self._modules[id], self.options)) # Load the metadata and IR cache meta_path, _, _ = get_cache_names(id, path, self.options) @@ -210,10 +212,14 @@ def report_config_data( if hash != real_hash: return None - # The module hasn't been parsed yet, so take the decorated classes - # from the cached IR. It was built from the source the cache is for. - decorated = sorted(c["name"] for c in ir_data["ir"]["classes"] if c["is_decorated"]) - return (*self.group_map[id], decorated) + # The module hasn't been parsed yet, so take the class kinds from the + # cached IR. It was built from the source the cache is for. + kinds = sorted( + (c["name"], c["is_ext_class"], c["is_trait"], c["is_decorated"]) + for c in ir_data["ir"]["classes"] + if not c["is_generated"] + ) + return (*self.group_map[id], kinds) def get_additional_deps(self, file: MypyFile) -> list[tuple[int, str, int]]: # Report dependency on modules in the module's group @@ -242,10 +248,22 @@ def get_additional_indirect_deps(self, file: MypyFile) -> set[str]: return mods -def decorated_class_names(tree: MypyFile) -> list[str]: - """Names of the classes that build_type_map sets ClassIR.is_decorated for.""" +def class_kinds(tree: MypyFile, options: Options) -> list[tuple[str, bool, bool, bool]]: + """Return (name, is_ext_class, is_trait, is_decorated) for each class in a module. + + These match the ClassIR flags that build_type_map sets. + """ + # Any errors are reported when the module is compiled. + errors = Errors(options) return sorted( - node.name for node in tree.defs if isinstance(node, ClassDef) and is_decorated_class(node) + ( + node.name, + is_extension_class(tree.path, node, errors), + is_trait(node), + is_decorated_class(node), + ) + for node in tree.defs + if isinstance(node, ClassDef) ) diff --git a/mypyc/test-data/run-multimodule.test b/mypyc/test-data/run-multimodule.test index 557f364cfa43..c5a614c95044 100644 --- a/mypyc/test-data/run-multimodule.test +++ b/mypyc/test-data/run-multimodule.test @@ -1644,6 +1644,134 @@ import native NT(x=4) {'x': 5} +[case testIncrementalCompilationTogglingNonExtClass] +# Mypy ignores mypyc_attr, so making C a non-native class (or a native class +# again) doesn't change the interface of other.py. native.py must still be +# recompiled, since it constructs C and accesses its attributes differently. +# In step 3 other.py is unchanged, so it must stay fresh. +from other import C + +def make(x: int) -> C: + return C(x) + +def get(c: C) -> int: + return c.x + +[file other.py] +from mypy_extensions import mypyc_attr + +class C: + def __init__(self, x: int) -> None: + self.x = x + +[file other.py.2] +from mypy_extensions import mypyc_attr + +@mypyc_attr(native_class=False) +class C: + def __init__(self, x: int) -> None: + self.x = x + +[file native.py.3] +from other import C + +def make(x: int) -> C: + return C(x + 1) + +def get(c: C) -> int: + return c.x + +[file other.py.4] +from mypy_extensions import mypyc_attr + +class C: + def __init__(self, x: int) -> None: + self.x = x + +[file driver.py] +from native import get, make + +c = make(3) +print(get(c), hasattr(c, "__dict__")) +[out] +3 False +[out2] +3 True +[out3] +4 True +[out4] +4 False + +[rechecked native, other] +[rechecked2 native] +[rechecked3 native, other] + +[case testIncrementalCompilationTogglingTrait] +# Mypy also ignores @trait. Making T a trait changes how other.py calls m(), +# so native.py must be recompiled to give D a vtable that matches. +# In step 3 other.py is unchanged, so it must stay fresh. +from other import T + +class D(T): + def m(self) -> int: + return 2 + +[file other.py] +from mypy_extensions import trait + +class T: + def m(self) -> int: + return 1 + +def call(t: T) -> int: + return t.m() + +[file other.py.2] +from mypy_extensions import trait + +@trait +class T: + def m(self) -> int: + return 1 + +def call(t: T) -> int: + return t.m() + +[file native.py.3] +from other import T + +class D(T): + def m(self) -> int: + return 3 + +[file other.py.4] +from mypy_extensions import trait + +class T: + def m(self) -> int: + return 1 + +def call(t: T) -> int: + return t.m() + +[file driver.py] +from native import D +from other import call + +print(call(D())) +[out] +2 +[out2] +2 +[out3] +3 +[out4] +3 + +[rechecked native, other] +[rechecked2 native] +[rechecked3 native, other] + [case testExtraLibRtSourceFileDep] table_list = list(range(256)) table_list[ord('A')] = ord('B')