Lesson 6 - Decorators and Metaprogramming

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.wraps decorator
  • 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))  # 15

This 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 call

The @ 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 before

The @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: Done

The 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:

  1. repeat(times=3) is called, returns decorator
  2. @decorator is applied to greet
  3. When we call greet("Alice"), it calls wrapper("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,)
100

Note: 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 -5

Chaining 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
8

The 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? True

Decorator 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 Charlie

A 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: 10

Built-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.setter

Summary

In this lesson, we learned about decorators—a powerful Python feature:

  • Decorators are functions that modify other functions
  • Use @decorator syntax as shorthand
  • Use *args, **kwargs to handle functions with any arguments
  • Always use @functools.wraps to 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.