Descriptors: reusable attribute rules
A property protects one attribute of one class. A descriptor is a small class whose __get__ and __set__ run whenever an attribute is read or assigned, so one rule ("never negative", "one of these values") can guard any attribute of any class.
- Write a descriptor class with
__set_name__,__get__and__set__ - Use one descriptor class for several attributes of several classes
- Explain where the value is stored and why a descriptor is placed on the class, not the object
Products have a price and a stock level that must never be negative; order lines have a quantity with the same rule. With properties (see Data Models & Typing) each attribute needs its own getter and setter. A descriptor writes the rule once, as a class, and is reused by putting an instance of it in any class body: price = NonNegative().
When an attribute that is a descriptor on the class is read or assigned through an object, Python calls the descriptor's methods instead:
__set_name__(self, owner, name)runs once, when the owning class is created, and tells the descriptor which attribute name it was given ("price");__get__(self, obj, objtype)runs onproduct.price(objis None when it is read from the class itself);__set__(self, obj, value)runs onproduct.price = …: validate here, then store the value in the object's own dictionary,obj.__dict__.
property itself is a descriptor; this is the mechanism underneath it.
Run each example to see what it prints. Running examples doesn't count toward progress.
class NonNegative: def __set_name__(self, owner, name): self.name = name def __get__(self, obj, objtype=None): if obj is None: return self return obj.__dict__[self.name] def __set__(self, obj, value): if value < 0: raise ValueError(f"{self.name} can't be negative: {value}") obj.__dict__[self.name] = value class Product: price = NonNegative() stock = NonNegative() def __init__(self, sku, price, stock): self.sku = sku self.price = price self.stock = stock mat = Product("SPO-11681", 31.0, 12) mat.stock -= 5 print(mat.price, mat.stock, vars(mat)) for field, value in [("price", -1), ("stock", -3)]: try: setattr(mat, field, value) except ValueError as err: print("refused:", err) print(type(Product.price).__name__, mat.stock)
class NonNegative: def __set_name__(self, owner, name): self.name = name def __get__(self, obj, objtype=None): if obj is None: return self return obj.__dict__[self.name] def __set__(self, obj, value): if value < 0: raise ValueError(f"{self.name} can't be negative: {value}") obj.__dict__[self.name] = value class OrderLine: quantity = NonNegative() def __init__(self, sku, quantity): self.sku = sku self.quantity = quantity print(OrderLine("SPO-11681", 2).quantity) OrderLine("HOM-22847", -1)
class OneOf: def __init__(self, *allowed): self.allowed = allowed def __set_name__(self, owner, name): self.name = name def __get__(self, obj, objtype=None): if obj is None: return self return obj.__dict__[self.name] def __set__(self, obj, value): value = value.strip().lower() if value not in self.allowed: raise ValueError(f"{self.name} must be one of {', '.join(self.allowed)}, not {value!r}") obj.__dict__[self.name] = value class Order: status = OneOf("pending", "paid", "shipped", "delivered", "cancelled") channel = OneOf("web", "app", "phone") def __init__(self, order_id, status, channel): self.order_id = order_id self.status = status self.channel = channel order = Order("O100282", " Paid", "APP") print(order.status, order.channel) order.status = "shipped" try: order.status = "lost" except ValueError as err: print("refused:", err) print(order.status)
When to use it: when the same attribute rule appears in several places: non-negative numbers, codes matching a pattern, values from a fixed set, text with a maximum length. For a one-off rule on one attribute, a property is simpler. Frameworks (ORMs, dataclass-like libraries) use descriptors for their field declarations.
Mistakes to avoid: storing the value on the descriptor (self.value = value): there is one descriptor per class attribute, shared by every object, so all products would share one price; putting the descriptor on the object in __init__ instead of in the class body (it then does nothing); and forgetting the obj is None case in __get__.
- A descriptor is a class attribute whose
__get__/__set__run on reads and assignments through objects. __set_name__tells it its attribute name; store per-object values inobj.__dict__, never on the descriptor.- One descriptor class can guard many attributes in many classes; property is the built-in one-off version.
- Next: passing collaborators into objects, so they can be swapped and tested.