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)
| Term | Here |
|---|---|
| Descriptor class | Quantity |
| Managed class | LineItem (declares descriptor class attributes) |
| Descriptor instance | LineItem.weight, LineItem.price — two gizmos from the Quantity mill, ever |
| Managed instance | one LineItem row |
| Storage attribute | that row’s weight / price in its __dict__ |
| Managed attribute | the 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__.
self vs instance
1 / 2Quantity.__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 valueNonBlank: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.
Descriptor kinds
1 / 2After obj.__dict__['over'] = 8, Overriding has __get__ and __set__
Pick an answer to see if you are right.
Usage tips
propertyfor 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.