πŸŽ‰ 75% of content is free forever β€” Unlock Premium from $10/mo β†’
CW
Search courses…
πŸ’Ό Servicesℹ️ Aboutβœ‰οΈ ContactView Pricing Plansfrom $10

Descriptors & Properties in Python

Python InterviewPython Internals⭐ Premium

Advertisement

Descriptors & Properties in Python

Difficulty: Hard | Companies: Google, Meta, Amazon, Netflix, Stripe

Descriptor Protocol Deep Dive

from typing import Any, Optional, Type

class BasicDescriptor:
    """Basic descriptor implementing the full protocol."""
    
    def __set_name__(self, owner: type, name: str):
        """Called when descriptor is assigned to a class attribute."""
        self.name = name
        self.private_name = f"_{name}"
        print(f"Descriptor {self.name} assigned to {owner.__name__}")
    
    def __get__(self, obj: Any, objtype: Optional[type] = None) -> Any:
        """Called when attribute is accessed."""
        if obj is None:
            return self  # Return descriptor itself when accessed via class
        
        value = getattr(obj, self.private_name, None)
        print(f"Getting {self.name}: {value}")
        return value
    
    def __set__(self, obj: Any, value: Any) -> None:
        """Called when attribute is set."""
        print(f"Setting {self.name}: {value}")
        setattr(obj, self.private_name, value)
    
    def __delete__(self, obj: Any) -> None:
        """Called when attribute is deleted."""
        print(f"Deleting {self.name}")
        if hasattr(obj, self.private_name):
            delattr(obj, self.private_name)

class Person:
    """Example class using descriptors."""
    name = BasicDescriptor()
    age = BasicDescriptor()
    
    def __init__(self, name: str, age: int):
        self.name = name  # Triggers __set__
        self.age = age    # Triggers __set__

# Usage
p = Person("Alice", 30)  # Sets both name and age
print(p.name)            # Gets name
print(p.age)             # Gets age

# Access via class returns descriptor itself
print(Person.name)       # <BasicDescriptor object>

ℹ️

The __set_name__ method is called during class creation, allowing descriptors to know their attribute name and class owner.

Data vs Non-Data Descriptors

class NonDataDescriptor:
    """Non-data descriptor: only implements __get__."""
    
    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return lambda: f"Called from {obj}"

class DataDescriptor:
    """Data descriptor: implements both __get__ and __set__."""
    
    def __set_name__(self, owner, name):
        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 Example:
    non_data = NonDataDescriptor()
    data = DataDescriptor()
    
    def __init__(self):
        self.data = "initial"  # Uses data descriptor
        self.non_data = "instance"  # Binds to instance, shadows descriptor

# Data descriptors take precedence over instance attributes
e = Example()
print(e.data)        # Uses DataDescriptor.__get__
print(e.non_data)    # Returns "instance" (instance attribute)

# Non-data descriptors can be overridden by instance attributes
def method_descriptor(self):
    return "From descriptor"

class MyClass:
    method = method_descriptor  # Non-data descriptor

obj = MyClass()
obj.method = lambda: "From instance"  # Shadows descriptor
print(obj.method())  # "From instance"

Validated Fields with Descriptors

from typing import Callable, Any, Optional
import re

class ValidatedField:
    """Descriptor with validation."""
    
    def __init__(
        self,
        validator: Callable[[Any], bool],
        error_message: str = "Invalid value",
        default: Any = None
    ):
        self.validator = validator
        self.error_message = error_message
        self.default = default
    
    def __set_name__(self, owner, name):
        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, self.default)
    
    def __set__(self, obj, value):
        if not self.validator(value):
            raise ValueError(f"{self.name}: {self.error_message}")
        setattr(obj, self.private_name, value)

class Email(ValidatedField):
    """Email validation descriptor."""
    
    EMAIL_REGEX = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
    
    def __init__(self, **kwargs):
        super().__init__(
            validator=lambda x: bool(re.match(self.EMAIL_REGEX, x)) if x else False,
            error_message="Must be a valid email address",
            **kwargs
        )

class PositiveNumber(ValidatedField):
    """Positive number validation descriptor."""
    
    def __init__(self, **kwargs):
        super().__init__(
            validator=lambda x: isinstance(x, (int, float)) and x > 0 if x is not None else False,
            error_message="Must be a positive number",
            **kwargs
        )

class StringLength(ValidatedField):
    """String length validation descriptor."""
    
    def __init__(self, min_length: int = 0, max_length: int = 255, **kwargs):
        def validator(x):
            if not isinstance(x, str):
                return False
            return min_length <= len(x) <= max_length
        
        super().__init__(
            validator=validator,
            error_message=f"Must be between {min_length} and {max_length} characters",
            **kwargs
        )

class User:
    """Example user class with validated fields."""
    email = Email()
    age = PositiveNumber()
    name = StringLength(min_length=2, max_length=50)
    
    def __init__(self, email: str, age: int, name: str):
        self.email = email
        self.age = age
        self.name = name

# Usage
user = User("john@example.com", 30, "John Doe")
print(user.email)  # john@example.com

try:
    user.email = "invalid-email"  # Raises ValueError
except ValueError as e:
    print(e)  # email: Must be a valid email address

⚠️

Data descriptors override instance attributes, while non-data descriptors can be overridden. Use data descriptors for validation and non-data descriptors for methods.

Custom Property Implementation

from typing import Callable, Any, Optional, TypeVar
from functools import wraps

F = TypeVar('F', bound=Callable[..., Any])

