30. Descriptors, __init_subclass__, and metaclasses: how frameworks work
30.1 Attribute lookup, and descriptors
obj.attr is not a simple dict lookup. Simplified, type(obj).__getattribute__
does this:
- Look for
attron the class (and its MRO). If it is a data descriptor (has__set__or__delete__), call its__get__. - Otherwise look in
obj.__dict__. - Otherwise, if the class attribute is a non-data descriptor (only
__get__), call it; if it is a plain value, return it. - Otherwise call
__getattr__if defined, or raiseAttributeError.
A descriptor is any object with __get__/__set__. property,
methods (functions are descriptors: that is how self gets bound),
classmethod, staticmethod, and __slots__ are all descriptors. You can
write your own for reusable field validation:
class Positive:
def __set_name__(self, owner, name): # called at class creation with the field name
self.name = "_" + name
def __get__(self, obj, objtype=None):
if obj is None:
return self
return getattr(obj, self.name)
def __set__(self, obj, value):
if not isinstance(value, (int, float)) or value <= 0:
raise ValueError(f"{self.name[1:]} must be positive, got {value!r}")
setattr(obj, self.name, value)
class Order:
qty = Positive()
price = Positive()
def __init__(self, qty, price):
self.qty, self.price = qty, price # goes through Positive.__set__
o = Order(2, 9.5)
print(o.qty * o.price)
Order(0, 1)
# ValueError: qty must be positive, got 0
This is how Django model fields, SQLAlchemy columns, and many ORMs work: the class attribute is a descriptor that translates attribute access into database values.
30.2 __init_subclass__: hooks when a class is defined
class Plugin:
registry: dict[str, type["Plugin"]] = {}
def __init_subclass__(cls, /, name: str, **kwargs):
super().__init_subclass__(**kwargs)
Plugin.registry[name] = cls # register every subclass automatically
class CsvExporter(Plugin, name="csv"): ...
class JsonExporter(Plugin, name="json"): ...
print(Plugin.registry) # {'csv': <class ...CsvExporter>, 'json': <class ...JsonExporter>}
Plugin registries, command dispatch, and serializer registration are one
__init_subclass__ away. Reach for this before metaclasses.
30.3 Metaclasses: the class of a class
Classes are objects too; their type is type. A metaclass is a
subclass of type that customizes class creation:
class Fields(type):
def __new__(mcls, name, bases, ns):
ns["_fields"] = [k for k, v in ns.get("__annotations__", {}).items()]
return super().__new__(mcls, name, bases, ns)
class Model(metaclass=Fields):
pass
class User(Model):
id: int
email: str
print(User._fields) # ['id', 'email']
Pydantic v2's BaseModel uses a metaclass to read your annotations when
the class is defined and build a fast validator (written in Rust) for it;
that is why defining a model class does real work. In application code you
almost never need a metaclass: decorators, __init_subclass__, and
descriptors cover nearly every case. In 3.14, annotations are evaluated
lazily (PEP 649/749), which mostly matters to authors of such libraries.
30.4 Introspection
import inspect
def handler(user_id: int, verbose: bool = False) -> str: ...
sig = inspect.signature(handler)
for name, p in sig.parameters.items():
print(name, p.annotation, p.default)
print(handler.__annotations__)
FastAPI reads exactly this: the parameter names, annotations, and defaults
of your route function tell it which query parameters to parse and how to
validate them (Ch 44). Typer builds a CLI the same way. Knowing that the
"magic" is inspect.signature plus annotations makes those frameworks
predictable.