Skip to content

← Precise Type Hints with Literals and Generics step 21 of 26

Hard Framework

Find Out What isinstance Checks on a Protocol

You have a list of objects from several libraries, and you want to close every one that has a close method. They share no base class. A Protocol describes them anyway. A Protocol is a class that lists the methods an object must have. Any object with those methods matches it, without inheriting from it. The next track covers Protocols in depth.

from typing import Protocol, runtime_checkable


@runtime_checkable
class SupportsClose(Protocol):
    def close(self) -> None: ...

@runtime_checkable lets you write isinstance(obj, SupportsClose). Without it, that call raises TypeError. After the check, mypy treats obj as having close(self) -> None.

The runtime check looks only for an attribute named close. It does not check that close is a method, or what arguments it takes:

class NotCallable:
    close = 3


isinstance(NotCallable(), SupportsClose)  # True
NotCallable().close()                     # TypeError: 'int' object is not callable

So even after the check passes, the call can still fail. This exercise counts how often that happens.

What to write

Add @runtime_checkable to SupportsClose. Keep the fixture classes and FACTORIES as they are.

close_all(objects: Iterable[object]) -> int:

  • Read the objects once, in order.
  • For each object that passes isinstance(obj, SupportsClose), call obj.close() with no arguments.
  • Count a success each time close() returns normally, whatever it returns.
  • Put only the obj.close() call inside try, and catch Exception. On an exception, skip that object and go on.
  • Do not catch BaseException, so KeyboardInterrupt still stops the loop.
  • Close an object each time it appears. An empty input gives 0.

The fixtures

Kind Class Passes the check What close() does
plain PlainCloser yes sets closed to True
raising RaisingCloser yes raises OSError
arity ArityMismatch yes raises TypeError for the missing force argument
notcallable NotCallable yes raises TypeError, because close is 3
dynamic DynamicCloser no not called
noclose NoClose no not called

DynamicCloser returns close from __getattr__, which Python calls only for attributes it cannot find. The check does not call __getattr__, so it does not find close.

What solve returns

solve(kinds: list[str]) -> tuple[int, list[str]]:

  • Create one object per kind with FACTORIES[kind](), in order and with duplicates. An unknown kind raises KeyError. Do not skip it.
  • Before closing anything, list the class names of the objects that pass the check, in order and with duplicates.
  • Return (close_all(objects), names).
assert solve(["plain", "plain"]) == (2, ["PlainCloser", "PlainCloser"])
assert solve(["plain", "raising", "arity", "notcallable", "dynamic", "noclose"]) == (
    1, ["PlainCloser", "RaisingCloser", "ArityMismatch", "NotCallable"],
)
assert solve([]) == (0, [])

Going further

Since Python 3.12, the check uses inspect.getattr_static. That is why __getattr__ cannot satisfy it, and why a property named close passes without Python running it. A hasattr check does not look at signatures either. See the runtime_checkable documentation.