Repository: python/mypy
Pull request: Generalize class/static method and property alias support
Fixes https://github.com/python/mypy/issues/6700

This is another followup for the `checkmember` work. Currently, we only support non-instance method aliasing in few very specific cases. I am making this support general.


Problem statement:
Property, classmethod and staticmethod aliases not supported
MyPy does not support property aliases. Example:

```
$ cat a.py 
class A:
    @property
    def f(self) -> int:
        return 1
    g = f

x: int = A().f
y: int = A().g
$ mypy a.py 
a.py:8: error: Incompatible types in assignment (expression has type "Callable[[], int]", variable has type "int")
```

Hints from issue discussion:
Thanks for reporting!

Yeah, unfortunately properties are known to be often problematic. They were implemented via some special-casing before a more general descriptor support was added.
The problem is not only with properties, but with static and class methods too.
```
$ cat b.py
class A:
    @classmethod
    def f(cls) -> int:
        return 1
    g = f

x: int = A().f()
y: int = A().g()
$ mypy b.py
b.py:8: error: Invalid self argument "A" to attribute function "g" with type "Callable[[Type[A]], int]"
```
```
$ cat c.py
class A:
    @staticmethod
    def f() -> int:
        return 1
    g = f

x: int = A().f()
y: int = A().g()
$ mypy c.py
c.py:8: error: Attribute function "g" with type "Callable[[], int]" does not accept self argument
```
Hm, this is more serious than I though then, raising priority to high.
And apparently with callable properties on a `dataclass` (though the same errors are returned for a class with callable properties, which do not receive a `self` passed in when they are called):

```python
from typing import Callable
from dataclasses import dataclass

@dataclass
class Demo:
    call_this: Callable[[str], str]

def call(s: str) -> str:
    return s

demo = Demo(call_this=call)

demo.call_this("test")
```

When run through mypy, this gets:

```
demo.py:13: error: Invalid self argument "Demo" to attribute function "call_this" with type "Callable[[str], str]"
demo.py:13: error: Too many arguments
```
Currently, this issue forces rather awkward typing for `@classmethod`s, as we can't even *omit* the typing inside the class definition. AFAIK, the current only workarounds are:
 - Hint the alias as `typing.Callable[..., <return_type>]` inside the class definition
 - Bind the alias outside of the class definition (no need to provide a type-hint)

I'm not sure what is supposed to be a convincing way to type the alias; some attempts below:

```python
from __future__ import annotations

from typing import Callable
from types import MethodType


class A:
    @classmethod
    def _internal(cls) -> int:
        return 0

    public_1 = _internal                            # (bad; see `value_1`)
    public_2: classmethod[int] = _internal          # Incompatible types in assignment (expression has type "Callable[[Type[A]], int]", variable has type "classmethod[int]")
    public_3: Callable[[], int] = _internal         # Incompatible types in assignment (expression has type "Callable[[Type[A]], int]", variable has type "Callable[[], int]")
    public_4: Callable[[type[A]], int] = _internal  # (bad; see `value_4`)
    public_5: MethodType = _internal                # Incompatible types in assignment (expression has type "Callable[[Type[A]], int]", variable has type "MethodType")
    public_6: Callable[..., int] = _internal        # OK

public_7 = A._internal                              # OK


value_1: int = A.public_1()  # Too few arguments
value_2: int = A.public_2()  # (bad; see `public_2`)
value_3: int = A.public_3()  # (bad; see `public_3`)
value_4: int = A.public_4()  # Too few arguments
value_5: int = A.public_5()  # (bad; see `public_5`)
value_6: int = A.public_6()  # OK
value_7: int = public_7()    # OK
```

`public_4` / `value_4` is especially concerning, as it is contradictory; what `mypy` thinks is the correct type for the `public_4` method causes a `mypy` error when actually called.
Do you really require it to be an alias? Here's another workaround:

