Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Python type-checking Protocols and Descriptors

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?

like image 739
salvolm Avatar asked Sep 03 '25 16:09

salvolm


1 Answers

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.

like image 50
Alex Waygood Avatar answered Sep 05 '25 13:09

Alex Waygood