Decorators and typing all 3 written
Functions that take and return functions, the standard library built on that idea, and type hints that catch mistakes before you run anything.
36 Decorators, built up from a plain function
A decorator is a function that takes a function and returns a function. The
@ syntax is shorthand for one assignment, and once you have seen that written out there is
nothing left to be mysterious about.
Built by hand, with no @ anywhere
# A function that takes a function and returns a replacement for it.
def announced(func):
def wrapper(*args, **kwargs):
print(f" calling {func.__name__}")
result = func(*args, **kwargs)
print(f" {func.__name__} returned {result!r}")
return result
return wrapper
def add(a, b):
return a + b
# The decorator applied manually: rebind the name to the wrapper.
add = announced(add)
print("add(2, 3):")
total = add(2, 3)
print("->", total)
test("the wrapped function still works", total, 5)
test("the name now refers to the wrapper", add.__name__, "wrapper")
What it printed, when the build ran it
add(2, 3): calling add add returned 5 -> 5 ok the wrapped function still works ok the name now refers to the wrapper
And @ is exactly that rebinding, written above the function instead of below it.
def announced(func):
def wrapper(*args, **kwargs):
print(f" calling {func.__name__}")
return func(*args, **kwargs)
return wrapper
@announced # identical to: multiply = announced(multiply)
def multiply(a, b):
return a * b
print("multiply(3, 4):")
print("->", multiply(3, 4))
print()
print("The two forms are the same operation. @ just puts it before the def,")
print("so a reader sees the decoration before reading the body.")
test("the decorated function works", multiply(3, 4), 12)
What it printed, when the build ran it
multiply(3, 4): calling multiply -> 12 The two forms are the same operation. @ just puts it before the def, so a reader sees the decoration before reading the body. calling multiply ok the decorated function works
The metadata problem, and functools.wraps
The wrapper is a different function, so it has its own name, docstring and signature. Everything that
reads those — help(), debuggers, documentation tools, some frameworks — now sees the wrapper
instead of your function.
import functools
def naive(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
def careful(func):
@functools.wraps(func) # copy the metadata across
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@naive
def documented_a(x):
"""Does something useful."""
return x
@careful
def documented_b(x):
"""Does something useful."""
return x
print("without wraps:")
print(" __name__:", documented_a.__name__)
print(" __doc__: ", documented_a.__doc__)
print("with wraps:")
print(" __name__:", documented_b.__name__)
print(" __doc__: ", documented_b.__doc__)
# wraps also keeps a reference to the original, which is occasionally vital.
print()
print("the original is reachable:", documented_b.__wrapped__.__name__)
test("without wraps the identity is lost", documented_a.__name__, "wrapper")
test("and so is the docstring", documented_a.__doc__, None)
test("wraps restores both", (documented_b.__name__, documented_b.__doc__),
("documented_b", "Does something useful."))
test("and keeps the original reachable", documented_b.__wrapped__.__name__, "documented_b")
What it printed, when the build ran it
without wraps: __name__: wrapper __doc__: None with wraps: __name__: documented_b __doc__: Does something useful. the original is reachable: documented_b ok without wraps the identity is lost ok and so is the docstring ok wraps restores both ok and keeps the original reachable
Always use @functools.wraps. The cost of omitting it is not cosmetic: two
different functions decorated by the same naive decorator both report __name__ ==
"wrapper", so a traceback, a log line or a profiler cannot tell them apart. There is no reason
to leave it out.
A decorator that does something useful
import functools, time
def timed(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
try:
return func(*args, **kwargs)
finally:
wrapper.last_duration = time.perf_counter() - start
return wrapper
@timed
def slow_sum(n):
return sum(range(n))
result = slow_sum(200_000)
print("result:", result)
print(f"took: {slow_sum.last_duration:.6f}s")
# The finally means the timing is recorded even when the function raises.
@timed
def fails():
raise ValueError("boom")
try:
fails()
except ValueError:
pass
print()
print("timing was still recorded on failure:", fails.last_duration >= 0)
test("the decorated function returns the right answer", result, sum(range(200_000)))
test("the duration was recorded", slow_sum.last_duration > 0, True)
test("and recorded even when the call raised", fails.last_duration >= 0, True)
What it printed, when the build ran it
result: 19999900000 took: 0.002802s timing was still recorded on failure: True ok the decorated function returns the right answer ok the duration was recorded ok and recorded even when the call raised
Decorators that take arguments: one more layer
A decorator taking arguments is a function that returns a decorator. That is three nested functions, which is the part people find confusing — so it is worth writing out slowly.
import functools
def repeat(times): # 1. takes the ARGUMENT
def decorator(func): # 2. takes the FUNCTION
@functools.wraps(func)
def wrapper(*args, **kwargs): # 3. takes the CALL's arguments
results = [func(*args, **kwargs) for _ in range(times)]
return results
return wrapper
return decorator
@repeat(times=3)
def greet(name):
return f"hi {name}"
print("greet('ada'):", greet("ada"))
# Written out without @, the three layers are visible:
def shout(text):
return text.upper()
shout = repeat(2)(shout)
print("shout('hey'): ", shout("hey"))
test("the argument controlled the repetition", greet("ada"), ["hi ada", "hi ada", "hi ada"])
test("and the manual form is identical", shout("hey"), ["HEY", "HEY"])
test("wraps still preserved the name", greet.__name__, "greet")
What it printed, when the build ran it
greet('ada'): ['hi ada', 'hi ada', 'hi ada']
shout('hey'): ['HEY', 'HEY']
ok the argument controlled the repetition
ok and the manual form is identical
ok wraps still preserved the name
How to tell which you are looking at. @decorator with no brackets is
applied to your function directly. @decorator(...) is called first, and whatever it
returns is applied. That is why @functools.cache and
@functools.lru_cache(maxsize=100) both work — the first is a decorator, the second is a
decorator factory.
import functools
# A decorator that works both with and without arguments, which is what
# library code does to be forgiving. The trick: detect whether it was handed
# a function or an argument.
def retry(func=None, *, attempts=3):
def decorator(f):
@functools.wraps(f)
def wrapper(*args, **kwargs):
last = None
for attempt in range(1, attempts + 1):
try:
return f(*args, **kwargs)
except ValueError as exc:
last = exc
raise last
return wrapper
# Called as @retry -> func is the function. As @retry(...) -> func is None.
return decorator if func is None else decorator(func)
calls = []
@retry # no brackets
def flaky_a():
calls.append("a")
if len(calls) < 2:
raise ValueError("not yet")
return "a succeeded"
@retry(attempts=5) # with brackets
def flaky_b():
calls.append("b")
raise ValueError("always fails")
print("flaky_a():", flaky_a())
try:
flaky_b()
except ValueError as exc:
print("flaky_b() gave up after 5:", exc)
print()
print("call log:", calls)
test("the bare form works", flaky_a.__name__, "flaky_a")
test("the argument form respected attempts=5", calls.count("b"), 5)
test("and the last exception was re-raised", len(calls) > 5, True)
What it printed, when the build ran it
flaky_a(): a succeeded flaky_b() gave up after 5: always fails call log: ['a', 'a', 'b', 'b', 'b', 'b', 'b'] ok the bare form works ok the argument form respected attempts=5 ok and the last exception was re-raised
Stacking: the order is bottom-up
import functools
def outer(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return f"outer({func(*args, **kwargs)})"
return wrapper
def inner(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return f"inner({func(*args, **kwargs)})"
return wrapper
@outer
@inner
def value():
return "x"
print("result:", value())
print()
print("@inner is applied FIRST (it is closest to the def), then @outer wraps")
print("that. So the equivalent assignment is: value = outer(inner(value))")
# Reversing the order reverses the nesting.
@inner
@outer
def other():
return "x"
print()
print("reversed:", other())
test("the decorator nearest the def is applied first", value(), "outer(inner(x))")
test("and swapping them swaps the nesting", other(), "inner(outer(x))")
What it printed, when the build ran it
result: outer(inner(x)) @inner is applied FIRST (it is closest to the def), then @outer wraps that. So the equivalent assignment is: value = outer(inner(value)) reversed: inner(outer(x)) ok the decorator nearest the def is applied first ok and swapping them swaps the nesting
Decorating a method, and the self that must pass through
import functools
def logged(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
# self arrives as args[0] -- *args is what makes this work unchanged.
print(f" {func.__name__} called on {type(args[0]).__name__}")
return func(*args, **kwargs)
return wrapper
class Account:
def __init__(self, balance):
self.balance = balance
@logged
def deposit(self, amount):
self.balance += amount
return self.balance
a = Account(100)
print("deposit(50):")
print("->", a.deposit(50))
test("the method still mutates the instance", a.balance, 150)
test("and the decorator saw the instance", a.deposit(0), 150)
What it printed, when the build ran it
deposit(50): deposit called on Account -> 150 ok the method still mutates the instance deposit called on Account ok and the decorator saw the instance
Order matters with staticmethod, classmethod and
property. Those must be the outermost decorator — closest to the
class body, furthest from the def — because they change how the attribute is
looked up rather than wrapping a call. Get it wrong and the failure is worse than an error: reading
the attribute quietly hands back a bound method instead of the value.
import functools
def logged(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
class Broken:
@logged # wrong: this wraps the property OBJECT, not the getter
@property
def value(self):
return 42
got = Broken().value
print("reading .value gave a:", type(got).__name__)
print("which is not 42, and did not raise")
# The error only appears when something tries to use it.
try:
got()
except TypeError as exc:
print()
print("calling it:", exc)
test("reading the attribute silently returns a method, not the value",
type(got).__name__, "method")
test("so nothing raised at the point of the mistake", got is not None, True)
What it printed, when the build ran it
reading .value gave a: method which is not 42, and did not raise calling it: 'property' object is not callable ok reading the attribute silently returns a method, not the value ok so nothing raised at the point of the mistake
import functools
def logged(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
class Correct:
@property # outermost
@logged # wraps the getter itself
def value(self):
return 42
print("value:", Correct().value, "<- the value, not a method")
print("the class attribute is a property:", isinstance(Correct.value, property))
test("property outermost gives the value", Correct().value, 42)
test("and the attribute really is a property", isinstance(Correct.value, property), True)
What it printed, when the build ran it
value: 42 <- the value, not a method the class attribute is a property: True ok property outermost gives the value ok and the attribute really is a property
A class as a decorator, when state is involved
import functools
class CountCalls:
"""A decorator with state, written as a class."""
def __init__(self, func):
functools.update_wrapper(self, func) # the wraps equivalent
self.func = func
self.count = 0
def __call__(self, *args, **kwargs):
self.count += 1
return self.func(*args, **kwargs)
@CountCalls
def ping():
return "pong"
ping(); ping(); ping()
print("result:", ping())
print("calls: ", ping.count)
print("name preserved:", ping.__name__)
test("the function works", ping(), "pong")
test("the decorator counted every call", ping.count, 5)
test("update_wrapper preserved the name", ping.__name__, "ping")
What it printed, when the build ran it
result: pong calls: 4 name preserved: ping ok the function works ok the decorator counted every call ok update_wrapper preserved the name
What to take from this lesson
@decoratoris exactlyname = decorator(name).- Always apply
@functools.wraps(func)to the wrapper. - A decorator with arguments is a function returning a decorator — three nested layers.
- Stacked decorators apply bottom-up: the one nearest the
defgoes first. *args, **kwargsis what lets one decorator work on functions and methods alike.property,classmethodandstaticmethodmust be outermost.
37 functools: caching, partials and wraps
functools is the module for functions that operate on functions.
Three of its members you have already met; the rest solve problems you have probably already
hand-rolled.
cache and lru_cache: remembering results
from functools import cache
import time
calls = []
@cache # unbounded; lru_cache(maxsize=None) is the same thing
def fibonacci(n):
calls.append(n)
if n < 2:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
print("fibonacci(30) =", fibonacci(30))
print("distinct arguments computed:", len(set(calls)))
print("total calls to the body: ", len(calls))
print()
print("Without the cache this recursion makes over a million calls.")
print("cache info:", fibonacci.cache_info())
test("the answer is right", fibonacci(30), 832040)
test("each argument was computed once", len(calls), len(set(calls)))
test("which is 31 distinct values for n=0..30", len(set(calls)), 31)
test("and later calls are pure cache hits", fibonacci.cache_info().hits > 0, True)
What it printed, when the build ran it
fibonacci(30) = 832040 distinct arguments computed: 31 total calls to the body: 31 Without the cache this recursion makes over a million calls. cache info: CacheInfo(hits=28, misses=31, maxsize=None, currsize=31) ok the answer is right ok each argument was computed once ok which is 31 distinct values for n=0..30 ok and later calls are pure cache hits
The difference between the two: cache is unbounded, lru_cache(maxsize=n)
keeps only the most recently used n and discards the rest.
from functools import lru_cache
computed = []
@lru_cache(maxsize=2) # room for two results only
def square(n):
computed.append(n)
return n * n
square(1); square(2) # both cached
square(1) # a hit -- 1 is now the most recently used
square(3) # evicts the least recent, which is 2
square(2) # recomputed, because it was evicted
print("arguments actually computed:", computed)
print("cache info:", square.cache_info())
# The cache can be inspected and cleared.
square.cache_clear()
print("after cache_clear:", square.cache_info())
test("2 had to be recomputed after eviction", computed, [1, 2, 3, 2])
test("the cache never held more than maxsize", square.cache_info().maxsize, 2)
test("cache_clear empties it", square.cache_info().currsize, 0)
What it printed, when the build ran it
arguments actually computed: [1, 2, 3, 2] cache info: CacheInfo(hits=1, misses=4, maxsize=2, currsize=2) after cache_clear: CacheInfo(hits=0, misses=0, maxsize=2, currsize=0) ok 2 had to be recomputed after eviction ok the cache never held more than maxsize ok cache_clear empties it
Arguments must be hashable, and the cache never forgets. A cached function cannot be
called with a list or a dict. And an unbounded @cache holds a reference to every argument and
every result for the lifetime of the process — which is a memory leak if the inputs keep varying. Use
lru_cache(maxsize=...) unless the set of possible arguments is genuinely small.
from functools import cache
@cache
def total(values):
return sum(values)
total([1, 2, 3])
The traceback it really produced, when the build ran it
Traceback (most recent call last):
File "example.py", line 7, in <module>
total([1, 2, 3])
~~~~~^^^^^^^^^^^
TypeError: unhashable type: 'list'
from functools import cache
@cache
def total(values):
return sum(values)
# The fix: accept something hashable at the boundary.
print("with a tuple:", total((1, 2, 3)))
# Or wrap, so callers can still pass a list.
def total_from_list(values):
return total(tuple(values))
print("via a wrapper:", total_from_list([1, 2, 3]))
test("a tuple works as a cached argument", total((1, 2, 3)), 6)
test("and converting at the boundary keeps the caller's convenience",
total_from_list([1, 2, 3]), 6)
What it printed, when the build ran it
with a tuple: 6 via a wrapper: 6 ok a tuple works as a cached argument ok and converting at the boundary keeps the caller's convenience
The subtler cost: caching a method keeps every instance alive, because the cache holds
self as part of the key.
from functools import lru_cache
class Heavy:
def __init__(self, label):
self.label = label
@lru_cache(maxsize=None) # the cache lives on the CLASS, not the instance
def compute(self):
return f"computed for {self.label}"
# Each instance that calls compute() is stored in the class-level cache forever.
for i in range(3):
Heavy(f"item{i}").compute()
print("cache entries:", Heavy.compute.cache_info().currsize)
print("Those three instances cannot be garbage collected, because the cache")
print("on the CLASS is still holding each 'self' as part of its key.")
# The safe patterns: cached_property for per-instance, or cache a plain function.
class Better:
def __init__(self, label):
self.label = label
def compute(self):
return _compute(self.label) # cache keyed on the VALUE, not on self
@lru_cache(maxsize=None)
def _compute(label):
return f"computed for {label}"
print()
print("Better:", Better("x").compute())
print("keyed on the label only, so no instance is retained")
test("the method cache retained one entry per instance",
Heavy.compute.cache_info().currsize, 3)
test("the value-keyed version still caches", _compute("x"), _compute("x"))
test("and its cache holds one entry for one label",
_compute.cache_info().currsize, 1)
What it printed, when the build ran it
cache entries: 3 Those three instances cannot be garbage collected, because the cache on the CLASS is still holding each 'self' as part of its key. Better: computed for x keyed on the label only, so no instance is retained ok the method cache retained one entry per instance ok the value-keyed version still caches ok and its cache holds one entry for one label
cached_property: compute once, per instance
The answer to lesson 31's warning that a property should be cheap. cached_property computes
on first access and stores the result on the instance, so later reads are plain attribute lookups.
from functools import cached_property
class Report:
def __init__(self, rows):
self.rows = rows
self.computations = 0
@property
def total_uncached(self):
self.computations += 1
return sum(self.rows)
@cached_property
def total_cached(self):
self.computations += 1
return sum(self.rows)
r = Report([1, 2, 3])
r.total_uncached, r.total_uncached, r.total_uncached
uncached_count = r.computations
print("plain property, read 3 times -> computed", uncached_count, "times")
r.computations = 0
r.total_cached, r.total_cached, r.total_cached
cached_count = r.computations
print("cached_property, read 3 times -> computed", cached_count, "time")
# It is stored in the instance dict, so it can be invalidated by deleting it.
print()
print("stored on the instance:", "total_cached" in r.__dict__)
del r.total_cached
r.computations = 0
r.total_cached
print("after deleting it, recomputed:", r.computations, "time")
test("a plain property recomputed on all three reads", uncached_count, 3)
test("cached_property computed only once", cached_count, 1)
test("the result is stored on the instance", "total_cached" in r.__dict__, True)
test("deleting the attribute forces a recompute", r.computations, 1)
What it printed, when the build ran it
plain property, read 3 times -> computed 3 times cached_property, read 3 times -> computed 1 time stored on the instance: True after deleting it, recomputed: 1 time ok a plain property recomputed on all three reads ok cached_property computed only once ok the result is stored on the instance ok deleting the attribute forces a recompute
cached_property needs a __dict__, so it cannot be used with
__slots__ — including @dataclass(slots=True). And because the value is
stored on the instance, it goes stale if the inputs change: it is right for something derived from data
that does not change after construction, and wrong for anything else.
partial: fixing some arguments now
from functools import partial
def power(base, exponent):
return base ** exponent
# Fix the first argument positionally.
powers_of_two = partial(power, 2)
print("powers_of_two(10):", powers_of_two(10))
# Or fix a later one by keyword.
square = partial(power, exponent=2)
cube = partial(power, exponent=3)
print("square(7):", square(7))
print("cube(3): ", cube(3))
# A real use: supplying a configured function where a plain one is expected.
lines = ["10", "1010", "ff"]
as_binary = partial(int, base=2)
print()
print("parsed as binary:", [as_binary(x) for x in lines[:2]])
print("parsed as hex: ", int("ff", base=16))
# partial keeps what it was built from, which is useful when debugging.
print()
print("func:", powers_of_two.func.__name__, " args:", powers_of_two.args)
test("a positional argument can be fixed", powers_of_two(10), 1024)
test("and a keyword one", (square(7), cube(3)), (49, 27))
test("partial composes with built-ins", as_binary("1010"), 10)
test("and records what it was built from", powers_of_two.args, (2,))
What it printed, when the build ran it
powers_of_two(10): 1024 square(7): 49 cube(3): 27 parsed as binary: [2, 10] parsed as hex: 255 func: power args: (2,) ok a positional argument can be fixed ok and a keyword one ok partial composes with built-ins ok and records what it was built from
partial versus a lambda. partial(power, 2) and
lambda e: power(2, e) do the same thing here, but partial binds the value
immediately — which is why it appeared in lesson 17 as a fix for late binding — and it keeps
.func and .args visible for debugging. A lambda is fine for a one-off;
partial is better when the result is stored, passed around, or built in a loop.
reduce, and why it is usually the wrong tool
from functools import reduce
import math
import operator
numbers = [1, 2, 3, 4]
# reduce folds a sequence into one value.
print("reduce(add):", reduce(operator.add, numbers))
print("but sum() exists:", sum(numbers))
print()
print("reduce(mul):", reduce(operator.mul, numbers))
print("and math.prod exists:", math.prod(numbers))
# Where reduce is genuinely useful: a fold with no built-in equivalent.
words = ["apple", "fig", "cherry"]
longest = reduce(lambda a, b: a if len(a) >= len(b) else b, words)
print()
print("longest via reduce:", longest)
print("but max(key=len) is clearer:", max(words, key=len))
# An initial value matters for an empty sequence.
print()
print("reduce with initial on empty:", reduce(operator.add, [], 0))
test("reduce can add", reduce(operator.add, numbers), 10)
test("and sum agrees", sum(numbers), reduce(operator.add, numbers))
test("max with a key is equivalent to that fold", max(words, key=len), longest)
test("an initial value makes the empty case safe", reduce(operator.add, [], 0), 0)
What it printed, when the build ran it
reduce(add): 10 but sum() exists: 10 reduce(mul): 24 and math.prod exists: 24 longest via reduce: cherry but max(key=len) is clearer: cherry reduce with initial on empty: 0 ok reduce can add ok and sum agrees ok max with a key is equivalent to that fold ok an initial value makes the empty case safe
from functools import reduce
import operator
reduce(operator.add, [])
The traceback it really produced, when the build ran it
Traceback (most recent call last):
File "example.py", line 4, in <module>
reduce(operator.add, [])
~~~~~~^^^^^^^^^^^^^^^^^^
TypeError: reduce() of empty iterable with no initial value
Reach for sum, math.prod, min, max, or an
explicit loop before reduce. Guido removed it from builtins in Python 3 for a
reason: reduce(lambda a, b: ..., xs) requires the reader to simulate the fold in their head,
while a three-line loop states what it is doing. Keep it for the genuine folds with no built-in, and give
it an initial value.
singledispatch: replacing an isinstance chain
from functools import singledispatch
from datetime import date
from decimal import Decimal
# The pattern this replaces: a growing isinstance chain in one function.
def describe_by_chain(value):
if isinstance(value, int):
return f"int: {value}"
if isinstance(value, (list, tuple)):
return f"sequence of {len(value)}"
if isinstance(value, date):
return f"date: {value.isoformat()}"
return f"something: {value!r}"
# The same behaviour, dispatched on the argument's type.
@singledispatch
def describe(value):
return f"something: {value!r}" # the fallback
@describe.register
def _(value: int):
return f"int: {value}"
@describe.register
def _(value: list):
return f"sequence of {len(value)}"
@describe.register
def _(value: date):
return f"date: {value.isoformat()}"
values = [1, [1, 2], date(2026, 1, 1), "x"]
for v in values:
print(f" {repr(v):>22} {describe(v)}")
print()
print("Registering a new type does not touch the existing function -- which")
print("is the whole difference from editing an isinstance chain.")
# It dispatches on the MRO, so a bool (a subclass of int) finds the int handler.
print()
print("describe(True):", describe(True), "<- bool is a subclass of int")
test("each registered type gets its own implementation",
[describe(v) for v in values],
[describe_by_chain(v) for v in values])
test("the fallback catches unregistered types", describe("x"), "something: 'x'")
test("dispatch follows the MRO, so bool uses the int handler", describe(True), "int: True")
test("and the registry is introspectable", date in describe.registry, True)
What it printed, when the build ran it
1 int: 1
[1, 2] sequence of 2
datetime.date(2026, 1, 1) date: 2026-01-01
'x' something: 'x'
Registering a new type does not touch the existing function -- which
is the whole difference from editing an isinstance chain.
describe(True): int: True <- bool is a subclass of int
ok each registered type gets its own implementation
ok the fallback catches unregistered types
ok dispatch follows the MRO, so bool uses the int handler
ok and the registry is introspectable
total_ordering and wraps, recalled
from functools import total_ordering, wraps
# total_ordering, from lesson 29: define __eq__ and one comparison, get the rest.
@total_ordering
class Weight:
def __init__(self, grams):
self.grams = grams
def __eq__(self, other):
return self.grams == other.grams
def __lt__(self, other):
return self.grams < other.grams
a, b = Weight(100), Weight(200)
print("a < b :", a < b)
print("a <= b:", a <= b)
print("a > b :", a > b)
print("a >= b:", a >= b)
print("derived methods:", sorted(
m for m in ("__le__", "__gt__", "__ge__") if m in Weight.__dict__
))
# wraps, from lesson 36: the one line every decorator needs.
def noop(func):
@wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@noop
def documented():
"""Kept."""
return 1
print()
print("wraps kept:", documented.__name__, "/", documented.__doc__)
test("total_ordering derived the other comparisons", (a <= b, a > b, a >= b), (True, False, False))
test("and they were added to the class", "__le__" in Weight.__dict__, True)
test("wraps preserved the identity", documented.__name__, "documented")
What it printed, when the build ran it
a < b : True a <= b: True a > b : False a >= b: False derived methods: ['__ge__', '__gt__', '__le__'] wraps kept: documented / Kept. ok total_ordering derived the other comparisons ok and they were added to the class ok wraps preserved the identity
What to take from this lesson
@cachefor memoisation with a small argument space;@lru_cache(maxsize=n)otherwise. Arguments must be hashable.- Never put
lru_cacheon a method — the class-level cache retains every instance. cached_propertyfor an expensive derived value, on a type whose inputs do not change.partialto fix arguments now, which also binds values immediately.- Prefer
sum,max,math.prodor a loop overreduce; givereducean initial value when you do use it. singledispatchinstead of an isinstance chain that keeps growing.
38 Type hints, and what they do and do not do
Annotations describe what a function expects and returns. Python itself does nothing with them at runtime — the checking is done by a separate tool before you run anything. Knowing exactly where that line falls is the difference between type hints helping and type hints lying.
Nothing is checked at runtime. Proven.
def repeat(text: str, times: int) -> str:
return text * times
# Correct use.
print("repeat('ab', 2):", repeat("ab", 2))
# Entirely wrong types. The annotation does not object.
print("repeat([1], 3): ", repeat([1], 3), "<- a list, and it worked")
print("repeat('x', True):", repeat("x", True), "<- a bool where int was promised")
# The annotations are just data hanging off the function.
print()
print("annotations:", repeat.__annotations__)
test("the annotated use works", repeat("ab", 2), "abab")
test("a wrong type is not rejected", repeat([1], 3), [1, 1, 1])
test("annotations are only recorded", set(repeat.__annotations__), {"text", "times", "return"})
What it printed, when the build ran it
repeat('ab', 2): abab
repeat([1], 3): [1, 1, 1] <- a list, and it worked
repeat('x', True): x <- a bool where int was promised
annotations: {'text': <class 'str'>, 'times': <class 'int'>, 'return': <class 'str'>}
ok the annotated use works
ok a wrong type is not rejected
ok annotations are only recorded
So an annotation is a promise you still have to keep. If a value genuinely must be an integer, validate it and raise — lesson 22's discipline applies unchanged. Annotate and check; the annotation tells a reader and a checker what the check enforces.
What a checker would catch, shown rather than run
This course installs nothing, and the type checkers — mypy, pyright — are
third-party. So the panel below is a listing: the errors are what a checker reports on this exact code,
and they are labelled as not executed here because nothing in this build can produce them.
# Run through mypy, this file produces the four errors shown in the comments.
# NOT RUN HERE: mypy is not part of the standard library and this course
# installs nothing, so these messages are quoted rather than captured.
def greet(name: str) -> str:
return f"hello {name}"
greet(42)
# error: Argument 1 to "greet" has incompatible type "int"; expected "str"
total: int = "not a number"
# error: Incompatible types in assignment
# (expression has type "str", variable has type "int")
def bad_return(x: int) -> str:
return x
# error: Incompatible return value type (got "int", expected "str")
def maybe(flag: bool) -> str | None:
return "yes" if flag else None
print(maybe(True).upper())
# error: Item "None" of "str | None" has no attribute "upper"
These are mypy diagnostics, and mypy is a third-party package. This course verifies every runnable panel by executing it and installs nothing, so a checker cannot run here — the messages are quoted from what mypy reports on this code rather than captured from a run. Everything on this page that CAN be demonstrated at runtime is in an executed panel.
The fourth error is the one that earns its keep. str | None says the
function may return nothing, and a checker then refuses every use that assumes otherwise. That is the
single highest-value thing type hints do: they turn "this occasionally returns None and crashes three
months later" into an error before the code ever runs.
The notation you will actually write
from typing import Any
# Builtin generics, since 3.9 -- no typing.List needed.
def totals(rows: list[dict[str, int]]) -> dict[str, int]:
out: dict[str, int] = {}
for row in rows:
for key, value in row.items():
out[key] = out.get(key, 0) + value
return out
print(totals([{"a": 1}, {"a": 2, "b": 3}]))
# Unions with |, since 3.10. Optional[X] is just X | None.
def parse(text: str) -> int | None:
try:
return int(text)
except ValueError:
return None
print()
print("parse('12'):", parse("12"))
print("parse('x'): ", parse("x"))
# Tuples: fixed length, or homogeneous with ...
def pair() -> tuple[str, int]:
return ("a", 1)
def many() -> tuple[int, ...]:
return (1, 2, 3)
print()
print("fixed-length tuple:", pair())
print("variable-length: ", many())
# Callable, and Any as the deliberate escape hatch.
from collections.abc import Callable
def apply_twice(fn: Callable[[int], int], value: int) -> int:
return fn(fn(value))
print()
print("apply_twice:", apply_twice(lambda n: n + 3, 1))
test("builtin generics work", totals([{"a": 1}, {"a": 2}]), {"a": 3})
test("a union return is just a normal return", (parse("12"), parse("x")), (12, None))
test("Callable describes a function parameter", apply_twice(lambda n: n * 2, 3), 12)
What it printed, when the build ran it
{'a': 3, 'b': 3}
parse('12'): 12
parse('x'): None
fixed-length tuple: ('a', 1)
variable-length: (1, 2, 3)
apply_twice: 7
ok builtin generics work
ok a union return is just a normal return
ok Callable describes a function parameter
get_type_hints: the runtime behaviour annotations do have
from typing import get_type_hints
def transfer(amount: int, note: str = "") -> bool:
return amount > 0
# __annotations__ is the raw form; get_type_hints resolves strings and defaults.
print("raw: ", transfer.__annotations__)
print("resolved:", get_type_hints(transfer))
# It works on classes too, which is how dataclasses and validation libraries
# read your field types.
class Account:
owner: str
balance: int = 0
print()
print("class hints:", get_type_hints(Account))
# Forward references -- a name not defined yet -- are written as strings and
# resolved later. This is the one case where the string form is required.
class Node:
def __init__(self, value: int, parent: "Node | None" = None):
self.value = value
self.parent = parent
print()
print("forward reference resolved:", get_type_hints(Node.__init__)["parent"])
test("get_type_hints resolves the annotations", get_type_hints(transfer)["amount"], int)
test("it reads class-level annotations", get_type_hints(Account)["balance"], int)
test("and resolves a string forward reference",
get_type_hints(Node.__init__)["parent"] is not None, True)
What it printed, when the build ran it
raw: {'amount': <class 'int'>, 'note': <class 'str'>, 'return': <class 'bool'>}
resolved: {'amount': <class 'int'>, 'note': <class 'str'>, 'return': <class 'bool'>}
class hints: {'owner': <class 'str'>, 'balance': <class 'int'>}
forward reference resolved: __main__.Node | None
ok get_type_hints resolves the annotations
ok it reads class-level annotations
ok and resolves a string forward reference
TypedDict: a shape for a dict
For data that arrives as a dict — parsed JSON, mostly — TypedDict describes the expected
keys. At runtime it is a plain dict with no checking whatsoever, which is worth seeing.
from typing import TypedDict
class Movie(TypedDict):
title: str
year: int
correct: Movie = {"title": "Arrival", "year": 2016}
print("correct:", correct)
print("its actual type:", type(correct).__name__)
# Constructed with completely wrong types. Nothing objects at runtime.
wrong = Movie(title=1, year="not a year")
print()
print("wrong types accepted:", wrong)
print("still just a dict: ", type(wrong).__name__)
# And it is a dict, so every dict operation works.
print()
print("keys:", list(correct))
print("get with a default:", correct.get("director", "unknown"))
test("a TypedDict is a plain dict at runtime", type(correct).__name__, "dict")
test("wrong value types are not rejected", wrong["title"], 1)
test("and dict methods all work", correct.get("director", "unknown"), "unknown")
What it printed, when the build ran it
correct: {'title': 'Arrival', 'year': 2016}
its actual type: dict
wrong types accepted: {'title': 1, 'year': 'not a year'}
still just a dict: dict
keys: ['title', 'year']
get with a default: unknown
ok a TypedDict is a plain dict at runtime
ok wrong value types are not rejected
ok and dict methods all work
Protocol: describing a shape rather than a base class
Lesson 30 argued for composition and duck typing. A Protocol is how you give that a name
without forcing anyone to inherit from you: any class with the right methods satisfies it.
from typing import Protocol, runtime_checkable
class Renderable(Protocol):
def render(self) -> str: ...
# Neither of these inherits from Renderable.
class Button:
def render(self) -> str:
return "[Button]"
class Heading:
def render(self) -> str:
return "# Heading"
class NotRenderable:
pass
def draw(items: list[Renderable]) -> str:
return " ".join(item.render() for item in items)
print("draw:", draw([Button(), Heading()]))
print("neither inherits from the protocol:", Button.__bases__)
test("structural typing needs no inheritance", draw([Button()]), "[Button]")
test("and the classes are unrelated to the protocol", Renderable in Button.__mro__, False)
What it printed, when the build ran it
draw: [Button] # Heading neither inherits from the protocol: (<class 'object'>,) ok structural typing needs no inheritance ok and the classes are unrelated to the protocol
By default a Protocol cannot be used with isinstance, because checking a
structural type at runtime is not free. Opting in with @runtime_checkable enables it — and it
checks only that the methods exist, not their signatures.
from typing import Protocol
class Renderable(Protocol):
def render(self) -> str: ...
class Button:
def render(self) -> str:
return "[Button]"
isinstance(Button(), Renderable)
The traceback it really produced, when the build ran it
Traceback (most recent call last):
File "example.py", line 10, in <module>
isinstance(Button(), Renderable)
~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^
File "<python>/lib/python3.14/typing.py", line 2048, in __instancecheck__
raise TypeError("Instance and class checks can only be used with"
" @runtime_checkable protocols")
TypeError: Instance and class checks can only be used with @runtime_checkable protocols
from typing import Protocol, runtime_checkable
@runtime_checkable
class Renderable(Protocol):
def render(self) -> str: ...
class Button:
def render(self) -> str:
return "[Button]"
class Misleading:
def render(self, extra, arguments, entirely_wrong):
return "?"
class Nothing:
pass
print("Button: ", isinstance(Button(), Renderable))
print("Nothing: ", isinstance(Nothing(), Renderable))
print("Misleading: ", isinstance(Misleading(), Renderable), "<- signature NOT checked")
test("a conforming class passes", isinstance(Button(), Renderable), True)
test("a class without the method fails", isinstance(Nothing(), Renderable), False)
test("but only the method's existence is checked, not its signature",
isinstance(Misleading(), Renderable), True)
What it printed, when the build ran it
Button: True Nothing: False Misleading: True <- signature NOT checked ok a conforming class passes ok a class without the method fails ok but only the method's existence is checked, not its signature
Generics, with the current syntax
Since 3.12 a type parameter is declared in square brackets on the function or class, with no
TypeVar import. The point of a generic is that the relationship between input and output types
is preserved — first(list[int]) returns an int, not an Any.
from typing import get_type_hints
# PEP 695 syntax: the type parameter is declared in brackets.
def first[T](items: list[T]) -> T:
return items[0]
print("first([1, 2]): ", first([1, 2]))
print("first(['a', 'b']): ", first(["a", "b"]))
print("hints:", get_type_hints(first))
# A generic class.
class Box[T]:
def __init__(self, item: T) -> None:
self.item = item
def get(self) -> T:
return self.item
box_of_int = Box(5)
print()
print("Box(5).get():", box_of_int.get())
print("Box[int] is a real object:", Box[int])
# Constraining the parameter: T must support <
def largest[T: (int, float, str)](items: list[T]) -> T:
return max(items)
print()
print("largest([3, 1]): ", largest([3, 1]))
print("largest(['a', 'z']):", largest(["a", "z"]))
test("a generic function works for any type", (first([1, 2]), first(["a"])), (1, "a"))
test("a generic class works too", Box(5).get(), 5)
test("and can be subscripted", Box[int] is not None, True)
test("a constrained parameter still just runs", largest([3, 1]), 3)
What it printed, when the build ran it
first([1, 2]): 1
first(['a', 'b']): a
hints: {'items': list[T], 'return': T}
Box(5).get(): 5
Box[int] is a real object: __main__.Box[int]
largest([3, 1]): 3
largest(['a', 'z']): z
ok a generic function works for any type
ok a generic class works too
ok and can be subscripted
ok a constrained parameter still just runs
Where hints pay for themselves, and where they do not
Annotate the boundaries. Public functions, class attributes, anything another module
calls, and anything returning X | None. Those are where a wrong assumption travels furthest
and where a checker catches the most. Skip them on short local helpers and on obvious loop variables:
for i: int in range(10) is not even legal, and
count: int = 0 adds nothing a reader did not already know.
# Over-annotated: every line states what is already obvious.
def over(values: list[int]) -> int:
total: int = 0
index: int = 0
for value in values:
doubled: int = value * 2
total = total + doubled
index = index + 1
return total
# The same function, annotated only where it helps: the signature.
def measured(values: list[int]) -> int:
total = 0
for value in values:
total += value * 2
return total
print("over: ", over([1, 2, 3]))
print("measured:", measured([1, 2, 3]))
print()
print("Both are equally checkable: a checker infers the locals from the signature.")
test("both produce the same answer", over([1, 2, 3]), measured([1, 2, 3]))
test("the useful annotations are on the signature",
set(measured.__annotations__), {"values", "return"})
What it printed, when the build ran it
over: 12 measured: 12 Both are equally checkable: a checker infers the locals from the signature. ok both produce the same answer ok the useful annotations are on the signature
Adding hints to existing code
- Start at the edges. Annotate the functions other modules call; work inwards only as far as is useful.
- Use
Anydeliberately when a type is genuinely dynamic, rather than contorting an annotation to fit. It is an escape hatch, and saying so is better than lying. - Turn the checker on gradually. Both mypy and pyright can check a subset of files, so a large codebase does not need a single enormous change.
- Never let an annotation become false. A hint that disagrees with the code is worse than none, because the next reader will trust it.
What to take from this lesson
- Python does not check annotations at runtime. A separate tool does, before you run anything.
- Write
list[int],dict[str, int]andX | None— notyping.ListorOptionalneeded. X | Noneis the highest-value hint there is: it forces the missing-value case to be handled.TypedDictis a plain dict at runtime;Protocoldescribes a shape without inheritance, and@runtime_checkablechecks only that the methods exist.- Annotate signatures and boundaries; let locals be inferred.
- Annotate and validate — a hint is not a guard.