Skip to content

[mypyc] Fix class decorators that don't return a class - #22128

Open
rheard wants to merge 2 commits into
python:masterfrom
rheard:fix-mypyc-1229
Open

rheard wants to merge 2 commits into
python:masterfrom
rheard:fix-mypyc-1229

Conversation

@rheard

@rheard rheard commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

Fixes mypyc/mypyc#1229.

A class decorator can replace a class with an object that isn't a class. functools.cache is the common case, as is a singleton decorator that returns a function. Such a class compiles as a non-native class, which can use arbitrary class decorators, but every method call and every function returning the class failed with X object expected; got X. Compiled code stored the decorated object, such as a functools._lru_cache_wrapper, in the static that type checks, isinstance() and super() use as the class.

Changes:

  • The type object static now gets the decorated object only if it's a class, so a decorator that returns a new class (like six.add_metaclass) works as before. Otherwise it gets the undecorated class, which is what instances still are. The module attribute is still the decorated object.
  • References to the name of a class with decorators other than the ones native classes support load it from the module globals instead of the static, so Group(3) still goes through the cache, as in Python. A new ClassIR.is_decorated flag tracks this, since a class's decorators aren't available when its module comes from the incremental cache. Classes whose decorators are all ones native classes support (like @dataclass and @final) are unchanged, since those return a class.

One difference from Python: isinstance(x, Group) keeps the fast check against the undecorated class, where Python raises TypeError because Group isn't a class. mypy treats Group as a class anyway, so this seemed better than a slower generic isinstance() for every decorated class, but I can switch it to the generic call if we'd rather match the error.

@p-sawicki

Copy link
Copy Markdown
Collaborator

it looks like with incremental compilation adding a decorator does not change the interface of the class and thus doesn't force recompilation of dependent modules. codex came up with this test case:

[case testIncrementalCompilationAddingNonClassDecorator]
from typing import Any, cast
from other import C

def same_instance() -> bool:
    cls = cast(Any, C)
    return cls() is cls()

[file other.py]
from functools import cache
from mypy_extensions import mypyc_attr

@mypyc_attr(native_class=False)
class C:
    pass

[file other.py.2]
from functools import cache
from mypy_extensions import mypyc_attr

@cache
@mypyc_attr(native_class=False)
class C:
    pass

[file driver.py]
from native import same_instance
from other import C

print(same_instance(), C() is C())
[out]
False False
[out2]
True True

we might need to also return which classes are decorated from https://github.com/python/mypy/blob/master/mypyc/codegen/emitmodule.py#L157 so that removing/adding a non-class decorator changes the interface.

@rheard

rheard commented Oct 7, 2026

Copy link
Copy Markdown
Contributor Author

@p-sawicki I think codex is correct on this one; I wasn't too familiar with the incremental compilation feature.

I've gone ahead and added your test with your suggested fix. Thanks!

Also worth noting: adding @mypyc_attr(native_class=False) to a native class between builds has the same problem on master. Fixing that would mean reporting whether each class is native in the same way, so I'd rather leave it for a separate PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Class decorated with functools.cache fails with "X object expected; got X"

2 participants