Descriptor Use Cases¶
Mental Model
Descriptors are reusable attribute behavior: write validation, type-checking, or caching logic once in a descriptor class, then attach it to any attribute on any class. One descriptor definition can manage dozens of attributes across unrelated classes — this is the mechanism that powers framework-level patterns like Django ORM fields.
This page organizes descriptor patterns into three tiers to help you focus on what matters most:
- Core — patterns every Python developer should know
- Advanced — useful in production code, but not everyday
- Framework-level — patterns used when building libraries or frameworks
When to Use Descriptors
Use descriptors when:
- Behavior must be shared across many attributes or classes
- You need reusable control over attribute access
- You are building a framework or library
Avoid descriptors when:
- Logic is simple → use
@property - Validation is one-off → use
__init__ordataclass - A plain attribute will do → don't over-engineer
Validation (Core)¶
1. Type Checking¶
```python class TypedAttribute: def init(self, name, expected_type): self.name = name self.expected_type = expected_type
def __get__(self, instance, owner):
if instance is None:
return self
return instance.__dict__.get(self.name)
def __set__(self, instance, value):
if not isinstance(value, self.expected_type):
raise TypeError(
f"{self.name} must be {self.expected_type.__name__}"
)
instance.__dict__[self.name] = value
class Person: name = TypedAttribute('name', str) age = TypedAttribute('age', int) height = TypedAttribute('height', (int, float))
p = Person() p.name = "Alice" # ✅ OK p.age = 30 # ✅ OK
p.age = "30" # ❌ TypeError¶
```
2. Range Validation¶
```python class BoundedNumber: def init(self, name, min_value=None, max_value=None): self.name = name self.min_value = min_value self.max_value = max_value
def __get__(self, instance, owner):
if instance is None:
return self
return instance.__dict__.get(self.name)
def __set__(self, instance, value):
if self.min_value is not None and value < self.min_value:
raise ValueError(f"{self.name} must be >= {self.min_value}")
if self.max_value is not None and value > self.max_value:
raise ValueError(f"{self.name} must be <= {self.max_value}")
instance.__dict__[self.name] = value
class Temperature: celsius = BoundedNumber('celsius', min_value=-273.15) fahrenheit = BoundedNumber('fahrenheit', min_value=-459.67)
t = Temperature() t.celsius = 25 # ✅ OK
t.celsius = -300 # ❌ ValueError¶
```
3. Pattern Validation¶
```python import re
class RegexValidated: def init(self, name, pattern): self.name = name self.pattern = re.compile(pattern)
def __get__(self, instance, owner):
if instance is None:
return self
return instance.__dict__.get(self.name)
def __set__(self, instance, value):
if not self.pattern.match(value):
raise ValueError(
f"{self.name} must match pattern: {self.pattern.pattern}"
)
instance.__dict__[self.name] = value
class User: email = RegexValidated('email', r'^[\w.-]+@[\w.-]+.\w+\(') phone = RegexValidated('phone', r'^\d{3}-\d{3}-\d{4}\)')
user = User() user.email = "alice@example.com" # ✅ OK
user.email = "invalid" # ❌ ValueError¶
```
ORM Patterns (Framework-Level)¶
1. Database Field¶
```python class Field: def init(self, field_type, required=False, default=None): self.field_type = field_type self.required = required self.default = default self.name = None
def __set_name__(self, owner, name):
self.name = name
def __get__(self, instance, owner):
if instance is None:
return self
return instance.__dict__.get(self.name, self.default)
def __set__(self, instance, value):
if value is None and self.required:
raise ValueError(f"{self.name} is required")
if value is not None and not isinstance(value, self.field_type):
raise TypeError(f"{self.name} must be {self.field_type.__name__}")
instance.__dict__[self.name] = value
class Model: def save(self): data = {} for name, field in type(self).dict.items(): if isinstance(field, Field): value = getattr(self, name) if value is not None: data[name] = value print(f"Saving to database: {data}") return data
class User(Model): username = Field(str, required=True) email = Field(str, required=True) age = Field(int, default=0)
user = User() user.username = "alice" user.email = "alice@example.com" user.age = 30 user.save() # Saving to database: {'username': 'alice', 'email': 'alice@example.com', 'age': 30} ```
2. Foreign Key¶
```python class ForeignKey: def init(self, model_class): self.model_class = model_class self.name = None
def __set_name__(self, owner, name):
self.name = name
def __get__(self, instance, owner):
if instance is None:
return self
fk_id = instance.__dict__.get(f"{self.name}_id")
if fk_id is not None:
# Simulate database lookup
return self.model_class.get(fk_id)
return None
def __set__(self, instance, value):
if not isinstance(value, self.model_class):
raise TypeError(f"Must be instance of {self.model_class.__name__}")
instance.__dict__[f"{self.name}_id"] = value.id
class Department: def init(self, id, name): self.id = id self.name = name
@staticmethod
def get(id):
# Simulate database lookup
return Department(id, f"Department {id}")
class Employee: department = ForeignKey(Department)
dept = Department(1, "Engineering") emp = Employee() emp.department = dept ```
3. Lazy Relationship¶
```python class LazyRelationship: def init(self, model_class, foreign_key): self.model_class = model_class self.foreign_key = foreign_key self.name = None
def __set_name__(self, owner, name):
self.name = name
def __get__(self, instance, owner):
if instance is None:
return self
# Check cache
cache_key = f"_{self.name}_cache"
if cache_key not in instance.__dict__:
# Load from database
fk_value = getattr(instance, self.foreign_key)
instance.__dict__[cache_key] = self.model_class.query(fk_value)
return instance.__dict__[cache_key]
```
Performance Optimization (Core)¶
1. Lazy Loading¶
```python class LazyProperty: def init(self, func): self.func = func self.name = func.name
def __get__(self, instance, owner):
if instance is None:
return self
# Load once and cache
value = self.func(instance)
instance.__dict__[self.name] = value
return value
class DataProcessor: def init(self, filename): self.filename = filename
@LazyProperty
def data(self):
print(f"Loading {self.filename}...")
# Expensive I/O operation
with open(self.filename) as f:
return f.read()
processor = DataProcessor('data.txt')
Not loaded yet¶
print(processor.data) # Loading data.txt... print(processor.data) # No loading (cached) ```
2. Memoization¶
```python class Memoized: def init(self, func): self.func = func self.cache = {}
def __get__(self, instance, owner):
if instance is None:
return self
def memoized_func(*args):
if args not in self.cache:
self.cache[args] = self.func(instance, *args)
return self.cache[args]
return memoized_func
class Calculator: @Memoized def fibonacci(self, n): print(f"Computing fib({n})") if n <= 1: return n return self.fibonacci(n-1) + self.fibonacci(n-2)
calc = Calculator() print(calc.fibonacci(5)) # Computes each value once print(calc.fibonacci(5)) # All from cache ```
3. Weak References¶
```python import weakref
class WeakAttribute: def init(self): self.data = weakref.WeakValueDictionary()
def __get__(self, instance, owner):
if instance is None:
return self
return self.data.get(id(instance))
def __set__(self, instance, value):
self.data[id(instance)] = value
class Node: parent = WeakAttribute() ```
Access Control (Advanced)¶
1. Read-Only After Init¶
```python class ReadOnlyAfterInit: def init(self, name): self.name = name self.initialized = weakref.WeakSet()
def __get__(self, instance, owner):
if instance is None:
return self
return instance.__dict__.get(self.name)
def __set__(self, instance, value):
if instance in self.initialized:
raise AttributeError(f"{self.name} is read-only after initialization")
instance.__dict__[self.name] = value
self.initialized.add(instance)
class Config: api_key = ReadOnlyAfterInit('api_key')
config = Config() config.api_key = "secret123" # ✅ OK (first time)
config.api_key = "new" # ❌ AttributeError (read-only)¶
```
2. Permission-Based Access¶
```python class PermissionRequired: def init(self, name, read_perm=None, write_perm=None): self.name = name self.read_perm = read_perm self.write_perm = write_perm
def __get__(self, instance, owner):
if instance is None:
return self
if self.read_perm and not instance.has_permission(self.read_perm):
raise PermissionError(f"No read access to {self.name}")
return instance.__dict__.get(self.name)
def __set__(self, instance, value):
if self.write_perm and not instance.has_permission(self.write_perm):
raise PermissionError(f"No write access to {self.name}")
instance.__dict__[self.name] = value
```
3. Audit Trail¶
```python from datetime import datetime
class AuditedAttribute: def init(self, name): self.name = name
def __get__(self, instance, owner):
if instance is None:
return self
return instance.__dict__.get(self.name)
def __set__(self, instance, value):
# Log change
if not hasattr(instance, '_audit_log'):
instance._audit_log = []
old_value = instance.__dict__.get(self.name)
instance._audit_log.append({
'field': self.name,
'old': old_value,
'new': value,
'timestamp': datetime.now()
})
instance.__dict__[self.name] = value
class AuditedModel: name = AuditedAttribute('name') value = AuditedAttribute('value')
model = AuditedModel() model.name = "Alice" model.value = 100 model.value = 200 print(model._audit_log) ```
Type Conversion (Advanced)¶
1. Auto-Converting¶
```python class AutoConvert: def init(self, name, converter): self.name = name self.converter = converter
def __get__(self, instance, owner):
if instance is None:
return self
return instance.__dict__.get(self.name)
def __set__(self, instance, value):
instance.__dict__[self.name] = self.converter(value)
class DataRecord: count = AutoConvert('count', int) price = AutoConvert('price', float) active = AutoConvert('active', bool)
record = DataRecord() record.count = "42" # Stored as int(42) record.price = "19.99" # Stored as float(19.99) record.active = "yes" # Stored as bool("yes") = True ```
2. JSON Serialization¶
```python import json
class JSONField: def init(self, name): self.name = name
def __get__(self, instance, owner):
if instance is None:
return self
json_str = instance.__dict__.get(self.name)
return json.loads(json_str) if json_str else None
def __set__(self, instance, value):
instance.__dict__[self.name] = json.dumps(value)
class Config: settings = JSONField('settings')
config = Config() config.settings = {'debug': True, 'port': 8080} print(config.settings) # {'debug': True, 'port': 8080} ```
3. Unit Conversion¶
```python class Temperature: def init(self, name): self.name = name
def __get__(self, instance, owner):
if instance is None:
return self
# Stored in Celsius
return instance.__dict__.get(self.name, 0)
def __set__(self, instance, value):
# Value can be dict with unit
if isinstance(value, dict):
if value.get('unit') == 'F':
# Convert F to C
celsius = (value['value'] - 32) * 5/9
instance.__dict__[self.name] = celsius
else:
instance.__dict__[self.name] = value['value']
else:
instance.__dict__[self.name] = value
class Weather: temp = Temperature('temp')
weather = Weather() weather.temp = {'value': 77, 'unit': 'F'} print(weather.temp) # 25.0 (Celsius) ```
Exercises¶
Exercise 1.
Create a RangeValidator descriptor that ensures a value falls within a specified range. The descriptor should accept min_val and max_val in its __init__. Use it in a Student class with grade (0--100) and age (5--25) fields. Demonstrate that out-of-range values raise ValueError.
Solution to Exercise 1
class RangeValidator:
def __init__(self, min_val, max_val):
self.min_val = min_val
self.max_val = max_val
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(self.name)
def __set__(self, obj, value):
if not self.min_val <= value <= self.max_val:
raise ValueError(
f"{self.name} must be between {self.min_val} and {self.max_val}, got {value}"
)
obj.__dict__[self.name] = value
class Student:
grade = RangeValidator(0, 100)
age = RangeValidator(5, 25)
def __init__(self, name, grade, age):
self.name = name
self.grade = grade
self.age = age
s = Student("Alice", 95, 20)
print(s.grade) # 95
try:
s.grade = 105
except ValueError as e:
print(f"Error: {e}")
Exercise 2.
Write a TypeChecked descriptor that enforces a specific type on assignment. It should accept the expected type in __init__. Use it in a Config class: name must be str, port must be int, debug must be bool. Show that assigning a wrong type raises TypeError.
Solution to Exercise 2
class TypeChecked:
def __init__(self, expected_type):
self.expected_type = expected_type
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(self.name)
def __set__(self, obj, value):
if not isinstance(value, self.expected_type):
raise TypeError(
f"{self.name} must be {self.expected_type.__name__}, got {type(value).__name__}"
)
obj.__dict__[self.name] = value
class Config:
name = TypeChecked(str)
port = TypeChecked(int)
debug = TypeChecked(bool)
def __init__(self, name, port, debug):
self.name = name
self.port = port
self.debug = debug
c = Config("app", 8080, True)
print(c.name) # app
try:
c.port = "not a number"
except TypeError as e:
print(f"Error: {e}")
Exercise 3.
Build a Cached descriptor that computes a value on first access (using a provided callable) and caches the result. Subsequent accesses return the cached value without recomputation. Use it in a DataLoader class that has a data attribute whose computation is expensive (simulate with time.sleep). Show that the second access is instant.
Solution to Exercise 3
import time
class Cached:
def __init__(self, func):
self.func = func
def __set_name__(self, owner, name):
self.name = name
def __get__(self, obj, objtype=None):
if obj is None:
return self
if self.name not in obj.__dict__:
obj.__dict__[self.name] = self.func(obj)
return obj.__dict__[self.name]
class DataLoader:
@Cached
def data(self):
print("Computing (expensive)...")
time.sleep(0.1) # Simulate expensive computation
return [1, 2, 3, 4, 5]
loader = DataLoader()
print(loader.data) # Computing (expensive)... [1, 2, 3, 4, 5]
print(loader.data) # [1, 2, 3, 4, 5] — cached, no recomputation