Decorators & Metaclasses in Python
Difficulty: Hard | Companies: Google, Meta, Amazon, Netflix, Stripe
Function Decorators
Basic Decorator Patterns
from functools import wraps
import time
from typing import Callable, Any
def timer_decorator(func: Callable) -> Callable:
"""Measure execution time of a function."""
@wraps(func)
def wrapper(*args, **kwargs):
start_time = time.perf_counter()
result = func(*args, **kwargs)
end_time = time.perf_counter()
print(f"{func.__name__} executed in {end_time - start_time:.4f}s")
return result
return wrapper
def retry(max_attempts: int = 3, delay: float = 1.0):
"""Decorator with parameters for retrying failed operations."""
def decorator(func: Callable) -> Callable:
@wraps(func)
def wrapper(*args, **kwargs):
last_exception = None
for attempt in range(max_attempts):
try:
return func(*args, **kwargs)
except Exception as e:
last_exception = e
if attempt < max_attempts - 1:
time.sleep(delay * (2 ** attempt)) # Exponential backoff
raise last_exception
return wrapper
return decorator
# Usage
@timer_decorator
def slow_function():
time.sleep(0.1)
return "Done"
@retry(max_attempts=5, delay=0.5)
def unreliable_api_call():
import random
if random.random() < 0.7:
raise ConnectionError("API unavailable")
return {"status": "success"}
βΉοΈ
Always use @wraps(func) to preserve the original function's metadata (name, docstring, etc.). This is crucial for debugging and introspection.
Stacking Decorators
def log_args(func):
"""Log function arguments."""
@wraps(func)
def wrapper(*args, **kwargs):
args_str = ", ".join([repr(a) for a in args])
kwargs_str = ", ".join([f"{k}={v!r}" for k, v in kwargs.items()])
print(f"Calling {func.__name__}({args_str}, {kwargs_str})")
return func(*args, **kwargs)
return wrapper
def cache_result(func):
"""Cache function results."""
cache = {}
@wraps(func)
def wrapper(*args, **kwargs):
key = (args, tuple(sorted(kwargs.items())))
if key not in cache:
cache[key] = func(*args, **kwargs)
return cache[key]
wrapper.cache = cache
return wrapper
def validate_positive(func):
"""Validate that all numeric arguments are positive."""
@wraps(func)
def wrapper(*args, **kwargs):
for arg in args:
if isinstance(arg, (int, float)) and arg < 0:
raise ValueError(f"Negative value not allowed: {arg}")
for value in kwargs.values():
if isinstance(value, (int, float)) and value < 0:
raise ValueError(f"Negative value not allowed: {value}")
return func(*args, **kwargs)
return wrapper
# Stacked decorators - executed bottom-up
@log_args
@cache_result
@validate_positive
def calculate_discount(price: float, discount: float) -> float:
"""Calculate discounted price."""
return price * (1 - discount / 100)
# All three decorators are applied
result = calculate_discount(100, 20) # Logs, validates, caches, calculates
Class-Based Decorators
class CountCalls:
"""Decorator class that counts function calls."""
def __init__(self, func):
self.func = func
self.call_count = 0
self.call_history = []
def __call__(self, *args, **kwargs):
self.call_count += 1
self.call_history.append({
'args': args,
'kwargs': kwargs,
'timestamp': time.time()
})
return self.func(*args, **kwargs)
def get_stats(self):
return {
'total_calls': self.call_count,
'history': self.call_history
}
def reset(self):
self.call_count = 0
self.call_history = []
@CountCalls
def my_function(x, y):
return x + y
# Usage
my_function(1, 2)
my_function(3, 4)
print(my_function.get_stats()) # {'total_calls': 2, 'history': [...]}
class RateLimiter:
"""Rate limiter decorator class."""
def __init__(self, max_calls: int, period: float):
self.max_calls = max_calls
self.period = period
self.calls = []
def __call__(self, func):
@wraps(func)
def wrapper(*args, **kwargs):
now = time.time()
# Remove old calls outside the period
self.calls = [call for call in self.calls if now - call < self.period]
if len(self.calls) >= self.max_calls:
raise RuntimeError(f"Rate limit exceeded: {self.max_calls} calls per {self.period}s")
self.calls.append(now)
return func(*args, **kwargs)
wrapper.limiter = self
return wrapper
@RateLimiter(max_calls=10, period=1.0)
def api_endpoint():
return "Response"
Decorator Libraries
Implementation of a Decorator Factory
from typing import TypeVar, Callable, Any, Optional
from functools import wraps
import logging
F = TypeVar('F', bound=Callable[..., Any])
def log_execution(
level: str = "INFO",
include_args: bool = True,
include_result: bool = False,
logger_name: Optional[str] = None
) -> Callable[[F], F]:
"""
Comprehensive logging decorator factory.
Args:
level: Logging level (DEBUG, INFO, WARNING, ERROR)
include_args: Whether to log function arguments
include_result: Whether to log return value
logger_name: Name of the logger to use
"""
def decorator(func: F) -> F:
logger = logging.getLogger(logger_name or func.__module__)
@wraps(func)
def wrapper(*args, **kwargs):
log_func = getattr(logger, level.lower())
# Log entry
message = f"Entering {func.__qualname__}"
if include_args:
args_repr = [repr(a) for a in args]
kwargs_repr = [f"{k}={v!r}" for k, v in kwargs.items()]
params = ", ".join(args_repr + kwargs_repr)
message += f" with args: {params}"
log_func(message)
try:
result = func(*args, **kwargs)
# Log result
if include_result:
log_func(f"Exiting {func.__qualname__} with result: {result!r}")
else:
log_func(f"Exiting {func.__qualname__} successfully")
return result
except Exception as e:
logger.error(f"Exception in {func.__qualname__}: {e}")
raise
return wrapper
return decorator
# Usage
@log_execution(level="DEBUG", include_args=True, include_result=True)
def add_numbers(a: int, b: int) -> int:
"""Add two numbers."""
return a + b
β οΈ
Decorator factories with many parameters can become complex. Consider using dataclasses or configuration objects for decorator parameters.
Metaclasses
Understanding Metaclasses
class SingletonMeta(type):
"""Metaclass that implements Singleton pattern."""
_instances = {}
def __call__(cls, *args, **kwargs):
if cls not in cls._instances:
instance = super().__call__(*args, **kwargs)
cls._instances[cls] = instance
return cls._instances[cls]
class Database(metaclass=SingletonMeta):
"""Singleton database connection."""
def __init__(self):
self.connection = None
def connect(self):
print("Connecting to database...")
self.connection = "connected"
# Usage - both variables point to same instance
db1 = Database()
db2 = Database()
print(db1 is db2) # True
class ValidatedModelMeta(type):
"""Metaclass that adds validation to model classes."""
def __new__(mcs, name, bases, namespace):
# Collect validators from class definition
validators = {}
for key, value in namespace.items():
if hasattr(value, '__validator__'):
validators[key] = value
namespace['_validators'] = validators
# Create class
cls = super().__new__(mcs, name, bases, namespace)
# Add validation method
def validate_instance(self):
for field_name, validator in self._validators.items():
value = getattr(self, field_name, None)
if not validator(value):
raise ValueError(f"Validation failed for {field_name}")
cls.validate = validate_instance
return cls
def validate_type(expected_type):
"""Decorator that marks a field with its expected type."""
def decorator(func):
func.__validator__ = lambda x: isinstance(x, expected_type)
return func
return decorator
class User(metaclass=ValidatedModelMeta):
name = validate_type(str)(lambda x: len(x) > 0)
age = validate_type(int)(lambda x: 0 < x < 150)
def __init__(self, name, age):
self.name = name
self.age = age
# Usage
user = User("John", 30)
user.validate() # Passes validation
Abstract Base Classes with Metaclasses
from abc import ABCMeta, abstractmethod
from typing import ClassVar
class PluginMeta(ABCMeta):
"""Metaclass for plugin system with auto-registration."""
_plugins = {}
def __init_subclass__(cls, plugin_name=None, **kwargs):
super().__init_subclass__(**kwargs)
if plugin_name:
PluginMeta._plugins[plugin_name] = cls
@classmethod
def get_plugin(mcs, name):
return mcs._plugins.get(name)
@classmethod
def list_plugins(mcs):
return list(mcs._plugins.keys())
class Plugin(metaclass=PluginMeta):
"""Base class for all plugins."""
@abstractmethod
def execute(self, data):
pass
@abstractmethod
def get_name(self) -> str:
pass
# Plugin implementations
class JsonPlugin(Plugin, plugin_name="json"):
def execute(self, data):
import json
return json.dumps(data)
def get_name(self):
return "JSON Serializer"
class XmlPlugin(Plugin, plugin_name="xml"):
def execute(self, data):
return f"<data>{data}</data>"
def get_name(self):
return "XML Serializer"
# Usage
plugin = PluginMeta.get_plugin("json")()
print(plugin.execute({"key": "value"}))
print(PluginMeta.list_plugins()) # ['json', 'xml']
Advanced Decorator Patterns
Memoization Decorator with LRU Cache
from functools import wraps
from collections import OrderedDict
from typing import Any, Callable, Optional
class LRUCache:
"""Least Recently Used cache decorator."""
def __init__(self, maxsize: int = 128, typed: bool = False):
self.maxsize = maxsize
self.typed = typed
self.cache = OrderedDict()
self.hits = 0
self.misses = 0
def __call__(self, func: Callable) -> Callable:
@wraps(func)
def wrapper(*args, **kwargs):
# Create cache key
key = args
if self.typed:
key = key + tuple(type(v).__name__ for v in kwargs.values())
key += tuple(sorted(kwargs.items()))
# Check cache
if key in self.cache:
self.hits += 1
self.cache.move_to_end(key)
return self.cache[key]
# Compute and cache
self.misses += 1
result = func(*args, **kwargs)
self.cache[key] = result
# Evict if necessary
if len(self.cache) > self.maxsize:
self.cache.popitem(last=False)
return result
wrapper.cache_info = lambda: {
'hits': self.hits,
'misses': self.misses,
'size': len(self.cache),
'maxsize': self.maxsize
}
wrapper.cache_clear = lambda: self.cache.clear()
return wrapper
@LRUCache(maxsize=256)
def fibonacci(n: int) -> int:
if n < 2:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
# Usage
print(fibonacci(50))
print(fibonacci.cache_info())
Property Decorators with Validation
class Property:
"""Descriptor-based property with validation."""
def __init__(self, fget=None, fset=None, fdel=None, doc=None):
self.fget = fget
self.fset = fset
self.fdel = fdel
self.__doc__ = doc or (fget.__doc__ if fget else None)
def __get__(self, obj, objtype=None):
if obj is None:
return self
if self.fget is None:
raise AttributeError("unreadable attribute")
return self.fget(obj)
def __set__(self, obj, value):
if self.fset is None:
raise AttributeError("can't set attribute")
self.fset(obj, value)
def __delete__(self, obj):
if self.fdel is None:
raise AttributeError("can't delete attribute")
self.fdel(obj)
def getter(self, fget):
return type(self)(fget, self.fset, self.fdel, self.__doc__)
def setter(self, fset):
return type(self)(self.fget, fset, self.fdel, self.__doc__)
def deleter(self, fdel):
return type(self)(self.fget, self.fset, fdel, self.__doc__)
class Temperature:
def __init__(self, celsius=0):
self._celsius = celsius
@Property
def celsius(self):
"""Temperature in Celsius."""
return self._celsius
@celsius.setter
def celsius(self, value):
if value < -273.15:
raise ValueError("Temperature below absolute zero")
self._celsius = value
@Property
def fahrenheit(self):
"""Temperature in Fahrenheit."""
return self._celsius * 9/5 + 32
@fahrenheit.setter
def fahrenheit(self, value):
self.celsius = (value - 32) * 5/9
# Usage
temp = Temperature(25)
print(temp.celsius) # 25
print(temp.fahrenheit) # 77.0
temp.fahrenheit = 32
print(temp.celsius) # 0.0
Follow-Up Questions
-
Explain the difference between function decorators and class decorators.
-
How do metaclasses differ from regular inheritance?
-
When would you use a metaclass over a class decorator?
-
How does the
@propertydecorator work internally? -
Explain the concept of descriptor protocol and its relationship to decorators.