Python Specific
Descriptors
When building frameworks, ORMs, or validation systems, you need fine-grained control over attribute access. Descriptors provide the low-level mechanism that powers @property, class methods, and ORM field definitions, letting you intercept and customize get, set, and delete operations.
This distinction is crucial: data descriptors cannot be shadowed by instance attributes, making them ideal for validation. Non-data descriptors can be overridden, which is how method caching works.
Practical Applications
descriptor_protocol.py
"""Basic descriptor protocol"""
# Simple descriptor
print("Simple descriptor:")
class Descriptor:
def __get__(self, obj, type=None):
print(f" __get__ called: obj={obj}, type={type}")
return 42
def __set__(self, obj, value):
print(f" __set__ called: obj={obj}, value={value}")
class MyClass:
attr = Descriptor()
obj = MyClass()
# Getting attribute
print("Getting attribute:")
value = obj.attr
print(f"value={value}")
# Setting attribute
print("\nSetting attribute:")
obj.attr = 100
# Storing values
print("\nStoring values:")
class ValueDescriptor:
def __init__(self):
self.value = None
def __get__(self, obj, type=None):
print(f" Getting value: {self.value}")
return self.value
def __set__(self, obj, value):
print(f" Setting value: {value}")
self.value = value
class Container:
data = ValueDescriptor()
c = Container()
c.data = 123
print(f"Retrieved: {c.data}")
# Multiple instances problem
print("\nMultiple instances problem:")
# Problem: shared descriptor instance
c1 = Container()
c2 = Container()
c1.data = "first"
c2.data = "second"
print(f"c1.data = {c1.data}") # Will be "second"!
print(f"c2.data = {c2.data}")
print(" Note: Both share same descriptor instance")
# Proper storage
print("\nProper storage:")
class ProperDescriptor:
def __init__(self, name):
self.name = name
def __get__(self, obj, type=None):
if obj is None:
return self
return obj.__dict__.get(self.name)
def __set__(self, obj, value):
obj.__dict__[self.name] = value
class Person:
name = ProperDescriptor('name')
age = ProperDescriptor('age')
p1 = Person()
p2 = Person()
p1.name = "Alice"
p1.age = 30
p2.name = "Bob"
p2.age = 25
print(f"p1: {p1.name}, {p1.age}")
print(f"p2: {p2.name}, {p2.age}")
# Class vs instance access
print("\nClass vs instance access:")
class SmartDescriptor:
def __get__(self, obj, type=None):
if obj is None:
return f"Descriptor accessed from class {type.__name__}"
return f"Descriptor accessed from instance of {type.__name__}"
class MyClass:
attr = SmartDescriptor()
# Access from class
print(f"MyClass.attr: {MyClass.attr}")
# Access from instance
obj = MyClass()
print(f"obj.attr: {obj.attr}")
# Read-only descriptor
print("\nRead-only descriptor:")
class ReadOnlyDescriptor:
def __init__(self, value):
self.value = value
def __get__(self, obj, type=None):
return self.value
def __set__(self, obj, value):
raise AttributeError("Cannot modify read-only attribute")
class Config:
VERSION = ReadOnlyDescriptor("1.0.0")
config = Config()
print(f"Version: {config.VERSION}")
try:
config.VERSION = "2.0.0"
except AttributeError as e:
print(f"Error: {e}")
# Descriptor with state
print("\nDescriptor with state:")
class CountedDescriptor:
def __init__(self, name):
self.name = name
self.access_count = 0
def __get__(self, obj, type=None):
self.access_count += 1
print(f" Access #{self.access_count}")
return obj.__dict__.get(self.name)
def __set__(self, obj, value):
obj.__dict__[self.name] = value
class TrackedClass:
value = CountedDescriptor('value')
tracked = TrackedClass()
tracked.value = 100
print("Accessing value multiple times:")
print(tracked.value)
print(tracked.value)
print(tracked.value)
# Delete support
print("\nDelete support:")
class DeletableDescriptor:
def __init__(self, name):
self.name = name
def __get__(self, obj, type=None):
return obj.__dict__.get(self.name, "Not set")
def __set__(self, obj, value):
obj.__dict__[self.name] = value
def __delete__(self, obj):
print(f" Deleting {self.name}")
if self.name in obj.__dict__:
del obj.__dict__[self.name]
class MyClass:
attr = DeletableDescriptor('attr')
obj = MyClass()
obj.attr = "value"
print(f"attr = {obj.attr}")
del obj.attr
print(f"After delete: {obj.attr}")
# Practical example
print("\nPractical example:")
class TypedDescriptor:
def __init__(self, name, expected_type):
self.name = name
self.expected_type = expected_type
def __get__(self, obj, type=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__}")
obj.__dict__[self.name] = value
class User:
name = TypedDescriptor('name', str)
age = TypedDescriptor('age', int)
email = TypedDescriptor('email', str)
user = User()
user.name = "Alice"
user.age = 30
user.email = "alice@example.com"
print(f"User: {user.name}, {user.age}, {user.email}")
try:
user.age = "thirty"
except TypeError as e:
print(f"Error: {e}")
descriptor_property.py
"""Property as a descriptor"""
# Property implementation
print("Property implementation:")
# This is roughly how @property works
class PropertyDescriptor:
def __init__(self, fget=None, fset=None, fdel=None):
self.fget = fget
self.fset = fset
self.fdel = fdel
def __get__(self, obj, type=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)
class Temperature:
def __init__(self, celsius):
self._celsius = celsius
def get_celsius(self):
return self._celsius
def set_celsius(self, value):
self._celsius = value
celsius = PropertyDescriptor(get_celsius, set_celsius)
temp = Temperature(25)
print(f"Celsius: {temp.celsius}")
temp.celsius = 30
print(f"Updated: {temp.celsius}")
# Standard @property
print("\nStandard @property:")
class Circle:
def __init__(self, radius):
self._radius = radius
@property
def radius(self):
"""Get radius"""
return self._radius
@radius.setter
def radius(self, value):
"""Set radius"""
if value < 0:
raise ValueError("Radius cannot be negative")
self._radius = value
@property
def area(self):
"""Computed area"""
import math
return math.pi * self._radius ** 2
circle = Circle(5)
print(f"Radius: {circle.radius}")
print(f"Area: {circle.area:.2f}")
circle.radius = 10
print(f"New radius: {circle.radius}")
print(f"New area: {circle.area:.2f}")
# Read-only property
print("\nRead-only property:")
class Person:
def __init__(self, first_name, last_name):
self._first_name = first_name
self._last_name = last_name
@property
def full_name(self):
"""Read-only computed property"""
return f"{self._first_name} {self._last_name}"
person = Person("Alice", "Smith")
print(f"Full name: {person.full_name}")
try:
person.full_name = "Bob Jones"
except AttributeError as e:
print(f"Error: {e}")
# Cached property
print("\nCached property:")
class CachedProperty:
def __init__(self, func):
self.func = func
self.name = func.__name__
def __get__(self, obj, type=None):
if obj is None:
return self
# Check if cached
cache_name = f'_cached_{self.name}'
if cache_name not in obj.__dict__:
# Compute and cache
print(f" Computing {self.name}...")
obj.__dict__[cache_name] = self.func(obj)
return obj.__dict__[cache_name]
class DataProcessor:
def __init__(self, data):
self.data = data
@CachedProperty
def expensive_result(self):
# Expensive computation
return sum(x ** 2 for x in self.data)
processor = DataProcessor([1, 2, 3, 4, 5])
print("First access:")
result1 = processor.expensive_result
print(f"Result: {result1}")
print("\nSecond access (cached):")
result2 = processor.expensive_result
print(f"Result: {result2}")
# Validated property
print("\nValidated property:")
class ValidatedProperty:
def __init__(self, name, validator):
self.name = name
self.validator = validator
def __get__(self, obj, type=None):
if obj is None:
return self
return obj.__dict__.get(self.name)
def __set__(self, obj, value):
if not self.validator(value):
raise ValueError(f"Invalid value for {self.name}: {value}")
obj.__dict__[self.name] = value
def is_positive(value):
return value > 0
def is_valid_email(value):
return "@" in value and "." in value
class Account:
balance = ValidatedProperty('balance', is_positive)
email = ValidatedProperty('email', is_valid_email)
account = Account()
account.balance = 100
account.email = "user@example.com"
print(f"Balance: {account.balance}")
print(f"Email: {account.email}")
try:
account.balance = -50
except ValueError as e:
print(f"Error: {e}")
# Lazy property
print("\nLazy property:")
class LazyProperty:
def __init__(self, func):
self.func = func
self.name = func.__name__
def __get__(self, obj, type=None):
if obj is None:
return self
# Compute once and replace descriptor with value
value = self.func(obj)
setattr(obj, self.name, value)
return value
class Resource:
def __init__(self, filename):
self.filename = filename
@LazyProperty
def content(self):
print(f" Loading {self.filename}...")
return f"Content of {self.filename}"
resource = Resource("data.txt")
print("Resource created")
print("\nFirst access:")
print(resource.content)
print("\nSecond access:")
print(resource.content)
# Practical example
print("\nPractical example:")
# Combining property with validation
class Rectangle:
def __init__(self, width, height):
self._width = width
self._height = height
@property
def width(self):
return self._width
@width.setter
def width(self, value):
if value <= 0:
raise ValueError("Width must be positive")
self._width = value
@property
def height(self):
return self._height
@height.setter
def height(self, value):
if value <= 0:
raise ValueError("Height must be positive")
self._height = value
@property
def area(self):
"""Computed property"""
return self._width * self._height
@property
def perimeter(self):
"""Computed property"""
return 2 * (self._width + self._height)
rect = Rectangle(10, 5)
print(f"Rectangle: {rect.width}x{rect.height}")
print(f"Area: {rect.area}")
print(f"Perimeter: {rect.perimeter}")
rect.width = 20
print(f"Updated rectangle: {rect.width}x{rect.height}")
print(f"New area: {rect.area}")
descriptor_custom.py
"""Custom descriptor classes"""
# Typed descriptor
print("Typed descriptor:")
class TypedDescriptor:
def __init__(self, name, expected_type):
self.name = name
self.expected_type = expected_type
def __get__(self, obj, type=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 Person:
name = TypedDescriptor('name', str)
age = TypedDescriptor('age', int)
height = TypedDescriptor('height', float)
person = Person()
person.name = "Alice"
person.age = 30
person.height = 1.65
print(f"Person: {person.name}, {person.age}, {person.height}m")
try:
person.age = "thirty"
except TypeError as e:
print(f"Error: {e}")
# Bounded descriptor
print("\nBounded descriptor:")
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, obj, type=None):
if obj is None:
return self
return obj.__dict__.get(self.name)
def __set__(self, obj, 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}")
obj.__dict__[self.name] = value
class Score:
value = BoundedNumber('value', min_value=0, max_value=100)
score = Score()
initial_score =
score.value = initial_score
print(f"Score: {score.value}")
try:
score.value = 150
except ValueError as e:
print(f"Error: {e}")
# String descriptor
print("\nString descriptor:")
class ValidatedString:
def __init__(self, name, min_length=0, max_length=None, pattern=None):
self.name = name
self.min_length = min_length
self.max_length = max_length
self.pattern = pattern
def __get__(self, obj, type=None):
if obj is None:
return self
return obj.__dict__.get(self.name)
def __set__(self, obj, value):
if not isinstance(value, str):
raise TypeError(f"{self.name} must be a string")
if len(value) < self.min_length:
raise ValueError(f"{self.name} must be at least {self.min_length} characters")
if self.max_length and len(value) > self.max_length:
raise ValueError(f"{self.name} must be at most {self.max_length} characters")
if self.pattern:
import re
if not re.match(self.pattern, value):
raise ValueError(f"{self.name} doesn't match pattern")
obj.__dict__[self.name] = value
class User:
username = ValidatedString('username', min_length=3, max_length=20)
email = ValidatedString('email', pattern=r'^[\w\.-]+@[\w\.-]+\.\w+$')
user = User()
user.username = "alice"
user.email = "alice@example.com"
print(f"User: {user.username}, {user.email}")
try:
user.username = "ab" # Too short
except ValueError as e:
print(f"Error: {e}")
# Choice descriptor
print("\nChoice descriptor:")
class Choice:
def __init__(self, name, choices):
self.name = name
self.choices = choices
def __get__(self, obj, type=None):
if obj is None:
return self
return obj.__dict__.get(self.name)
def __set__(self, obj, value):
if value not in self.choices:
raise ValueError(f"{self.name} must be one of {self.choices}")
obj.__dict__[self.name] = value
class Status:
value = Choice('value', ['pending', 'active', 'completed', 'cancelled'])
status = Status()
status.value = 'active'
print(f"Status: {status.value}")
try:
status.value = 'unknown'
except ValueError as e:
print(f"Error: {e}")
# Logged descriptor
print("\nLogged descriptor:")
class LoggedDescriptor:
def __init__(self, name):
self.name = name
def __get__(self, obj, type=None):
if obj is None:
return self
value = obj.__dict__.get(self.name)
print(f" Getting {self.name}: {value}")
return value
def __set__(self, obj, value):
print(f" Setting {self.name}: {obj.__dict__.get(self.name)} -> {value}")
obj.__dict__[self.name] = value
class TrackedObject:
x = LoggedDescriptor('x')
y = LoggedDescriptor('y')
tracked = TrackedObject()
tracked.x = 10
tracked.y = 20
print(f"Sum: {tracked.x + tracked.y}")
# Auto-converting descriptor
print("\nAuto-converting descriptor:")
class IntDescriptor:
def __init__(self, name):
self.name = name
def __get__(self, obj, type=None):
if obj is None:
return self
return obj.__dict__.get(self.name)
def __set__(self, obj, value):
# Auto-convert to int
try:
obj.__dict__[self.name] = int(value)
except (TypeError, ValueError) as e:
raise ValueError(f"Cannot convert {value} to int")
class Config:
port = IntDescriptor('port')
timeout = IntDescriptor('timeout')
config = Config()
config.port = "8080" # String converted to int
config.timeout = 30.5 # Float converted to int
print(f"Port: {config.port} (type: {type(config.port).__name__})")
print(f"Timeout: {config.timeout} (type: {type(config.timeout).__name__})")
# Practical example
print("\nPractical example:")
# Database field descriptor
class Field:
def __init__(self, name, field_type, required=False, default=None):
self.name = name
self.field_type = field_type
self.required = required
self.default = default
def __get__(self, obj, type=None):
if obj is None:
return self
return obj.__dict__.get(self.name, self.default)
def __set__(self, obj, value):
if value is None:
if self.required:
raise ValueError(f"{self.name} is required")
obj.__dict__[self.name] = self.default
elif not isinstance(value, self.field_type):
raise TypeError(f"{self.name} must be {self.field_type.__name__}")
else:
obj.__dict__[self.name] = value
class DatabaseRecord:
id = Field('id', int, required=True)
name = Field('name', str, required=True)
email = Field('email', str)
age = Field('age', int, default=0)
record = DatabaseRecord()
record.id = 1
record.name = "Alice"
record.email = "alice@example.com"
print(f"Record: id={record.id}, name={record.name}, email={record.email}, age={record.age}")
try:
record2 = DatabaseRecord()
record2.name = "Bob"
# Missing required 'id'
except ValueError as e:
print(f"Error: {e}")
"""Custom descriptor classes"""
# Typed descriptor
print("Typed descriptor:")
class TypedDescriptor:
def __init__(self, name, expected_type):
self.name = name
self.expected_type = expected_type
def __get__(self, obj, type=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 Person:
name = TypedDescriptor('name', str)
age = TypedDescriptor('age', int)
height = TypedDescriptor('height', float)
person = Person()
person.name = "Alice"
person.age = 30
person.height = 1.65
print(f"Person: {person.name}, {person.age}, {person.height}m")
try:
person.age = "thirty"
except TypeError as e:
print(f"Error: {e}")
# Bounded descriptor
print("\nBounded descriptor:")
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, obj, type=None):
if obj is None:
return self
return obj.__dict__.get(self.name)
def __set__(self, obj, 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}")
obj.__dict__[self.name] = value
class Score:
value = BoundedNumber('value', min_value=0, max_value=100)
score = Score()
initial_score =
score.value = initial_score
print(f"Score: {score.value}")
try:
score.value = 150
except ValueError as e:
print(f"Error: {e}")
# String descriptor
print("\nString descriptor:")
class ValidatedString:
def __init__(self, name, min_length=0, max_length=None, pattern=None):
self.name = name
self.min_length = min_length
self.max_length = max_length
self.pattern = pattern
def __get__(self, obj, type=None):
if obj is None:
return self
return obj.__dict__.get(self.name)
def __set__(self, obj, value):
if not isinstance(value, str):
raise TypeError(f"{self.name} must be a string")
if len(value) < self.min_length:
raise ValueError(f"{self.name} must be at least {self.min_length} characters")
if self.max_length and len(value) > self.max_length:
raise ValueError(f"{self.name} must be at most {self.max_length} characters")
if self.pattern:
import re
if not re.match(self.pattern, value):
raise ValueError(f"{self.name} doesn't match pattern")
obj.__dict__[self.name] = value
class User:
username = ValidatedString('username', min_length=3, max_length=20)
email = ValidatedString('email', pattern=r'^[\w\.-]+@[\w\.-]+\.\w+$')
user = User()
user.username = "alice"
user.email = "alice@example.com"
print(f"User: {user.username}, {user.email}")
try:
user.username = "ab" # Too short
except ValueError as e:
print(f"Error: {e}")
# Choice descriptor
print("\nChoice descriptor:")
class Choice:
def __init__(self, name, choices):
self.name = name
self.choices = choices
def __get__(self, obj, type=None):
if obj is None:
return self
return obj.__dict__.get(self.name)
def __set__(self, obj, value):
if value not in self.choices:
raise ValueError(f"{self.name} must be one of {self.choices}")
obj.__dict__[self.name] = value
class Status:
value = Choice('value', ['pending', 'active', 'completed', 'cancelled'])
status = Status()
status.value = 'active'
print(f"Status: {status.value}")
try:
status.value = 'unknown'
except ValueError as e:
print(f"Error: {e}")
# Logged descriptor
print("\nLogged descriptor:")
class LoggedDescriptor:
def __init__(self, name):
self.name = name
def __get__(self, obj, type=None):
if obj is None:
return self
value = obj.__dict__.get(self.name)
print(f" Getting {self.name}: {value}")
return value
def __set__(self, obj, value):
print(f" Setting {self.name}: {obj.__dict__.get(self.name)} -> {value}")
obj.__dict__[self.name] = value
class TrackedObject:
x = LoggedDescriptor('x')
y = LoggedDescriptor('y')
tracked = TrackedObject()
tracked.x = 10
tracked.y = 20
print(f"Sum: {tracked.x + tracked.y}")
# Auto-converting descriptor
print("\nAuto-converting descriptor:")
class IntDescriptor:
def __init__(self, name):
self.name = name
def __get__(self, obj, type=None):
if obj is None:
return self
return obj.__dict__.get(self.name)
def __set__(self, obj, value):
# Auto-convert to int
try:
obj.__dict__[self.name] = int(value)
except (TypeError, ValueError) as e:
raise ValueError(f"Cannot convert {value} to int")
class Config:
port = IntDescriptor('port')
timeout = IntDescriptor('timeout')
config = Config()
config.port = "8080" # String converted to int
config.timeout = 30.5 # Float converted to int
print(f"Port: {config.port} (type: {type(config.port).__name__})")
print(f"Timeout: {config.timeout} (type: {type(config.timeout).__name__})")
# Practical example
print("\nPractical example:")
# Database field descriptor
class Field:
def __init__(self, name, field_type, required=False, default=None):
self.name = name
self.field_type = field_type
self.required = required
self.default = default
def __get__(self, obj, type=None):
if obj is None:
return self
return obj.__dict__.get(self.name, self.default)
def __set__(self, obj, value):
if value is None:
if self.required:
raise ValueError(f"{self.name} is required")
obj.__dict__[self.name] = self.default
elif not isinstance(value, self.field_type):
raise TypeError(f"{self.name} must be {self.field_type.__name__}")
else:
obj.__dict__[self.name] = value
class DatabaseRecord:
id = Field('id', int, required=True)
name = Field('name', str, required=True)
email = Field('email', str)
age = Field('age', int, default=0)
record = DatabaseRecord()
record.id = 1
record.name = "Alice"
record.email = "alice@example.com"
print(f"Record: id={record.id}, name={record.name}, email={record.email}, age={record.age}")
try:
record2 = DatabaseRecord()
record2.name = "Bob"
# Missing required 'id'
except ValueError as e:
print(f"Error: {e}")
"""Custom descriptor classes"""
# Typed descriptor
print("Typed descriptor:")
class TypedDescriptor:
def __init__(self, name, expected_type):
self.name = name
self.expected_type = expected_type
def __get__(self, obj, type=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 Person:
name = TypedDescriptor('name', str)
age = TypedDescriptor('age', int)
height = TypedDescriptor('height', float)
person = Person()
person.name = "Alice"
person.age = 30
person.height = 1.65
print(f"Person: {person.name}, {person.age}, {person.height}m")
try:
person.age = "thirty"
except TypeError as e:
print(f"Error: {e}")
# Bounded descriptor
print("\nBounded descriptor:")
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, obj, type=None):
if obj is None:
return self
return obj.__dict__.get(self.name)
def __set__(self, obj, 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}")
obj.__dict__[self.name] = value
class Score:
value = BoundedNumber('value', min_value=0, max_value=100)
score = Score()
initial_score =
score.value = initial_score
print(f"Score: {score.value}")
try:
score.value = 150
except ValueError as e:
print(f"Error: {e}")
# String descriptor
print("\nString descriptor:")
class ValidatedString:
def __init__(self, name, min_length=0, max_length=None, pattern=None):
self.name = name
self.min_length = min_length
self.max_length = max_length
self.pattern = pattern
def __get__(self, obj, type=None):
if obj is None:
return self
return obj.__dict__.get(self.name)
def __set__(self, obj, value):
if not isinstance(value, str):
raise TypeError(f"{self.name} must be a string")
if len(value) < self.min_length:
raise ValueError(f"{self.name} must be at least {self.min_length} characters")
if self.max_length and len(value) > self.max_length:
raise ValueError(f"{self.name} must be at most {self.max_length} characters")
if self.pattern:
import re
if not re.match(self.pattern, value):
raise ValueError(f"{self.name} doesn't match pattern")
obj.__dict__[self.name] = value
class User:
username = ValidatedString('username', min_length=3, max_length=20)
email = ValidatedString('email', pattern=r'^[\w\.-]+@[\w\.-]+\.\w+$')
user = User()
user.username = "alice"
user.email = "alice@example.com"
print(f"User: {user.username}, {user.email}")
try:
user.username = "ab" # Too short
except ValueError as e:
print(f"Error: {e}")
# Choice descriptor
print("\nChoice descriptor:")
class Choice:
def __init__(self, name, choices):
self.name = name
self.choices = choices
def __get__(self, obj, type=None):
if obj is None:
return self
return obj.__dict__.get(self.name)
def __set__(self, obj, value):
if value not in self.choices:
raise ValueError(f"{self.name} must be one of {self.choices}")
obj.__dict__[self.name] = value
class Status:
value = Choice('value', ['pending', 'active', 'completed', 'cancelled'])
status = Status()
status.value = 'active'
print(f"Status: {status.value}")
try:
status.value = 'unknown'
except ValueError as e:
print(f"Error: {e}")
# Logged descriptor
print("\nLogged descriptor:")
class LoggedDescriptor:
def __init__(self, name):
self.name = name
def __get__(self, obj, type=None):
if obj is None:
return self
value = obj.__dict__.get(self.name)
print(f" Getting {self.name}: {value}")
return value
def __set__(self, obj, value):
print(f" Setting {self.name}: {obj.__dict__.get(self.name)} -> {value}")
obj.__dict__[self.name] = value
class TrackedObject:
x = LoggedDescriptor('x')
y = LoggedDescriptor('y')
tracked = TrackedObject()
tracked.x = 10
tracked.y = 20
print(f"Sum: {tracked.x + tracked.y}")
# Auto-converting descriptor
print("\nAuto-converting descriptor:")
class IntDescriptor:
def __init__(self, name):
self.name = name
def __get__(self, obj, type=None):
if obj is None:
return self
return obj.__dict__.get(self.name)
def __set__(self, obj, value):
# Auto-convert to int
try:
obj.__dict__[self.name] = int(value)
except (TypeError, ValueError) as e:
raise ValueError(f"Cannot convert {value} to int")
class Config:
port = IntDescriptor('port')
timeout = IntDescriptor('timeout')
config = Config()
config.port = "8080" # String converted to int
config.timeout = 30.5 # Float converted to int
print(f"Port: {config.port} (type: {type(config.port).__name__})")
print(f"Timeout: {config.timeout} (type: {type(config.timeout).__name__})")
# Practical example
print("\nPractical example:")
# Database field descriptor
class Field:
def __init__(self, name, field_type, required=False, default=None):
self.name = name
self.field_type = field_type
self.required = required
self.default = default
def __get__(self, obj, type=None):
if obj is None:
return self
return obj.__dict__.get(self.name, self.default)
def __set__(self, obj, value):
if value is None:
if self.required:
raise ValueError(f"{self.name} is required")
obj.__dict__[self.name] = self.default
elif not isinstance(value, self.field_type):
raise TypeError(f"{self.name} must be {self.field_type.__name__}")
else:
obj.__dict__[self.name] = value
class DatabaseRecord:
id = Field('id', int, required=True)
name = Field('name', str, required=True)
email = Field('email', str)
age = Field('age', int, default=0)
record = DatabaseRecord()
record.id = 1
record.name = "Alice"
record.email = "alice@example.com"
print(f"Record: id={record.id}, name={record.name}, email={record.email}, age={record.age}")
try:
record2 = DatabaseRecord()
record2.name = "Bob"
# Missing required 'id'
except ValueError as e:
print(f"Error: {e}")
descriptor_data_vs_nondata.py
"""Data vs non-data descriptors"""
# Non-data descriptor
print("Non-data descriptor:")
class NonDataDescriptor:
def __get__(self, obj, type=None):
return "From descriptor"
class TestClass:
attr = NonDataDescriptor()
obj = TestClass()
# Get from descriptor
print(f"obj.attr: {obj.attr}")
# Set creates instance attribute
obj.attr = "From instance"
print(f"After setting: {obj.attr}") # Instance wins
# Check __dict__
print(f"obj.__dict__: {obj.__dict__}")
# Data descriptor
print("\nData descriptor:")
class DataDescriptor:
def __init__(self, name):
self.name = name
def __get__(self, obj, type=None):
if obj is None:
return self
return obj.__dict__.get(self.name, "From descriptor")
def __set__(self, obj, value):
print(f" Descriptor __set__ called")
obj.__dict__[self.name] = value
class TestClass2:
attr = DataDescriptor('attr')
obj2 = TestClass2()
# Get from descriptor
print(f"obj2.attr: {obj2.attr}")
# Set through descriptor
obj2.attr = "Value"
print(f"After setting: {obj2.attr}")
# Cannot bypass descriptor
obj2.__dict__['attr'] = "Direct"
print(f"obj2.attr (still via descriptor): {obj2.attr}")
# Lookup order
print("\nLookup order:")
class SimpleNonData:
def __get__(self, obj, type=None):
return "non-data descriptor"
class SimpleData:
def __get__(self, obj, type=None):
return "data descriptor"
def __set__(self, obj, value):
pass
class Demo:
non_data = SimpleNonData()
data = SimpleData()
demo = Demo()
print("Before instance attributes:")
print(f" non_data: {demo.non_data}")
print(f" data: {demo.data}")
# Add instance attributes
demo.__dict__['non_data'] = "instance value"
demo.__dict__['data'] = "instance value"
print("\nAfter instance attributes:")
print(f" non_data: {demo.non_data}") # Instance wins
print(f" data: {demo.data}") # Descriptor wins
print(f"\nInstance __dict__: {demo.__dict__}")
# Delete behavior
print("\nDelete behavior:")
class DeletableDescriptor:
def __init__(self, name):
self.name = name
def __get__(self, obj, type=None):
if obj is None:
return self
return obj.__dict__.get(self.name, "default")
def __set__(self, obj, value):
obj.__dict__[self.name] = value
def __delete__(self, obj):
print(f" Descriptor __delete__ called")
if self.name in obj.__dict__:
del obj.__dict__[self.name]
class Container:
value = DeletableDescriptor('value')
container = Container()
container.value = 100
print(f"value: {container.value}")
# Delete through descriptor
del container.value
print(f"After delete: {container.value}")
# Method as non-data descriptor
print("\nMethod as non-data descriptor:")
class MethodDescriptor:
def __init__(self, func):
self.func = func
def __get__(self, obj, type=None):
if obj is None:
return self
# Return bound method
return lambda *args, **kwargs: self.func(obj, *args, **kwargs)
class MyClass:
def __init__(self, value):
self.value = value
@MethodDescriptor
def get_value(self):
return self.value
obj = MyClass(42)
print(f"get_value(): {obj.get_value()}")
# Overriding data descriptor
print("\nOverriding data descriptor:")
class StrictDescriptor:
def __init__(self, name):
self.name = name
def __get__(self, obj, type=None):
if obj is None:
return self
return obj.__dict__.get(self.name)
def __set__(self, obj, value):
if not isinstance(value, int):
raise TypeError("Must be int")
obj.__dict__[self.name] = value
class Numbers:
value = StrictDescriptor('value')
nums = Numbers()
nums.value = 100
print(f"value: {nums.value}")
# Cannot override with direct assignment
try:
nums.value = "text"
except TypeError as e:
print(f"Error: {e}")
# Priority demonstration
print("\nPriority demonstration:")
print("Lookup priority:")
print("1. Data descriptors (from type(obj).__mro__)")
print("2. Instance attributes (from obj.__dict__)")
print("3. Non-data descriptors (from type(obj).__mro__)")
print("4. Class attributes")
print("5. __getattr__() if defined")
class ExampleNonData:
def __get__(self, obj, type=None):
return "non-data"
class ExampleData:
def __get__(self, obj, type=None):
if obj is None:
return self
return obj.__dict__.get('_data', 'data descriptor')
def __set__(self, obj, value):
obj.__dict__['_data'] = value
class PriorityDemo:
non_data = ExampleNonData()
data = ExampleData()
class_attr = "class attribute"
demo = PriorityDemo()
print(f"\nInitial state:")
print(f" non_data: {demo.non_data}")
print(f" data: {demo.data}")
print(f" class_attr: {demo.class_attr}")
# Add instance attributes
demo.__dict__['non_data'] = "instance non_data"
demo.__dict__['class_attr'] = "instance class_attr"
print(f"\nAfter adding instance attributes:")
print(f" non_data: {demo.non_data}") # instance wins (non-data)
print(f" data: {demo.data}") # descriptor wins (data)
print(f" class_attr: {demo.class_attr}") # instance wins (regular attr)
# Practical example
print("\nPractical example:")
# Validator that can be overridden
class Validator:
def __init__(self, name, validate_func=None):
self.name = name
self.validate_func = validate_func
def __get__(self, obj, type=None):
if obj is None:
return self
return obj.__dict__.get(self.name)
def __set__(self, obj, value):
if self.validate_func and not self.validate_func(value):
raise ValueError(f"Invalid value for {self.name}")
obj.__dict__[self.name] = value
class Product:
price = Validator('price', lambda x: x >= 0)
quantity = Validator('quantity', lambda x: x >= 0)
product = Product()
product.price = 19.99
product.quantity = 10
print(f"Product: price=${product.price}, quantity={product.quantity}")
try:
product.price = -5
except ValueError as e:
print(f"Error: {e}")
descriptor_practical.py
"""Practical descriptor applications"""
# ORM-style field
print("ORM-style field:")
class Field:
def __init__(self, field_type, default=None):
self.field_type = field_type
self.default = default
self.name = None
def __set_name__(self, owner, name):
self.name = name
def __get__(self, obj, type=None):
if obj is None:
return self
return obj.__dict__.get(self.name, self.default)
def __set__(self, obj, value):
if value is not None and not isinstance(value, self.field_type):
raise TypeError(f"{self.name} must be {self.field_type.__name__}")
obj.__dict__[self.name] = value
class User:
id = Field(int)
name = Field(str)
email = Field(str)
age = Field(int, default=0)
user = User()
user.id = 1
user.name = "Alice"
user.email = "alice@example.com"
print(f"User: id={user.id}, name={user.name}, email={user.email}, age={user.age}")
# Lazy loading
print("\nLazy loading:")
class LazyLoad:
def __init__(self, loader_func):
self.loader_func = loader_func
self.name = None
def __set_name__(self, owner, name):
self.name = name
def __get__(self, obj, type=None):
if obj is None:
return self
# Check if already loaded
cache_name = f'_lazy_{self.name}'
if cache_name not in obj.__dict__:
print(f" Loading {self.name}...")
obj.__dict__[cache_name] = self.loader_func(obj)
return obj.__dict__[cache_name]
class Report:
def __init__(self, report_id):
self.report_id = report_id
@LazyLoad
def data(self):
# Expensive operation
return f"Report data for {self.report_id}"
@LazyLoad
def statistics(self):
# Another expensive operation
return {"count": 100, "avg": 50}
report = Report(123)
print("Report created (data not loaded)")
print("\nFirst access to data:")
print(report.data)
print("\nSecond access (cached):")
print(report.data)
print("\nFirst access to statistics:")
print(report.statistics)
# Validation with __set_name__
print("\nValidation with __set_name__:")
class Validated:
def __init__(self, validator):
self.validator = validator
self.name = None
def __set_name__(self, owner, name):
self.name = name
def __get__(self, obj, type=None):
if obj is None:
return self
return obj.__dict__.get(self.name)
def __set__(self, obj, value):
if not self.validator(value):
raise ValueError(f"Invalid value for {self.name}: {value}")
obj.__dict__[self.name] = value
class Account:
balance = Validated(lambda x: x >= 0)
interest_rate = Validated(lambda x: 0 <= x <= 1)
account = Account()
account.balance = 1000
account.interest_rate = 0.05
print(f"Account: balance={account.balance}, interest_rate={account.interest_rate}")
try:
account.balance = -100
except ValueError as e:
print(f"Error: {e}")
# Tracked attributes
print("\nTracked attributes:")
class Tracked:
def __init__(self):
self.name = None
def __set_name__(self, owner, name):
self.name = name
def __get__(self, obj, type=None):
if obj is None:
return self
return obj.__dict__.get(self.name)
def __set__(self, obj, value):
old_value = obj.__dict__.get(self.name)
obj.__dict__[self.name] = value
# Track changes
if not hasattr(obj, '_changes'):
obj._changes = []
obj._changes.append({
'field': self.name,
'old': old_value,
'new': value
})
class TrackedModel:
name = Tracked()
value = Tracked()
model = TrackedModel()
model.name = "Initial"
model.value = 100
model.name = "Updated"
model.value = 200
print("Changes:")
for change in model._changes:
print(f" {change['field']}: {change['old']} -> {change['new']}")
# Type conversion
print("\nType conversion:")
class Converted:
def __init__(self, converter):
self.converter = converter
self.name = None
def __set_name__(self, owner, name):
self.name = name
def __get__(self, obj, type=None):
if obj is None:
return self
return obj.__dict__.get(self.name)
def __set__(self, obj, value):
converted = self.converter(value)
obj.__dict__[self.name] = converted
class Config:
port = Converted(int)
debug = Converted(bool)
timeout = Converted(float)
config = Config()
config.port = "8080"
config.debug = "yes"
config.timeout = "30.5"
print(f"Config: port={config.port} ({type(config.port).__name__})")
print(f" debug={config.debug} ({type(config.debug).__name__})")
print(f" timeout={config.timeout} ({type(config.timeout).__name__})")
# Units descriptor
print("\nUnits descriptor:")
class Quantity:
def __init__(self, unit):
self.unit = unit
self.name = None
def __set_name__(self, owner, name):
self.name = name
def __get__(self, obj, type=None):
if obj is None:
return self
value = obj.__dict__.get(self.name, 0)
return f"{value} {self.unit}"
def __set__(self, obj, value):
if not isinstance(value, (int, float)):
raise TypeError(f"{self.name} must be numeric")
obj.__dict__[self.name] = value
class Product:
weight = Quantity("kg")
length = Quantity("cm")
price = Quantity("USD")
product = Product()
product.weight = 2.5
product.length = 30
product.price = 19.99
print(f"Product: weight={product.weight}, length={product.length}, price={product.price}")
# Practical example
print("\nPractical example:")
# Complete model with multiple descriptor types
class IntField:
def __init__(self, min_value=None, max_value=None):
self.min_value = min_value
self.max_value = max_value
self.name = None
def __set_name__(self, owner, name):
self.name = name
def __get__(self, obj, type=None):
if obj is None:
return self
return obj.__dict__.get(self.name)
def __set__(self, obj, value):
if not isinstance(value, int):
raise TypeError(f"{self.name} must be int")
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}")
obj.__dict__[self.name] = value
class StringField:
def __init__(self, max_length=None):
self.max_length = max_length
self.name = None
def __set_name__(self, owner, name):
self.name = name
def __get__(self, obj, type=None):
if obj is None:
return self
return obj.__dict__.get(self.name, "")
def __set__(self, obj, value):
if not isinstance(value, str):
raise TypeError(f"{self.name} must be str")
if self.max_length and len(value) > self.max_length:
raise ValueError(f"{self.name} too long (max {self.max_length})")
obj.__dict__[self.name] = value
class Student:
id = IntField(min_value=1)
name = StringField(max_length=50)
age = IntField(min_value=0, max_value=150)
grade = IntField(min_value=0, max_value=100)
student = Student()
student.id = 1
student.name = "Alice"
student.age = 20
student.grade = 85
print(f"Student: id={student.id}, name={student.name}, age={student.age}, grade={student.grade}")
# Validation works
try:
student.grade = 105
except ValueError as e:
print(f"Error: {e}")
Modern Python frameworks like Django and SQLAlchemy use descriptors extensively for their ORM field definitions.
Types
- Data descriptors: Implement
__get__()and__set__()(and optionally__delete__()) - Non-data descriptors: Only implement
__get__()
Use Cases
- Validation and type checking
- Lazy loading of expensive resources
- Computed and cached attributes
- ORM field definitions
- Logging and debugging attribute access
descriptor protocol
Implementing `__get__`, `__set__`, and/or `__delete__` methods to intercept attribute access on a class.
property descriptor
Understanding that `@property` is syntactic sugar for a descriptor that calls your getter/setter/deleter functions.
custom descriptor
Building reusable descriptor classes for validation, type checking, or other attribute behaviors that can be applied to multiple classes.
descriptor types
Data descriptors (with `__set__` or `__delete__`) take priority over instance attributes; non-data descriptors (only `__get__`) do not.
descriptor applications
Real-world uses including ORM fields, lazy loading, validation, change tracking, and type conversion.
Exercise: descriptor_practice.py
Create a RangeChecked descriptor that validates numeric attributes are within a specified min/max range