How to Validate Attributes with Descriptors in Python
Attribute validation is essential for ensuring data integrity within objects. While properties (@property) are the standard way to validate individual attributes, repeating the same validation logic (e.g., checking for positive integers) across multiple attributes or classes leads to code duplication.
Descriptors offer a powerful, reusable way to encapsulate attribute management logic. A descriptor is a class that defines how an attribute is accessed (__get__) and modified (__set__).
This guide explains how to implement descriptors for robust attribute validation.
Understanding the Descriptor Protocol
A descriptor is simply a class that implements at least one of these methods:
__get__(self, instance, owner): Retrieve the value.__set__(self, instance, value): Set the value.__delete__(self, instance): Delete the value.
When you assign a descriptor instance to a class attribute, Python delegates attribute access to these methods.
Method 1: Basic Validation Descriptor
Let's create a descriptor that ensures an attribute is always a positive integer.
class PositiveInteger:
def __init__(self, name):
self.name = name # The internal name to store the value
def __get__(self, instance, owner):
# Retrieve from the instance's dictionary
return instance.__dict__[self.name]
def __set__(self, instance, value):
if not isinstance(value, int) or value <= 0:
raise ValueError(f"{self.name} must be a positive integer")
# Store in the instance's dictionary
instance.__dict__[self.name] = value
class Product:
# Descriptors are class attributes
price = PositiveInteger("price")
quantity = PositiveInteger("quantity")
def __init__(self, name, price, quantity):
self.name = name
self.price = price # Calls PositiveInteger.__set__
self.quantity = quantity # Calls PositiveInteger.__set__
# Testing
p = Product("Laptop", 1000, 5)
print(f"Price: {p.price}")
try:
p.price = -50
except ValueError as e:
print(f"Error: {e}")
Output:
Price: 1000
Error: price must be a positive integer
In this basic example, we had to pass "price" manually to PositiveInteger("price"). This redundancy is error-prone. Method 2 fixes this.
Method 2: Using __set_name__ (Python 3.6+)
Python 3.6 introduced __set_name__, which is automatically called when the descriptor is instantiated. This allows the descriptor to know the name of the variable it is assigned to, removing the need to pass it manually.
class ValidString:
def __set_name__(self, owner, name):
self.public_name = name
self.private_name = '_' + name
def __get__(self, instance, owner):
return getattr(instance, self.private_name)
def __set__(self, instance, value):
if not isinstance(value, str):
raise TypeError(f"{self.public_name} must be a string")
if len(value) == 0:
raise ValueError(f"{self.public_name} cannot be empty")
setattr(instance, self.private_name, value)
class User:
username = ValidString() # Name automatically captured as 'username'
email = ValidString() # Name automatically captured as 'email'
def __init__(self, username, email):
self.username = username
self.email = email
# Testing
user = User("alice", "alice@example.com")
print(f"User: {user.username}")
try:
user.username = ""
except ValueError as e:
print(f"Error: {e}")
Output:
User: alice
Error: username cannot be empty
Method 3: Creating a Reusable Validator Class
We can generalize this pattern further by creating a base Validator class that accepts check functions.
class Validator:
def __init__(self, *checks):
self.checks = checks
def __set_name__(self, owner, name):
self.private_name = '_' + name
def __get__(self, instance, owner):
return getattr(instance, self.private_name)
def __set__(self, instance, value):
for check in self.checks:
check(value)
setattr(instance, self.private_name, value)
# Define reusable check functions
def is_int(val):
if not isinstance(val, int): raise TypeError("Must be an integer")
def is_positive(val):
if val <= 0: raise ValueError("Must be positive")
def is_even(val):
if val % 2 != 0: raise ValueError("Must be even")
# Usage
class Config:
max_connections = Validator(is_int, is_positive)
buffer_size = Validator(is_int, is_even)
def __init__(self, conns, buf):
self.max_connections = conns
self.buffer_size = buf
# Testing
try:
c = Config(10, 3)
except ValueError as e:
print(f"Error: {e}")
Output:
Error: Must be even
Conclusion
To validate attributes using descriptors efficiently:
- Implement
__set__to intercept assignments and run validation logic. - Use
__set_name__to automatically capture the attribute name for error messages and storage. - Store Values in
instance.__dict__or usingsetattrwith a private name to avoid infinite recursion.