Description
Currently we assume that Callable types do not implement the descriptor protocol -- specifically, the bound method descriptor behavior that function and lambda objects implement, to bind the first argument to the receiver instance.
This is a reasonable choice, since the Callable type definitely includes objects which are not bound method descriptors (e.g. you can assign a callable instance or protocol or staticmethod object -- none of which are bound method descriptors -- to a callable type). If you consider the bound-method descriptor behavior as additional behavior that can be provided by a subtype, then our current behavior makes the most sense: Callable
implies only __call__
, function and lambda objects are subtypes of Callable which add the __get__
method as well.
The problem with this is that adding a bound-method __get__
is Liskov-incompatible -- it changes the behavior in an incompatible way, that makes the bound method descriptor object not safely substitutable (as a class attribute) for a callable with the same signature that is not a bound-method descriptor.
This is a fundamental problem with the Python type system, and there's not much we can do about it. Any choice we make in this area will be unsound; the only sound option would be a wholesale change to Python typing to (in general) not consider adding a __get__
method to be a Liskov-compatible change in a subtype; this is not practical.
Given that we can't be sound, we may want to at least be compatible and match the behavior of existing type checkers. I've created this "test suite" to check the behavior of existing type checkers:
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)
It seems that both pyright and mypy implement a heuristic in which callable class attributes explicitly annotated as ClassVar
are assumed to be bound-method descriptors, and those not so annotated are assumed not to be. (Note that this distinction is specific to callable types; both mypy and pyright are in general happy to execute the descriptor protocol on class attributes not explicitly annotated as ClassVar
.) Mypy appears to add one additional wrinkle: __call__
attributes annotated with a callable type are always assumed to be bound-method descriptors, unlike other attributes. Pyright doesn't implement this bit.
Pyrefly currently implements the same thing we do: callables are never assumed to be bound-method descriptors.