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"))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.
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.
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="?")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__)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 () neededGoing 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.