Skip to main content

Attribute Descriptors

Source: Fluent Python, 2nd Edition — Chapter 24 by Luciano Ramalho

Hettinger: descriptors are how you reuse get/set logic (Django/SQLAlchemy fields). A descriptor implements some of __get__, __set__, __delete__. property implements all three. Partial is fine. Functions, @classmethod, @staticmethod are descriptors too. 3.6 __set_name__ retired a pile of class-decorator/metaclass hacks from the first edition.

Vocabulary (LineItem + Quantity)​

TermHere
Descriptor classQuantity
Managed classLineItem (declares descriptor class attributes)
Descriptor instanceLineItem.weight, LineItem.price — two gizmos from the Quantity mill, ever
Managed instanceone LineItem row
Storage attributethat row’s weight / price in its __dict__
Managed attributethe public name clients use

Mills & gizmos: classes are factories; instances are the widgets. Descriptors get a magnifying glass (__get__) and a claw (__set__). Thousands of line items, still two Quantity objects. Store values on instance, never on self (the descriptor).

Take #3 — storage_name by hand​

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

def __set__(self, instance, value):
if value > 0:
instance.__dict__[self.storage_name] = value
else:
raise ValueError(f'{self.storage_name} must be > 0')

def __get__(self, instance, owner):
return instance.__dict__[self.storage_name]

self = descriptor (LineItem.weight); instance = the LineItem; owner = managed class. setattr(instance, self.storage_name, value) would re-enter __set__. __get__ is required if public name ≠ storage (rooms = Quantity('number_of_rooms')). Access via class: instance is None — return self for introspection.

price = Quantity('weight') after a sloppy paste clobbers weight. RHS of weight = Quantity() runs before the name exists — hence __set_name__.

Quiz

self vs instance

1 / 2

Quantity.__set__ stores on self.__dict__

Pick an answer to see if you are right.

Starter __get__ returns 0 → first test fails; __set__ is pass so zero may still “succeed” with subtotal 0 unless you raise.

Take #4 — __set_name__(self, owner, name)​

Interpreter calls this on each descriptor in the class body (3.6+). self.storage_name = name. Then weight = Quantity() — no string. If names match, __get__ optional: product.price hits the instance dict after __set__ filled it. Typical: Quantity lives in model.py; Django fields look like this.

Take #5 — Template Method / self-delegation​

Blank description shipped nothing. Validated ABC: __set_name__ + __set__ that value = self.validate(name, value) then dict write. Subclasses only implement validate.

  • Quantity: <= 0 → error, else return value
  • NonBlank: strip(); empty → error; return stripped string

LineItem.description = NonBlank().

Overriding vs not​

Python: read instance then class; write usually creates an instance attr. Descriptors punch holes in that:

Overriding / data / enforced — has __set__. Assignment goes through it. property always has __set__ (default: can't set attribute). Even after obj.__dict__['over'] = 8, obj.over still __get__s.

Overriding, no __get__: read via instance returns the descriptor object (from the class). After you stuff __dict__, reads see the instance value; writes still __set__.

Non-overriding / non-data / shadowable — no __set__. obj.non_over = 7 shadows. del obj.non_over restores __get__. Methods and @cached_property are this.

Assigning on the class (Managed.over = 1) kills the descriptor for everyone. __set__ on Managed does not intercept Managed.x = .... Controlling class-level sets → descriptor on the metaclass (Chapter 25). type is not yours to patch.

Methods​

User functions have __get__. obj.spam → bound method; Managed.spam → function. obj.spam = 7 shadows that instance. fn.__get__(instance) ≈ partial(fn, instance). Bound method: .__self__ is the instance, .__func__ is the original function; __call__ does func(self, ...). Text.reverse as a function still reverses tuples via map. Special methods: repr(x) is type(x).__repr__(x) — instance __repr__ does not count.

Quiz

Descriptor kinds

1 / 2

After obj.__dict__['over'] = 8, Overriding has __get__ and __set__

Pick an answer to see if you are right.

Usage tips​

  • property for simple / read-only.
  • Validation: __set__ only, write __dict__, let get be a raw dict hit.
  • Expensive compute: __get__ only, then set the instance attr (cache / cached_property).
  • Dynamic names from JSON can shadow non-dunder methods. FrozenJSON/Record stayed on dunders, staticmethod, properties.
  • Descriptor class docstring is help(LineItem.weight) for every Quantity — you cannot say “kilograms” vs “USD” without extra protocol.
  • __delete__ exists; rarely written.

Starter __set__ accepts -1 and __set_name__ never sets storage_name — ok can AttributeError → bad.