🎓 A1Learner
🔍

Python Descriptors (Survey)

A descriptor is an object that controls access to an attribute on a class.

A descriptor in action

Upper lives on the Person class. Its methods run when name is read or stored.

class Upper:
    def __get__(self, obj, objtype=None):
        return obj._name.upper()

    def __set__(self, obj, value):
        obj._name = value

class Person:
    name = Upper()

    def __init__(self, name):
        self.name = name

print(Person("ada").name)

Properties are descriptors

The property object lives in the class dictionary. That is why type reports the name property.

class Circle:
    def __init__(self, radius):
        self._radius = radius

    @property
    def radius(self):
        return self._radius

print(type(Circle.__dict__["radius"]).__name__)
print(Circle(3).radius)

One check, many fields

Positive guards width and height here. Each field keeps its own storage name.

class Positive:
    def __init__(self, storage):
        self.storage = storage

    def __get__(self, obj, objtype=None):
        return getattr(obj, self.storage)

    def __set__(self, obj, value):
        if value <= 0:
            raise ValueError("must be positive")
        setattr(obj, self.storage, value)

class Box:
    width = Positive("_width")
    height = Positive("_height")

    def __init__(self, width, height):
        self.width = width
        self.height = height

box = Box(3, 4)
print(box.width, box.height)

Non-data can be shadowed

Greeting defines only __get__. Storing an instance value shadows it.

class Greeting:
    def __get__(self, obj, objtype=None):
        return "hello"

class Host:
    greeting = Greeting()

host = Host()
print(host.greeting)
host.greeting = "hi"
print(host.greeting)

Example

class Positive:
    def __init__(self, storage_name):
        self.storage_name = storage_name

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return getattr(obj, self.storage_name)

    def __set__(self, obj, value):
        if value <= 0:
            raise ValueError("value must be positive")
        setattr(obj, self.storage_name, value)

class Product:
    price = Positive("_price")
    stock = Positive("_stock")

    def __init__(self, name, price, stock):
        self.name = name
        self.price = price
        self.stock = stock

product = Product("Mug", 12, 30)
print(product.name, product.price, product.stock)
product.price = 15
print(product.price)
try:
    product.stock = -1
except ValueError as error:
    print(f"ValueError: {error}")
print(product.stock)

Key points

  • Descriptor: an object that controls access to an attribute on a class
  • Use __get__ to control what reading an attribute returns
  • Use __set__ to control what writing an attribute stores
  • Data descriptor: a descriptor with __set__, so it beats instance values
  • Non-data descriptor: a descriptor with only __get__, so instance values can shadow it
  • Use one descriptor class to reuse the same check on many fields

In this example, Positive is a descriptor class. It stores the name of a private storage slot. __get__ reads that slot from the instance. __set__ rejects zero and negative values. It stores good values in the slot instead. Product places one Positive on price. It places another Positive on stock. Making a Product stores 12 and 30 through __set__. Changing price to 15 passes the same check. Setting stock to -1 raises ValueError. The stored stock stays at 30.

Note

A property is a ready-made data descriptor.

Properties appeared in the Properties lesson.

Try it Yourself

Edit the code, then press Run.

Running is simulated · Python Playground

Exercise

Why use one Positive descriptor instead of two separate properties?