11# Callables as descriptors?
22
3- <!-- blacken-docs:off -->
4-
53``` toml
64[environment ]
75python-version = " 3.14"
@@ -11,8 +9,8 @@ python-version = "3.14"
119
1210Some common callable objects (functions, lambdas) are also bound-method descriptors. That is, they
1311have 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
1816from ty_extensions import CallableTypeOf
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
3331Other callable objects (` staticmethod ` objects, instances of classes with a ` __call__ ` method but no
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:
6058class 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
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
112109A 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
116114from 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 .
120118def memoize (f : Callable[[C1, int ], str ]) -> Callable[[C1, int ], str ]:
121119 raise NotImplementedError
122120
@@ -146,3 +144,47 @@ class C2:
146144
147145C2().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+ ```
0 commit comments