How can I use a descriptor to implement a protocol?

Viewed 45

Let's say I define a simple protocol using typing.Protocol, and a class that implements it:

from typing import Protocol


class I(Protocol):
    def m(self, x: int) -> int:
        ...


class A(I):
    def m(self, x: int) -> int:
        return x + 1

So far, no problem, and mypy doesn't report any errors. (I inherited explicitly from the protocol so that mypy would tell me if my implementation works or not.)

Now, let's say I want to implement my protocol with some classes that work similarly to how Python's built-in functions/bound methods work, that is accessing the attribute on the class gives an "unbound` instance, and accessing it on the instance gives me a "bound" instance that holds on to a reference to the instance, something like this:

from typing import (
    Any,
    Callable,
    Generic,
    Optional,
    Type,
    TypeVar,
    Union,
    overload,
)

from typing_extensions import Concatenate, ParamSpec


P = ParamSpec("P")
R = TypeVar("R")


class BoundField(Generic[P, R]):
    def __init__(
        self, func: Callable[Concatenate[Any, P], R], instance: Any
    ) -> None:
        self.func = func
        self.instance = instance

    def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R:
        return self.func(self.instance, *args, **kwargs)


class UnboundField(Generic[P, R]):
    def __init__(self, func: Callable[Concatenate[Any, P], R]) -> None:
        self.func = func

    @overload
    def __get__(
        self, instance: None, owner: Optional[Type]
    ) -> "UnboundField[P, R]":
        ...

    @overload
    def __get__(
        self, instance: object, owner: Optional[Type]
    ) -> BoundField[P, R]:
        ...

    def __get__(
        self, instance: Optional[object], owner: Optional[Type] = None
    ) -> Union["UnboundField[P, R]", BoundField[P, R]]:
        if instance is None:
            return self
        return BoundField(func=self.func, instance=instance)

    def __call__(self, instance: Any, *args: P.args, **kwargs: P.kwargs) -> R:
        return self.func(instance, *args, **kwargs)


def decorator(func: Callable[Concatenate[Any, P], R]) -> UnboundField[P, R]:
    return UnboundField(func)


class B(I):
    @decorator
    def m(self, x: int) -> int:
        return x * 2


reveal_type(A.m)
reveal_type(A().m)
reveal_type(B.m)
reveal_type(B().m)

In this case, although the B.m object is not a function or a bound method, it implements all the behavior of a function or a bound method, so there's no reason B shouldn't be considered to implement the I protocol. Unfortunately, mypy complains:

$ mypy --version
mypy 0.961 (compiled: yes)
$ mypy protodesc.py 
protodesc.py:77: error: Signature of "m" incompatible with supertype "I"
protodesc.py:81: note: Revealed type is "def (self: tmp.protodesc.A, x: builtins.int) -> builtins.int"
protodesc.py:82: note: Revealed type is "def (x: builtins.int) -> builtins.int"
protodesc.py:83: note: Revealed type is "tmp.protodesc.UnboundField[[x: builtins.int], builtins.int]"
protodesc.py:84: note: Revealed type is "tmp.protodesc.BoundField[[x: builtins.int], builtins.int]"
Found 1 error in 1 file (checked 1 source file)

Of course, I want to do more with my UnboundField and BoundField than just define the __get__ and __call__ methods, which is why I can't do something like cast(Callable[Concatenate[Any, P], R], UnboundField(func)) inside the decorator (because I want to add other methods to UnboundField and BoundField and use them elsewhere, which mypy will complain about if it thinks that they are just simple Callables without extra methods).

Note that mypy does understand descriptors in general, for example:

from typing import Any, Generic, Optional, Type, TypeVar, Union, overload


T = TypeVar("T")


class B:
    def __init__(self, arg: int = 42):
        self.arg = arg


class D(Generic[T]):
    """Descriptor class."""

    @overload
    def __get__(self, instance: None, type: Type[T]) -> "D":
        ...

    @overload
    def __get__(self, instance: T, type: Optional[Type[T]]) -> B:
        ...

    def __get__(
        self, instance: Optional[T], type: Optional[Type[T]] = None
    ) -> Union[B, "D"]:
        return B() if instance is not None else self


class A:
    b: D = D()


reveal_type(A.b)
reveal_type(A().b)
reveal_type(A().b.arg)
print(A().b.arg)
$ mypy descriptor.py 
descriptor.py:33: note: Revealed type is "tmp.descriptor.D[Any]"
descriptor.py:34: note: Revealed type is "tmp.descriptor.B"
descriptor.py:35: note: Revealed type is "builtins.int"
Success: no issues found in 1 source file

Is there some reason that I'm missing that this doesn't work, or is it a deficiency in mypy?

0 Answers
Related