Decorator Basics
A decorator is a function that wraps another function to add behavior. @decorator is syntactic sugar for func = decorator(func).
import time
# Simple decorator - adds timing
def timer(func):
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
end = time.perf_counter()
print(f"{func.__name__} took {end - start:.4f}s")
return result
return wrapper
# Apply with @ syntax
@timer
def slow_add(a, b):
time.sleep(0.1)
return a + b
result = slow_add(3, 4) # slow_add took 0.1001s
print(result) # 7
# Equivalent without @ syntax:
# slow_add = timer(slow_add)
# Decorator for logging
def log_calls(func):
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}({args}, {kwargs})")
result = func(*args, **kwargs)
print(f"{func.__name__} returned {result!r}")
return result
return wrapper
@log_calls
def add(a, b):
return a + b
add(3, 4)
# Calling add((3, 4), {})
# add returned 7
# Decorator that validates arguments
def positive_args(func):
def wrapper(*args, **kwargs):
if any(a <= 0 for a in args if isinstance(a, (int, float))):
raise ValueError(f"All arguments to {func.__name__} must be positive")
return func(*args, **kwargs)
return wrapper
@positive_args
def sqrt(n):
return n ** 0.5
print(sqrt(9)) # 3.0
try:
sqrt(-1) # ValueError: All arguments to sqrt must be positive
except ValueError as e:
print(e)
@wraps and functools
Without @wraps, decorated functions lose their name and docstring. Always use it.
from functools import wraps, lru_cache, cache
# Without @wraps - metadata is lost
def bad_decorator(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@bad_decorator
def my_function():
"""This is my function."""
pass
print(my_function.__name__) # wrapper (wrong!)
print(my_function.__doc__) # None (lost!)
# With @wraps - metadata preserved
def good_decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@good_decorator
def my_function():
"""This is my function."""
pass
print(my_function.__name__) # my_function (correct)
print(my_function.__doc__) # This is my function.
# --- functools.lru_cache ---
# Cache results of expensive function calls (memoization)
@lru_cache(maxsize=128)
def fibonacci(n):
if n < 2:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
print(fibonacci(50)) # fast because results are cached
print(fibonacci.cache_info())
# CacheInfo(hits=48, misses=51, maxsize=128, currsize=51)
fibonacci.cache_clear() # clear the cache
# functools.cache - Python 3.9+ unbounded cache (same as lru_cache(maxsize=None))
@cache
def factorial(n):
return 1 if n == 0 else n * factorial(n - 1)
print(factorial(10)) # 3628800
# --- functools.cached_property ---
# Compute expensive property once and cache on the instance
from functools import cached_property
class DataSet:
def __init__(self, data):
self._data = data
@cached_property
def statistics(self):
"""Computed once and cached."""
print("Computing statistics...")
return {
"mean": sum(self._data) / len(self._data),
"min": min(self._data),
"max": max(self._data),
}
ds = DataSet([1, 2, 3, 4, 5])
print(ds.statistics) # Computing statistics... {mean: 3.0, ...}
print(ds.statistics) # (no "Computing" - returned from cache)
Decorators with Arguments
from functools import wraps
import time
# Decorator factory - returns a decorator
def repeat(times):
"""Repeat the decorated function `times` times."""
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
for _ in range(times):
result = func(*args, **kwargs)
return result
return wrapper
return decorator
@repeat(3)
def greet(name):
print(f"Hello, {name}!")
greet("Alice")
# Hello, Alice!
# Hello, Alice!
# Hello, Alice!
# Decorator with retry logic
def retry(max_attempts=3, delay=1.0, exceptions=(Exception,)):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
last_error = None
for attempt in range(1, max_attempts + 1):
try:
return func(*args, **kwargs)
except exceptions as e:
last_error = e
if attempt < max_attempts:
print(f"Attempt {attempt} failed: {e}. Retrying...")
# time.sleep(delay) # uncomment in real use
raise last_error
return wrapper
return decorator
import random
@retry(max_attempts=3, exceptions=(ValueError,))
def unreliable():
if random.random() < 0.7:
raise ValueError("Random failure")
return "Success!"
# Decorator with optional arguments (works with or without parentheses)
def debug(_func=None, *, prefix="DEBUG"):
"""Can be used as @debug or @debug(prefix='INFO')."""
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
print(f"[{prefix}] {func.__name__}({args}, {kwargs})")
result = func(*args, **kwargs)
print(f"[{prefix}] -> {result!r}")
return result
return wrapper
if _func is not None: # called as @debug (no parentheses)
return decorator(_func)
return decorator # called as @debug(prefix=...)
@debug
def add(a, b): return a + b
@debug(prefix="INFO")
def multiply(a, b): return a * b
add(2, 3)
multiply(4, 5)
Class Decorators
A class can be used as a decorator if it implements __call__. Classes are also often decorated themselves.
from functools import wraps
# Class used as a decorator (has __call__)
class CountCalls:
def __init__(self, func):
wraps(func)(self) # copy metadata
self.func = func
self.call_count = 0
def __call__(self, *args, **kwargs):
self.call_count += 1
print(f"Call #{self.call_count} to {self.func.__name__}")
return self.func(*args, **kwargs)
@CountCalls
def say_hello(name):
print(f"Hello, {name}!")
say_hello("Alice") # Call #1 to say_hello
say_hello("Bob") # Call #2 to say_hello
print(say_hello.call_count) # 2
# Decorator applied to a class
def singleton(cls):
"""Ensure only one instance of the 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 Config:
def __init__(self):
self.debug = False
self.port = 8080
c1 = Config()
c2 = Config()
print(c1 is c2) # True - same object
# Add methods to a class with a decorator
def add_method(method):
"""Decorator to add a method to all instances via the class."""
def decorator(cls):
setattr(cls, method.__name__, method)
return cls
return decorator
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
@add_method
def distance(self):
return (self.x**2 + self.y**2) ** 0.5
p = Point(3, 4)
# p.distance() works now because add_method added it
Built-in Decorators
class Temperature:
def __init__(self, celsius):
self._celsius = celsius
# @property - getter
@property
def celsius(self):
return self._celsius
# @property.setter - setter with validation
@celsius.setter
def celsius(self, value):
if value < -273.15:
raise ValueError("Below absolute zero")
self._celsius = value
# Computed read-only property
@property
def fahrenheit(self):
return self._celsius * 9/5 + 32
class Circle:
PI = 3.14159265
def __init__(self, radius):
self.radius = radius
# @classmethod - receives cls, not self
# Typical use: alternative constructors
@classmethod
def from_diameter(cls, diameter):
return cls(diameter / 2)
@classmethod
def unit_circle(cls):
return cls(1)
# @staticmethod - no self or cls
# Typical use: utility functions related to the class
@staticmethod
def is_valid_radius(r):
return isinstance(r, (int, float)) and r > 0
def area(self):
return self.PI * self.radius ** 2
# Usage
t = Temperature(25)
print(t.celsius) # 25
print(t.fahrenheit) # 77.0
t.celsius = 100
c1 = Circle.from_diameter(10) # classmethod
print(c1.radius) # 5.0
c2 = Circle.unit_circle() # classmethod
print(c2.radius) # 1
print(Circle.is_valid_radius(5)) # True - staticmethod
Stacking Decorators
from functools import wraps
import time
def timer(func):
@wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
elapsed = time.perf_counter() - start
print(f"[TIMER] {func.__name__}: {elapsed:.4f}s")
return result
return wrapper
def log(func):
@wraps(func)
def wrapper(*args, **kwargs):
print(f"[LOG] Calling {func.__name__}")
result = func(*args, **kwargs)
print(f"[LOG] {func.__name__} done")
return result
return wrapper
# Decorators apply bottom-up
@timer # applied second (outermost)
@log # applied first (innermost)
def process(n):
return sum(range(n))
process(1000)
# [LOG] Calling process
# [LOG] process done
# [TIMER] process: 0.0001s
# Equivalent: process = timer(log(process))
# Execution order: timer's wrapper -> log's wrapper -> process
# Real-world: authentication + rate limiting
def require_auth(func):
@wraps(func)
def wrapper(request, *args, **kwargs):
if not getattr(request, "user", None):
raise PermissionError("Authentication required")
return func(request, *args, **kwargs)
return wrapper
def rate_limit(calls_per_minute=60):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs) # simplified - real impl tracks time
return wrapper
return decorator
# Applied outermost first: rate_limit wraps require_auth wraps get_data
@rate_limit(calls_per_minute=100)
@require_auth
def get_data(request, item_id):
return f"Data for {item_id}"