Decorators 🎀
You have used @property, @dataclass and @staticmethod without knowing what the @ does. It is simpler than it looks, and it is one of Python's genuinely elegant ideas.
Functions are objects
def greet(name):
return f"Hello, {name}"
# a function can be assigned, passed and returned like any other value
say = greet
print(say("Guybrush"))
print(greet.__name__)
def apply_twice(func, value):
return func(func(value))
print(apply_twice(str.upper, "ho"))
def make_multiplier(n):
def multiply(x):
return x * n # remembers n: this is a closure
return multiply
triple = make_multiplier(3)
print(triple(5))
Hello, Guybrush
greet
HO
15
Everything a decorator does rests on those two facts: a function can be passed to another function, and a function can remember variables from where it was defined.
A decorator, built by hand
def shout(func):
"""Take a function, return a new one that shouts the result."""
def wrapper(*args, **kwargs):
result = func(*args, **kwargs)
return result.upper() + "!"
return wrapper
def greet(name):
return f"hello, {name}"
greet = shout(greet) # replace the name with the wrapped version
print(greet("guybrush"))
HELLO, GUYBRUSH!
Now the same thing with the syntax:
def shout(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs).upper() + "!"
return wrapper
@shout
def greet(name):
return f"hello, {name}"
print(greet("elaine"))
HELLO, ELAINE!
@shout above a definition means exactly greet = shout(greet).
That is the whole feature. Everything else is what you choose to put in the wrapper.
The one thing you must remember: functools.wraps
import functools
def bad(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
def good(func):
@functools.wraps(func) # copy the name, docstring and signature over
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@bad
def alpha():
"""The alpha function."""
@good
def beta():
"""The beta function."""
print(alpha.__name__, "|", alpha.__doc__)
print(beta.__name__, "|", beta.__doc__)
wrapper | None
beta | The beta function.
Without it, every decorated function in your program is called wrapper and has no docstring. Debugging, logging, help() and test frameworks all break in confusing ways. One line, and you never think about it again.
A useful one: timing
import functools, time
def timed(func):
"""Print how long a function took."""
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
elapsed = time.perf_counter() - start
print(f" {func.__name__} took under a second: {elapsed < 1}")
return result
return wrapper
@timed
def slow_sum(n):
return sum(range(n))
print(slow_sum(1_000_000))
slow_sum took under a second: True
499999500000
Decorators that take arguments
import functools
def repeat(times):
"""A decorator factory: returns the actual decorator."""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return [func(*args, **kwargs) for _ in range(times)]
return wrapper
return decorator
@repeat(3)
def insult():
return "You fight like a dairy farmer!"
for line in insult():
print(line)
You fight like a dairy farmer!
You fight like a dairy farmer!
You fight like a dairy farmer!
Three levels of function, which is where people's eyes glaze over. Read it outside-in: repeat(3) is called first and returns decorator. Then decorator is applied to insult and returns wrapper. Then wrapper is what you actually call.
@repeat(3) means insult = repeat(3)(insult). Every decorator with brackets works this way, including @app.route('/') in Flask and @pytest.mark.parametrize.
Retry: the decorator everyone eventually writes
import functools
def retry(attempts=3):
"""Retry a function when it raises, up to a limit."""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
last_error = None
for attempt in range(1, attempts + 1):
try:
return func(*args, **kwargs)
except Exception as err:
last_error = err
print(f" attempt {attempt} failed: {err}")
raise last_error
return wrapper
return decorator
calls = {"n": 0}
@retry(attempts=3)
def flaky():
calls["n"] += 1
if calls["n"] < 3:
raise ConnectionError("network hiccup")
return "succeeded on attempt 3"
print(flaky())
attempt 1 failed: network hiccup
attempt 2 failed: network hiccup
succeeded on attempt 3
In real use you would add a delay between attempts and only retry specific exceptions.
The library tenacity does all of that, and now you know exactly what it is
doing.
The built-in decorators worth knowing
import functools
class Circle:
def __init__(self, radius):
self.radius = radius
@property
def area(self):
return 3.14159 * self.radius ** 2
@staticmethod
def describe():
"""No self: just a function that lives in the class's namespace."""
return "A circle is round."
@classmethod
def unit(cls):
"""Gets the class, not an instance. The standard 'alternative constructor'."""
return cls(1)
print(Circle(2).area)
print(Circle.describe())
print(Circle.unit().radius)
@functools.lru_cache(maxsize=None)
def fib(n):
return n if n < 2 else fib(n - 1) + fib(n - 2)
print(fib(35))
print(fib.cache_info().hits > 0)
12.56636
A circle is round.
1
9227465
True
@lru_cache is the single best value-for-effort decorator in the standard
library. That fib(35) would take many seconds without it and is instant
with it, because every repeated call is answered from memory. It only works for
functions whose result depends purely on their arguments, and whose arguments are
hashable.
| Decorator | Does |
|---|---|
@property | Method usable as an attribute |
@staticmethod | A function in a class, with no self |
@classmethod | Receives the class; used for alternative constructors |
@functools.wraps | Preserve the wrapped function's identity |
@functools.lru_cache | Memoise results |
@functools.cache | The same, simpler, 3.9+ |
@dataclass | Write the boilerplate (Lesson 33) |
@abstractmethod | Subclasses must implement it (Lesson 32) |
Stacking
import functools
def bold(func):
@functools.wraps(func)
def wrapper(*a, **kw):
return f"<b>{func(*a, **kw)}</b>"
return wrapper
def italic(func):
@functools.wraps(func)
def wrapper(*a, **kw):
return f"<i>{func(*a, **kw)}</i>"
return wrapper
@bold
@italic
def text():
return "hello"
print(text())
<b><i>hello</i></b>
Decorators apply bottom-up: italic wraps the function first, then
bold wraps that. The result reads top-down in the output, which is a happy
accident that makes stacking feel natural.
A logging decorator
Write @logged that prints the call with its arguments and then the result. It must work with any function.
Reveal solution
import functools
def logged(func):
"""Print each call and its result."""
@functools.wraps(func)
def wrapper(*args, **kwargs):
shown = [repr(a) for a in args] + [f"{k}={v!r}" for k, v in kwargs.items()]
print(f"-> {func.__name__}({', '.join(shown)})")
result = func(*args, **kwargs)
print(f"<- {result!r}")
return result
return wrapper
@logged
def add(a, b=0):
return a + b
add(2, b=3)
add("ho", "ho")
-> add(2, b=3)
<- 5
-> add('ho', 'ho')
<- 'hoho'Validate arguments
Write @positive that raises ValueError if any numeric argument is negative, before the function runs.
Reveal solution
import functools
def positive(func):
"""Reject any negative number passed to func."""
@functools.wraps(func)
def wrapper(*args, **kwargs):
for value in list(args) + list(kwargs.values()):
if isinstance(value, (int, float)) and value < 0:
raise ValueError(f"{func.__name__} got a negative value: {value}")
return func(*args, **kwargs)
return wrapper
@positive
def area(width, height):
return width * height
print(area(3, 4))
try:
area(3, -4)
except ValueError as err:
print("Refused:", err)
12
Refused: area got a negative value: -4Count calls, and expose the count
Write @counted that tracks how many times a function was called and makes the number readable from outside.
Reveal solution
import functools
def counted(func):
"""Count calls. The count is readable as func.calls."""
@functools.wraps(func)
def wrapper(*args, **kwargs):
wrapper.calls += 1
return func(*args, **kwargs)
wrapper.calls = 0
return wrapper
@counted
def hello(name):
return f"hi {name}"
hello("a")
hello("b")
hello("c")
print(f"{hello.__name__} was called {hello.calls} times")
hello was called 3 timesAttaching state to the wrapper function object is the standard trick, and it works because functions are objects and you can hang attributes on them. It is how lru_cache exposes cache_info().