Skip to content

Assume (with mypy/pyright) that a Callable ClassVar is a bound method descriptor? #491

Open
@carljm

Description

@carljm

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    callsIssues relating to call-signature checking and diagnosticstyping semanticstyping-module features, spec compliance, etc

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions