An Order holds a quantity that must be a positive whole number. A check
in __post_init__ catches a bad value when you build the order, but it
does not run when code changes the field later:
from dataclasses import dataclass
@dataclass
class Order:
quantity: int
def __post_init__(self) -> None:
if self.quantity <= 0:
raise ValueError("quantity must be positive")
order = Order(2)
order.quantity = -5
print(order) # Order(quantity=-5)
To run the check on every assignment, make the field a descriptor.
A descriptor runs code when you read or set an attribute
A descriptor is an object stored on a class. It runs code when you
read or set the attribute of the same name on an instance. property and
cached_property are descriptors. You write your own as a class with
these methods:
__set_name__(owner, name)runs once, when Python creates the owner class, and tells the descriptor the attribute's name.__get__(obj, objtype)runs when you read the attribute.__set__(obj, value)runs when you assign to it.
Positive checks the value in __set__ and stores it under a private
name, _quantity:
from dataclasses import dataclass
class Positive:
def __set_name__(self, owner: type, name: str) -> None:
self.storage_name = "_" + name
def __get__(self, obj: object | None, objtype: type | None = None) -> int:
if obj is None:
raise AttributeError("no default")
value: object = getattr(obj, self.storage_name)
if not isinstance(value, int):
raise TypeError("invalid stored quantity")
return value
def __set__(self, obj: object, value: int) -> None:
if type(value) is not int:
raise TypeError("quantity must be an integer")
if value <= 0:
raise ValueError("quantity must be positive")
setattr(obj, self.storage_name, value)
@dataclass
class Order:
quantity: Positive = Positive()
order = Order(quantity=2)
order.quantity = 3
print(order.quantity) # 3
order.quantity = 0 # raises ValueError: quantity must be positive
Order(quantity=2) runs the generated __init__, which assigns
self.quantity = 2. Because Order.quantity is a descriptor, Python calls
Positive.__set__ instead of storing the value on the instance. So the
same check runs in the constructor and on every later assignment. The
check uses type(value) is not int so that it also rejects True, since
bool is a subclass of int.
Raise AttributeError to make the field required
When @dataclass builds Order, it reads Order.quantity from the class
to find the field's default. That read calls __get__(None, Order), with
None because there is no instance. Positive raises AttributeError
there, which tells the decorator that the field has no default. So
Order() raises TypeError for the missing quantity. If __get__
returned a value instead, that value would become the default.
Keep that branch to one line. An AttributeError raised by mistake in
longer code there would also make the field required.
Common mistakes
Adding slots=True. The decorator then builds a new class with a slot
named quantity, and the slot replaces the descriptor. __set__ never
runs, so Order(quantity=-1) succeeds. Leave slots=True off a class
with descriptor fields.
Adding frozen=True. __set__ stores the value with an ordinary
setattr, and a frozen class rejects that. Building an order raises
FrozenInstanceError: cannot assign to field '_quantity'.
Expecting the field type to be int. The annotation is Positive,
so fields(Order)[0].type is Positive. Code that converts values by
field type, like the codec in Build a JSON Codec for Dataclasses, sees the
descriptor class. mypy reads the signatures of __get__ and __set__
instead. It types order.quantity as int and rejects
Order(quantity="x").
Use a descriptor when many fields share a rule
For one attribute with its own rule, a property with a setter is shorter.
For a check that compares several fields when you build the object, use
__post_init__. A descriptor is the better choice when the same rule
applies to many fields, because you write it once and reuse it on each one.
Check your understanding
You add slots=True to the dataclass Order above. What does Order(quantity=-1) do now?
Show answer
Hide answer
It builds the order without an error. With slots=True, the decorator
makes a new class with a slot named quantity, and the slot takes the
descriptor's place. The generated __init__ writes straight to the slot,
so Positive.__set__ never runs. Remove slots=True to get the check
back.
Going further
- Code that writes
order._quantitydirectly skips the check, because the descriptor runs only forquantity. - SQLAlchemy and Django use descriptors for model attributes. That is how reading an attribute of a model object can run a database query.
The descriptor guide explains the protocol in full, and the descriptor-typed fields section of the dataclasses documentation covers the default rule.