Lesson 6 - Decorators and Metaprogramming
On this page
- Introduction
- Functions as First-Class Objects
- Your First Decorator
- Decorators with Arguments
- Practical Example: Timing Decorator
- The Problem: Lost Metadata
- Solution:
functools.wraps - Decorators with Parameters
- Practical Example: Caching Decorator
- Practical Example: Validation Decorator
- Chaining Decorators
- Class Decorators
- Decorator Classes
- A Comprehensive Example: Book Order System
- Built-in Decorators You Should Know
- Summary
Introduction
Decorators are one of Python’s most elegant features. They let you modify or enhance functions and methods without changing their code. You’ve already seen decorators like @property, @classmethod, and @staticmethod. Now we’ll learn how to create our own.
Decorators are built on the concepts we learned in the previous lesson—closures and functions as first-class objects. In this lesson, we’ll cover:
- How decorators work
- Creating simple decorators
- Decorators with arguments
- Chaining multiple decorators
- Class decorators
- The
functools.wrapsdecorator - Practical decorator examples
Functions as First-Class Objects
In Python, functions are first-class objects—they can be:
- Assigned to variables
- Passed as arguments to other functions
- Returned from functions
- Stored in data structures
def greet(name):
return f"Hello, {name}!"
# Assign function to a variable
say_hello = greet
print(say_hello("Alice")) # Hello, Alice!
# Store functions in a list
def add(a, b):
return a + b
def multiply(a, b):
return a * b
operations = [add, multiply]
print(operations[0](5, 3)) # 8
print(operations[1](5, 3)) # 15This is the foundation of decorators.
Your First Decorator
A decorator is a function that takes another function and extends its behavior without explicitly modifying it.
def my_decorator(func):
def wrapper():
print("Before function call")
func()
print("After function call")
return wrapper
def say_hello():
print("Hello!")
# Apply decorator manually
say_hello = my_decorator(say_hello)
say_hello()Output:
Before function call
Hello!
After function callThe @ syntax is shorthand for this:
def my_decorator(func):
def wrapper():
print("Before function call")
func()
print("After function call")
return wrapper
@my_decorator
def say_hello():
print("Hello!")
say_hello() # Same output as beforeThe @my_decorator line is equivalent to say_hello = my_decorator(say_hello).
Decorators with Arguments
The basic decorator doesn’t work if the decorated function takes arguments. Let’s fix that using *args and **kwargs:
def my_decorator(func):
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}")
result = func(*args, **kwargs)
print(f"Finished {func.__name__}")
return result
return wrapper
@my_decorator
def add(a, b):
return a + b
@my_decorator
def greet(name, greeting="Hello"):
return f"{greeting}, {name}!"
print(add(5, 3))
print(greet("Alice"))
print(greet("Bob", greeting="Hi"))Output:
Calling add
Finished add
8
Calling greet
Finished greet
Hello, Alice!
Calling greet
Finished greet
Hi, Bob!Practical Example: Timing Decorator
Let’s create a decorator that measures how long a function takes to run:
import time
def timer(func):
"""Decorator that measures function execution time"""
def wrapper(*args, **kwargs):
start_time = time.time()
result = func(*args, **kwargs)
end_time = time.time()
duration = end_time - start_time
print(f"{func.__name__} took {duration:.4f} seconds")
return result
return wrapper
@timer
def process_books(num_books):
"""Simulate processing books"""
total = 0
for i in range(num_books):
total += i ** 2
return total
@timer
def slow_function():
"""Simulate a slow operation"""
time.sleep(1)
return "Done"
result1 = process_books(1000000)
print(f"Result: {result1}")
result2 = slow_function()
print(f"Result: {result2}")Output:
process_books took 0.0521 seconds
Result: 333332833333500000
slow_function took 1.0012 seconds
Result: DoneThe Problem: Lost Metadata
Decorators replace the original function with the wrapper, which can lose important metadata:
def my_decorator(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@my_decorator
def calculate_price(base_price, tax_rate):
"""Calculate final price with tax"""
return base_price * (1 + tax_rate)
print(calculate_price.__name__) # wrapper (should be calculate_price!)
print(calculate_price.__doc__) # None (lost the docstring!)Solution: functools.wraps
The functools.wraps decorator copies metadata from the original function to the wrapper:
from functools import wraps
def my_decorator(func):
@wraps(func) # Preserve original function's metadata
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@my_decorator
def calculate_price(base_price, tax_rate):
"""Calculate final price with tax"""
return base_price * (1 + tax_rate)
print(calculate_price.__name__) # calculate_price ✓
print(calculate_price.__doc__) # Calculate final price with tax ✓Best Practice: Always use @wraps(func) in your decorator wrappers.
Decorators with Parameters
What if we want to pass arguments to the decorator itself? We need an extra level of nesting:
from functools import wraps
def repeat(times):
"""Decorator that repeats function execution"""
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
for _ in range(times):
result = func(*args, **kwargs)
return result
return wrapper
return decorator
@repeat(times=3)
def greet(name):
print(f"Hello, {name}!")
greet("Alice")Output:
Hello, Alice!
Hello, Alice!
Hello, Alice!Here’s what happens:
repeat(times=3)is called, returnsdecorator@decoratoris applied togreet- When we call
greet("Alice"), it callswrapper("Alice")
Practical Example: Caching Decorator
Let’s create a decorator that caches function results to avoid redundant computations:
from functools import wraps
def cache(func):
"""Cache function results"""
cached_results = {}
@wraps(func)
def wrapper(*args):
if args in cached_results:
print(f"Returning cached result for {args}")
return cached_results[args]
print(f"Computing result for {args}")
result = func(*args)
cached_results[args] = result
return result
return wrapper
@cache
def expensive_calculation(n):
"""Simulate an expensive calculation"""
time.sleep(1) # Simulate delay
return n ** 2
# First call - computes
print(expensive_calculation(5))
# Second call with same argument - uses cache
print(expensive_calculation(5))
# Different argument - computes
print(expensive_calculation(10))
# Same as second call - uses cache
print(expensive_calculation(10))Output:
Computing result for (5,)
25
Returning cached result for (5,)
25
Computing result for (10,)
100
Returning cached result for (10,)
100Note: Python’s standard library provides @functools.lru_cache which is a more sophisticated caching decorator.
Practical Example: Validation Decorator
Let’s create a decorator that validates function arguments:
from functools import wraps
def validate_positive(func):
"""Ensure all numeric arguments are positive"""
@wraps(func)
def wrapper(*args, **kwargs):
# Check positional arguments
for arg in args:
if isinstance(arg, (int, float)) and arg < 0:
raise ValueError(f"All arguments must be positive, got {arg}")
# Check keyword arguments
for value in kwargs.values():
if isinstance(value, (int, float)) and value < 0:
raise ValueError(f"All arguments must be positive, got {value}")
return func(*args, **kwargs)
return wrapper
@validate_positive
def calculate_book_price(base_price, tax_rate, shipping=5.99):
"""Calculate total book price"""
return base_price * (1 + tax_rate) + shipping
# Valid call
print(f"Price: ${calculate_book_price(29.99, 0.08):.2f}")
# Invalid call - negative price
try:
calculate_book_price(-10, 0.08)
except ValueError as e:
print(f"Error: {e}")
# Invalid call - negative shipping
try:
calculate_book_price(29.99, 0.08, shipping=-5)
except ValueError as e:
print(f"Error: {e}")Output:
Price: $38.39
Error: All arguments must be positive, got -10
Error: All arguments must be positive, got -5Chaining Decorators
You can apply multiple decorators to a single function. They’re applied bottom-to-top:
from functools import wraps
import time
def timer(func):
@wraps(func)
def wrapper(*args, **kwargs):
start = time.time()
result = func(*args, **kwargs)
print(f"{func.__name__} took {time.time() - start:.4f}s")
return result
return wrapper
def debug(func):
@wraps(func)
def wrapper(*args, **kwargs):
args_repr = [repr(a) for a in args]
kwargs_repr = [f"{k}={v!r}" for k, v in kwargs.items()]
signature = ", ".join(args_repr + kwargs_repr)
print(f"Calling {func.__name__}({signature})")
result = func(*args, **kwargs)
print(f"{func.__name__} returned {result!r}")
return result
return wrapper
@timer
@debug
def add(a, b):
time.sleep(0.1) # Simulate work
return a + b
result = add(5, 3)Output:
Calling add(5, 3)
add returned 8
add took 0.1002s
8The decorators are applied like this: timer(debug(add))
Class Decorators
Decorators can also be applied to classes:
def singleton(cls):
"""Ensure only one instance of a class exists"""
instances = {}
@wraps(cls)
def get_instance(*args, **kwargs):
if cls not in instances:
instances[cls] = cls(*args, **kwargs)
return instances[cls]
return get_instance
@singleton
class BookStore:
def __init__(self, name):
self.name = name
self.books = []
def add_book(self, book):
self.books.append(book)
# Both variables reference the same instance
store1 = BookStore("Main Store")
store1.add_book("Python Basics")
store2 = BookStore("Another Store") # Name is ignored
store2.add_book("Python Advanced")
print(f"store1 books: {store1.books}")
print(f"store2 books: {store2.books}")
print(f"Same instance? {store1 is store2}")Output:
store1 books: ['Python Basics', 'Python Advanced']
store2 books: ['Python Basics', 'Python Advanced']
Same instance? TrueDecorator Classes
You can also create decorators using classes instead of functions:
from functools import wraps
class CountCalls:
"""Decorator that counts how many times a function is called"""
def __init__(self, func):
wraps(func)(self)
self.func = func
self.count = 0
def __call__(self, *args, **kwargs):
self.count += 1
print(f"{self.func.__name__} has been called {self.count} time(s)")
return self.func(*args, **kwargs)
@CountCalls
def process_order(customer):
print(f"Processing order for {customer}")
process_order("Alice")
process_order("Bob")
process_order("Charlie")Output:
process_order has been called 1 time(s)
Processing order for Alice
process_order has been called 2 time(s)
Processing order for Bob
process_order has been called 3 time(s)
Processing order for CharlieA Comprehensive Example: Book Order System
Let’s combine multiple decorators in a realistic scenario:
from functools import wraps
import time
def timer(func):
"""Measure execution time"""
@wraps(func)
def wrapper(*args, **kwargs):
start = time.time()
result = func(*args, **kwargs)
duration = time.time() - start
print(f"⏱️ {func.__name__} took {duration:.4f}s")
return result
return wrapper
def log_calls(func):
"""Log function calls"""
@wraps(func)
def wrapper(*args, **kwargs):
print(f"📝 Calling {func.__name__}")
result = func(*args, **kwargs)
print(f"✅ {func.__name__} completed")
return result
return wrapper
def validate_stock(func):
"""Validate that books are in stock"""
@wraps(func)
def wrapper(self, book_title, quantity):
if book_title not in self.inventory:
raise ValueError(f"❌ '{book_title}' not in inventory")
if self.inventory[book_title] < quantity:
raise ValueError(
f"❌ Not enough stock. Available: {self.inventory[book_title]}, "
f"Requested: {quantity}"
)
return func(self, book_title, quantity)
return wrapper
class BookStore:
def __init__(self, name):
self.name = name
self.inventory = {
"Python Basics": 10,
"Python Advanced": 5,
"Data Science 101": 8
}
@timer
@log_calls
def restock(self, book_title, quantity):
"""Add books to inventory"""
if book_title in self.inventory:
self.inventory[book_title] += quantity
else:
self.inventory[book_title] = quantity
print(f" 📦 Restocked {quantity} copies of '{book_title}'")
return self.inventory[book_title]
@timer
@log_calls
@validate_stock
def sell(self, book_title, quantity):
"""Sell books (with validation)"""
self.inventory[book_title] -= quantity
total = quantity * 29.99 # Simplified pricing
print(f" 💰 Sold {quantity} x '{book_title}' for ${total:.2f}")
return total
# Use the bookstore
store = BookStore("Main Store")
print("=== Restocking ===")
store.restock("Python Expert", 15)
print("\n=== Valid Sale ===")
store.sell("Python Basics", 3)
print("\n=== Invalid Sale (not in stock) ===")
try:
store.sell("Nonexistent Book", 1)
except ValueError as e:
print(e)
print("\n=== Invalid Sale (insufficient quantity) ===")
try:
store.sell("Python Advanced", 10)
except ValueError as e:
print(e)Output:
=== Restocking ===
📝 Calling restock
📦 Restocked 15 copies of 'Python Expert'
✅ restock completed
⏱️ restock took 0.0001s
=== Valid Sale ===
📝 Calling sell
💰 Sold 3 x 'Python Basics' for $89.97
✅ sell completed
⏱️ sell took 0.0001s
=== Invalid Sale (not in stock) ===
❌ 'Nonexistent Book' not in inventory
=== Invalid Sale (insufficient quantity) ===
❌ Not enough stock. Available: 5, Requested: 10Built-in Decorators You Should Know
Python’s standard library includes useful decorators:
from functools import lru_cache, wraps
import time
# @lru_cache - caching with LRU (Least Recently Used) strategy
@lru_cache(maxsize=128)
def fibonacci(n):
if n < 2:
return n
return fibonacci(n-1) + fibonacci(n-2)
start = time.time()
print(fibonacci(35))
print(f"Took {time.time() - start:.4f}s with cache")
# @property - we've seen this before
class Book:
def __init__(self, price):
self._price = price
@property
def price(self):
return self._price
@price.setter
def price(self, value):
if value < 0:
raise ValueError("Price cannot be negative")
self._price = value
book = Book(29.99)
print(book.price) # Uses @property
book.price = 34.99 # Uses @price.setterSummary
In this lesson, we learned about decorators—a powerful Python feature:
- Decorators are functions that modify other functions
- Use
@decoratorsyntax as shorthand - Use
*args, **kwargsto handle functions with any arguments - Always use
@functools.wrapsto preserve function metadata - Decorators can have parameters (requires extra nesting level)
- Multiple decorators can be chained (applied bottom-to-top)
- Class decorators and decorator classes are also possible
Common uses for decorators:
- Timing and profiling
- Logging and debugging
- Validation and type checking
- Caching/memoization
- Authentication and authorization
- Rate limiting
Decorators let you add functionality to functions without modifying their code—a key principle in writing maintainable software.
In the next lesson, we’ll explore iterators and the iterator protocol—understanding how for loops really work in Python.