PYTH-270 · Lesson 7 of 12 · 25 min
Pythonic patterns: protocols, decorators and context managers
▶ Video coming soon A video version of this lesson is on the way.
Lessons 4 to 6 translated the Gang of Four's patterns into Python. This lesson goes the other way: it looks at the design tools Python itself provides, which handle problems that need whole patterns in other languages. Duck typing and protocols describe interfaces without inheritance. Decorators add behavior around functions. Context managers guarantee that setup is always paired with clean-up. Single dispatch chooses an implementation by type, and enumerations give names to fixed sets of values.
Knowing these tools is what makes Python code look Pythonic rather than like Java written in Python. Sam's first pull request at Ridgeline contained an AbstractExporterFactoryInterface; Lena replaced it with a Protocol of four lines and a dictionary. By the end of this lesson you will be able to make the same kind of simplification, and you will build a small plugin system for Ridgeline's report exporters.
Duck typing and EAFP
Python's core approach to interfaces is duck typing: if an object has the methods you need, you can use it, whatever its class. The name comes from the saying that if it walks like a duck and quacks like a duck, it is a duck. A function that calls provider.charge(cents) works with LumenPay, the Northwind adapter, the logging wrapper and a test fake, because all of them have charge, and none needs to inherit from a common base class.
Duck typing pairs naturally with a coding style called EAFP, easier to ask forgiveness than permission: try the operation and handle the exception if it fails, rather than checking types in advance (look before you leap, or LBYL). Checking isinstance(provider, LumenPay) before charging would defeat duck typing, because the adapter and the fake would be rejected even though they work.
def first_line(source):
"""Works with any object that has readline(): a file, a socket, a StringIO..."""
try:
return source.readline().rstrip("\n")
except AttributeError:
raise TypeError("expected something with a readline() method") from None
import io
print(first_line(io.StringIO("RC-101,Sunrise Blend\nRC-102,Ethiopia Guji")))
try:
first_line(42)
except TypeError as e:
print("TypeError:", e)RC-101,Sunrise Blend TypeError: expected something with a readline() method
Protocols: duck typing that tools can check
Duck typing is flexible but invisible: nothing in the code says which methods a provider must have, so a missing method shows up only when that line runs. typing.Protocol, added in Python 3.8, writes the expected interface down as a class. Type checkers such as mypy then verify that objects passed to a function have the right methods, without those objects inheriting from anything. This is called structural typing: compatibility is decided by structure, not by declared ancestry.
Protocols are the Pythonic answer to the interface segregation principle from lesson 2. You can declare small, precise interfaces, one per need, such as Chargeable with charge, Refundable with refund, and Labeled with name and price, and each function asks only for the protocol it uses. Adding @runtime_checkable also lets isinstance check an object against the protocol at run time, which checks only that the methods exist, not their signatures.
from typing import Protocol, runtime_checkable
@runtime_checkable
class Labelled(Protocol):
name: str
def price_text(self) -> str: ...
class Coffee:
def __init__(self, name, price):
self.name, self.price = name, price
def price_text(self):
return f"{self.price:.2f}"
class GiftCard: # unrelated class, same shape
def __init__(self, amount):
self.name, self.amount = "Gift card", amount
def price_text(self):
return f"{self.amount:.2f}"
def shelf_label(item: Labelled) -> str:
return f"{item.name:<16}{item.price_text():>8}"
for thing in [Coffee("Ethiopia Guji", 22), GiftCard(25), "not a product"]:
if isinstance(thing, Labelled):
print(shelf_label(thing))
else:
print("skipped:", repr(thing))Ethiopia Guji 22.00 Gift card 25.00 skipped: 'not a product'
Abstract base classes (the abc module) are the other way to describe interfaces. An ABC uses inheritance: subclasses must implement every abstract method or they cannot be instantiated, and the ABC can provide shared method implementations. Use an ABC when you control the classes, want to share code, and want missing methods caught at instantiation. Use a Protocol when the implementers are unrelated, come from third-party libraries, or should not depend on your code at all.
Decorators for cross-cutting concerns
Some concerns cut across many functions: logging, timing, retrying, caching, checking permissions, validating arguments. Writing the same lines at the top of every function scatters one concern across the codebase. A decorator is a function that takes a function and returns a new function wrapping it, so the concern is written once and applied with an @ line. You met decorators in PYTH-102; here the focus is on using them as a design tool.
Two details make decorators well behaved. functools.wraps copies the wrapped function's name, docstring and other metadata to the wrapper, so tracebacks, help() and test tools show the real name. And a decorator that takes arguments, such as @retry(times=3), is a function that returns a decorator, which is why it needs one more level of nesting.
import functools
def logged(log):
def decorate(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
log.append(f"call {func.__name__}{args}")
result = func(*args, **kwargs)
log.append(f"{func.__name__} returned {result!r}")
return result
return wrapper
return decorate
audit = []
@logged(audit)
def reorder_quantity(stock, target=40):
"""How many bags to roast to reach the target."""
return max(0, target - stock)
reorder_quantity(9)
reorder_quantity(42)
print("\n".join(audit))
print(reorder_quantity.__name__, "-", reorder_quantity.__doc__)call reorder_quantity(9,) reorder_quantity returned 31 call reorder_quantity(42,) reorder_quantity returned 0 reorder_quantity - How many bags to roast to reach the target.
Decorators are the open-closed principle in miniature: the decorated function is not edited, yet it gains behavior. They are also how many frameworks register code: Flask's @app.route, pytest's @pytest.fixture and the @shipper registry from lesson 2 all add the function to a table as a side effect of decorating it.
Try it yourself
A retry decorator
Calls to the shipping company's API sometimes fail with ConnectionError. Write a decorator factory retry(times) so that @retry(3) calls the function up to 3 times: if a call raises ConnectionError, try again; if all attempts fail, re-raise the last error. Any other exception is raised immediately without retrying.
Use functools.wraps so the decorated function keeps its __name__. (Real code would wait between attempts; leave that out here.)
import functools
def retry(times):
def decorate(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
return decorateContext managers: setup always paired with clean-up
Many operations come in pairs: open and close a file, acquire and release a lock, begin and commit or roll back a transaction, change a setting and change it back. Forgetting the second half, or skipping it when an exception occurs, causes leaked files, stuck locks and half-finished updates. The with statement and context managers guarantee the pairing: the clean-up runs however the block exits, normally or by an exception.
You can write a context manager as a class with __enter__ and __exit__, or more easily as a generator decorated with contextlib.contextmanager: the code before yield is the setup, the value yielded is bound by as, and the code after yield, placed in a finally block or after catching exceptions, is the clean-up. Ridgeline uses one to make multi-step stock changes all-or-nothing.
from contextlib import contextmanager
@contextmanager
def stock_transaction(stock):
snapshot = dict(stock) # setup: remember the starting state
try:
yield stock
except Exception:
stock.clear()
stock.update(snapshot) # clean-up on failure: roll back
raise
stock = {"RC-101": 42, "RC-104": 9}
try:
with stock_transaction(stock) as s:
s["RC-101"] -= 12 # first line of a wholesale order
if s["RC-104"] < 10:
raise ValueError("not enough RC-104 for the second line")
s["RC-104"] -= 10
except ValueError as e:
print("order refused:", e)
print(stock) # unchanged: the first line was rolled backorder refused: not enough RC-104 for the second line
{'RC-101': 42, 'RC-104': 9}Try it yourself
A temporary setting
Ridgeline's code reads a module-level dictionary SETTINGS. Tests sometimes need to change a setting briefly. Write a context manager temporary(key, value) using contextlib.contextmanager: inside the with block, SETTINGS[key] is value; afterwards the old value is restored, even if the block raises an exception.
If the key did not exist before, it must be removed afterwards.
from contextlib import contextmanager
SETTINGS = {"reorder_point": 10, "currency": "CAD"}
@contextmanager
def temporary(key, value):
SETTINGS[key] = value
yieldSingle dispatch and enumerations
functools.singledispatch chooses which implementation of a function to run based on the type of its first argument. It replaces if isinstance chains with separately registered implementations, and it is open for extension: another module can register an implementation for a new type without editing the original. Ridgeline uses it to format different kinds of values for receipts: Decimal amounts, dates and plain strings each get their own small function.
An enumeration (enum.Enum) gives names to a fixed set of values, such as order statuses or grind types. Using OrderStatus.PAID instead of the string 'paid' means a typo fails immediately instead of creating a status nobody checks for, editors can autocomplete the names, and the full set of values is listed in one place. Combined with the transition table from lesson 6, an enum makes the state machine both safe and readable.
from enum import Enum
from functools import singledispatch
from decimal import Decimal
from datetime import date
class Grind(Enum):
WHOLE = "whole bean"
FILTER = "filter"
ESPRESSO = "espresso"
@singledispatch
def receipt_text(value):
return str(value)
@receipt_text.register
def _(value: Decimal):
return f"{value:,.2f}"
@receipt_text.register
def _(value: date):
return value.strftime("%d %b %Y")
@receipt_text.register
def _(value: Grind):
return value.value.title()
for v in [Decimal("1234.5"), date(2026, 11, 10), Grind.ESPRESSO, "RC-106"]:
print(receipt_text(v))
print(Grind("filter") is Grind.FILTER)1,234.50 10 Nov 2026 Espresso RC-106 True
A plugin system with a protocol and a registry
Putting these tools together gives a design that appears in countless Python applications: plugins. Anita wants Ridgeline's monthly report as CSV for the accountant, as JSON for the web dashboard and, next year, perhaps as a spreadsheet. Each format is an exporter with a name and an export method. A Protocol describes the shape, a registry holds the exporters, and a decorator registers each one. The report code asks the registry for an exporter by name and never changes when formats are added.
Try it yourself
An exporter registry
Write a decorator exporter(cls) that creates an instance of the class it decorates and stores it in the dictionary EXPORTERS under the instance's name attribute, then returns the class unchanged. Then write export(fmt, rows) that finds the exporter and returns EXPORTERS[fmt].export(rows), raising ValueError for unknown formats.
Register two exporters: CsvExporter (name "csv") whose export returns lines like RC-101,42 joined with newlines, and TotalExporter (name "total") whose export returns the sum of the second values. Each row is a tuple (sku, bags).
EXPORTERS = {}
class CsvExporter:
name = "csv"
def export(self, rows):
return "\n".join(f"{sku},{bags}" for sku, bags in rows)
def export(fmt, rows):
return CsvExporter().export(rows)Worked problem
From Java-style to Pythonic
Sam's pull request adds report exporting with an ExporterInterface abstract class, an ExporterFactory class with a create method containing an if-chain, and a ReportManager singleton. Lena asks for a Pythonic version.
Show solution
- The interface: a typing.Protocol named Exporter with name and export(rows), so third-party exporters need not inherit anything.
- The factory: a dictionary EXPORTERS filled by an @exporter decorator, so new formats register themselves instead of extending an if-chain.
- The singleton: a plain module-level function export(fmt, rows); the module is the single shared instance.
- Formatting helpers that branched on type: functools.singledispatch.
- The result is about a third of the length, and adding a format touches only the new exporter's file.
Answer: Protocol instead of an interface hierarchy, a decorator-filled registry instead of a factory class, and a module function instead of a singleton.
Common mistakes
MistakeChecking isinstance against concrete classes before calling a method.
InsteadUse duck typing and EAFP, or check against a runtime_checkable Protocol.
MistakeForcing unrelated or third-party classes to inherit from your abstract base class.
InsteadDescribe the interface with a Protocol; structure decides compatibility.
MistakeWriting decorators without functools.wraps.
InsteadAlways wrap the inner function with @functools.wraps(func).
MistakeDoing clean-up only at the end of a block, so exceptions skip it.
InsteadUse a with statement and a context manager, with clean-up in finally.
MistakeRepresenting a fixed set of values as bare strings.
InsteadUse an Enum so typos fail and the set is listed in one place.
MistakeGrowing if isinstance chains to format different types.
InsteadUse functools.singledispatch and register an implementation per type.
Summary and key terms
- Duck typing uses any object with the needed methods; EAFP tries the operation and handles failure.
- typing.Protocol describes an interface by structure, so type checkers can verify duck typing; runtime_checkable allows isinstance checks.
- Use ABCs for shared implementation in classes you control, and Protocols for unrelated implementers.
- Decorators apply cross-cutting concerns such as logging and retrying in one place; use functools.wraps.
- Context managers guarantee clean-up; contextlib.contextmanager makes them easy to write.
- singledispatch selects an implementation by argument type; Enum names a fixed set of values.
- A protocol plus a decorator-filled registry is a simple, open-ended plugin system.
- duck typing
- Using an object according to the methods it has, not its declared type.
- EAFP
- Easier to ask forgiveness than permission: try first, handle exceptions.
- LBYL
- Look before you leap: check conditions before acting.
- Protocol
- A typing construct that describes an interface by the methods and attributes it requires.
- structural typing
- Deciding type compatibility by structure rather than inheritance.
- abstract base class
- A class with abstract methods that subclasses must implement.
- cross-cutting concern
- Behavior such as logging or retrying that applies across many functions.
- functools.wraps
- A decorator that copies a wrapped function's name and docstring to its wrapper.
- context manager
- An object that sets up and reliably cleans up around a with block.
- single dispatch
- Choosing a function implementation by the type of its first argument.
- enumeration
- A set of named constant values, written with enum.Enum.
- plugin
- An independently added implementation found through a registry.
Practice problems
Work each problem on paper first, then reveal the answer to check.
What does EAFP stand for, and how does it differ from LBYL?
Show answer
Easier to ask forgiveness than permission: try and handle exceptions, rather than checking first.
Does an object need to inherit from a Protocol to satisfy it?
Show answer
No: having the required methods and attributes is enough.
What does isinstance check for a runtime_checkable Protocol?
Show answer
Only that the required methods and attributes exist, not their signatures.
Why does a decorator with arguments need an extra level of nesting?
Show answer
The outer call takes the arguments and returns the actual decorator, which then takes the function.
Where should the clean-up code go in a generator-based context manager?
Show answer
After the yield, in a finally block (or an except block that re-raises) so it runs on errors too.
Name one benefit of OrderStatus.PAID over the string paid.
Show answer
A typo raises an error immediately, and all allowed values are listed in one place.
Lesson quiz · 5 questions
Score 60% or more to complete this lesson.
Create a free account to save your progress, take quizzes and earn a certificate.