Decorators
You've already seen a few lines that start with @, like @lru_cache and @dataclass. These are decorators: a way to add extra behaviour to a function without changing the function's own code. Timing, logging, caching and checking permissions are all commonly done with decorators.
Decorators build on two ideas from Part 3: functions are values you can pass around, and inner functions can remember variables from the function around them.
Step 1: functions can be passed around
A function name without () is the function itself, and you can pass it to another function:
def shout(text):
return text.upper() + "!"
def call_twice(func, value):
print(func(value))
print(func(value))
call_twice(shout, "hello")Step 2: functions can create functions
A function can define a new function inside itself and return it:
def make_greeter(greeting):
def greet(name):
return f"{greeting}, {name}!"
return greet
say_hi = make_greeter("Hi")
say_hello = make_greeter("Hello")
print(say_hi("Ada"))
print(say_hello("Grace"))Each call to make_greeter creates a new greet function, and each one remembers the greeting it was made with (this is the closure from the Scope lesson). say_hi always says "Hi" and say_hello always says "Hello", even though make_greeter finished running long ago.
Step 3: wrapping a function
Put those together: a function that takes a function, builds a new function around it, and returns the new one. The new function, usually called wrapper, does something extra and calls the original:
def announce(func):
def wrapper():
print("About to run...")
func()
print("Done!")
return wrapper
def say_hello():
print("Hello!")
say_hello = announce(say_hello)
say_hello()announce is a decorator. The line say_hello = announce(say_hello) replaces say_hello with the wrapped version. Step by step:
announce(say_hello)runs once. It defineswrapper(without running it) and returns it. Insidewrapper,funcstill refers to the originalsay_hello.say_hello = ...points the namesay_helloatwrapper.- Now every
say_hello()call actually runswrapper(): it printsAbout to run..., calls the original function (Hello!), then printsDone!.
The original function's code was never edited; it just got wrapped.
The @ syntax
Writing say_hello = announce(say_hello) is clumsy, so Python has a shortcut. Put @announce on the line above the function:
def announce(func):
def wrapper():
print("About to run...")
func()
print("Done!")
return wrapper
@announce
def say_hello():
print("Hello!")
say_hello()@announce above def say_hello means exactly the same as say_hello = announce(say_hello) after it. The decorator runs once, when Python reaches the def, not each time the function is called. Each call then runs the wrapper. Write the decorator name without (): @announce, not @announce().
Decorating functions with arguments
Our wrapper takes no arguments, so it only works on functions without any. To wrap any function, let the wrapper accept any arguments with *args and **kwargs (from the Function Arguments lesson) and pass them on. Also return the original function's result:
def log_calls(func):
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__} with {args} {kwargs}")
result = func(*args, **kwargs)
print(f"{func.__name__} returned {result}")
return result
return wrapper
@log_calls
def add(a, b):
return a + b
@log_calls
def greet(name, punctuation="!"):
return "Hi " + name + punctuation
add(2, 3)
greet("Ada", punctuation="?")For add(2, 3), the wrapper receives args = (2, 3) and kwargs = {}, and passes them straight on with func(*args, **kwargs). For greet("Ada", punctuation="?"), args is ("Ada",) and kwargs is {'punctuation': '?'}. func.__name__ is the original function's name, which makes the log readable.
Don't forget return result. Without it, the wrapper returns None, so add(2, 3) would give None even though the original add returned 5. The wrapper takes the original's place, so it must hand back whatever the original returned.
This shape works as a template for almost every decorator you'll write:
def my_decorator(func):
def wrapper(*args, **kwargs):
# do something before
result = func(*args, **kwargs)
# do something after
return result
return wrapper
A practical decorator: timing
Here's a decorator that measures how long a function takes:
import time
def timed(func):
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
elapsed = time.perf_counter() - start
print(f"{func.__name__} took {elapsed:.4f} seconds")
return result
return wrapper
@timed
def total_of_squares(n):
return sum(i * i for i in range(n))
print(total_of_squares(1_000_000))Once written, you can add @timed to any function you want to measure, without touching its code.
Keeping the function's name with functools.wraps
A plain wrapper hides the original function's name and docstring:
def announce(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@announce
def say_hello():
"""Print a greeting."""
print("Hello!")
print(say_hello.__name__)
print(say_hello.__doc__)It prints wrapper and None: the name say_hello now refers to the wrapper, so you see the wrapper's name and its (missing) docstring. That makes error messages and help() confusing.
Add @functools.wraps(func) to the wrapper to copy them across. It's good practice in every decorator:
import functools
def announce(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@announce
def say_hello():
"""Print a greeting."""
print("Hello!")
print(say_hello.__name__)
print(say_hello.__doc__)Decorators you'll meet
Python and its libraries come with many ready-made decorators:
| Decorator | What it does |
|---|---|
@functools.lru_cache |
remembers results so repeated calls are instant |
@dataclasses.dataclass |
writes __init__, __repr__ and __eq__ for a class |
@staticmethod / @classmethod |
methods that don't need an object |
@property |
lets a method be read like an attribute |
@property is a nice one to know:
class Circle:
def __init__(self, radius):
self.radius = radius
@property
def area(self):
return round(3.14159 * self.radius ** 2, 2)
c = Circle(3)
print(c.area) # no () neededarea is written as a method, but @property lets you read it like an attribute, without (). It's worked out fresh each time you read it, so after c.radius = 10, c.area gives the new area. Assigning c.area = 5 is an AttributeError, because there's no stored value to change. That makes @property handy for values that are always calculated from other attributes.
Going deeper: Decorators that take arguments optional
Sometimes you want to configure a decorator, as in @repeat(3). That needs one more layer: a function that takes the settings and returns a decorator:
import functools
def repeat(times):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for _ in range(times):
result = func(*args, **kwargs)
return result
return wrapper
return decorator
@repeat(3)
def cheer():
print("Hip hip hooray!")
cheer()@repeat(3) first calls repeat(3), which returns decorator. Then decorator wraps cheer.
Going deeper: Stacking decorators optional
You can put several decorators on one function. They're applied from the bottom up, so the one closest to the function wraps it first:
def bold(func):
def wrapper():
return "<b>" + func() + "</b>"
return wrapper
def italic(func):
def wrapper():
return "<i>" + func() + "</i>"
return wrapper
@bold
@italic
def hello():
return "hello"
print(hello())Exercises
Exercise 1: Before and after
Write a decorator sandwich that prints --- before and after calling the function. Calling say_hi() should print three lines: ---, Hi! and ---.
def sandwich(func):
pass
@sandwich
def say_hi():
print("Hi!")
say_hi()Inside sandwich, define a function wrapper() that prints ---, calls func(), then prints --- again.
Don't forget the last line of the decorator: return wrapper.
Exercise 2: Shout the result
Write a decorator uppercase that calls the function and returns its result in capitals. It should work with any arguments.
def uppercase(func):
pass
@uppercase
def greet(name):
return f"hello, {name}"
print(greet("ada")) # HELLO, ADAGive the wrapper *args and **kwargs, and pass them on: result = func(*args, **kwargs).
Return result.upper() from the wrapper, then return wrapper from the decorator.
Exercise 3: Count the calls
Write a decorator count_calls that counts how many times the function has been called and prints Call number N each time. You can store the count as an attribute on the wrapper, like wrapper.calls = 0.
def count_calls(func):
pass
@count_calls
def ping():
print("ping")
ping()
ping()
ping()Set wrapper.calls = 0 after defining wrapper but before returning it.
Inside the wrapper, add 1 to wrapper.calls, print it, then call func(*args, **kwargs).
Exercise 4: Only positive numbers
Write a decorator positive_only that raises a ValueError if any argument is a negative number, and otherwise calls the function normally.
def positive_only(func):
pass
@positive_only
def area(width, height):
return width * height
print(area(3, 4))
try:
area(3, -4)
except ValueError as e:
print("Error:", e)In the wrapper, loop over args and check each value before calling the function.
If a value is below 0, raise ValueError("arguments must not be negative"). Otherwise return func(*args).
Summary
- A decorator is a function that takes a function and returns a new, wrapped function.
@decoratorabove adefis a shortcut forfunc = decorator(func).- Use
*argsand**kwargsin the wrapper so it works with any arguments, and return the result. - Add
@functools.wraps(func)to keep the original name and docstring. - Decorators add behaviour like timing, logging or caching without changing the function itself.
- Built-in decorators include
@property,@staticmethod,@classmethodand@functools.lru_cache.
Know something worth teaching? Write your own tutorial with runnable examples and share it.