|$ curl https://forge-ai.dev/api/markdown?path=docs/python/descriptors
$cat docs/descriptors.md
updated Today·20-26 min read·published

Descriptors

PythonDescriptorsOOPAdvanced🎯Free Tools
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
1class 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
21class User:
22 name = LoggedAccess()
23 def __init__(self, name: str):
24 self.name = name
25
26u = User("Ada")
27print(u.name)
28del u.name
Data vs Non-data Descriptors
KindDefinesPrecedence
Data descriptor__get__ + __set__/__delete__Overrides instance __dict__
Non-data descriptor__get__ onlyInstance __dict__ wins
data_vs.py
Python
1class NonData:
2 def __get__(self, obj, owner=None):
3 return "descriptor"
4
5class Data:
6 def __get__(self, obj, owner=None):
7 return "descriptor"
8 def __set__(self, obj, value):
9 obj.__dict__["shadow"] = value
10
11class Demo:
12 nd = NonData()
13 d = Data()
14
15x = Demo()
16x.__dict__["nd"] = "instance"
17print(x.nd) # instance — non-data loses
18x.d = "set"
19print(x.d) # descriptor — data wins over later instance attrs for .d lookup path
20print(x.__dict__.get("shadow"))
property Implementation Sketch
property_impl.py
Python
1class 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
1from collections.abc import Callable
2from typing import Any
3
4class 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
22def 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
29def 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
36class 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
43p = Product(9.99, 3)
44# Product(-1.0, 1) # ValueError
Lazy Attributes
lazy.py
Python
1class 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
15class 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
24pg = Page("a.txt")
25print(pg.content)
26print(pg.content) # cached on instance
Methods Are Descriptors
methods.py
Python
1class A:
2 def method(self):
3 return self
4
5a = A()
6print(A.method) # function
7print(a.method) # bound method via function.__get__
8print(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
1class 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
10class 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__.

StepLooks at
1type(obj).__mro__ data descriptors
2obj.__dict__
3non-data descriptors / class dict
4obj.__getattr__ if defined
Mini Field Framework
framework.py
Python
1class 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
7class 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
22class Model(metaclass=SchemaMeta):
23 pass
24
25class 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
32print(Person("Ada", 36).name)
33print(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.