Skip to content

Commit bf21fb5

Browse files
committed
More explanation
1 parent b0dabe4 commit bf21fb5

File tree

2 files changed

+56
-11
lines changed

2 files changed

+56
-11
lines changed

crates/ty_python_semantic/resources/mdtest/call/callables_as_descriptors.md

Lines changed: 53 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
# Callables as descriptors?
22

3-
<!-- blacken-docs:off -->
4-
53
```toml
64
[environment]
75
python-version = "3.14"
@@ -11,8 +9,8 @@ python-version = "3.14"
119

1210
Some common callable objects (functions, lambdas) are also bound-method descriptors. That is, they
1311
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):
12+
first argument. The bound-method object therefore has a different signature, lacking the first
13+
argument:
1614

1715
```py
1816
from ty_extensions import CallableTypeOf
@@ -26,8 +24,8 @@ def _(
2624
accessed_on_class: CallableTypeOf[C1.method],
2725
accessed_on_instance: CallableTypeOf[C1().method],
2826
):
29-
reveal_type(accessed_on_class) # revealed: (self: C1, x: int) -> str
30-
reveal_type(accessed_on_instance) # revealed: (x: int) -> str
27+
reveal_type(accessed_on_class) # revealed: (self: C1, x: int) -> str
28+
reveal_type(accessed_on_instance) # revealed: (x: int) -> str
3129
```
3230

3331
Other callable objects (`staticmethod` objects, instances of classes with a `__call__` method but no
@@ -46,7 +44,7 @@ def _(
4644
accessed_on_class: CallableTypeOf[C2.non_descriptor_callable],
4745
accessed_on_instance: CallableTypeOf[C2().non_descriptor_callable],
4846
):
49-
reveal_type(accessed_on_class) # revealed: (c2: C2, x: int) -> str
47+
reveal_type(accessed_on_class) # revealed: (c2: C2, x: int) -> str
5048
reveal_type(accessed_on_instance) # revealed: (c2: C2, x: int) -> str
5149
```
5250

@@ -60,7 +58,6 @@ class NonDescriptorCallable3:
6058
class C3:
6159
def method(self: C3, x: int) -> str:
6260
return str(x)
63-
6461
non_descriptor_callable: NonDescriptorCallable3 = NonDescriptorCallable3()
6562

6663
callable_m: Callable[[C3, int], str] = method
@@ -74,7 +71,7 @@ def _(
7471
method_accessed_on_instance: CallableTypeOf[C3().method],
7572
callable_accessed_on_instance: CallableTypeOf[C3().non_descriptor_callable],
7673
):
77-
reveal_type(method_accessed_on_instance) # revealed: (x: int) -> str
74+
reveal_type(method_accessed_on_instance) # revealed: (x: int) -> str
7875
reveal_type(callable_accessed_on_instance) # revealed: (c3: C3, x: int) -> str
7976
```
8077

@@ -110,13 +107,14 @@ certain use cases (at the cost of purity and simplicity).
110107
## Use case: Decorating a method with a `Callable`-typed decorator
111108

112109
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:
110+
intention that it shouldn't influence the method's descriptor behavior. For example, we treat
111+
`method_decorated` below as a bound method, even though its type is `Callable[[C1, int], str]`:
114112

115113
```py
116114
from typing import Callable
117115

118116
# TODO: this could use a generic signature, but we don't support
119-
# `ParamSpec` and solving of typevars inside `Callable` types.
117+
# `ParamSpec` and solving of typevars inside `Callable` types yet.
120118
def memoize(f: Callable[[C1, int], str]) -> Callable[[C1, int], str]:
121119
raise NotImplementedError
122120

@@ -146,3 +144,47 @@ class C2:
146144

147145
C2().method_decorated(1)
148146
```
147+
148+
Note that we currently only apply this heuristic when calling a function such as `memoize` via the
149+
decorator syntax. This is inconsistent, because the above *should* be equivalent to the following,
150+
but here we emit errors:
151+
152+
```py
153+
def memoize3(f: Callable[[C3, int], str]) -> Callable[[C3, int], str]:
154+
raise NotImplementedError
155+
156+
class C3:
157+
def method(self, x: int) -> str:
158+
return str(x)
159+
method_decorated = memoize3(method)
160+
161+
# error: [missing-argument]
162+
# error: [invalid-argument-type]
163+
C3().method_decorated(1)
164+
```
165+
166+
The reason for this is that the heuristic is problematic. We don't *know* that the `Callable` in the
167+
return type of `memoize` is actually related to the method that we pass in. But when `memoize` is
168+
applied as a decorator, it is reasonable to assume so.
169+
170+
In general, a function call might however return a `Callable` that is unrelated to the argument
171+
passed in. And here, it seems more reasonable and safe to treat the `Callable` as a non-descriptor.
172+
This allows correct programs like the following to pass type checking (that are currently rejected
173+
by pyright and mypy with a heuristic that apparently applies in a wider range of situations):
174+
175+
```py
176+
class SquareCalculator:
177+
def __init__(self, post_process: Callable[[float], int]):
178+
self.post_process = post_process
179+
180+
def __call__(self, x: float) -> int:
181+
return self.post_process(x * x)
182+
183+
def square_then(c: Callable[[float], int]) -> Callable[[float], int]:
184+
return SquareCalculator(c)
185+
186+
class Calculator:
187+
square_then_round = square_then(round)
188+
189+
reveal_type(Calculator().square_then_round(3.14)) # revealed: Unknown | int
190+
```

crates/ty_python_semantic/src/types/infer/builder.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2183,6 +2183,9 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
21832183
if is_input_function_like
21842184
&& let Some(callable_type) = return_ty.unwrap_as_callable_type()
21852185
{
2186+
// When a method on a class is decorated with a function that returns a `Callable`, assume that
2187+
// the returned callable is also function-like. See "Decorating a method with a `Callable`-typed
2188+
// decorator" in `callables_as_descriptors.md` for the extended explanation.
21862189
Type::Callable(CallableType::new(
21872190
self.db(),
21882191
callable_type.signatures(self.db()),

0 commit comments

Comments
 (0)