I observe a behavior about typing.Protocol
when Descriptors are involved which I do not quite fully understand. Consider the following code:
import typing as t
T = t.TypeVar('T')
class MyDescriptor(t.Generic[T]):
def __set_name__(self, owner, name):
self.name = name
def __set__(self, instance, value: T):
instance.__dict__[self.name] = value
def __get__(self, instance, owner) -> T:
return instance.__dict__[self.name]
class Named(t.Protocol):
first_name: str
class Person:
first_name = MyDescriptor[str]()
age: int
def __init__(self):
self.first_name = 'John'
def greet(obj: Named):
print(f'Hello {obj.first_name}')
person = Person()
greet(person)
Is the class Person
implicitly implementing the Named
protocol? According to mypy, it isn't:
error: Argument 1 to "greet" has incompatible type "Person"; expected "Named"
note: Following member(s) of "Person" have conflicts:
note: first_name: expected "str", got "MyDescriptor[str]"
I guess that's because mypy quickly concludes that str
and MyDescriptor[str]
are simply 2 different types. Fair enough.
However, using a plain str
for first_name
or wrapping it in a descriptor that gets and sets a str
is just an implementation detail. Duck-typing here tells me that the way we will use first_name
(the interface) won't change.
In other words, Person
implements Named
.
As a side note, PyCharm's type-checker does not complain in this particular case (though I am not sure if it's by design or by chance).
According to the intended use of typing.Protocol
, is my understanding wrong?
I'm struggling to find a reference for it, but I think MyPy struggles a little with some of the finer details of descriptors (you can sort of understand why, there's a fair bit of magic going on there). I think a workaround here would just be to use typing.cast
:
import typing as t
T = t.TypeVar('T')
class MyDescriptor(t.Generic[T]):
def __set_name__(self, owner, name: str) -> None:
self.name = name
def __set__(self, instance, value: T) -> None:
instance.__dict__[self.name] = value
def __get__(self, instance, owner) -> T:
name = instance.__dict__[self.name]
return t.cast(T, name)
class Named(t.Protocol):
first_name: str
class Person:
first_name = t.cast(str, MyDescriptor[str]())
age: int
def __init__(self) -> None:
self.first_name = 'John'
def greet(obj: Named) -> None:
print(f'Hello {obj.first_name}')
person = Person()
greet(person)
This passes MyPy.