class Property:
    """Custom property implementation using descriptors."""
    
    def __init__(
        self,
        fget: Optional[Callable] = None,
        fset: Optional[Callable] = None,
        fdel: Optional[Callable] = None,
        doc: Optional[str] = None
    ):
        self.fget = fget
        self.fset = fset
        self.fdel = fdel
        self.__doc__ = doc or (fget.__doc__ if fget else None)
    
    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):
        """Create new property with getter."""
        return type(self)(fget, self.fset, self.fdel, self.__doc__)
    
    def setter(self, fset):
        """Create new property with setter."""
        return type(self)(self.fget, fset, self.fdel, self.__doc__)
    
    def deleter(self, fdel):
        """Create new property with deleter."""
        return type(self)(self.fget, self.fset, fdel, self.__doc__)

class Temperature:
    """Example using custom property."""
    
    def __init__(self, celsius: float = 0):
        self._celsius = celsius
    
    @Property
    def celsius(self) -> float:
        """Temperature in Celsius."""
        return self._celsius
    
    @celsius.setter
    def celsius(self, value: float):
        if value < -273.15:
            raise ValueError("Temperature below absolute zero")
        self._celsius = value
    
    @Property
    def fahrenheit(self) -> float:
        """Temperature in Fahrenheit."""
        return self._celsius * 9/5 + 32
    
    @fahrenheit.setter
    def fahrenheit(self, value: float):
        self.celsius = (value - 32) * 5/9

# Usage
temp = Temperature(25)
print(temp.celsius)    # 25
print(temp.fahrenheit) # 77.0
temp.fahrenheit = 32
print(temp.celsius)    # 0.0

Cached Properties

import time
from typing import Callable, Any
from functools import wraps

class cached_property:
    """Cached property descriptor."""
    
    def __init__(self, func: Callable):
        self.func = func
        self.attrname = None
        self.__doc__ = func.__doc__
    
    def __set_name__(self, owner, name):
        self.attrname = name
    
    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        
        # Check if value is cached
        try:
            cache = obj.__dict__
        except AttributeError:
            cache = {}
        
        if self.attrname in cache:
            return cache[self.attrname]
        
        # Compute and cache value
        value = self.func(obj)
        cache[self.attrname] = value
        return value

class TTLCacheProperty:
    """Time-to-live cached property."""
    
    def __init__(self, ttl: float = 60.0):
        self.ttl = ttl
        self.attrname = None
    
    def __set_name__(self, owner, name):
        self.attrname = f"_ttl_{name}"
    
    def __call__(self, func: Callable) -> 'TTLCacheProperty':
        self.func = func
        self.__doc__ = func.__doc__
        return self
    
    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        
        cache = obj.__dict__
        now = time.time()
        
        # Check if cached value exists and is not expired
        if self.attrname in cache:
            value, timestamp = cache[self.attrname]
            if now - timestamp < self.ttl:
                return value
        
        # Compute and cache with timestamp
        value = self.func(obj)
        cache[self.attrname] = (value, now)
        return value

class ExpensiveCalculation:
    """Example with cached and TTL properties."""
    
    @cached_property
    def computed_value(self) -> int:
        """Expensive computation - cached after first call."""
        print("Computing expensive value...")
        time.sleep(1)
        return 42
    
    @TTLCacheProperty(ttl=5.0)
    def dynamic_value(self) -> float:
        """Value that expires after TTL."""
        print("Computing dynamic value...")
        return time.time()

# Usage
calc = ExpensiveCalculation()
print(calc.computed_value)  # Computes
print(calc.computed_value)  # Returns cached
print(calc.dynamic_value)   # Computes
print(calc.dynamic_value)   # Returns cached
time.sleep(6)
print(calc.dynamic_value)   # Recomputes (TTL expired)

Descriptors in Python Standard Library

from typing import ClassVar
import math

class StaticMethod:
    """Manual implementation of staticmethod."""
    
    def __init__(self, func):
        self.func = func
    
    def __get__(self, obj, objtype=None):
        return self.func

class ClassMethod:
    """Manual implementation of classmethod."""
    
    def __init__(self, func):
        self.func = func
    
    def __get__(self, obj, objtype=None):
        if objtype is None:
            objtype = type(obj)
        
        def new_func(*args, **kwargs):
            return self.func(objtype, *args, **kwargs)
        
        return new_func

class Circle:
    """Example using descriptor-based methods."""
    
    def __init__(self, radius: float):
        self.radius = radius
    
    @StaticMethod
    def validate_radius(value: float) -> bool:
        """Static method for validation."""
        return isinstance(value, (int, float)) and value >= 0
    
    @ClassMethod
    def from_diameter(cls, diameter: float) -> 'Circle':
        """Class method to create from diameter."""
        return cls(diameter / 2)
    
    @property
    def area(self) -> float:
        """Calculated property."""
        return math.pi * self.radius ** 2

# Usage
circle = Circle.from_diameter(10)  # Uses classmethod descriptor
print(circle.radius)  # 5.0
print(circle.area)    # Uses property descriptor
print(Circle.validate_radius(5))  # Uses staticmethod descriptor

ℹ️

Python's built-in property, classmethod, and staticmethod are all implemented using the descriptor protocol. Understanding descriptors gives you deep insight into Python's object model.

Follow-Up Questions

  1. Explain the difference between data descriptors and non-data descriptors.

  2. How does Python's property decorator work internally?

  3. When would you use descriptors over regular methods?

  4. How do descriptors interact with inheritance?

  5. Explain the descriptor lookup chain and precedence rules.

Advertisement