'How to use __subclasshook__ with Mypy?

How come that under Mypy, __subclasshook__ works for one-trick ponies from collections.abc, but not for user-defined classes?

For instance, this program

from collections.abc import Hashable

class A:
    def __hash__(self) -> int:
        return 0

a: Hashable = A()

outputs

$ mypy demo.py --strict
Success: no issues found in 1 source file

But this equivalent program

from abc import ABCMeta, abstractmethod

def _check_methods(C: type, *methods: str) -> bool:
    mro = C.__mro__
    for method in methods:
        for B in mro:
            if method in B.__dict__:
                if B.__dict__[method] is None:
                    return NotImplemented
                break
        else:
            return NotImplemented
    return True

class Hashable(metaclass=ABCMeta):
    __slots__ = ()

    @abstractmethod
    def __hash__(self) -> int:
        return 0

    @classmethod
    def __subclasshook__(cls, C: type) -> bool:
        if cls is Hashable:
            return _check_methods(C, "__hash__")
        return NotImplemented

class A:
    def __hash__(self) -> int:
        return 0

a: Hashable = A()

outputs

$ mypy demo.py --strict
demo.py:32: error: Incompatible types in assignment (expression has type "A", variable has type "Hashable")
Found 1 error in 1 file (checked 1 source file)

Does Mypy handle one-trick ponies in a special way?



Solution 1:[1]

Mypy does not use the implementations of the standard library but its specifications (’stub files’) from the typeshed package. In this package, collections.abc.Hashable is a typing.Protocol.

typeshed/stdlib/_collections_abc.pyi:

from typing import (
    AbstractSet as Set,
    AsyncGenerator as AsyncGenerator,
    AsyncIterable as AsyncIterable,
    AsyncIterator as AsyncIterator,
    Awaitable as Awaitable,
    ByteString as ByteString,
    Callable as Callable,
    Collection as Collection,
    Container as Container,
    Coroutine as Coroutine,
    Generator as Generator,
    Generic,
    Hashable as Hashable,
    ItemsView as ItemsView,
    Iterable as Iterable,
    Iterator as Iterator,
    KeysView as KeysView,
    Mapping as Mapping,
    MappingView as MappingView,
    MutableMapping as MutableMapping,
    MutableSequence as MutableSequence,
    MutableSet as MutableSet,
    Reversible as Reversible,
    Sequence as Sequence,
    Sized as Sized,
    TypeVar,
    ValuesView as ValuesView,
)

typeshed/stdlib/typing.pyi:

@runtime_checkable
class Hashable(Protocol, metaclass=ABCMeta):
    # TODO: This is special, in that a subclass of a hashable class may not be hashable
    #   (for example, list vs. object). It's not obvious how to represent this. This class
    #   is currently mostly useless for static checking.
    @abstractmethod
    def __hash__(self) -> int: ...

Solution 2:[2]

Yes, mypy treats these kind of classes as special cases. Remember, mypy is for static type checking, which means it works without running your code at all, only analyzing the source code. It never actually calls __subclasshook__ or the like to determine what is or isn't hashable. Your "equivalent" class is only equivalent at runtime, as it relies on __subclasshook__ being called.

If you want mypy to work with something it doesn't already know about, you would have to write a mypy plugin to handle it.

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1 Maggyero
Solution 2 chepner