Implementing Python Protocols using both regular methods and class methods

Viewed 1450

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

  • In Foo1, bar() is a regular method
  • In Foo2, bar() is a @classmethod
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?

3 Answers

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.

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.

The second item in the list of foos, Foo2 is a class not an instance such as Foo1(). This needs to be specified by wrapping around Type:

def foreach_foo_call_bar(
        foos: Iterable[Union[SupportsBar,
                             Type[SupportsBar]]]):
    # ...

You can also do this without the use of a Protocol:

def foreach_foo_call_bar(
        foos: Iterable[Union[Foo1,
                             Type[Foo2]]]):
    # ...
Related