save(self, connection) — what is a connection?
The easy answer, sqlite3.Connection, is a decision with consequences: the
library now only works with one driver, and every test needs a real database.
The better answer describes what the library actually uses, which is two
methods.
Nominal and structural
Two ways to say what a value must be:
- Nominal: it must inherit from this class.
isinstance(x, Connection)with an ABC, and the author ofxmust have known about you. - Structural: it must have these methods. A
Protocol, and the author ofxneeds to know nothing at all.
For a library parameter, structural is nearly always right, because it lets callers pass things you never anticipated: a connection pool, a wrapper that logs slow queries, a fake in a test.
from collections.abc import Sequence
from typing import Any, Protocol, runtime_checkable
@runtime_checkable
class Connection(Protocol):
def execute(self, sql: str, parameters: Sequence[Any] = (), /) -> Cursor: ...
def commit(self) -> None: ...
Nothing in the library imports sqlite3 after this — only db.py, the
scaffolding, does.
The positional-only slash
parameters: Sequence[Any] = (), / marks the parameters as positional-only, so
the protocol doesn’t promise anything about their names. Without the /, any
implementation would have to call its first parameter sql, and a driver that
called it query would stop matching. When you describe someone else’s API,
positional-only is usually what you mean.
The mistake that takes an hour: a plain attribute
save() needs cursor.lastrowid, so Cursor needs it too. The obvious
spelling is wrong:
@runtime_checkable
class Cursor(Protocol):
lastrowid: int | None # wrong
A bare annotation in a Protocol means a mutable attribute: readable and
writable. sqlite3.Cursor.lastrowid is a read-only property, so a real
connection no longer matches, and mypy says so at every call site:
error: Argument 1 to "fetch" of "Model" has incompatible type
"sqlite3.Connection"; expected "protocols.Connection" [arg-type]
note: Following member(s) of "Connection" have conflicts: ...
The message names the argument, not the member that actually conflicts, so it reads as “my protocol is wrong somewhere”. It is: write the read-only member as a property.
@runtime_checkable
class Cursor(Protocol):
@property
def lastrowid(self) -> int | None: ...
Ask for read-write only when you write to it. A protocol is a list of demands, and each one rules out implementations.
Only a checker sees this
This was a real bug in this library, and the test suite passed the whole time
it was there — runtime_checkable doesn’t look at types. Nothing but a type
checker can catch a protocol that is too demanding, which is the strongest
argument in this course for running one in CI.
What runtime_checkable actually checks
isinstance(x, SomeProtocol) is allowed only with @runtime_checkable, and
then it checks that the attribute names exist. Not their signatures, not
their types.
from typing import Protocol, runtime_checkable
@runtime_checkable
class Connection(Protocol):
def execute(self, sql: str, parameters: tuple = (), /) -> object: ...
def commit(self) -> None: ...
class Fake:
def execute(self, sql, parameters=(), /):
return None
def commit(self):
return None
class Nonsense:
def execute(self): # wrong signature entirely
return None
commit = "not even a method" # not even callable
class NoCommit:
def execute(self, sql, parameters=(), /):
return None
for candidate in (Fake(), Nonsense(), NoCommit()):
print(f"{type(candidate).__name__:9} {isinstance(candidate, Connection)}")Nonsense passes. Its execute takes no arguments and its commit is a
string. The names are there, so isinstance is satisfied — and it would fail
on the first real call.
So use runtime_checkable for what it’s good at: a friendly early error
(“that doesn’t look like a connection”) or a branch in your own code. Don’t
treat it as validation, and don’t let it replace the checker.
Protocols for your own objects, too
A protocol is also how a library describes the objects it hands back without
tying callers to a class. Row in this library is three lines:
class Row(Protocol):
def keys(self) -> Sequence[str]: ...
def __getitem__(self, key: str, /) -> Any: ...
sqlite3.Row satisfies it; so does a plain dict; so does whatever a future
driver returns. Model.get needs nothing else from a row, and saying exactly
that is what keeps the library portable.
A protocol for what a report needs
render_report currently demands a Post. Loosen it to a protocol so anything
with the right shape works — including a summary object that was never designed
for this function.
import unittest
from typing import Protocol, runtime_checkable
@runtime_checkable
class Summarisable(Protocol):
"""TODO: describe what render_report uses: a str title, an int views,
and a summary() method returning a str."""
def render_report(items) -> str:
"""TODO: annotate items as a sequence of Summarisable."""
lines = [f"{item.title} ({item.views} views): {item.summary()}" for item in items]
return "\n".join(lines)
class Post:
def __init__(self, title: str, views: int) -> None:
self.title = title
self.views = views
def summary(self) -> str:
return "a post"
class WeeklyDigest:
"""Written for something else entirely, and it fits."""
title = "Week 12"
views = 0
def summary(self) -> str:
return "3 posts"
class Broken:
title = "no views attribute"
def summary(self) -> str:
return "?"
class Reports(unittest.TestCase):
def test_renders_posts(self):
report = render_report([Post("Hello", 3)])
self.assertEqual(report, "Hello (3 views): a post")
def test_renders_anything_with_the_shape(self):
report = render_report([Post("Hello", 3), WeeklyDigest()])
self.assertIn("Week 12 (0 views): 3 posts", report)
def test_the_protocol_recognises_both(self):
self.assertIsInstance(Post("Hello", 3), Summarisable)
self.assertIsInstance(WeeklyDigest(), Summarisable)
def test_and_rejects_something_missing_a_member(self):
self.assertNotIsInstance(Broken(), Summarisable)
unittest.TextTestRunner(verbosity=2).run(
unittest.defaultTestLoader.loadTestsFromTestCase(Reports)
)Hint 1: the idea
A protocol lists only what the function uses: a title, a views and a
summary(). Anything with those members matches, with no inheritance needed.
One question decides how to declare title and views: does render_report
ever write them, or only read them?
Hint 2: a first step
Add def summary(self) -> str: ... to the protocol, and declare title and
views. The isinstance tests use the runtime check, which looks only at
names. Then annotate items with the widest type the function needs — it only
loops over them.
Show one solution
from collections.abc import Sequence
@runtime_checkable
class Summarisable(Protocol):
@property
def title(self) -> str: ...
@property
def views(self) -> int: ...
def summary(self) -> str: ...
def render_report(items: Sequence[Summarisable]) -> str:
lines = [f"{item.title} ({item.views} views): {item.summary()}" for item in items]
return "\n".join(lines)title and views are properties in the protocol because render_report only
reads them. Declared as plain attributes (title: str) the protocol would
demand writability — and then WeeklyDigest, whose title is a class
attribute, still matches, but a class using @property would not. Read-only is
both weaker and more accurate.
Sequence[Summarisable] rather than list[Summarisable]: the function only
iterates, and list would refuse a tuple for no reason. Iterable would be
weaker still and correct here — the smallest type that does the job is the one
to pick.
What you learned
- A
Protocoldescribes what your code uses, so callers can pass anything of the right shape and your library needn’t import a driver. - A plain annotation in a protocol demands read and write; use
@propertyfor things you only read. - Positional-only parameters keep a protocol from constraining argument names.
runtime_checkablechecks that names exist and nothing more — an early smoke test, never validation.