Python Decorators

Python Decorators

Decorators are a powerful feature that allows you to modify the behavior of functions or classes without changing their source code. They are commonly used for logging, authentication, caching, and more.

What are Decorators?

Decorators are functions that take another function and extend its behavior without modifying it.

Basic Decorator

def my_decorator(func):
    def wrapper():
        print("Something before the function.")
        func()
        print("Something after the function.")
    return wrapper

@my_decorator
def say_hello():
    print("Hello!")

# Using the decorator
say_hello()

The @ syntax applies the decorator. my_decorator returns wrapper, which calls the original function. See function definitions for basics.

Manual Decoration

def my_decorator(func):
    def wrapper():
        print("Before")
        func()
        print("After")
    return wrapper

def say_hello():
    print("Hello!")

# Manual decoration (equivalent to @ syntax)
decorated_function = my_decorator(say_hello)
decorated_function()

The @ syntax is just syntactic sugar for function wrapping.

Decorators with Arguments

Decorators can accept arguments for configuration.

Decorator with Parameters

def repeat(times):
    def decorator(func):
        def wrapper(*args, **kwargs):
            for _ in range(times):
                func(*args, **kwargs)
        return wrapper
    return decorator

@repeat(3)
def greet(name):
    print(f"Hello, {name}!")

greet("Alice")

repeat(3) returns a decorator, which is applied to greet. See closures for how this works.

Function Arguments

Decorators must handle any function signature.

def logger(func):
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__} with args: {args}, kwargs: {kwargs}")
        result = func(*args, **kwargs)
        print(f"{func.__name__} returned: {result}")
        return result
    return wrapper

@logger
def add(a, b):
    return a + b

@logger
def greet(name, prefix="Hello"):
    return f"{prefix}, {name}!"

add(5, 3)
greet("Bob", prefix="Hi")

*args and **kwargs capture all arguments. Essential for flexible decorators.

Built-in Decorators

Python provides useful built-in decorators.

@staticmethod

class MathUtils:
    @staticmethod
    def add(a, b):
        return a + b
    
    @staticmethod
    def multiply(a, b):
        return a * b

# Call without creating instance
result = MathUtils.add(5, 3)
print(result)  # 8

Static methods don’t need self and can be called on the class. See static methods.

@classmethod

class Person:
    def __init__(self, name, age):
        self.name = name
        self.age = age
    
    @classmethod
    def from_birth_year(cls, name, birth_year):
        age = 2023 - birth_year
        return cls(name, age)

person = Person.from_birth_year("Alice", 1990)
print(f"{person.name} is {person.age} years old")

Class methods receive the class as first argument. Useful for alternative constructors.

@property

class Circle:
    def __init__(self, radius):
        self._radius = radius
    
    @property
    def radius(self):
        return self._radius
    
    @radius.setter
    def radius(self, value):
        if value <= 0:
            raise ValueError("Radius must be positive")
        self._radius = value
    
    @property
    def area(self):
        return 3.14159 * self._radius ** 2

circle = Circle(5)
print(f"Area: {circle.area}")  # No parentheses needed
circle.radius = 10
print(f"New area: {circle.area}")

Properties allow method calls to look like attribute access. See property.

Practical Decorators

Timing Decorator

import time

def timer(func):
    def wrapper(*args, **kwargs):
        start = time.time()
        result = func(*args, **kwargs)
        end = time.time()
        print(f"{func.__name__} took {(end - start) * 1000:.2f} ms")
        return result
    return wrapper

@timer
def slow_function():
    time.sleep(1)
    return "Done!"

@timer
def fast_function():
    return sum(range(1000000))

slow_function()
fast_function()

Measure function execution time. Useful for performance monitoring.

Caching Decorator

def cache(func):
    cache_dict = {}
    
    def wrapper(*args, **kwargs):
        # Create hashable key from arguments
        key = (args, tuple(sorted(kwargs.items())))
        if key not in cache_dict:
            cache_dict[key] = func(*args, **kwargs)
        return cache_dict[key]
    
    return wrapper

