MatanRubin
MatanRubin

Reputation: 1085

Implementing Python Protocols using both regular methods and class methods

Assume I have two classes Foo1 and Foo2 that implement a method bar():

class Foo1:

    def bar(self) -> None:
        print("foo1.bar")


class Foo2:

    @classmethod
    def bar(cls) -> None:
        print("Foo2.bar")

Now assume I have a function that accepts a list of "anything that has a bar() method" and calls it:

def foreach_foo_call_bar(foos):
    for foo in foos:
        foo.bar()

Calling this function works fine during runtime:

foreach_foo_call_bar([Foo1(), Foo2])

as both Foo1() and Foo2 has a bar() method.

However, how can I properly add type hints to foreach_foo_call_bar()?

I tried creating a PEP544 Protocol called SupportsBar:

class SupportsBar(Protocol):

    def bar(self) -> None:
        pass

and annotating like so:

def foreach_foo_call_bar(foos: Iterable[SupportsBar]):
   ...

But mypy says:

List item 1 has incompatible type "Type[Foo2]"; expected "SupportsBar"

Any idea how to make this properly annotated?

Upvotes: 27

Views: 4310

Answers (2)

Evan Grim
Evan Grim

Reputation: 5225

This is an open issue with mypy. The BFDL hisself even acknowledged it as incorrect behavior precisely 2 years before @MatanRubin asked this question. It remains unresolved, but was recently (November 2019) marked as high priority so hopefully the example provided here will no longer generate a false positive soon.

Upvotes: 8

chepner
chepner

Reputation: 531165

The issue appears to be that Protocol is specifically checking if an instance method is supported, not just that an attribute of the correct name exists. In the case of Foo2, that means the metaclass needs an instance method named bar; the following seems to behave as expected and type-checks.

# Define a metaclass that provides an instance method bar for its instances.
# A metaclass instance method is almost equivalent to a class method.
class Barrable(type):
    def bar(cls) -> None:
        print(cls.__name__ + ".bar")


class Foo1:
    def bar(self) -> None:
        print("foo1.bar")


class Foo2(metaclass=Barrable):
    pass


class SupportsBar(Protocol):
    def bar(self) -> None:
        pass


def foreach_foo_call_bar(foos: Iterable[SupportsBar]):
    for foo in foos:
        foo.bar()

I won't claim that converting a class method to a metaclass instance method is a good workaround (in fact, it does nothing for static methods), but it points to this being a fundamental limitation of Protocol that it doesn't handle arbitrary attributes.

Upvotes: 10

Related Questions