[mypyc] Fix crash on a property with another decorator (#22157)
Fixes mypyc/mypyc#1238.
mypy only rejects decorators on top of `@property`, so it accepts a
property like this:
```python
class C:
@property
@functools.lru_cache(maxsize=None)
def p(self) -> int: ...
```
In a native class, `prepare_method_def()` left the getter out of
`method_decls`, like any decorated method, but still set
`is_prop_getter` and `property_types`. Reading `c.p` in compiled code
then generated a native `GetAttr` with no getter behind it, and the
compiler crashed in `emit_method_call` with `KeyError: "'C' has no
attribute 'p'"`. If nothing compiled read the property, the module
compiled, but the method was missing from the type's method table, so
the decorator was called with the property's `getset_descriptor` and its
result replaced the property. With `lru_cache` the import failed
(`TypeError: the first argument must be callable`).
A getter with other decorators is now handled like other decorated
methods. `handle_ext_method()` applies the decorators, wraps the result
with `builtins.property` and sets it on the class. Non-native classes
already did this. The property isn't a native one, so compiled code
reads it with a generic attribute lookup and coerces the result to the
type mypy inferred, which is the decorated function's return type.
`@functools.cached_property` over another decorator takes the same path.
A setter for such a property would need the native setter exposed as a
Python callable, so for now it's reported as an error instead of
crashing:
```
error: Property setter not supported when the getter has other decorators
```
`@property` over `@abstractmethod`, `@override` or `@final` isn't
affected, since mypy removes those from the decorator list.
A decorated property that overrides a native property still fails like a
decorated method that overrides a native method (mypyc/mypyc#767). This
PR doesn't change that.diff --git a/mypyc/irbuild/function.py b/mypyc/irbuild/function.py
index f242034..0f6c736 100644
--- a/mypyc/irbuild/function.py
+++ b/mypyc/irbuild/function.py
@@ -520,6 +520,12 @@
elif func_ir.decl.kind == FUNC_STATICMETHOD:
stat_meth = builder.load_module_attr_by_fullname("builtins.staticmethod", fdef.line)
decorated_func = builder.py_call(stat_meth, [decorated_func], fdef.line)
+ elif fdef.is_property:
+ # Mypy also removes @property, and only accepts other decorators below it.
+ # The decorated getter can't be called natively, so this becomes a regular
+ # Python property instead of a native one.
+ prop = builder.load_module_attr_by_fullname("builtins.property", fdef.line)
+ decorated_func = builder.py_call(prop, [decorated_func], fdef.line)
# Set the callable object representing the decorated method as an attribute of the
# extension class.
@@ -527,7 +533,7 @@
py_setattr_op, [typ, builder.load_str(name), decorated_func], fdef.line
)
- if fdef.is_property:
+ elif fdef.is_property:
# If there is a property setter, it will be processed after the getter,
# We populate the optional setter field with none for now.
assert name not in class_ir.properties
@@ -535,9 +541,14 @@
elif fdef in builder.prop_setters:
# The respective property getter must have been processed already
- assert name in class_ir.properties
- getter_ir, _ = class_ir.properties[name]
- class_ir.properties[name] = (getter_ir, func_ir)
+ if name in class_ir.properties:
+ getter_ir, _ = class_ir.properties[name]
+ class_ir.properties[name] = (getter_ir, func_ir)
+ else:
+ # A getter with other decorators isn't a native property (see above).
+ builder.error(
+ "Property setter not supported when the getter has other decorators", fdef.line
+ )
class_ir.methods[func_ir.decl.name] = func_ir
diff --git a/mypyc/irbuild/prepare.py b/mypyc/irbuild/prepare.py
index 318f94a..301e8cf 100644
--- a/mypyc/irbuild/prepare.py
+++ b/mypyc/irbuild/prepare.py
@@ -347,7 +347,9 @@
decl.is_decorated_override = True
ir.method_decls[node.name] = decl
- if node.func.is_property:
+ # A getter with other decorators isn't a native property. It's set up at runtime
+ # like other decorated methods, and accessed using generic attribute operations.
+ if node.func.is_property and not node.decorators:
assert node.func.type, f"Expected return type annotation for property '{node.name}'"
decl.is_prop_getter = True
ir.property_types[node.name] = decl.sig.ret_type
diff --git a/mypyc/test-data/irbuild-classes.test b/mypyc/test-data/irbuild-classes.test
index 1d2343c..43e9c2b 100644
--- a/mypyc/test-data/irbuild-classes.test
+++ b/mypyc/test-data/irbuild-classes.test
@@ -1493,6 +1493,60 @@
r2 = r1.x
return r2
+[case testPropertyWithOtherDecorator]
+from typing import TypeVar
+
+T = TypeVar("T")
+
+def dec(f: T) -> T:
+ return f
+
+class C:
+ @property
+ @dec
+ def p(self) -> int:
+ return 1
+
+def get(c: C) -> int:
+ return c.p
+[out]
+def dec(f):
+ f :: object
+L0:
+ return f
+def C.p(self):
+ self :: __main__.C
+L0:
+ return 2
+def get(c):
+ c :: __main__.C
+ r0 :: str
+ r1 :: object
+ r2 :: int
+L0:
+ r0 = 'p'
+ r1 = CPyObject_GetAttr(c, r0)
+ r2 = unbox(int, r1)
+ return r2
+
+[case testPropertySetterWithDecoratedGetter]
+from typing import TypeVar
+
+T = TypeVar("T")
+
+def dec(f: T) -> T:
+ return f
+
+class C:
+ @property
+ @dec
+ def p(self) -> int:
+ return 1
+
+ @p.setter
+ def p(self, value: int) -> None: # E: Property setter not supported when the getter has other decorators
+ pass
+
[case testPropertyReturningCallable]
from typing import Callable
diff --git a/mypyc/test-data/run-classes.test b/mypyc/test-data/run-classes.test
index ebeb04c..19a1804 100644
--- a/mypyc/test-data/run-classes.test
+++ b/mypyc/test-data/run-classes.test
@@ -2739,6 +2739,182 @@
assert AttrOverride().f(-6) == 6
assert call_trait_f(UsesTrait(), 1) == 0
+[case testPropertyWithOtherDecorator]
+from functools import cache, cached_property
+from typing import Any, Callable
+
+from mypy_extensions import mypyc_attr, trait
+from testutil import assertRaises
+
+def double(f: Callable[[Any], int]) -> Callable[[Any], int]:
+ def wrapper(self: Any) -> int:
+ return f(self) * 2
+
+ return wrapper
+
+def to_str(f: Callable[[Any], int]) -> Callable[[Any], str]:
+ def wrapper(self: Any) -> str:
+ return f"<{f(self)}>"
+
+ return wrapper
+
+class C:
+ def __init__(self) -> None:
+ self.calls = 0
+
+ @property
+ @double
+ def p(self) -> int:
+ self.calls += 1
+ return 21
+
+ @property
+ @to_str
+ def s(self) -> int:
+ return 5
+
+ @property
+ @to_str
+ @double
+ def two(self) -> int:
+ return 3
+
+ @property
+ @cache
+ def cached(self) -> int:
+ self.calls += 1
+ return 7
+
+ @cached_property
+ @double
+ def cp(self) -> int:
+ return 4
+
+ def via_self(self) -> int:
+ return self.p + 1
+
+class Base:
+ @property
+ @double
+ def x(self) -> int:
+ return 1
+
+class NativeOverride(Base):
+ @property
+ def x(self) -> int:
+ return 5
+
+class DecoratedOverride(Base):
+ @property
+ @double
+ def x(self) -> int:
+ return 10
+
+class AttrOverride(Base):
+ x: int = 7
+
+@trait
+class T:
+ @property
+ @double
+ def t(self) -> int:
+ return 4
+
+class UsesTrait(T):
+ pass
+
+@mypyc_attr(allow_interpreted_subclasses=True)
+class Open:
+ @property
+ @double
+ def o(self) -> int:
+ return 8
+
+@mypyc_attr(native_class=False)
+class NonNative:
+ def __init__(self) -> None:
+ self.calls = 0
+
+ @property
+ @double
+ def p(self) -> int:
+ self.calls += 1
+ return 3
+
+ @property
+ @to_str
+ def s(self) -> int:
+ return 5
+
+ @property
+ @cache
+ def cached(self) -> int:
+ self.calls += 1
+ return 7
+
+def get_x(b: Base) -> int:
+ return b.x
+
+def get_t(t: T) -> int:
+ return t.t
+
+def get_o(o: Open) -> int:
+ return o.o
+
+def test_property_with_other_decorator() -> None:
+ c = C()
+ assert c.p == 42
+ assert c.p + 1 == 43
+ assert c.calls == 2
+ assert c.via_self() == 43
+ assert c.s == "<5>"
+ assert c.two == "<6>"
+ a: Any = c
+ assert a.p == 42
+ assert a.s == "<5>"
+ assert isinstance(getattr(C, "p"), property)
+ with assertRaises(AttributeError):
+ a.p = 1
+
+def test_property_with_caching_decorator() -> None:
+ c = C()
+ assert c.cached == 7
+ assert c.cached == 7
+ assert c.calls == 1
+ assert C().cached == 7
+ assert c.cp == 8
+
+def test_property_with_other_decorator_inherited() -> None:
+ assert get_x(Base()) == 2
+ assert get_x(NativeOverride()) == 5
+ assert NativeOverride().x == 5
+ assert get_x(DecoratedOverride()) == 20
+ assert DecoratedOverride().x == 20
+ assert get_x(AttrOverride()) == 7
+ assert AttrOverride().x == 7
+ assert get_t(UsesTrait()) == 8
+ assert UsesTrait().t == 8
+
+def test_property_with_other_decorator_interpreted_subclass() -> None:
+ assert get_o(Open()) == 16
+ make_type: Any = type
+ sub = make_type("Sub", (Open,), {"o": 3})
+ assert get_o(sub()) == 3
+
+def test_property_with_other_decorator_non_native() -> None:
+ n = NonNative()
+ assert n.p == 6
+ assert n.calls == 1
+ assert n.s == "<5>"
+ assert n.cached == 7
+ assert n.cached == 7
+ assert n.calls == 2
+ a: Any = n
+ assert a.p == 6
+ assert isinstance(getattr(NonNative, "p"), property)
+ with assertRaises(AttributeError):
+ a.p = 1
+
[case testPropertySetters]
from mypy_extensions import trait