```python
class A:
    @classmethod
    def public_8(cls) -> int:
        return cls._internal()
```
Are there any updates on this issue?
I'm not sure if this is useful information, but I just want to note that the types (according to `reveal_type`) seem to only be incorrect:

<details>
<summary>• For properties: in instances but not the class itself.</summary>

```py
class Foo:
    @property
    def colour(self) -> int:
        return 42

    color = colour


reveal_type(Foo.colour)  # def (self: repro.Foo) -> builtins.int
reveal_type(Foo.color)  # def (self: repro.Foo) -> builtins.int

reveal_type(Foo().colour)  # builtins.int
reveal_type(Foo().color)  # def () -> builtins.int
```
</details>

<details>
<summary>• For classmethods: in the class but not instances.</summary>

```py
class Foo:
    @staticmethod
    def bar(cls) -> int:
        return 42

    life = bar


reveal_type(Foo.bar)  # def () -> builtins.int
reveal_type(Foo().bar)  # def () -> builtins.int

reveal_type(Foo.life)  # def (cls: type[repro.Foo]) -> builtins.int
reveal_type(Foo().life)  # def () -> builtins.int
```
</details>

<details>
<summary>• For staticmethods: nowhere, allegedly.</summary>

```py
class Foo:
    @staticmethod
    def bar() -> int:
        return 42

    life = bar


reveal_type(Foo.bar)  # def () -> builtins.int
reveal_type(Foo().bar)  # def () -> builtins.int

reveal_type(Foo.life)  # def () -> builtins.int
reveal_type(Foo().life)  # def () -> builtins.int
```
The last line still has a diagnostic of `Attribute function "life" with type "Callable[[], int]" does not accept self argument`.
</details>

Commit message: Add property support as well

Patch to review:
diff --git a/mypy/checker.py b/mypy/checker.py
index 0639340d30bb..eac7d794e415 100644
--- a/mypy/checker.py
+++ b/mypy/checker.py
@@ -4400,9 +4400,9 @@ def set_inferred_type(self, var: Var, lvalue: Lvalue, type: Type) -> None:
         refers to the variable (lvalue). If var is None, do nothing.
         """
         if var and not self.current_node_deferred:
-            # TODO: should we also set 'is_ready = True' here?
             var.type = type
             var.is_inferred = True
+            var.is_ready = True
             if var not in self.var_decl_frames:
                 # Used for the hack to improve optional type inference in conditionals
                 self.var_decl_frames[var] = {frame.id for frame in self.binder.frames}
@@ -4412,9 +4412,23 @@ def set_inferred_type(self, var: Var, lvalue: Lvalue, type: Type) -> None:
                     self.inferred_attribute_types[lvalue.def_var] = type
             self.store_type(lvalue, type)
             p_type = get_proper_type(type)
-            if isinstance(p_type, CallableType) and is_node_static(p_type.definition):
-                # TODO: handle aliases to class methods (similarly).
-                var.is_staticmethod = True
+            definition = None
+            if isinstance(p_type, CallableType):
+                definition = p_type.definition
+            elif isinstance(p_type, Overloaded):
+                # Randomly select first item, if items are different, there will
+                # be an error during semantic analysis.
+                definition = p_type.items[0].definition
+            if definition:
+                if is_node_static(definition):
+                    var.is_staticmethod = True
+                elif is_node_class(definition):
+                    var.is_classmethod = True
+                elif is_property(definition):
+                    var.is_property = True
+                    if isinstance(p_type, Overloaded):
+                        # TODO: in theory we can have a property with a deleter only.
+                        var.is_settable_property = True
 
     def set_inference_error_fallback_type(self, var: Var, lvalue: Lvalue, type: Type) -> None:
         """Store best known type for variable if type inference failed.
@@ -8531,15 +8545,21 @@ def visit_type_alias_type(self, t: TypeAliasType) -> Type:
         return t.copy_modified(args=[a.accept(self) for a in t.args])
 
 
