Skip to content

← Interfaces with Protocols and ABCs step 5 of 26

Medium Primitives

Catch the Backends That isinstance Lets Through

The type hints track showed that isinstance against a @runtime_checkable protocol checks only that each member exists, not its type or signature. This exercise adds a data member and a class check, and you write the validation that isinstance skips.

The starter's Backend protocol has a data member and a method:

@runtime_checkable
class Backend(Protocol):
    name: str

    def close(self, timeout: float) -> None: ...

isinstance(obj, Backend) is True for an object whose name is None, and for one whose close takes no arguments. Code that relies on that check crashes on both.

What to write

members_valid(obj: object) -> bool makes these checks in order:

  1. Read name with getattr(obj, "name", None). Return False unless it is a str.
  2. Read close with getattr(obj, "close", None). Return False unless it is callable.
  3. Call inspect.signature(close). Return False if that raises TypeError or ValueError.
  4. Return whether the signature has a parameter whose name is exactly "timeout".

Never call close. Only the parameter's name matters, not its type, default or kind.

select_backends(candidates) keeps each candidate that passes isinstance(c, Backend) and then members_valid(c), checked in that order. It returns type(c).__name__ for each candidate it keeps.

closable_subclasses(candidates) returns type(c).__name__ for each candidate where issubclass(type(c), Closable) is True. It does not call members_valid. It uses Closable, which is Backend without name, because issubclass refuses a protocol that has a data member:

TypeError: Protocols with non-method members don't support issubclass(). Non-method members: 'name'.

Both functions keep the input order and any duplicates. Read the input only once, because it may be a generator.

solve(candidates) builds one fresh object for each key with REGISTRY[key](), and returns a dictionary with three lists:

  • "isinstance_accepts", from the supplied naive_select.
  • "validated_accepts", from select_backends.
  • "closable_subclasses", from closable_subclasses.

An empty input gives three empty lists.

The supplied candidates

Candidate isinstance with Backend members_valid issubclass with Closable
Postgres, Sqlite Passes Passes Passes
NullName Passes Fails, name is None Passes
NoTimeout Passes Fails, no timeout parameter Passes
NoClose Fails Not reached Fails

For example, solve(["null_name", "sqlite", "sqlite"]) returns ["NullName", "Sqlite", "Sqlite"] for "isinstance_accepts" and for "closable_subclasses", and ["Sqlite", "Sqlite"] for "validated_accepts".

The timeout check is deliberately small. A close that also requires a second argument passes it.