Frontend Labs
⋯

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:

  1. Look for attr on the class (and its MRO). If it is a data descriptor (has __set__ or __delete__), call its __get__.
  2. Otherwise look in obj.__dict__.
  3. Otherwise, if the class attribute is a non-data descriptor (only __get__), call it; if it is a plain value, return it.
  4. Otherwise call __getattr__ if defined, or raise AttributeError.

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.


Generated from real-life-python-guide.md · 8 Oct 2026 16:36