+def is_node_class(node: Node | None) -> bool | None:
+    """Find out if a node describes a classmethod."""
+    if isinstance(node, FuncDef):
+        return node.is_class
+    if isinstance(node, Var):
+        return node.is_classmethod
+    return None
+
+
 def is_node_static(node: Node | None) -> bool | None:
     """Find out if a node describes a static function method."""
-
     if isinstance(node, FuncDef):
         return node.is_static
-
     if isinstance(node, Var):
         return node.is_staticmethod
-
     return None
 
 
@@ -8786,6 +8806,8 @@ def is_static(func: FuncBase | Decorator) -> bool:
 
 
 def is_property(defn: SymbolNode) -> bool:
+    if isinstance(defn, FuncDef):
+        return defn.is_property
     if isinstance(defn, Decorator):
         return defn.func.is_property
     if isinstance(defn, OverloadedFuncDef):
diff --git a/mypy/checkmember.py b/mypy/checkmember.py
index 1633eaf52983..75197d5dbf2a 100644
--- a/mypy/checkmember.py
+++ b/mypy/checkmember.py
@@ -371,8 +371,6 @@ def analyze_instance_member_access(
                     signature, mx.self_type, method.is_class, mx.context, name, mx.msg
                 )
                 signature = bind_self(signature, mx.self_type, is_classmethod=method.is_class)
-        # TODO: should we skip these steps for static methods as well?
-        # Since generic static methods should not be allowed.
         typ = map_instance_to_supertype(typ, method.info)
         member_type = expand_type_by_instance(signature, typ)
         freeze_all_type_vars(member_type)
