Combining Descriptors, Decorators, and Metaclasses
Descriptors, decorators, and metaclasses are not competitors; they're complementary tools that work together synergistically. Descriptors handle attribute-level customization; decorators (especially class decorators) perform one-time transformations; metaclasses control class creation itself. Production frameworks use all three in concert: descriptors for field validation, decorators for cross-cutting concerns like caching and logging, and metaclasses for schema building and inheritance chains. Understanding when to reach for each tool—and how to compose them without creating maintenance nightmares—is the hallmark of a master Python developer. This article shows real patterns where all three work together in harmony.
Pattern 1: Descriptors + Metaclass for Declarative Models
Combine descriptors (per-field behavior) with metaclasses (schema generation) to build declarative models:
class ValidatedField:
"""A descriptor with type and range validation."""
def __init__(self, type_, min_val=None, max_val=None):
self.type_ = type_
self.min_val = min_val
self.max_val = max_val
self.name = None
def __set_name__(self, owner, name):
self.name = name
def __get__(self, obj, objtype=None):
if obj is None:
return self
return obj.__dict__.get(f'_{self.name}')
def __set__(self, obj, value):
if not isinstance(value, self.type_):
raise TypeError(f'{self.name} must be {self.type_.__name__}')
if self.min_val is not None and value < self.min_val:
raise ValueError(f'{self.name} must be >= {self.min_val}')
if self.max_val is not None and value > self.max_val:
raise ValueError(f'{self.name} must be <= {self.max_val}')
obj.__dict__[f'_{self.name}'] = value
class DeclarativeModelMeta(type):
"""Metaclass that collects descriptors and generates __init__."""
def __new__(mcs, name, bases, namespace):
# Collect field descriptors
fields = {}
for key, value in namespace.items():
if isinstance(value, ValidatedField):
fields[key] = value
namespace['_fields'] = fields
# Auto-generate __init__
def __init__(self, **kwargs):
for field_name in self._fields:
if field_name in kwargs:
setattr(self, field_name, kwargs[field_name])
namespace['__init__'] = __init__
# Auto-generate __repr__
def __repr__(self):
fields_str = ', '.join(
f'{k}={getattr(self, k, None)!r}'
for k in self._fields
)
return f'{name}({fields_str})'
namespace['__repr__'] = __repr__
return super().__new__(mcs, name, bases, namespace)
class Model(metaclass=DeclarativeModelMeta):
pass
class Product(Model):
price = ValidatedField(float, min_val=0.01, max_val=999999)
quantity = ValidatedField(int, min_val=0, max_val=100000)
product = Product(price=29.99, quantity=50)
print(product) # Output: Product(price=29.99, quantity=50)
# Validation error
try:
product.price = -10
except ValueError as e:
print(f'Error: {e}')
The descriptor handles per-instance validation; the metaclass generates schema and initialization code. Neither tool alone is sufficient.
Pattern 2: Decorator + Descriptor for Caching
Use a decorator to add caching logic around methods that use descriptors:
from functools import wraps
class ComputedProperty:
"""A descriptor for expensive computed properties."""
def __init__(self, func):
self.func = func
self.name = func.__name__
def __get__(self, obj, objtype=None):
if obj is None:
return self
# Call the function to compute the value
return self.func(obj)
def cached_method(func):
"""A decorator that caches method results on the instance."""
@wraps(func)
def wrapper(self, *args, **kwargs):
cache_key = (func.__name__, args, tuple(sorted(kwargs.items())))
if not hasattr(self, '_method_cache'):
self._method_cache = {}
if cache_key not in self._method_cache:
self._method_cache[cache_key] = func(self, *args, **kwargs)
return self._method_cache[cache_key]
return wrapper
class User:
def __init__(self, first_name, last_name):
self.first_name = first_name
self.last_name = last_name
@property
def full_name(self):
"""A simple property (descriptor)."""
return f'{self.first_name} {self.last_name}'
@cached_method
def calculate_reputation(self, factor=1.0):
"""An expensive computation that gets cached."""
print(f'Computing reputation with factor {factor}...')
import time
time.sleep(0.1) # Simulate expensive operation
return 100 * factor
def invalidate_cache(self):
"""Clear the method cache."""
self._method_cache = {}
user = User("Alice", "Smith")
print(user.full_name) # Output: Alice Smith
# First call computes
result1 = user.calculate_reputation(1.5)
print(f'Reputation: {result1}') # Output includes "Computing reputation..."
# Second call uses cache
result2 = user.calculate_reputation(1.5)
print(f'Reputation: {result2}') # Output does NOT include "Computing reputation..."
# Different argument: recomputes
result3 = user.calculate_reputation(2.0)
print(f'Reputation: {result3}') # Output includes "Computing reputation..."
The decorator wraps methods to add caching; descriptors (like @property) define read-only attributes. Together they provide clean, DRY attribute access with intelligent caching.
Pattern 3: Metaclass + Class Decorator for Validation and Logging
Apply both a metaclass (for structure) and decorators (for behavior) to a model:
def log_changes(cls):
"""A class decorator that logs attribute changes."""
original_setattr = cls.__setattr__
def logged_setattr(self, name, value):
print(f'{cls.__name__}.{name} = {value!r}')
original_setattr(self, name, value)
cls.__setattr__ = logged_setattr
return cls
class ValidatingMeta(type):
"""A metaclass that ensures all methods are documented."""
def __new__(mcs, name, bases, namespace):
for attr_name, attr_value in namespace.items():
if callable(attr_value) and not attr_name.startswith('_'):
if not attr_value.__doc__:
raise TypeError(
f'{name}.{attr_name} must have a docstring'
)
return super().__new__(mcs, name, bases, namespace)
@log_changes
class ValidatedModel(metaclass=ValidatingMeta):
pass
class BankAccount(ValidatedModel):
def __init__(self, account_holder, initial_balance):
self.account_holder = account_holder
self.balance = initial_balance
def deposit(self, amount):
"""Record a deposit."""
self.balance += amount
def withdraw(self, amount):
"""Record a withdrawal."""
if amount <= self.balance:
self.balance -= amount
account = BankAccount("Alice", 1000)
# Output: BankAccount.account_holder = 'Alice'
# BankAccount.balance = 1000
account.deposit(500) # Output: BankAccount.balance = 1500
account.withdraw(200) # Output: BankAccount.balance = 1300
The metaclass enforces documentation standards at class definition time; the decorator logs all attribute changes at runtime. Neither alone provides both guarantees.
Pattern 4: Descriptor + Decorator + Metaclass: A Complete Framework
Combine all three for a flexible validation framework:
class TypeCheckedField:
"""Descriptor with optional custom validation."""
def __init__(self, type_, validator=None):
self.type_ = type_
self.validator = validator
self.name = None
def __set_name__(self, owner, name):
self.name = name
def __get__(self, obj, objtype=None):
if obj is None:
return self
return obj.__dict__.get(f'_{self.name}')
def __set__(self, obj, value):
if not isinstance(value, self.type_):
raise TypeError(f'{self.name} must be {self.type_.__name__}')
if self.validator and not self.validator(value):
raise ValueError(f'{self.name} validation failed for {value!r}')
obj.__dict__[f'_{self.name}'] = value
def audit_access(cls):
"""Decorator that tracks all attribute access."""
def __getattribute__(self, name):
if not name.startswith('_'):
print(f'[AUDIT] Accessed {cls.__name__}.{name}')
return super(cls, self).__getattribute__(name)
cls.__getattribute__ = __getattribute__
return cls
class FrameworkMeta(type):
"""Metaclass that enforces field definitions."""
def __new__(mcs, name, bases, namespace):
# Require that all subclasses declare fields
has_fields = any(isinstance(v, TypeCheckedField) for v in namespace.values())
if name not in ('BaseModel',) and not has_fields and bases:
raise TypeError(f'{name} must define at least one field')
# Auto-generate __repr__
def __repr__(self):
field_reprs = []
for key, value in self.__dict__.items():
if not key.startswith('_'):
field_reprs.append(f'{key}={value!r}')
return f'{name}({", ".join(field_reprs)})'
namespace['__repr__'] = __repr__
return super().__new__(mcs, name, bases, namespace)
@audit_access
class BaseModel(metaclass=FrameworkMeta):
pass
class User(BaseModel):
name = TypeCheckedField(str)
age = TypeCheckedField(int, validator=lambda x: 0 < x < 150)
user = User()
user.name = "Alice" # Output: [AUDIT] Accessed User.name
user.age = 30 # Output: [AUDIT] Accessed User.age
print(user) # Output: User(name='Alice', age=30)
This three-layer approach provides (1) per-field validation (descriptor), (2) cross-cutting audit logging (decorator), and (3) schema-level enforcement (metaclass).
Decision Tree: Which Tool to Use
Need to customize attribute access on instances?
→ Use descriptors (__get__, __set__)
Need to transform a class one-time at definition?
→ Use class decorators
Need to control how subclasses are created?
→ Use __init_subclass__
Need full control over class creation and inheritance?
→ Use metaclasses
Need to wrap methods for cross-cutting concerns (logging, caching)?
→ Use function/method decorators
Need all of the above in one system?
→ Combine them: descriptors for fields, decorators for behavior, metaclasses for schema
Key Takeaways
- Descriptors excel at attribute-level customization; metaclasses excel at class-level schema.
- Decorators provide composable, reusable transformations without modifying source code.
- Production frameworks layer all three: descriptors for validation, decorators for concerns, metaclasses for registration and schema.
- Understand the role of each tool to avoid over-engineering; not every problem needs a metaclass.
Frequently Asked Questions
Which should I learn first: descriptors, decorators, or metaclasses?
Learn in this order: (1) decorators (simplest), (2) descriptors (powerful but contained), (3) metaclasses (builds on the previous two).
Can I use descriptors without a metaclass?
Absolutely. Many frameworks use descriptors alone for field validation (e.g., Marshmallow). Metaclasses are optional; use them only when you need class-level control.
Is combining three tools an anti-pattern?
No, if each tool solves a distinct problem. Django, Pydantic, and SQLAlchemy all combine descriptors, decorators, and metaclasses. The key is clarity: document which tool handles what responsibility.
How do I avoid infinite recursion when combining these tools?
Each tool has its own "core" responsibility: descriptors handle attribute access, decorators wrap functions, metaclasses control class creation. Keep concerns separated and test at each layer independently.
Further Reading
- Composable Class Design with Metaclasses — Real Python comprehensive guide.
- Django's Implementation — see all three tools in production.
- Pydantic Field Validators — another production example combining tools.
- Python Design Patterns — factory, decorator, and other patterns using metaprogramming.