|$ curl https://forge-ai.dev/api/markdown?path=docs/python/descriptors
$cat docs/descriptors.md
updated Today·20-26 min read·published
Descriptors
Introduction
Descriptors are objects that customize attribute access via __get__, __set__, and/or __delete__. Properties, methods, classmethod, and staticmethod are all built on descriptors.
The Descriptor Protocol
protocol.py
Python
| 1 | class LoggedAccess: |
| 2 | def __set_name__(self, owner, name): |
| 3 | self.public_name = name |
| 4 | self.private_name = f"_{name}" |
| 5 | |
| 6 | def __get__(self, obj, objtype=None): |
| 7 | if obj is None: |
| 8 | return self |
| 9 | value = getattr(obj, self.private_name) |
| 10 | print("get", self.public_name, value) |
| 11 | return value |
| 12 | |
| 13 | def __set__(self, obj, value): |
| 14 | print("set", self.public_name, value) |
| 15 | setattr(obj, self.private_name, value) |
| 16 | |
| 17 | def __delete__(self, obj): |
| 18 | print("delete", self.public_name) |
| 19 | delattr(obj, self.private_name) |
| 20 | |
| 21 | class User: |
| 22 | name = LoggedAccess() |
| 23 | def __init__(self, name: str): |
| 24 | self.name = name |
| 25 | |
| 26 | u = User("Ada") |
| 27 | print(u.name) |
| 28 | del u.name |
Data vs Non-data Descriptors
| Kind | Defines | Precedence |
|---|---|---|
| Data descriptor | __get__ + __set__/__delete__ | Overrides instance __dict__ |
| Non-data descriptor | __get__ only | Instance __dict__ wins |
data_vs.py
Python
| 1 | class NonData: |
| 2 | def __get__(self, obj, owner=None): |
| 3 | return "descriptor" |
| 4 | |
| 5 | class Data: |
| 6 | def __get__(self, obj, owner=None): |
| 7 | return "descriptor" |
| 8 | def __set__(self, obj, value): |
| 9 | obj.__dict__["shadow"] = value |
| 10 | |
| 11 | class Demo: |
| 12 | nd = NonData() |
| 13 | d = Data() |
| 14 | |
| 15 | x = Demo() |
| 16 | x.__dict__["nd"] = "instance" |
| 17 | print(x.nd) # instance — non-data loses |
| 18 | x.d = "set" |
| 19 | print(x.d) # descriptor — data wins over later instance attrs for .d lookup path |
| 20 | print(x.__dict__.get("shadow")) |
property Implementation Sketch
property_impl.py
Python
| 1 | class property: |
| 2 | def __init__(self, fget=None, fset=None, fdel=None, doc=None): |
| 3 | self.fget = fget |
| 4 | self.fset = fset |
| 5 | self.fdel = fdel |
| 6 | self.__doc__ = doc or (fget.__doc__ if fget else None) |
| 7 | |
| 8 | def __get__(self, obj, objtype=None): |
| 9 | if obj is None: |
| 10 | return self |
| 11 | if self.fget is None: |
| 12 | raise AttributeError("unreadable") |
| 13 | return self.fget(obj) |
| 14 | |
| 15 | def __set__(self, obj, value): |
| 16 | if self.fset is None: |
| 17 | raise AttributeError("can't set") |
| 18 | self.fset(obj, value) |
| 19 | |
| 20 | def __delete__(self, obj): |
| 21 | if self.fdel is None: |
| 22 | raise AttributeError("can't delete") |
| 23 | self.fdel(obj) |
| 24 | |
| 25 | def getter(self, fget): |
| 26 | return type(self)(fget, self.fset, self.fdel, self.__doc__) |
| 27 | |
| 28 | def setter(self, fset): |
| 29 | return type(self)(self.fget, fset, self.fdel, self.__doc__) |
| 30 | |
| 31 | def deleter(self, fdel): |
| 32 | return type(self)(self.fget, self.fset, fdel, self.__doc__) |
📝
note
Prefer the real builtin @property. This sketch is for understanding — see properties.
Validator Patterns
validators.py
Python
| 1 | from collections.abc import Callable |
| 2 | from typing import Any |
| 3 | |
| 4 | class Validated: |
| 5 | def __init__(self, *validators: Callable[[Any], Any]): |
| 6 | self.validators = validators |
| 7 | |
| 8 | def __set_name__(self, owner, name): |
| 9 | self.name = name |
| 10 | self.private = f"_{name}" |
| 11 | |
| 12 | def __get__(self, obj, owner=None): |
| 13 | if obj is None: |
| 14 | return self |
| 15 | return getattr(obj, self.private) |
| 16 | |
| 17 | def __set__(self, obj, value): |
| 18 | for v in self.validators: |
| 19 | value = v(value) |
| 20 | setattr(obj, self.private, value) |
| 21 | |
| 22 | def gt(min_value: float): |
| 23 | def check(value): |
| 24 | if value <= min_value: |
| 25 | raise ValueError(f"must be > {min_value}") |
| 26 | return value |
| 27 | return check |
| 28 | |
| 29 | def typed(expected: type): |
| 30 | def check(value): |
| 31 | if not isinstance(value, expected): |
| 32 | raise TypeError(f"expected {expected}") |
| 33 | return value |
| 34 | return check |
| 35 | |
| 36 | class Product: |
| 37 | price = Validated(typed(float), gt(0)) |
| 38 | quantity = Validated(typed(int), gt(0)) |
| 39 | def __init__(self, price: float, quantity: int): |
| 40 | self.price = price |
| 41 | self.quantity = quantity |
| 42 | |
| 43 | p = Product(9.99, 3) |
| 44 | # Product(-1.0, 1) # ValueError |
Lazy Attributes
lazy.py
Python
| 1 | class lazy: |
| 2 | def __init__(self, func): |
| 3 | self.func = func |
| 4 | self.name = func.__name__ |
| 5 | |
| 6 | def __get__(self, obj, owner=None): |
| 7 | if obj is None: |
| 8 | return self |
| 9 | value = self.func(obj) |
| 10 | # store on instance -> non-data descriptor overridden next time? |
| 11 | # We are non-data (__get__ only), so instance dict wins after set. |
| 12 | setattr(obj, self.name, value) |
| 13 | return value |
| 14 | |
| 15 | class Page: |
| 16 | def __init__(self, path: str): |
| 17 | self.path = path |
| 18 | |
| 19 | @lazy |
| 20 | def content(self) -> str: |
| 21 | print("load") |
| 22 | return f"contents of {self.path}" |
| 23 | |
| 24 | pg = Page("a.txt") |
| 25 | print(pg.content) |
| 26 | print(pg.content) # cached on instance |
Methods Are Descriptors
methods.py
Python
| 1 | class A: |
| 2 | def method(self): |
| 3 | return self |
| 4 | |
| 5 | a = A() |
| 6 | print(A.method) # function |
| 7 | print(a.method) # bound method via function.__get__ |
| 8 | print(a.method()) # a |
| 9 | |
| 10 | # functions define __get__ -> non-data descriptors |
| 11 | # that's why instance methods bind to self |
__set_name__
PEP 487: descriptors learn their assigned attribute name when the owner class is created. Essential for storage-key patterns.
set_name.py
Python
| 1 | class Field: |
| 2 | def __set_name__(self, owner, name): |
| 3 | print(f"{owner.__name__}.{name} registered") |
| 4 | self.name = name |
| 5 | def __get__(self, obj, owner=None): |
| 6 | return obj.__dict__.get(self.name) |
| 7 | def __set__(self, obj, value): |
| 8 | obj.__dict__[self.name] = value |
| 9 | |
| 10 | class Model: |
| 11 | title = Field() |
| 12 | body = Field() |
Pitfalls
- Putting a data descriptor and also writing to __dict__ with the same name can confuse readers
- Descriptors must live on the class body, not per-instance
- Forgetting __set_name__ leads to hard-coded private attr bugs
- Slots + descriptors: ensure storage attributes are listed in __slots__
🔥
pro tip
Most application code should use @property or Pydantic/dataclass validators. Reach for custom descriptors when building frameworks or reusable field types.
Attribute Lookup Order
Simplified lookup for obj.attr: data descriptors on type → instance __dict__ → non-data descriptors → type __dict__ → __getattr__.
| Step | Looks at |
|---|---|
| 1 | type(obj).__mro__ data descriptors |
| 2 | obj.__dict__ |
| 3 | non-data descriptors / class dict |
| 4 | obj.__getattr__ if defined |
Mini Field Framework
framework.py
Python
| 1 | class SchemaMeta(type): |
| 2 | def __new__(mcls, name, bases, ns): |
| 3 | fields = {k: v for k, v in ns.items() if isinstance(v, Typed)} |
| 4 | ns["_fields"] = fields |
| 5 | return super().__new__(mcls, name, bases, ns) |
| 6 | |
| 7 | class Typed: |
| 8 | def __init__(self, py_type: type): |
| 9 | self.py_type = py_type |
| 10 | def __set_name__(self, owner, name): |
| 11 | self.name = name |
| 12 | self.storage = f"_{name}" |
| 13 | def __get__(self, obj, owner=None): |
| 14 | if obj is None: |
| 15 | return self |
| 16 | return getattr(obj, self.storage) |
| 17 | def __set__(self, obj, value): |
| 18 | if not isinstance(value, self.py_type): |
| 19 | raise TypeError(f"{self.name} expected {self.py_type}") |
| 20 | setattr(obj, self.storage, value) |
| 21 | |
| 22 | class Model(metaclass=SchemaMeta): |
| 23 | pass |
| 24 | |
| 25 | class Person(Model): |
| 26 | name = Typed(str) |
| 27 | age = Typed(int) |
| 28 | def __init__(self, name: str, age: int): |
| 29 | self.name = name |
| 30 | self.age = age |
| 31 | |
| 32 | print(Person("Ada", 36).name) |
| 33 | print(Person._fields.keys()) |
$Blueprint — Engineering Documentation·Section ID: PYTHON-DESCRIPTORS·Revision: 1.0
Community
Get help on Slack, Discord or VIP
Stuck on a guide? Join the community and ask.