Welcome back to Class Machinery: Dataclasses, Descriptors, Metaclasses! You've successfully completed the first lesson on advanced dataclasses, where you built robust, immutable configuration objects with sophisticated validation. You now understand how to leverage frozen, slots, and kw_only arguments alongside __post_init__ processing to create production-ready data containers.
Today, we're diving into one of Python's most elegant yet underutilized features: descriptors. While dataclasses help us create better data containers, descriptors give us fine-grained control over attribute access itself. They're the mechanism that powers Python's properties, methods, and even the classmethod and staticmethod decorators you use every day.
In this lesson, we'll build a reusable Range descriptor that provides type coercion and validation for numeric attributes. We'll explore the three core methods of the descriptor protocol: __get__, __set__, and __delete__, and also use the optional __set_name__ hook for automatic naming. By the end, you'll understand how to create descriptors that can be applied to multiple classes and attributes, providing centralized validation logic that makes your code more maintainable and DRY (Don't Repeat Yourself).
Descriptors are objects that define how attribute access is handled for other objects. When you access an attribute on an instance, Python checks if the class defines that attribute as a descriptor. If it does, Python delegates the access operation to the descriptor's special methods rather than performing the default attribute lookup.
The Python descriptor protocol consists of three main methods that control attribute access:
__get__(self, instance, owner): retrieval (reading a value)__set__(self, instance, value): assignment (writing a value)__delete__(self, instance): deletion (del obj.attr)
Python also defines an optional __set_name__(self, owner, name) method, which is called automatically when the descriptor is assigned to a class attribute during class creation. This hook is not part of per-access operations; instead, it provides the descriptor with its attribute name and the owning class, which is often useful for automatic naming and storage.
This protocol is incredibly powerful because it allows you to centralize attribute logic in reusable objects. Instead of writing custom property definitions for every validated attribute, you can create a single descriptor class and apply it to multiple attributes across different classes. This approach promotes code reuse and ensures consistent behavior across your entire application.
Our Range descriptor will provide numeric validation with configurable bounds and automatic type coercion. This pattern is common in data validation scenarios where you need to ensure values fall within acceptable ranges while converting them to the appropriate numeric type.
The Range constructor takes keyword-only parameters to prevent positional argument confusion. The coerce parameter accepts any callable (like int, float, or Decimal) that transforms the input value to the desired type. The min and max bounds are optional and get converted using the same coercion function to ensure type consistency. The _name and _storage attributes will be set later by the descriptor protocol to manage per-instance storage.
The __set_name__ method is Python's mechanism for allowing descriptors to know their own attribute names. This method is called automatically during class creation when a descriptor instance is assigned to a class attribute, eliminating the need to manually specify names.
When we assign price = Range(min=0, coerce=float) to a class, Python automatically calls __set_name__(Product, "price") on our descriptor instance. This lets us store the public attribute name ("price") and generate a corresponding private storage attribute name ("_price"). This automatic naming prevents errors and makes our descriptors truly reusable, since the same descriptor class can handle attributes with different names without manual configuration.
The __get__ method controls how values are retrieved when accessing descriptor-managed attributes. It must handle two different scenarios: access from the class itself versus access from an instance.
When accessed from a class (like Product.price), the instance parameter is None, and we return the descriptor itself to allow introspection. When accessed from an instance (like product.price), we retrieve the value from the instance's private storage attribute using getattr. The getattr function with a default of None ensures we don't raise an AttributeError if the attribute hasn't been set yet or has been deleted.
The __set__ method is where the real validation magic happens. This method is called whenever someone assigns a value to a descriptor-managed attribute, giving us complete control over the validation and storage process.
The validation process begins with type coercion using the configured callable. Next, we check the bounds if they're configured, raising descriptive ValueError exceptions that include the attribute name for clear error messages. Finally, we store the validated value using object.__setattr__ to bypass any potential descriptor interference and ensure the value reaches the instance's storage. This approach works even with slotted or frozen classes that might restrict normal attribute assignment.
The __delete__ method participates in the core protocol and is invoked when code executes del obj.attr. For our Range descriptor, deletion removes the stored value so that subsequent reads return None (thanks to the default in __get__), and if the attribute was never set, we raise a clear AttributeError instead.
Using object.__delattr__ ensures direct access to the instance's storage without being intercepted by custom __delattr__ implementations.
Now let's see how our Range descriptor works in practice by building a Product class with multiple validated attributes:
Each class attribute gets its own Range instance with tailored configuration. The price must be non-negative and coerced to float, rating is bounded between 0 and 5, and stock is a non-negative integer. During initialization, assigning to these attributes automatically triggers our descriptor's validation logic. The name normalization using " ".join(str(name).split()) collapses whitespace, demonstrating how descriptors can work alongside traditional validation patterns.
The Range descriptor's flexibility shines when working with different numeric types. Let's create a ServicePlan class that uses Decimal for precise financial calculations while maintaining the same validation patterns.
The monthly_fee attribute demonstrates using Decimal for precise financial arithmetic, while users shows how the same descriptor pattern works with integer bounds. The __repr__ method formats the decimal fee to two decimal places using banker's rounding, showing how descriptors integrate seamlessly with domain-specific formatting requirements. This class reuses our Range logic without any modification, demonstrating the power of descriptor-based validation.
Let's create instances to see our descriptor validation in action. The following code demonstrates successful object creation with automatic type coercion and whitespace normalization working together, and shows how deletion resets a value to None.
This code creates products with messy input data to showcase our normalization capabilities. The string price "10.50" gets coerced to float automatically, extra whitespace in the name gets normalized, and fractional ratings are accepted. The ServicePlan demonstrates Decimal coercion with the string "12.499" being properly converted and stored.
The output shows perfect data normalization: whitespace is collapsed in product names, string values are coerced to appropriate numeric types, decimal values are properly rounded, and deleting a validated attribute cleanly resets it to None. The final line confirms that our class attribute is indeed a Range descriptor instance, demonstrating that descriptors are first-class objects that can be introspected and manipulated at runtime.
Our descriptor's true value becomes apparent when testing its validation capabilities. Let's examine how it handles various error conditions with clear, informative error messages.
These tests verify our bound checking across different attribute types and instances. We attempt to set a negative price (violating the minimum bound), an excessive rating (violating the maximum bound), and zero users (violating the minimum bound for the service plan). Each violation should trigger our validation logic with appropriate error messages.
The validation works perfectly across different classes and attribute types. The error messages include the attribute name and expected bounds, making debugging straightforward. This demonstrates how a single descriptor implementation provides consistent validation behavior across multiple classes and attributes, eliminating code duplication while ensuring uniform error handling throughout your application.
Excellent work mastering the descriptor protocol! We've built a sophisticated Range validator that demonstrates the three main descriptor methods: __get__ for value retrieval, __set__ for validation and storage, and __delete__ for deletion. You've also seen how the __set_name__ hook provides automatic naming. You've seen how descriptors promote code reuse by centralizing validation logic in reusable objects that work across multiple classes and attributes.
The descriptor protocol is one of Python's most powerful features for attribute management, providing the foundation for properties, methods, and many other language features. By understanding descriptors, you gain insight into how Python itself works and unlock the ability to create sophisticated attribute behaviors that would be difficult or impossible with traditional approaches.
Our Range descriptor handles type coercion, bound validation, deletion, and automatic storage management while providing clear error messages and working seamlessly with different numeric types. This pattern scales beautifully to complex applications where consistent validation is crucial for data integrity.
In our next lesson, we'll explore class hooks and ABC contracts, where you'll learn how to enforce interface requirements using Python's Abstract Base Classes and leverage special hooks like __init_subclass__ to customize subclass behavior. But first, get ready to apply your descriptor knowledge with hands-on exercises that will challenge you to extend and customize the Range descriptor in creative ways!
