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