Descriptors

Descriptors

1. What are Descriptors?

Descriptors are objects that customize attribute access. They implement one or more of __get__, __set__, and __delete__.

class Descriptor:
    def __get__(self, obj, objtype=None):
        """Read attribute"""
        pass

    def __set__(self, obj, value):
        """Write attribute"""
        pass

    def __delete__(self, obj):
        """Delete attribute"""
        pass

Attribute Access Flow

When accessing obj.attr:
    
    
┌─────────────────────────────────────────┐
 1. Check data descriptor (type(obj).__dict__)
     Has both __get__ and __set__        
└─────────────────────────────────────────┘
     (if not found)
    
┌─────────────────────────────────────────┐
 2. Check instance __dict__ (obj.__dict__)
└─────────────────────────────────────────┘
     (if not found)
    
┌─────────────────────────────────────────┐
 3. Check non-data descriptor             
     Has only __get__                    
└─────────────────────────────────────────┘
     (if not found)
    
┌─────────────────────────────────────────┐
 4. Call __getattr__                      
└─────────────────────────────────────────┘

2. Data Descriptor vs Non-Data Descriptor

Type Methods Priority
Data Descriptor __get__ + __set__ High (takes precedence over instance dict)
Non-Data Descriptor __get__ only Low (after instance dict)

Non-Data Descriptor Example

class NonDataDescriptor:
    def __get__(self, obj, objtype=None):
        print("__get__ called")
        return "descriptor value"

class MyClass:
    attr = NonDataDescriptor()

obj = MyClass()
print(obj.attr)  # __get__ called, "descriptor value"

# Instance __dict__ takes precedence
obj.__dict__['attr'] = "instance value"
print(obj.attr)  # "instance value" (descriptor ignored)

Data Descriptor Example

class DataDescriptor:
    def __get__(self, obj, objtype=None):
        print("__get__ called")
        return obj.__dict__.get('_attr')

    def __set__(self, obj, value):
        print("__set__ called")
        obj.__dict__['_attr'] = value

class MyClass:
    attr = DataDescriptor()

obj = MyClass()
obj.attr = "test"     # __set__ called
print(obj.attr)       # __get__ called, "test"

# Descriptor takes precedence even with direct __dict__ assignment
obj.__dict__['attr'] = "direct"
print(obj.attr)       # __get__ called, "test" (descriptor wins!)

3. get Method Details

def __get__(self, obj, objtype=None):
    """
    obj: Instance accessing the descriptor (None if accessed via class)
    objtype: Class owning the descriptor
    """

Class vs Instance Access

class Verbose:
    def __get__(self, obj, objtype=None):
        if obj is None:
            # Accessed via class
            return f"Descriptor of class {objtype.__name__}"
        else:
            # Accessed via instance
            return f"Descriptor of instance {obj}"

class MyClass:
    attr = Verbose()

print(MyClass.attr)        # Descriptor of class MyClass
print(MyClass().attr)      # Descriptor of instance <MyClass>

4. Internal Implementation of property

@property is implemented using descriptors.

Implementing property from Scratch

class Property:
    def __init__(self, fget=None, fset=None, fdel=None, doc=None):
        self.fget = fget
        self.fset = fset
        self.fdel = fdel
        self.__doc__ = doc

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        if self.fget is None:
            raise AttributeError("unreadable attribute")
        return self.fget(obj)

    def __set__(self, obj, value):
        if self.fset is None:
            raise AttributeError("can't set attribute")
        self.fset(obj, value)

    def __delete__(self, obj):
        if self.fdel is None:
            raise AttributeError("can't delete attribute")
        self.fdel(obj)

    def getter(self, fget):
        return type(self)(fget, self.fset, self.fdel, self.__doc__)

    def setter(self, fset):
        return type(self)(self.fget, fset, self.fdel, self.__doc__)

    def deleter(self, fdel):
        return type(self)(self.fget, self.fset, fdel, self.__doc__)

Usage Example

class Circle:
    def __init__(self, radius):
        self._radius = radius

    @Property
    def radius(self):
        return self._radius

    @radius.setter
    def radius(self, value):
        if value < 0:
            raise ValueError("Radius must be positive")
        self._radius = value

c = Circle(5)
print(c.radius)    # 5
c.radius = 10
print(c.radius)    # 10

5. Attribute Validation Descriptors

Type Validation

class Typed:
    def __init__(self, expected_type):
        self.expected_type = expected_type
        self.name = None

    def __set_name__(self, owner, name):
        self.name = name

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return obj.__dict__.get(self.name)

    def __set__(self, obj, value):
        if not isinstance(value, self.expected_type):
            raise TypeError(
                f"{self.name} must be of type {self.expected_type.__name__}"
            )
        obj.__dict__[self.name] = value

class Person:
    name = Typed(str)
    age = Typed(int)

    def __init__(self, name, age):
        self.name = name
        self.age = age

p = Person("Alice", 30)
print(p.name)  # Alice
print(p.age)   # 30

# p.age = "thirty"  # TypeError!

Range Validation

class Bounded:
    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, objtype=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 at least {self.min_value}")
        if self.max_value is not None and value > self.max_value:
            raise ValueError(f"{self.name} must be at most {self.max_value}")
        obj.__dict__[self.name] = value

class Product:
    price = Bounded(min_value=0)
    quantity = Bounded(min_value=0, max_value=1000)

    def __init__(self, price, quantity):
        self.price = price
        self.quantity = quantity

