[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