Emmanuel Akinfulubi

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 decorator, applied by hand

ran at build time
# 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.

The same thing, with @

ran at build time
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.

The metadata a decorator destroys, and the fix

ran at build time
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

A timing decorator, with cleanup that survives an exception

ran at build time
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.

Three layers: argument, function, call

ran at build time
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.

A decorator usable with or without brackets

ran at build time
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

Two decorators, and which one runs first

ran at build time
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

A method decorator needs no special handling

ran at build time
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.

The failure is silent at the point of the mistake

ran at build time
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

@property must be outermost

ran at build time
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

A decorator that needs to remember something

ran at build time
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

  • @decorator is exactly name = 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 def goes first.
  • *args, **kwargs is what lets one decorator work on functions and methods alike.
  • property, classmethod and staticmethod must 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

Memoisation, in one line

ran at build time
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.

maxsize, and what eviction costs

ran at build time
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.

A cached function needs hashable arguments

raises TypeError
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'

Converting to something hashable

ran at build time
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.

Why lru_cache on a method leaks instances

ran at build time
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.

cached_property, and how to invalidate it

ran at build time
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

partial, for pre-supplying arguments

ran at build time
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

reduce, next to the built-in that replaces it

ran at build time
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

reduce on an empty sequence, with no initial value

raises TypeError
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

singledispatch, instead of a growing isinstance chain

ran at build time
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

The two you have already used

ran at build time
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

  • @cache for memoisation with a small argument space; @lru_cache(maxsize=n) otherwise. Arguments must be hashable.
  • Never put lru_cache on a method — the class-level cache retains every instance.
  • cached_property for an expensive derived value, on a type whose inputs do not change.
  • partial to fix arguments now, which also binds values immediately.
  • Prefer sum, max, math.prod or a loop over reduce; give reduce an initial value when you do use it.
  • singledispatch instead 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.

The annotations are documentation, not a guard

ran at build time
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.

What a type checker reports

shown, not run
# 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

list[...], dict[...], X | None, tuple[...], Callable[...]

ran at build time
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

The introspection that tooling is built on

ran at build time
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.

TypedDict is a dict, and only a checker cares

ran at build time
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.

A Protocol names a shape, not a parent

ran at build time
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.

A plain Protocol is not runtime-checkable

raises TypeError
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

runtime_checkable checks names, not signatures

ran at build time
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.

PEP 695 generics, on a function and a class

ran at build time
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.

Annotate the signature; let the locals be inferred

ran at build time
# 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 Any deliberately 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] and X | None — no typing.List or Optional needed.
  • X | None is the highest-value hint there is: it forces the missing-value case to be handled.
  • TypedDict is a plain dict at runtime; Protocol describes a shape without inheritance, and @runtime_checkable checks only that the methods exist.
  • Annotate signatures and boundaries; let locals be inferred.
  • Annotate and validate — a hint is not a guard.

Newsletter

New writing, when it lands.

Occasional notes on shipping software with AI agents. No schedule, no filler, unsubscribe in one click.