Decorators in Python: A Deep Dive
Exploring the mechanics of decorators beyond the basics — metaclasses, descriptor protocols, and practical patterns for production code.
Why Decorators Matter
Decorators are one of Python's most powerful metaprogramming features. At their core, they're syntactic sugar for higher-order functions — but understanding their full depth unlocks patterns that can dramatically simplify complex codebases.
Beyond the Basics
Most tutorials cover the simple case:
| 1 | def my_decorator(func): |
| 2 | def wrapper(*args, **kwargs): |
| 3 | print(class=class="syn-str">"syn-str">class="syn-str">"Before") |
| 4 | result = func(*args, **kwargs) |
| 5 | print(class=class="syn-str">"syn-str">class="syn-str">"After") |
| 6 | return result |
| 7 | return wrapper |
But production decorators need to handle edge cases: preserving function signatures, working with methods vs. functions, supporting both @decorator and @decorator() syntax, and integrating with type checkers.
The Descriptor Protocol Connection
When you apply a decorator to a method inside a class, Python's descriptor protocol comes into play. Understanding __get__, __set__, and __delete__ is essential for writing decorators that work correctly in class contexts.
| 1 | import functools |
| 2 | |
| 3 | class CachedProperty: |
| 4 | def __init__(self, func): |
| 5 | self.func = func |
| 6 | self.attrname = None |
| 7 | functools.update_wrapper(self, func) |
| 8 | |
| 9 | def __set_name__(self, owner, name): |
| 10 | self.attrname = name |
| 11 | |
| 12 | def __get__(self, instance, owner=None): |
| 13 | if instance is None: |
| 14 | return self |
| 15 | cache = instance.__dict__ |
| 16 | val = cache.get(self.attrname) |
| 17 | if val is None: |
| 18 | val = self.func(instance) |
| 19 | cache[self.attrname] = val |
| 20 | return val |
Practical Patterns
Retry with Exponential Backoff
| 1 | def retry(max_attempts=3, backoff_factor=2): |
| 2 | def decorator(func): |
| 3 | @functools.wraps(func) |
| 4 | async def wrapper(*args, **kwargs): |
| 5 | for attempt in range(max_attempts): |
| 6 | try: |
| 7 | return await func(*args, **kwargs) |
| 8 | except Exception as e: |
| 9 | if attempt == max_attempts - 1: |
| 10 | raise |
| 11 | await asyncio.sleep(backoff_factor ** attempt) |
| 12 | return wrapper |
| 13 | return decorator |
Type-Safe Validation
| 1 | def validate_types(**expected_types): |
| 2 | def decorator(func): |
| 3 | @functools.wraps(func) |
| 4 | def wrapper(*args, **kwargs): |
| 5 | for param, expected in expected_types.items(): |
| 6 | if param in kwargs and not isinstance(kwargs[param], expected): |
| 7 | raise TypeError(fclass=class="syn-str">"syn-str">class="syn-str">"{param} must be {expected.__name__}") |
| 8 | return func(*args, **kwargs) |
| 9 | return wrapper |
| 10 | return decorator |
Key Takeaways
- Always use
functools.wrapsto preserve metadata - Design decorators that work with both sync and async functions
- Consider the descriptor protocol when decorating methods
- Support both
@decoratorand@decorator()invocation styles - Write type stubs for complex decorators to maintain IDE support