Some common callable objects (functions, lambdas) are also bound-method descriptors. That is, they have a __get__
method which returns a bound-method object that binds the receiver instance to the first argument (and thus the bound-method object has a different signature, lacking the first argument).
Other callable objects (staticmethod objects, instances of classes with a __call__
method but no __get__
method) are not bound-method descriptors. If accessed as class attributes via an instance, they are simply themselves.
Both kinds of objects can inhabit the same Callable
type. (For example, def f(x: int) -> int
and staticmethod(f)
both inhabit the type Callable[[int], int]
.) Normally, this would suggest that we should not assume that a Callable
type is a bound-method descriptor, because not all of its inhabitants are.
But the bound-method descriptor behavior violates Liskov substitutability: it is not safe to substitute an object which is a bound-method descriptor for one that is not, or vice versa, even if they are both callable with exactly the same signature. When accessed as attributes, they will behave in incompatible ways.
So this is a soundness hole in the type system, both for subclassing in general (implementing __get__
on a subclass to return something that isn’t a subtype of Self
makes it an unsound subclass, but no type checker errors on this) and specifically for Callable
(a given Callable
type includes both bound-method descriptor subtypes and non-bound-method-descriptor subtypes, and no matter which behavior we choose for Callable
, some of those will become unsound subtypes.)
I suspect this underlying unsoundness is not worth fixing at this point; the disruption would outweigh the gain. But type checkers still need some answer to the question “should we consider a Callable type to be a bound-method descriptor?”
The simplest answer would be “Callable implies only __call__
, not __get__
– assume Callable is not a bound-method descriptor”. Both ty and pyrefly (new in-development type checkers) currently implement this. But neither pyright nor mypy do. Instead, both implement a hybrid heuristic, where a Callable
type as a class attribute is considered to be a bound-method descriptor if it is explicitly wrapped in ClassVar
, and not otherwise.
This special treatment of ClassVar
seems to be specific to Callable
. Both mypy and pyright will happily model the descriptor protocol on other types assigned as class attributes but not explicitly marked with ClassVar
.
This ClassVar
heuristic doesn’t appear to have any particular correctness advantage: it is just as unsound (although in different scenarios) as “assume Callable is always a bound method descriptor” or “assume Callable is never a bound method descriptor”. One possible advantage is that it at least allows the annotation author to spell either possibility (albeit not in a particularly obvious way.)
(Mypy seems to also implement an additional special case, where a __call__
attribute annotated as Callable
is always assumed to be a bound method descriptor, unlike other attributes. Pyright does not do this.)
As far as I can tell, the spec and conformance suite are currently silent on this question.
I would be interested to hear perspectives from typing users and developers of type checkers regarding which behavior is desirable here, and why. Is this something we should standardize in the typing spec?
In case the above isn’t clear, I’ve put together some sample code which illustrates many of the relevant scenarios (ty, pyrefly, mypy, pyright). The sample code is written to ensure that everything it does is correct at runtime, but due to the underlying unsoundness described above, every type-checker mis-understands the runtime behavior in some cases.
from typing import Callable, ClassVar, assert_type
class Descriptor:
def __get__(self, instance: object, owner: type) -> int:
return 1
def impl(self: "C", x: int) -> int:
return x
class C:
descriptor: Descriptor = Descriptor()
classvar_descriptor: ClassVar[Descriptor] = Descriptor()
callable: Callable[["C", int], int] = impl
classvar_callable: ClassVar[Callable[["C", int], int]] = impl
static_callable: Callable[["C", int], int] = staticmethod(impl)
static_classvar_callable: ClassVar[Callable[["C", int], int]] = staticmethod(impl)
c = C()
# Establish a baseline that type checkers generally respect the descriptor
# protocol for values assigned in the class body, whether annotated with
# ClassVar or no:
assert_type(c.descriptor, int)
assert_type(c.classvar_descriptor, int)
# The calls and assignments below are all correct per runtime behavior;
# if a type checker errors on any of them and expects a different
# signature, that indicates unsound behavior. Note that the static_*
# variants are annotated exactly the same as the non-static variants,
# but have different runtime behavior, because Callable does not
# distinguish descriptor vs non-descriptor. Thus, it's unlikely that any
# type checker can get all of these correct.
# If a type-checker assumes that callable types are not descriptors,
# it will (wrongly) error on these calls and assignments:
c.callable(1)
c.classvar_callable(1)
x1: Callable[[int], int] = c.callable
x1(1)
x2: Callable[[int], int] = c.classvar_callable
x2(1)
# If a type-checker assumes that callable types are descriptors,
# it will (wrongly) error on these calls and assignments:
c.static_callable(C(), 1)
c.static_classvar_callable(C(), 1)
y1: Callable[["C", int], int] = c.static_callable
y1(C(), 1)
y2: Callable[["C", int], int] = c.static_classvar_callable
y2(C(), 1)
# Now let's look specifically at annotated `__call__` attributes:
def cm_impl(self: "CallMethod", x: int) -> int:
return x
class CallMethod:
__call__: Callable[["CallMethod", int], int] = cm_impl
def cmc_impl(self: "CallMethodClassVar", x: int) -> int:
return x
class CallMethodClassVar:
__call__: ClassVar[Callable[["CallMethodClassVar", int], int]] = cmc_impl
def cms_impl(self: "CallMethodStatic", x: int) -> int:
return x
class CallMethodStatic:
__call__: Callable[["CallMethodStatic", int], int] = staticmethod(cms_impl)
def cmcs_impl(self: "CallMethodClassVarStatic", x: int) -> int:
return x
class CallMethodClassVarStatic:
__call__: ClassVar[Callable[["CallMethodClassVarStatic", int], int]] = staticmethod(cmcs_impl)
# Again, all of these are correct per runtime behavior; type checker
# errors indicate an unsound interpretation:
# Type checkers which assume callables are not descriptors will (wrongly)
# error on these:
CallMethod()(1)
cm: Callable[[int], int] = CallMethod()
cm(1)
CallMethodClassVar()(1)
cmc: Callable[[int], int] = CallMethodClassVar()
cmc(1)
# Type checkers which assume callables are descriptors will (wrongly)
# error on these:
CallMethodStatic()(CallMethodStatic(), 1)
cms: Callable[["CallMethodStatic", int], int] = CallMethodStatic()
cms(CallMethodStatic(), 1)
CallMethodClassVarStatic()(CallMethodClassVarStatic(), 1)
cmcs: Callable[["CallMethodClassVarStatic", int], int] = CallMethodClassVarStatic()
cmcs(CallMethodClassVarStatic(), 1)