- What Are Descriptors?
- Using Descriptors to Compute Attributes
- Using Descriptors to Store Data
- Combining Descriptors with Class Decorators for Validation
- Conclusion
Combining Descriptors with Class Decorators for Validation
Let's examine how to create attributes that are readable and writable and that are validated whenever they're set. We'll start by seeing an example of use, and then consider how to make the use possible:
@ValidString("name", empty_allowed=False) @ValidNumber("price", minimum=0, maximum=1e6) @ValidNumber("quantity", minimum=1, maximum=1000) class StockItem: def __init__(self, name, price, quantity): self.name = name self.price = price self.quantity = quantity
The StockItem class in this example has three attributes: self.name, self.price, and self.quantity. The first attribute must be a string and cannot be set to be empty. The second and third attributes must be numbers and can only be set to values in the ranges specified. For example:
cameras = StockItem("Camera", 45.99, 2) cameras.quantity += 1 # works fine, quantity is now 3 cameras.quantity = -2 # raises ValueError("quantity -2 is too small")
The validation is achieved by combining class decorators with descriptors.
A class decorator takes a class definition as its sole argument and returns a new class with the same name as the one it was passed. This feature allows us to take a class, process it in some way, and produce a modified version of the class. And just as with function and method decorators, we can apply as many class decorators as we like, each one modifying the class further to produce the class we want.
In the code shown above, it looks like we've used a class decorator that takes multiple arguments, but that's not the case. The ValidString() and ValidNumber() functions take various arguments, and both return a class decorator; the decorator they return is used to decorate the class. Let's look at the ValidString() function, since it's the shorter and simpler of the two:
def ValidString(attr_name, empty_allowed=True): def decorator(cls): name = "__" + attr_name def getter(self): return getattr(self, name) def setter(self, value): assert isinstance(value, str), (attr_name + " must be a string") if not empty_allowed and not value: raise ValueError(attr_name + " may not be empty") setattr(self, name, value) setattr(cls, attr_name, GenericDescriptor(getter, setter)) return cls return decorator
The function takes two arguments—the name of the attribute to validate, and one validation criterion (in this case, whether the attribute can be empty). Inside the function we create a decorator function. The decorator takes a class as argument and will create a private data attribute based on the attribute name. For example, the "name" attribute used in the example will have its data held in self.__name.
Next, a getter function is created that uses Python's getattr() function to return the attribute with the given name. Then a setter function is created, and here the validation code is written and the setattr() function is used to set the new value. After defining the getter and setter, setattr() is called on the class to create a new attribute with the given name (for instance, self.name), and this attribute's value is set to be a descriptor of type GenericDescriptor. Finally, the decorator function returns the modified class, and the ValidString() function returns the decorator function.
The class decorated with one or more uses of ValidString() will have two new attributes added for each use. For example, if the name given is "name", the attributes will be self.__name (which will hold the actual data) and self.name (a descriptor through which the data can be accessed).
Now that we've seen how the decorator is created, we're ready to see the—remarkably simple—descriptor:
class GenericDescriptor: def __init__(self, getter, setter): self.getter = getter self.setter = setter def __get__(self, instance, owner=None): if instance is None: return self return self.getter(instance) def __set__(self, instance, value): return self.setter(instance, value)
The GenericDescriptor takes a getter and a setter function and uses them to get and set the attribute data in the given instance. This means that the data is held in the instance, not in the descriptor—the descriptor purely provides the means of accessing the data by using its getter and setter functions.
The ValidNumber() function is almost identical to the ValidString() function, the only differences being the arguments it takes (specifying the minimum and maximum acceptable values), and the setter it creates. Here's an extract that just shows the setter:
def setter(self, value): assert isinstance(value, numbers.Number), ( attr_name + " must be a number") if minimum is not None and value < minimum: raise ValueError("{0} {1} is too small".format( attr_name, value)) if maximum is not None and value > maximum: raise ValueError("{0} {1} is too big".format( attr_name, value)) setattr(self, name, value)
The numbers.Number abstract base class is used to identify any kind of number.
The decorator/descriptor pattern shown here can be used to create validation functions for any type of data.