Warm-up · Activity 1 of 7
// A2.3 · ~30 min · Advanced
Structural typing with Protocol
After this lesson you can describe what an object must do with a Protocol, check it at runtime within the limits of that check, and choose between a Protocol and an ABC.
You will be able to
- Define a Protocol and use it as a type that any class with the right members satisfies
- Check protocols at runtime with @runtime_checkable, and know what that check does not test
- Choose between a Protocol and an ABC: structural or nominal, and what each does at runtime
Predict · Activity 2 of 7
Predict before you read on: File does not inherit from Closer. What does this print?
from typing import Protocol class Closer(Protocol): def close(self) -> str: ... class File: def close(self) -> str: return "file closed" def shut(item: Closer) -> str: return item.close() print(shut(File()), Closer in File.__mro__)Practice · Activity 3 of 7
Fill in the base class that makes Closer a protocol, so that any class with a close() method matches it.
class Closer(____):class Closer():Practice · Activity 4 of 7
mypy rejects the last line, and adds notes that compare Door with Closer. What does it show under Got:?
from typing import Protocol class Closer(Protocol): def close(self) -> None: ... class Door: def close(self, force: bool) -> None: pass def close_one(item: Closer) -> None: item.close() close_one(Door())Practice · Activity 5 of 7
Match each piece of code to what it does.
Brain teaser · Activity 6 of 7
Brain teaser. Door.close takes an extra argument and returns a str. What does this print?
from typing import Protocol, runtime_checkable @runtime_checkable class Closer(Protocol): def close(self) -> None: ... class Door: def close(self, force: bool) -> str: return "slam" print(isinstance(Door(), Closer))Apply · Activity 7 of 7
Mini-task. Write a protocol Writer with one method, write(self, text: str) -> int, and a function log(target: Writer, message: str) that writes "[log] " plus the message and a newline. Call it with an io.StringIO and with sys.stdout: neither inherits from Writer, and mypy --strict should accept both.
Check your work against this list
Build it yourself
Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.
Worked example
A protocol and an ABC side by side
Closer is a runtime-checkable protocol; Resource is an ABC with an abstract close() and a shared describe(). Connection inherits from Resource, so it gets describe() and must implement close(). TempFile inherits from nothing, yet close_all accepts both, because both have close(). The last lines show which runtime checks see which relationship.
main.py
from abc import ABC, abstractmethod
from typing import Protocol, runtime_checkable
@runtime_checkable
class Closer(Protocol):
def close(self) -> None: ...
class Resource(ABC):
"""A family of our own classes: shared code plus a required method."""
def __init__(self, name: str) -> None:
self.name = name
@abstractmethod
def close(self) -> None: ...
def describe(self) -> str:
return f"{type(self).__name__} {self.name}"
class Connection(Resource):
def close(self) -> None:
print("closing", self.describe())
class TempFile: # no base class: it only has the right method
def close(self) -> None:
print("deleting a temporary file")
def close_all(items: list[Closer]) -> None:
for item in items:
item.close()
close_all([Connection("db"), TempFile()])
print(isinstance(TempFile(), Closer), isinstance(TempFile(), Resource))
print(isinstance(Connection("db"), Closer))
Run it with
python main.pyOutput
closing Connection db
deleting a temporary file
True False
True- close_all asks only for close(), so it takes classes from anywhere, including ones you cannot change.
- TempFile is a Closer but not a Resource: the ABC counts only subclasses.
- Connection is both: it inherits from Resource and has close(), so the protocol matches it too.
- Connection("db") works because it overrides the abstract close(); Resource("db") would raise TypeError.
Change it and run it
Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.
The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.
Exercises
Exercise 1 of 2
Shapes you cannot change
Square and Circle come from a library you cannot change, and they do not inherit from HasArea. Turn HasArea from an ABC into a runtime-checkable Protocol, so both classes fit total_area without changes. Then write only_shapes(items), which keeps the items that have an area() method, using isinstance() with HasArea.
Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.
The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.
Hints
Hint 1
Replace ABC with Protocol and drop @abstractmethod: a protocol method has only ... as its body.
Hint 2
Without @runtime_checkable, isinstance(item, HasArea) raises TypeError. Import it from typing and decorate the class.
Hint 3
only_shapes is one list comprehension: [item for item in items if isinstance(item, HasArea)].
Show a solution
One way to solve it. Yours can look different and still pass the checks.
import math
from typing import Protocol, runtime_checkable
@runtime_checkable
class HasArea(Protocol):
def area(self) -> float: ...
# From a library: do not change these two classes.
class Square:
def __init__(self, side: float) -> None:
self.side = side
def area(self) -> float:
return self.side**2
class Circle:
def __init__(self, radius: float) -> None:
self.radius = radius
def area(self) -> float:
return math.pi * self.radius**2
def total_area(shapes: list[HasArea]) -> float:
return sum(shape.area() for shape in shapes)
def only_shapes(items: list[object]) -> list[HasArea]:
return [item for item in items if isinstance(item, HasArea)]
Run it on your computer
Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.
main.py
import math
from abc import ABC, abstractmethod
class HasArea(ABC):
@abstractmethod
def area(self) -> float: ...
# From a library: do not change these two classes.
class Square:
def __init__(self, side: float) -> None:
self.side = side
def area(self) -> float:
return self.side**2
class Circle:
def __init__(self, radius: float) -> None:
self.radius = radius
def area(self) -> float:
return math.pi * self.radius**2
def total_area(shapes: list[HasArea]) -> float:
return sum(shape.area() for shape in shapes)
def only_shapes(items: list[object]) -> list[HasArea]:
return []
test_main.py
import math
from typing import is_protocol
from main import Circle, HasArea, Square, only_shapes, total_area
def test_is_protocol():
"""HasArea is a Protocol, not an ABC"""
assert is_protocol(HasArea), "HasArea should inherit from typing.Protocol"
def test_runtime_checkable():
"""isinstance() works with HasArea, and Square and Circle match it"""
got = isinstance(Square(2), HasArea), isinstance(Circle(1), HasArea), isinstance("text", HasArea)
assert got == (True, True, False), f"isinstance() gave {got!r}, expected (True, True, False)"
def test_total_area():
"""total_area adds 4 for a square of side 2 and pi for a circle of radius 1"""
got = total_area([Square(2), Circle(1)])
assert math.isclose(got, 4 + math.pi), f"total_area returned {got!r}, expected {4 + math.pi!r}"
def test_only_shapes():
"""only_shapes keeps the square and the circle, and drops the rest"""
square, circle = Square(1), Circle(1)
got = only_shapes([square, "text", 3, circle])
assert got == [square, circle], f"only_shapes returned {got!r}, expected the square and the circle"
On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.
Run the program:
python main.pyRun the checks (needs learnrun.py in the same folder):
python learnrun.py testDownload learnrun.pyExercise 2 of 2
An abstract exporter
Here the classes are your own, and they share export(), so an ABC fits. Make Exporter an ABC with an abstract render(rows); export(rows) stays a shared method. Exporter() and any subclass without render() must then fail with TypeError. Finish JsonExporter: render returns json.dumps(rows).
Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.
The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.
Hints
Hint 1
from abc import ABC, abstractmethod, then class Exporter(ABC): and put @abstractmethod above render.
Hint 2
An abstract method may keep a body; ... is enough, because no one calls it directly.
Hint 3
JsonExporter needs its own render(self, rows) that returns json.dumps(rows).
Show a solution
One way to solve it. Yours can look different and still pass the checks.
import json
from abc import ABC, abstractmethod
class Exporter(ABC):
"""Base class for exporters: subclasses must implement render()."""
@abstractmethod
def render(self, rows: list[dict[str, str]]) -> str: ...
def export(self, rows: list[dict[str, str]]) -> str:
if not rows:
return ""
return self.render(rows) + "\n"
class CsvExporter(Exporter):
def render(self, rows: list[dict[str, str]]) -> str:
header = ",".join(rows[0])
lines = [",".join(row.values()) for row in rows]
return "\n".join([header, *lines])
class JsonExporter(Exporter):
def render(self, rows: list[dict[str, str]]) -> str:
return json.dumps(rows)
Run it on your computer
Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.
main.py
import json
class Exporter:
"""Base class for exporters: subclasses must implement render()."""
def render(self, rows: list[dict[str, str]]) -> str:
raise NotImplementedError
def export(self, rows: list[dict[str, str]]) -> str:
if not rows:
return ""
return self.render(rows) + "\n"
class CsvExporter(Exporter):
def render(self, rows: list[dict[str, str]]) -> str:
header = ",".join(rows[0])
lines = [",".join(row.values()) for row in rows]
return "\n".join([header, *lines])
class JsonExporter(Exporter):
pass
test_main.py
import json
from main import CsvExporter, Exporter, JsonExporter
ROWS = [{"name": "Ada", "city": "London"}, {"name": "Alan", "city": "Wilmslow"}]
def test_base_is_abstract():
"""Exporter() raises TypeError"""
try:
Exporter()
except TypeError:
return
assert False, "Exporter() created an object; make render an @abstractmethod of an ABC"
def test_missing_render():
"""A subclass without render() cannot be instantiated"""
class Broken(Exporter):
pass
try:
Broken()
except TypeError:
return
assert False, "a subclass without render() was instantiated"
def test_csv():
"""CsvExporter writes a header line and one line per row"""
got = CsvExporter().export(ROWS)
assert got == "name,city\nAda,London\nAlan,Wilmslow\n", f"CsvExporter().export returned {got!r}"
def test_json():
"""JsonExporter writes the rows as JSON, plus a newline"""
got = JsonExporter().export(ROWS)
assert got == json.dumps(ROWS) + "\n", f"JsonExporter().export returned {got!r}"
def test_empty():
"""export([]) returns an empty string"""
got = JsonExporter().export([])
assert got == "", f"export([]) returned {got!r}, expected ''"
On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.
Run the program:
python main.pyRun the checks (needs learnrun.py in the same folder):
python learnrun.py testDownload learnrun.pyCommon mistakes
isinstance() with a plain protocol
from typing import Protocol
class Closer(Protocol):
def close(self) -> None: ...
class File:
def close(self) -> None:
pass
print(isinstance(File(), Closer))
What Python prints
TypeError: Instance and class checks can only be used with @runtime_checkable protocolsWhy, and the fix
A protocol is for the type checker by default. To test it at runtime, decorate it: from typing import runtime_checkable, then @runtime_checkable above class Closer(Protocol):. Remember that the check then tests only that close exists, not its signature. mypy flags the original line too: Only @runtime_checkable protocols can be used with instance and class checks [misc].
Instantiating a protocol
from typing import Protocol
class Closer(Protocol):
def close(self) -> None: ...
item = Closer()
What Python prints
TypeError: Protocols cannot be instantiatedWhy, and the fix
A protocol describes objects; it is not one itself. Create an object of a class that has the members, and annotate with the protocol: item: Closer = File(). If you want shared default methods, a class can inherit from the protocol explicitly and is then instantiable, or use an ABC.
Forgetting an abstract method in a subclass
from abc import ABC, abstractmethod
class Shape(ABC):
@abstractmethod
def area(self) -> float: ...
class Square(Shape):
def __init__(self, side: float) -> None:
self.side = side
Square(2)
What Python prints
TypeError: Can't instantiate abstract class Square without an implementation for abstract method 'area'Why, and the fix
An ABC refuses to create an object while any @abstractmethod is not overridden. That is its runtime guarantee, and the error names the missing method. Add def area(self) -> float: return self.side ** 2 to Square. mypy reports the same before running: Cannot instantiate abstract class "Square" with abstract attribute "area".
Python in the browser: Pyodide 314.0.7, MPL-2.0. Licence and source
Exit ticket
5 questions, no hints. Score 80% or more to complete the lesson.
Finish every activity above to unlock the exit ticket.