@cache
def fibonacci(n):
    if n < 2:
        return n
    return fibonacci(n-1) + fibonacci(n-2)

print(fibonacci(10))  # Calculates once
print(fibonacci(10))  # Returns cached result

Cache expensive function calls. Prevents redundant computations.

Authentication Decorator

def requires_auth(func):
    def wrapper(user, *args, **kwargs):
        if not hasattr(user, 'is_authenticated') or not user.is_authenticated:
            raise PermissionError("User must be authenticated")
        return func(user, *args, **kwargs)
    return wrapper

class User:
    def __init__(self, name, authenticated=False):
        self.name = name
        self.is_authenticated = authenticated

@requires_auth
def view_profile(user):
    return f"Profile of {user.name}"

user1 = User("Alice", authenticated=True)
user2 = User("Bob", authenticated=False)

print(view_profile(user1))
# print(view_profile(user2))  # Raises PermissionError

Check authentication before allowing function execution.

functools.wraps

Preserve original function metadata when decorating.

from functools import wraps

def my_decorator(func):
    @wraps(func)  # Preserves func metadata
    def wrapper(*args, **kwargs):
        """Wrapper docstring"""
        print("Calling decorated function")
        return func(*args, **kwargs)
    return wrapper

@my_decorator
def example():
    """Original function docstring"""
    return "Hello"

print(example.__name__)  # "example" (not "wrapper")
print(example.__doc__)   # "Original function docstring"

wraps copies __name__, __doc__, etc. Essential for debugging and documentation.

Class Decorators

Decorators can also be applied to classes.

Class Decorator

def singleton(cls):
    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 DatabaseConnection:
    def __init__(self, host):
        self.host = host
        print(f"Connecting to {host}")

# Only one instance created
db1 = DatabaseConnection("localhost")
db2 = DatabaseConnection("localhost")
print(db1 is db2)  # True

Ensure only one instance of a class exists. Singleton pattern.

Dataclass Decorator

from dataclasses import dataclass

@dataclass
class Person:
    name: str
    age: int
    city: str = "Unknown"

person = Person("Alice", 30)
print(person)  # Person(name='Alice', age=30, city='Unknown')
print(person.name)  # Alice

dataclass automatically generates __init__, __repr__, etc. See dataclasses.

Decorator Stacking

Apply multiple decorators to one function.

def bold(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        return f"<b>{func(*args, **kwargs)}</b>"
    return wrapper

def italic(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        return f"<i>{func(*args, **kwargs)}</i>"
    return wrapper

@bold
@italic
def greet(name):
    return f"Hello, {name}!"

print(greet("Alice"))  # <b><i>Hello, Alice!</i></b>

Decorators apply from bottom to top. @bold wraps @italic which wraps greet.

Common Patterns

Retry Decorator

def retry(max_attempts=3, delay=1):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(max_attempts):
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    if attempt == max_attempts - 1:
                        raise e
                    print(f"Attempt {attempt + 1} failed: {e}")
                    time.sleep(delay)
        return wrapper
    return decorator

@retry(max_attempts=5, delay=0.5)
def unreliable_api_call():
    import random
    if random.random() < 0.7:
        raise ConnectionError("Network error")
    return "Success!"

result = unreliable_api_call()
print(result)

Retry failed operations automatically.

Deprecation Decorator

import warnings

def deprecated(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        warnings.warn(f"{func.__name__} is deprecated", 
                     DeprecationWarning, stacklevel=2)
        return func(*args, **kwargs)
    return wrapper

@deprecated
def old_function():
    return "This function is old"

old_function()  # Issues deprecation warning

Warn users about deprecated functions.

Best Practices

  1. Use functools.wraps to preserve function metadata
  2. Keep decorators simple and focused on one concern
  3. Use *args, **kwargs for flexible argument handling
  4. Consider performance impact of decorators
  5. Document decorator behavior and requirements
  6. Test decorated functions thoroughly

External Resources:

Related Tutorials:

Last updated on