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):
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")
instance.__dict__[self.name] = value
class Product:
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__
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 integerMethod 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
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 emptyMethod 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)
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")
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
try:
c = Config(10, 3)
except ValueError as e:
print(f"Error: {e}")Output:
Error: Must be evenConclusion
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.