p = Product(1000, 50)
# p.price = -100     # ValueError!
# p.quantity = 2000  # ValueError!

6. set_name (Python 3.6+)

Automatically receives the name when assigned to a class.

class Descriptor:
    def __set_name__(self, owner, name):
        """
        owner: Class owning the descriptor
        name: Attribute name assigned to descriptor
        """
        self.name = name
        self.private_name = f"_{name}"

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return getattr(obj, self.private_name, None)

    def __set__(self, obj, value):
        setattr(obj, self.private_name, value)

class MyClass:
    x = Descriptor()  # __set_name__(MyClass, 'x') is called
    y = Descriptor()  # __set_name__(MyClass, 'y') is called

obj = MyClass()
obj.x = 10
obj.y = 20
print(obj.x, obj.y)  # 10 20
print(obj.__dict__)  # {'_x': 10, '_y': 20}

7. ORM-Style Field Implementation

Basic Field Class

class Field:
    def __init__(self, default=None):
        self.default = default
        self.name = None

    def __set_name__(self, owner, name):
        self.name = name

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return obj.__dict__.get(self.name, self.default)

    def __set__(self, obj, value):
        obj.__dict__[self.name] = value

class StringField(Field):
    def __set__(self, obj, value):
        if not isinstance(value, str):
            raise TypeError(f"{self.name} must be a string")
        super().__set__(obj, value)

class IntegerField(Field):
    def __set__(self, obj, value):
        if not isinstance(value, int):
            raise TypeError(f"{self.name} must be an integer")
        super().__set__(obj, value)

class BooleanField(Field):
    def __set__(self, obj, value):
        if not isinstance(value, bool):
            raise TypeError(f"{self.name} must be a boolean")
        super().__set__(obj, value)

Model Class

class Model:
    def __init__(self, **kwargs):
        for key, value in kwargs.items():
            setattr(self, key, value)

    def __repr__(self):
        fields = ', '.join(
            f"{k}={v!r}"
            for k, v in self.__dict__.items()
        )
        return f"{self.__class__.__name__}({fields})"

class User(Model):
    name = StringField()
    age = IntegerField()
    is_active = BooleanField(default=True)

user = User(name="Alice", age=30)
print(user)  # User(name='Alice', age=30)
print(user.is_active)  # True (default value)

8. Lazy Evaluation

Implementing cached_property

class CachedProperty:
    """Compute on first access and cache"""
    def __init__(self, func):
        self.func = func
        self.name = None

    def __set_name__(self, owner, name):
        self.name = name

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        # Return cached value if exists
        if self.name in obj.__dict__:
            return obj.__dict__[self.name]
        # Compute and cache
        value = self.func(obj)
        obj.__dict__[self.name] = value
        return value

class DataAnalyzer:
    def __init__(self, data):
        self.data = data

    @CachedProperty
    def statistics(self):
        print("Computing statistics...")  # Only printed once
        return {
            "sum": sum(self.data),
            "avg": sum(self.data) / len(self.data),
            "max": max(self.data),
            "min": min(self.data),
        }

analyzer = DataAnalyzer([1, 2, 3, 4, 5])
print(analyzer.statistics)  # Computing statistics... (computed)
print(analyzer.statistics)  # (returned from cache)

Note: Python 3.8+ includes functools.cached_property

from functools import cached_property

class DataAnalyzer:
    @cached_property
    def statistics(self):
        # ...

9. Methods are Descriptors

Functions are non-data descriptors.

def func(self):
    pass

# Functions have __get__ method
print(hasattr(func, '__get__'))  # True

How Bound Methods are Created

class MyClass:
    def method(self):
        return "Hello"

obj = MyClass()

# Access via class: returns function
print(MyClass.method)  # <function MyClass.method>

# Access via instance: returns bound method
print(obj.method)      # <bound method MyClass.method>

# What actually happens
print(MyClass.__dict__['method'].__get__(obj, MyClass))
# <bound method MyClass.method>

10. staticmethod and classmethod

Implementing staticmethod

class StaticMethod:
    def __init__(self, func):
        self.func = func

    def __get__(self, obj, objtype=None):
        return self.func  # Return function as-is

class MyClass:
    @StaticMethod
    def static_func():
        return "static"

print(MyClass.static_func())    # static
print(MyClass().static_func())  # static

Implementing classmethod

class ClassMethod:
    def __init__(self, func):
        self.func = func

    def __get__(self, obj, objtype=None):
        if objtype is None:
            objtype = type(obj)
        # Bind class as first argument
        def method(*args, **kwargs):
            return self.func(objtype, *args, **kwargs)
        return method

class MyClass:
    @ClassMethod
    def class_func(cls):
        return f"class: {cls.__name__}"

print(MyClass.class_func())    # class: MyClass
print(MyClass().class_func())  # class: MyClass

11. Summary

Concept Description
Descriptor Object that customizes attribute access
__get__ Read attribute
__set__ Write attribute
__delete__ Delete attribute
__set_name__ Automatically set attribute name (3.6+)
Data Descriptor __get__ + __set__
Non-Data Descriptor __get__ only

12. Practice Problems

Exercise 1: Read-Only Attribute

Write a descriptor that allows setting once but prevents modification.

Exercise 2: Logging Descriptor

Write a descriptor that logs all attribute access and modifications.

Exercise 3: Unit Conversion

Write a descriptor that stores in base units but displays in different units. (e.g., store in meters, display in kilometers)


Next Steps

Check out 08_Async_Programming.md to learn about async/await!

to navigate between lessons