@@ -1218,8 +1216,11 @@ def analyze_class_attribute_access(
             #     C[int].x -> int
             t = erase_typevars(expand_type_by_instance(t, isuper), {tv.id for tv in def_vars})
 
-        is_classmethod = (is_decorated and cast(Decorator, node.node).func.is_class) or (
-            isinstance(node.node, SYMBOL_FUNCBASE_TYPES) and node.node.is_class
+        is_classmethod = (
+            (is_decorated and cast(Decorator, node.node).func.is_class)
+            or (isinstance(node.node, SYMBOL_FUNCBASE_TYPES) and node.node.is_class)
+            or isinstance(node.node, Var)
+            and node.node.is_classmethod
         )
         is_staticmethod = (is_decorated and cast(Decorator, node.node).func.is_static) or (
             isinstance(node.node, SYMBOL_FUNCBASE_TYPES) and node.node.is_static
@@ -1231,7 +1232,12 @@ def analyze_class_attribute_access(
             is_trivial_self = node.node.func.is_trivial_self and not node.node.decorators
         elif isinstance(node.node, (FuncDef, OverloadedFuncDef)):
             is_trivial_self = node.node.is_trivial_self
-        if isinstance(t, FunctionLike) and is_classmethod and not is_trivial_self:
+        if (
+            isinstance(t, FunctionLike)
+            and is_classmethod
+            and not is_trivial_self
+            and not t.bound()
+        ):
             t = check_self_arg(t, mx.self_type, False, mx.context, name, mx.msg)
         t = add_class_tvars(
             t,
@@ -1400,7 +1406,7 @@ class B(A[str]): pass
         tvars = original_vars if original_vars is not None else []
         if not mx.preserve_type_var_ids:
             t = freshen_all_functions_type_vars(t)
-        if is_classmethod:
+        if is_classmethod and not t.is_bound:
             if is_trivial_self:
                 t = bind_self_fast(t, mx.self_type)
             else:
diff --git a/test-data/unit/check-callable.test b/test-data/unit/check-callable.test
index 39e6c4fa3ff1..64a78bee40ea 100644
--- a/test-data/unit/check-callable.test
+++ b/test-data/unit/check-callable.test
@@ -587,6 +587,53 @@ class C(B):
 class B: ...
 [builtins fixtures/classmethod.pyi]
 
+[case testClassMethodAliasInClass]
+from typing import overload
+
+class C:
+    @classmethod
+    def foo(cls) -> int: ...
+
+    bar = foo
+
+    @overload
+    @classmethod
+    def foo2(cls, x: int) -> int: ...
+    @overload
+    @classmethod
+    def foo2(cls, x: str) -> str: ...
+    @classmethod
+    def foo2(cls, x):
+        ...
+
+    bar2 = foo2
+
+reveal_type(C.bar)  # N: Revealed type is "def () -> builtins.int"
+reveal_type(C().bar)  # N: Revealed type is "def () -> builtins.int"
+reveal_type(C.bar2)  # N: Revealed type is "Overload(def (x: builtins.int) -> builtins.int, def (x: builtins.str) -> builtins.str)"
+reveal_type(C().bar2)  # N: Revealed type is "Overload(def (x: builtins.int) -> builtins.int, def (x: builtins.str) -> builtins.str)"
+[builtins fixtures/classmethod.pyi]
+
+[case testPropertyAliasInClassBody]
+class A:
+    @property
+    def f(self) -> int: ...
+
+    g = f
+
+    @property
+    def f2(self) -> int: ...
+    @f2.setter
+    def f2(self, val: int) -> None: ...
+
+    g2 = f2
+
+reveal_type(A().g)  # N: Revealed type is "builtins.int"
+reveal_type(A().g2)  # N: Revealed type is "builtins.int"
+A().g2 = 1
+A().g2 = "no"  # E: Incompatible types in assignment (expression has type "str", variable has type "int")
+[builtins fixtures/property.pyi]
+
 [case testCallableUnionCallback]
 from typing import Union, Callable, TypeVar
 


Code review comments:
diff_hunk: @@ -8531,15 +8545,21 @@ def visit_type_alias_type(self, t: TypeAliasType) -> Type:
         return t.copy_modified(args=[a.accept(self) for a in t.args])
 
 
+def is_node_class(node: Node | None) -> bool | None:
path: mypy/checker.py
text: @user1:
Nit: Maybe rename to `is_classmethod_node` or similar? My initial thought what that this checks if the target is a "node class".

diff_hunk: @@ -587,6 +587,53 @@ class C(B):
 class B: ...
 [builtins fixtures/classmethod.pyi]
 
+[case testClassMethodAliasInClass]
+from typing import overload
+
+class C:
+    @classmethod
+    def foo(cls) -> int: ...
+
+    bar = foo
+
+    @overload
+    @classmethod
+    def foo2(cls, x: int) -> int: ...
+    @overload
+    @classmethod
+    def foo2(cls, x: str) -> str: ...
+    @classmethod
+    def foo2(cls, x):
+        ...
+
+    bar2 = foo2
+
+reveal_type(C.bar)  # N: Revealed type is "def () -> builtins.int"
+reveal_type(C().bar)  # N: Revealed type is "def () -> builtins.int"
+reveal_type(C.bar2)  # N: Revealed type is "Overload(def (x: builtins.int) -> builtins.int, def (x: builtins.str) -> builtins.str)"
+reveal_type(C().bar2)  # N: Revealed type is "Overload(def (x: builtins.int) -> builtins.int, def (x: builtins.str) -> builtins.str)"
+[builtins fixtures/classmethod.pyi]
+
+[case testPropertyAliasInClassBody]
+class A:
+    @property
+    def f(self) -> int: ...
+
+    g = f
+
+    @property
+    def f2(self) -> int: ...
+    @f2.setter
+    def f2(self, val: int) -> None: ...
+
+    g2 = f2
+
+reveal_type(A().g)  # N: Revealed type is "builtins.int"
+reveal_type(A().g2)  # N: Revealed type is "builtins.int"
+A().g2 = 1
path: test-data/unit/check-callable.test
text: @user1:
Test assignment to `A().g` (should generate an error).