|$ curl https://forge-ai.dev/api/markdown?path=docs/python/decorators
$cat docs/python-—-decorators.md
updated Recently·18 min read·published
Python — Decorators
◆Python◆Advanced
Introduction
Decorators are functions that modify the behavior of other functions or classes without changing their source code. They are a powerful metaprogramming tool that enables aspect-oriented programming patterns.
Decorator Basics
A decorator is a function that takes a function and returns a function. The @decorator syntax is syntactic sugar for func = decorator(func).
basic.py
Python
| 1 | # A decorator is just a function that takes a function |
| 2 | def my_decorator(func): |
| 3 | def wrapper(): |
| 4 | print("before function call") |
| 5 | func() |
| 6 | print("after function call") |
| 7 | return wrapper |
| 8 | |
| 9 | # Applying a decorator |
| 10 | @my_decorator |
| 11 | def say_hello(): |
| 12 | print("hello!") |
| 13 | |
| 14 | # This is equivalent to: |
| 15 | # say_hello = my_decorator(say_hello) |
| 16 | |
| 17 | say_hello() |
| 18 | # Output: |
| 19 | # before function call |
| 20 | # hello! |
| 21 | # after function call |
Preserving Function Metadata
Without care, decorators clobber the original function's name, docstring, and signature. Use functools.wraps to preserve them.
wraps.py
Python
| 1 | from functools import wraps |
| 2 | |
| 3 | def timer(func): |
| 4 | """Print how long the function takes.""" |
| 5 | @wraps(func) # ← critical! preserves func's metadata |
| 6 | def wrapper(*args, **kwargs): |
| 7 | import time |
| 8 | start = time.perf_counter() |
| 9 | result = func(*args, **kwargs) |
| 10 | elapsed = time.perf_counter() - start |
| 11 | print(f"{func.__name__} took {elapsed:.4f}s") |
| 12 | return result |
| 13 | return wrapper |
| 14 | |
| 15 | @timer |
| 16 | def slow_function(): |
| 17 | """A function that takes a while.""" |
| 18 | return sum(range(10_000_000)) |
| 19 | |
| 20 | # Without @wraps: |
| 21 | print(slow_function.__name__) # → "wrapper" (wrong!) |
| 22 | |
| 23 | # With @wraps: |
| 24 | print(slow_function.__name__) # → "slow_function" (correct) |
| 25 | print(slow_function.__doc__) # → "A function that takes a while." |
✓
best practice
Always use @functools.wraps in your decorators. Without it, debugging becomes painful because function names and docstrings are replaced.
Decorators with Arguments
with_args.py
Python
| 1 | # Decorator with arguments = three levels of nesting |
| 2 | def repeat(n: int): |
| 3 | """Run the decorated function n times.""" |
| 4 | def decorator(func): |
| 5 | @wraps(func) |
| 6 | def wrapper(*args, **kwargs): |
| 7 | for _ in range(n): |
| 8 | result = func(*args, **kwargs) |
| 9 | return result |
| 10 | return wrapper |
| 11 | return decorator |
| 12 | |
| 13 | @repeat(3) |
| 14 | def greet(name: str): |
| 15 | print(f"Hello, {name}!") |
| 16 | |
| 17 | greet("Alice") |
| 18 | # Output: |
| 19 | # Hello, Alice! |
| 20 | # Hello, Alice! |
| 21 | # Hello, Alice! |
| 22 | |
| 23 | # Equivalent to: greet = repeat(3)(greet) |
| 24 | |
| 25 | # Decorator with optional arguments |
| 26 | def cache(func=None, *, ttl: int = 60): |
| 27 | """Cache results with optional TTL.""" |
| 28 | def decorator(fn): |
| 29 | @wraps(fn) |
| 30 | def wrapper(*args, **kwargs): |
| 31 | key = (args, tuple(kwargs.items())) |
| 32 | if key not in wrapper._cache: |
| 33 | wrapper._cache[key] = fn(*args, **kwargs) |
| 34 | return wrapper._cache[key] |
| 35 | wrapper._cache = {} |
| 36 | return wrapper |
| 37 | |
| 38 | if func is not None: |
| 39 | return decorator(func) # used as @cache (no args) |
| 40 | return decorator # used as @cache(ttl=120) |
| 41 | |
| 42 | @cache |
| 43 | def expensive1(x): ... |
| 44 | |
| 45 | @cache(ttl=300) |
| 46 | def expensive2(x): ... |
Real-World Decorator Patterns
real_world.py
Python
| 1 | # 1. Timing / Profiling |
| 2 | import time |
| 3 | from functools import wraps |
| 4 | |
| 5 | def timer(func): |
| 6 | @wraps(func) |
| 7 | def wrapper(*args, **kwargs): |
| 8 | start = time.perf_counter() |
| 9 | result = func(*args, **kwargs) |
| 10 | elapsed = time.perf_counter() - start |
| 11 | print(f"{func.__name__}: {elapsed:.4f}s") |
| 12 | return result |
| 13 | return wrapper |
| 14 | |
| 15 | # 2. Logging |
| 16 | import logging |
| 17 | logger = logging.getLogger(__name__) |
| 18 | |
| 19 | def log_call(func): |
| 20 | @wraps(func) |
| 21 | def wrapper(*args, **kwargs): |
| 22 | logger.info(f"Calling {func.__name__} with args={args} kwargs={kwargs}") |
| 23 | try: |
| 24 | result = func(*args, **kwargs) |
| 25 | logger.info(f"{func.__name__} returned {result}") |
| 26 | return result |
| 27 | except Exception as e: |
| 28 | logger.error(f"{func.__name__} raised {e}") |
| 29 | raise |
| 30 | return wrapper |
| 31 | |
| 32 | # 3. Retry on failure |
| 33 | def retry(max_attempts: int = 3, delay: float = 1.0): |
| 34 | def decorator(func): |
| 35 | @wraps(func) |
| 36 | def wrapper(*args, **kwargs): |
| 37 | import time |
| 38 | for attempt in range(max_attempts): |
| 39 | try: |
| 40 | return func(*args, **kwargs) |
| 41 | except Exception as e: |
| 42 | if attempt == max_attempts - 1: |
| 43 | raise |
| 44 | time.sleep(delay) |
| 45 | return None |
| 46 | return wrapper |
| 47 | return decorator |
| 48 | |
| 49 | @retry(max_attempts=5, delay=0.5) |
| 50 | def fetch_data(url: str) -> dict: |
| 51 | ... |
| 52 | |
| 53 | # 4. Rate limiting |
| 54 | from time import sleep |
| 55 | |
| 56 | def rate_limit(calls: int, period: float): |
| 57 | def decorator(func): |
| 58 | last_reset = time.time() |
| 59 | call_count = 0 |
| 60 | |
| 61 | @wraps(func) |
| 62 | def wrapper(*args, **kwargs): |
| 63 | nonlocal last_reset, call_count |
| 64 | now = time.time() |
| 65 | if now - last_reset > period: |
| 66 | call_count = 0 |
| 67 | last_reset = now |
| 68 | if call_count >= calls: |
| 69 | raise RuntimeError("Rate limit exceeded") |
| 70 | call_count += 1 |
| 71 | return func(*args, **kwargs) |
| 72 | return wrapper |
| 73 | return decorator |
| 74 | |
| 75 | # 5. Deprecation warning |
| 76 | import warnings |
| 77 | |
| 78 | def deprecated(message: str = ""): |
| 79 | def decorator(func): |
| 80 | @wraps(func) |
| 81 | def wrapper(*args, **kwargs): |
| 82 | warnings.warn( |
| 83 | f"{func.__name__} is deprecated. {message}", |
| 84 | DeprecationWarning, |
| 85 | stacklevel=2, |
| 86 | ) |
| 87 | return func(*args, **kwargs) |
| 88 | return wrapper |
| 89 | return decorator |
| 90 | |
| 91 | @deprecated("Use new_function() instead") |
| 92 | def old_function(): |
| 93 | pass |
Class-based Decorators
class_decorators.py
Python
| 1 | # Decorator as a class (implement __call__) |
| 2 | class CountCalls: |
| 3 | """Count how many times a function is called.""" |
| 4 | |
| 5 | def __init__(self, func): |
| 6 | self.func = func |
| 7 | self.count = 0 |
| 8 | |
| 9 | def __call__(self, *args, **kwargs): |
| 10 | self.count += 1 |
| 11 | print(f"Call {self.count} of {self.func.__name__}") |
| 12 | return self.func(*args, **kwargs) |
| 13 | |
| 14 | @CountCalls |
| 15 | def say_hi(): |
| 16 | print("hi!") |
| 17 | |
| 18 | say_hi() # → Call 1 of say_hi / hi! |
| 19 | say_hi() # → Call 2 of say_hi / hi! |
| 20 | print(say_hi.count) # → 2 |
| 21 | |
| 22 | # Class decorators (decorating a class) |
| 23 | def add_repr(cls): |
| 24 | """Add __repr__ to any class.""" |
| 25 | original_init = cls.__init__ |
| 26 | |
| 27 | def __repr__(self): |
| 28 | args = ', '.join( |
| 29 | f"{k}={v!r}" for k, v in self.__dict__.items() |
| 30 | ) |
| 31 | return f"{cls.__name__}({args})" |
| 32 | |
| 33 | cls.__repr__ = __repr__ |
| 34 | return cls |
| 35 | |
| 36 | @add_repr |
| 37 | class Person: |
| 38 | def __init__(self, name: str, age: int): |
| 39 | self.name = name |
| 40 | self.age = age |
| 41 | |
| 42 | p = Person("Alice", 30) |
| 43 | print(p) # → Person(name='Alice', age=30) |
Built-in Decorators
builtin.py
Python
| 1 | # @property — method as attribute |
| 2 | class Circle: |
| 3 | def __init__(self, radius): |
| 4 | self._radius = radius |
| 5 | |
| 6 | @property |
| 7 | def radius(self): |
| 8 | return self._radius |
| 9 | |
| 10 | @radius.setter |
| 11 | def radius(self, value): |
| 12 | if value < 0: |
| 13 | raise ValueError("Radius can't be negative") |
| 14 | self._radius = value |
| 15 | |
| 16 | @property |
| 17 | def area(self): |
| 18 | return 3.14159 * self._radius ** 2 |
| 19 | |
| 20 | # @staticmethod — no self or cls |
| 21 | class Math: |
| 22 | @staticmethod |
| 23 | def add(a, b): |
| 24 | return a + b |
| 25 | |
| 26 | Math.add(2, 3) # → 5 (no instance needed) |
| 27 | |
| 28 | # @classmethod — receives cls instead of self |
| 29 | class Config: |
| 30 | settings = {} |
| 31 | |
| 32 | @classmethod |
| 33 | def from_file(cls, path: str): |
| 34 | import json |
| 35 | cls.settings = json.load(open(path)) |
| 36 | return cls() |
| 37 | |
| 38 | # @dataclass — auto-generate boilerplate |
| 39 | from dataclasses import dataclass |
| 40 | |
| 41 | @dataclass |
| 42 | class Point: |
| 43 | x: float |
| 44 | y: float |
| 45 | |
| 46 | # @lru_cache — memoization |
| 47 | from functools import lru_cache |
| 48 | |
| 49 | @lru_cache(maxsize=128) |
| 50 | def fib(n: int) -> int: |
| 51 | if n < 2: |
| 52 | return n |
| 53 | return fib(n - 1) + fib(n - 2) |
| 54 | |
| 55 | # @singledispatch — function overloading by type |
| 56 | from functools import singledispatch |
| 57 | |
| 58 | @singledispatch |
| 59 | def to_string(obj): |
| 60 | raise NotImplementedError |
| 61 | |
| 62 | @to_string.register(int) |
| 63 | def _(obj): |
| 64 | return str(obj) |
| 65 | |
| 66 | @to_string.register(list) |
| 67 | def _(obj): |
| 68 | return ", ".join(str(x) for x in obj) |
| 69 | |
| 70 | # @contextmanager — create context managers easily |
| 71 | from contextlib import contextmanager |
| 72 | |
| 73 | @contextmanager |
| 74 | def temporary_change(obj, attr, value): |
| 75 | original = getattr(obj, attr) |
| 76 | setattr(obj, attr, value) |
| 77 | try: |
| 78 | yield |
| 79 | finally: |
| 80 | setattr(obj, attr, original) |
$Blueprint — Engineering Documentation·Section ID: PYTHON-DEC·Revision: 1.0