runtime
144 / 250
lesson 144 Advanced OOP & Design · advanced

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.

You'll be able to
  • 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
The idea

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 on product.price (obj is None when it is read from the class itself);
  • __set__(self, obj, value) runs on product.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.

example-1.py · One rule, two attributes
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)
example-2.py · The rule also runs in __init__
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)
example-3.py · A choice of allowed values
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__.

Review
  • 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 in obj.__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.
Practise this

Loading your progress…

To master this lesson, solve both challenges without hints or the answer, and pass the concept check.

Descriptors: reusable attribute rules — Advanced OOP & Design · Runtime