Skip to content

Commit 342e85c

Browse files
committed
[ty] Callables as descriptors
1 parent 69f9182 commit 342e85c

File tree

1 file changed

+137
-0
lines changed

1 file changed

+137
-0
lines changed
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
# Callables as descriptors?
2+
3+
<!-- blacken-docs:off -->
4+
5+
```toml
6+
[environment]
7+
python-version = "3.14"
8+
```
9+
10+
## Introduction
11+
12+
Some common callable objects (functions, lambdas) are also bound-method descriptors. That is, they
13+
have a `__get__` method which returns a bound-method object that binds the receiver instance to the
14+
first argument (and thus the bound-method object has a different signature, lacking the first
15+
argument):
16+
17+
```py
18+
from ty_extensions import CallableTypeOf
19+
from typing import Callable
20+
21+
class C1:
22+
def method(self: C1, x: int) -> str:
23+
return str(x)
24+
25+
def _(
26+
accessed_on_class: CallableTypeOf[C1.method],
27+
accessed_on_instance: CallableTypeOf[C1().method],
28+
):
29+
reveal_type(accessed_on_class) # revealed: (self: C1, x: int) -> str
30+
reveal_type(accessed_on_instance) # revealed: (x: int) -> str
31+
```
32+
33+
Other callable objects (`staticmethod` objects, instances of classes with a `__call__` method but no
34+
dedicated `__get__` method) are *not* bound-method descriptors. If accessed as class attributes via
35+
an instance, they are simply themselves:
36+
37+
```py
38+
class NonDescriptorCallable2:
39+
def __call__(self, c2: C2, x: int) -> str:
40+
return str(x)
41+
42+
class C2:
43+
non_descriptor_callable: NonDescriptorCallable2 = NonDescriptorCallable2()
44+
45+
def _(
46+
accessed_on_class: CallableTypeOf[C2.non_descriptor_callable],
47+
accessed_on_instance: CallableTypeOf[C2().non_descriptor_callable],
48+
):
49+
reveal_type(accessed_on_class) # revealed: (c2: C2, x: int) -> str
50+
reveal_type(accessed_on_instance) # revealed: (c2: C2, x: int) -> str
51+
```
52+
53+
Both kinds of objects can inhabit the same `Callable` type:
54+
55+
```py
56+
class NonDescriptorCallable3:
57+
def __call__(self, c3: C3, x: int) -> str:
58+
return str(x)
59+
60+
class C3:
61+
def method(self: C3, x: int) -> str:
62+
return str(x)
63+
64+
non_descriptor_callable: NonDescriptorCallable3 = NonDescriptorCallable3()
65+
66+
callable_m: Callable[[C3, int], str] = method
67+
callable_n: Callable[[C3, int], str] = non_descriptor_callable
68+
```
69+
70+
However, when they are accessed on instances of `C3`, they have different signatures:
71+
72+
```py
73+
def _(
74+
method_accessed_on_instance: CallableTypeOf[C3().method],
75+
callable_accessed_on_instance: CallableTypeOf[C3().non_descriptor_callable],
76+
):
77+
reveal_type(method_accessed_on_instance) # revealed: (x: int) -> str
78+
reveal_type(callable_accessed_on_instance) # revealed: (c3: C3, x: int) -> str
79+
```
80+
81+
This leaves the question how the `callable_m` and `callable_n` attributes should be treated when
82+
accessed on instances of `C3`. If we treat `Callable` as being equivalent to a protocol that defines
83+
a `__call__` method (and no `__get__` method), then they should show no bound-method behavior. This
84+
is what we currently do:
85+
86+
```py
87+
reveal_type(C3().callable_m) # revealed: (C3, int, /) -> str
88+
reveal_type(C3().callable_n) # revealed: (C3, int, /) -> str
89+
```
90+
91+
However, this leads to unsoundness: `C3().callable_m` is actually `C3.method` which *is* a
92+
bound-method descriptor. We currently allow the following call, which will fail at runtime:
93+
94+
```py
95+
C3().callable_m(C3(), 1) # runtime error! ("takes 2 positional arguments but 3 were given")
96+
```
97+
98+
If we were to treat `Callable`s as bound-method descriptors, then the signatures of `callable_m` and
99+
`callable_n` when accessed on instances would bind the `self` argument:
100+
101+
- `C3().callable_m`: `(x: int) -> str`
102+
- `C3().callable_n`: `(x: int) -> str`
103+
104+
This would be equally unsound, because now we would allow a call to `C3().callable_n(1)` which would
105+
also fail at runtime.
106+
107+
There is no perfect solution here, but we can use some heuristics to improve the situation for
108+
certain use cases (at the cost of purity and simplicity).
109+
110+
## Use case: Decorating a method with a `Callable`-typed decorator
111+
112+
A commonly used pattern in the ecosystem is to use a `Callable`-typed decorator on a method with the
113+
intention that it shouldn't influence the method's descriptor behavior. For example:
114+
115+
```py
116+
from typing import Callable
117+
118+
# TODO: this could use a generic signature, but we don't support
119+
# `ParamSpec` and solving of typevars inside `Callable` types.
120+
def memoize(f: Callable[[C, int], str]) -> Callable[[C, int], str]:
121+
raise NotImplementedError
122+
123+
class C:
124+
def method(self, x: int) -> str:
125+
return str(x)
126+
127+
@memoize
128+
def method_decorated(self, x: int) -> str:
129+
return str(x)
130+
131+
C().method(1)
132+
133+
# TODO: We shouldn't issue any errors here
134+
# error: [missing-argument]
135+
# error: [invalid-argument-type]
136+
C().method_decorated(1)
137+
```

0 commit comments

Comments
 (0)