Part 5 · Advanced Python · Module 36

Metaprogramming

Metaprogramming is code that shapes other code: attributes that validate themselves, classes that register themselves, and class statements whose meaning you can redefine. Frameworks like Django, SQLAlchemy, Pydantic, and the standard library's dataclasses and enum are built from exactly the hooks in this module. Learn the mechanics and those libraries stop being magic.

⏱ ~3 hoursPrerequisites: M11 Data Model, M15 Decorators, M16 Typing

Classes are objects too

Metaprogramming means writing code that inspects or modifies the structure of a program (its classes, functions, and attributes) rather than just its data. In many languages that requires macros or code generation. In Python it falls out of one design decision: everything, including a class, is an ordinary runtime object. A class statement is executed like any other statement, and its result is an object you can pass around, inspect, and modify.

That object has a type too. The type of most classes is type, which is why type is called the default metaclass: the class of classes. And because type is itself a class, type(type) is type closes the loop.

There are five hooks, in increasing order of power and decreasing order of how often you should reach for them:

HookCustomizesTypical use
__getattr__ / __getattribute__attribute access on instancesproxies, lazy loading, delegation
Descriptorsone attribute, for every instance of a classvalidated fields, computed attributes, ORMs
__init_subclass__what happens when someone subclasses youplugin registries, subclass validation
Class decoratorsa class after it is built@dataclass, registration, adding methods
Metaclasseshow the class itself is builtABCMeta, EnumType, custom namespaces

The attribute lookup algorithm

Everything in this module hangs on one algorithm: what Python does when you write obj.name. That expression calls type(obj).__getattribute__(obj, "name"). For ordinary objects that is object.__getattribute__, which implements the following precedence. First, two definitions:

obj.name __getattribute__ 1. search type(obj).__mro__ found a DATA descriptor? yes: return desc.__get__(obj, type) 2. look in obj.__dict__ key present? yes: return the stored value 3. class attribute from step 1? non-data descriptor or plain descriptor: desc.__get__(obj, type) plain value: return it 4. raise AttributeError caught if __getattr__ exists return type(obj).__getattr__(obj, name) no no no
The precedence implemented by object.__getattribute__. Data descriptors beat the instance dictionary; the instance dictionary beats non-data descriptors; __getattr__ is the last resort.

Why this specific order? It lets property (a data descriptor) protect an attribute even if someone stuffs a same-named key into obj.__dict__, while letting an instance shadow a method (a non-data descriptor) by assigning to it. It is also why functools.cached_property works: it is a non-data descriptor that computes once and writes the result into the instance dictionary, so the next lookup stops at step 2 and never calls the descriptor again.

Assignment follows a shorter path: obj.name = v calls type(obj).__setattr__, which looks for a data descriptor on the type and calls its __set__; otherwise it writes to obj.__dict__.

__getattr__ vs __getattribute__

These two names are easy to confuse and behave very differently:

__getattr__(self, name)

A fallback, called only after normal lookup raises AttributeError. Cheap and safe: existing attributes are unaffected. Ideal for delegation and proxies. It must raise AttributeError for names it doesn't handle, or hasattr, getattr(obj, name, default), copy, and pickle all misbehave.

__getattribute__(self, name)

Intercepts every attribute access, including self.__dict__ and method lookups inside your own methods. Easy to recurse infinitely; to reach real attributes call super().__getattribute__(name). Every access pays for your Python code, so it is slow. Rarely the right choice.

Remember: data descriptor, then instance dict, then non-data descriptor or class attribute, then __getattr__. Special methods are looked up on the type.

Descriptors: reusable attribute logic

A property customizes one attribute of one class. When you need the same logic (say, "must be a positive int") on many attributes across many classes, you write a descriptor class once and instantiate it as a class attribute wherever you need it. The protocol has four methods:

MethodCalled whenArguments
__get__(self, obj, objtype=None)obj.attr or Cls.attrobj is the instance, or None for class access
__set__(self, obj, value)obj.attr = valuenever called for class-level assignment
__delete__(self, obj)del obj.attr
__set_name__(self, owner, name)once, when the owning class is createdthe class and the attribute name

The key design question is where to store the per-instance value. The descriptor object is shared by every instance (it lives on the class), so storing self.value = value on the descriptor would make every instance share one value, a classic bug. The standard answers:

__set_name__ (added in 3.6) solved the old annoyance of repeating the attribute name: age = Positive("age") becomes simply age = Positive().

Step through a smaller version to watch __set_name__ run at class-creation time (before any instance exists) and __set__ run during __init__:

Remember: a descriptor is shared by all instances, so per-instance state goes on the instance. __set_name__ tells the descriptor its name. Data descriptors are what make validation unavoidable.

Methods, properties, and classmethods are descriptors

Descriptors are not an exotic add-on; they are how Python's object model works. A function defined in a class body is stored in the class __dict__ as a plain function. Functions have a __get__ method, so they are non-data descriptors. When you access obj.method, step 3 of the lookup calls function.__get__(obj, type(obj)), which returns a bound method: a small object holding the function (__func__) and the instance (__self__). Calling it calls func(obj, *args). That is the entire mechanism behind self.

The other built-in method decorators are descriptors that bind differently:

DescriptorKind__get__(obj, cls) returns
plain functionnon-datamethod bound to obj (or the function itself when obj is None)
staticmethod(f)non-dataf unchanged: no binding at all
classmethod(f)non-dataf bound to cls, whether accessed from an instance or the class
property(fget, fset, fdel)data (always defines __set__)fget(obj); __set__ raises AttributeError when there is no setter
functools.cached_property(f)non-datacomputes f(obj) once and stores it in obj.__dict__
__slots__ memberdatathe value in the instance's fixed slot

To demystify property, here is a working pure-Python version. CPython's is written in C, but it behaves the same way:

Remember: self is not a keyword; it is the instance a function was bound to by function.__get__. property is a data descriptor; methods and cached_property are non-data descriptors.

__slots__: fixed attribute layouts

By default every instance can carry a __dict__, a hash table that lets you add any attribute at any time, plus a __weakref__ slot. That flexibility costs memory. Recent CPython versions soften it (ordinary instances store attributes "inline" with a key table shared across the class, and a real dict is only materialized if something asks for __dict__), but the overhead is still measurable, and when you create millions of small objects (points, graph nodes, parsed records) it adds up.

Declaring __slots__ = ("x", "y") tells type to allocate a fixed array of references in each instance instead of a dict, and to create a member descriptor on the class for each name (a data descriptor that reads and writes a fixed offset in the object). Consequences:

The trade-offs

GotchaWhyWhat to do
Subclass without __slots__ gets a __dict__ backSlots are per class; any class in the MRO lacking them adds a dictDeclare __slots__ (possibly ()) in every subclass
No __weakref__Weak references need their own slotAdd "__weakref__" to __slots__
cached_property failsIt writes to obj.__dict__, which doesn't existAdd "__dict__" to slots, or cache manually in a slot
Class attribute with a slot's nameA class attribute x = 0 would replace the member descriptor; Python raises ValueErrorSet defaults in __init__
Multiple inheritance layout conflictTwo bases with non-empty slots have incompatible C layoutsOnly one base may have non-empty slots; use __slots__ = () in mixins
Pickling, vars(), generic toolingCode that expects __dict__ breaksModern pickle handles slots; check other tools

Remember: __slots__ trades flexibility for memory. It replaces the per-instance dict with member descriptors, and every class in the hierarchy must cooperate.

__init_subclass__: hooking subclass creation

A very common reason people historically wrote metaclasses was "do something whenever a subclass is defined": register it, validate it, or configure it. PEP 487 (3.6) added a much simpler hook: __init_subclass__(cls, **kwargs), an implicit classmethod on the parent that runs after each subclass is created. Keyword arguments in the class statement (class Csv(Exporter, fmt="csv")) are passed to it.

Notice that the subclass registers itself just by being defined. That's why plugin systems often say "import your module to activate it", and why such registration is a form of import-time side effect: test isolation and import order now matter. Validation at class-definition time is a big win, though: the error appears when the module is imported, not hours later when a rarely used code path runs.

Remember: __init_subclass__ lives on the parent and runs once per subclass. It covers most registration and validation use cases that used to need a metaclass.

Class decorators

A class decorator is a callable that takes a fully built class and returns a class (usually the same one, modified). It runs once, after the class body and all class-creation hooks. Unlike __init_subclass__, it is opt-in per class and is not inherited: a subclass of a decorated class is not automatically decorated. @dataclass, @functools.total_ordering, and @enum.unique are all class decorators.

This is the same strategy dataclasses uses, except that it reads the class's annotations and generates source code for __init__ that it compiles with exec, so the generated methods run as fast as hand-written ones.

Metaclasses: customizing class creation

Since a class is an object, something must construct it. When Python executes a class statement, it gathers the name, the bases, and a namespace dictionary filled by running the body, then calls the metaclass with them. The default metaclass is type, and you can call it directly with the three-argument form type(name, bases, namespace):

The full class-creation sequence

For class C(Base, metaclass=M, flag=True): body, Python does the following (simplified from the data model reference):

  1. Resolve bases (including __mro_entries__, which is how list[int] and Generic[T] can appear as bases).
  2. Determine the metaclass: the explicit metaclass=, otherwise the most derived metaclass among the bases' types. If two bases have unrelated metaclasses, you get a metaclass conflict TypeError.
  3. Prepare the namespace: ns = M.__prepare__("C", bases, flag=True). The default returns an empty dict.
  4. Execute the body in ns. Each def and assignment becomes a key.
  5. Create the class: C = M("C", bases, ns, flag=True), which calls M.__new__ then M.__init__. Inside type.__new__, Python calls __set_name__ on every descriptor in the namespace and then the parent's __init_subclass__.
  6. Apply class decorators, innermost first, and bind the result to the name C.

What only a metaclass can do

Most class customization no longer needs a metaclass. What remains:

Remember: class creation is __prepare__ → body → metaclass.__new__ (which triggers __set_name__ and __init_subclass__) → __init__ → decorators.

Dynamic access, inspect, and annotationlib

Introspection is examining objects at runtime. The basic tools are built-ins: getattr(obj, name[, default]), setattr, hasattr, delattr, vars(obj) (the __dict__), and dir(obj) (all reachable names). They let you choose an attribute by a string computed at runtime, which is how command dispatchers, serializers, and test runners work.

The inspect module goes deeper: inspect.signature returns a Signature with typed Parameter objects (kind, default, annotation), Signature.bind validates arguments exactly like a real call would, getmembers lists attributes with a predicate, and isfunction/ismethod/ isclass/iscoroutinefunction classify objects. Frameworks like pytest (fixture injection by parameter name) and FastAPI (request parsing from type hints) are built on these.

Annotations in 3.14: deferred evaluation and annotationlib

For metaprogramming this matters in two ways. Libraries that read annotations at class-creation time (dataclasses, ORMs, validators) should use annotationlib.get_annotations(cls, format=Format.FORWARDREF) so that a not-yet-defined name doesn't crash class creation. And reading cls.__annotations__ directly now runs code (the __annotate__ function), so it can raise. Prefer annotationlib.get_annotations (or inspect.get_annotations) over poking at dunder attributes.

Practice

Write a data descriptor Positive that uses __set_name__ to learn its attribute name, stores values in the instance's __dict__, and raises ValueError when a value is not greater than 0 (also raise TypeError for non-numbers, including bool). Class access (Order.qty) must return the descriptor itself.

Hint

In __get__, return self when obj is None. Check isinstance(value, bool) or not isinstance(value, (int, float)) for the type test.

Show solution
class Positive:
    def __set_name__(self, owner, name):
        self.name = name

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return obj.__dict__[self.name]

    def __set__(self, obj, value):
        if isinstance(value, bool) or not isinstance(value, (int, float)):
            raise TypeError(f"{self.name} must be a number")
        if value <= 0:
            raise ValueError(f"{self.name} must be > 0")
        obj.__dict__[self.name] = value


class Order:
    qty = Positive()
    price = Positive()

    def __init__(self, qty, price):
        self.qty = qty
        self.price = price

Complete Command so that every subclass defined with a name= keyword is stored in Command.registry under that name. Subclasses without name= are abstract and are not registered. Registering the same name twice raises ValueError. Write run(name, *args) that instantiates the registered class and returns .execute(*args), raising KeyError for unknown names.

Hint

Call super().__init_subclass__(**kwargs) first, then return early if name is None.

Show solution
class Command:
    registry = {}

    def __init_subclass__(cls, name=None, **kwargs):
        super().__init_subclass__(**kwargs)
        if name is None:
            return
        if name in Command.registry:
            raise ValueError(f"command {name!r} already registered")
        Command.registry[name] = cls

    def execute(self, *args):
        raise NotImplementedError


def run(name, *args):
    return Command.registry[name]().execute(*args)


class Greet(Command, name="greet"):
    def execute(self, who):
        return f"hello {who}"

Write Config(data) wrapping a (possibly nested) dict so that cfg.db.host reads data["db"]["host"]. Nested dicts must come back wrapped as Config. Missing keys must raise AttributeError (so hasattr and getattr(cfg, k, default) work). Use __getattr__, not __getattribute__. Also make Config read-only: assigning any attribute other than in __init__ raises AttributeError.

Hint

Because you override __setattr__, store the dict with object.__setattr__(self, "_data", data). In __getattr__, convert KeyError into AttributeError.

Show solution
class Config:
    def __init__(self, data):
        object.__setattr__(self, "_data", data)

    def __getattr__(self, name):
        try:
            value = self._data[name]
        except KeyError:
            raise AttributeError(name) from None
        return Config(value) if isinstance(value, dict) else value

    def __setattr__(self, name, value):
        raise AttributeError("Config is read-only")

Write a class decorator auto_init that reads the class's annotations with annotationlib.get_annotations(cls) and adds an __init__ accepting those names as keyword-only arguments, in declaration order. A class attribute with the same name provides a default. Missing required arguments raise TypeError. Also add a classmethod-like attribute cls.__fields__, a tuple of the field names.

Hint

Define def __init__(self, **kwargs) inside the decorator, loop over the field names, and check for leftovers with set(kwargs) - set(fields). Defaults: cls.__dict__.get(name, MISSING) with a sentinel MISSING = object().

Show solution
import annotationlib

MISSING = object()

def auto_init(cls):
    fields = tuple(annotationlib.get_annotations(cls))
    defaults = {f: cls.__dict__.get(f, MISSING) for f in fields}

    def __init__(self, *args, **kwargs):
        if args:
            raise TypeError(f"{cls.__name__}() takes keyword arguments only")
        unknown = set(kwargs) - set(fields)
        if unknown:
            raise TypeError(f"unexpected arguments: {sorted(unknown)}")
        for f in fields:
            if f in kwargs:
                value = kwargs[f]
            elif defaults[f] is not MISSING:
                value = defaults[f]
            else:
                raise TypeError(f"missing required argument {f!r}")
            setattr(self, f, value)

    cls.__init__ = __init__
    cls.__fields__ = fields
    return cls

@auto_init
class User:
    name: str
    email: str
    active: bool = True

Write a metaclass ModelMeta whose __new__ collects every class attribute that is an instance of Field into cls._fields, a dict from name to field in definition order, including fields inherited from bases (base fields first; a subclass can override a base field by redefining it). Also give ModelMeta a __len__ so len(SomeModel) returns the number of fields.

Hint

Build the class with super().__new__, then walk reversed(cls.__mro__[1:]) merging each base's _fields (use getattr(base, "_fields", {})), and finally add this class's own fields from ns.

Show solution
class Field:
    def __init__(self, kind):
        self.kind = kind

class ModelMeta(type):
    def __new__(mcls, name, bases, ns):
        cls = super().__new__(mcls, name, bases, ns)
        fields = {}
        for base in reversed(cls.__mro__[1:]):
            fields.update(base.__dict__.get("_fields", {}))
        fields.update({k: v for k, v in ns.items() if isinstance(v, Field)})
        cls._fields = fields
        return cls

    def __len__(cls):
        return len(cls._fields)

class Model(metaclass=ModelMeta):
    pass

class Person(Model):
    name = Field(str)
    age = Field(int)

class Employee(Person):
    salary = Field(float)
    age = Field(float)

Mini-project: a tiny ORM-style model layer

Mini-projectorm-lite: models, fields, and validation

Libraries like Django's ORM and Pydantic let you declare a model as a class with typed fields and get validation, serialization, and querying for free. Build a miniature version using only the hooks from this module: descriptors for fields, __set_name__ for names, __init_subclass__ to collect fields, and __slots__-free instances backed by __dict__.

pixi init orm-lite
cd orm-lite
pixi add python=3.14 pytest
mkdir -p src tests
pixi run python -m pytest -q

Specification

  • Field(default=MISSING, *, required=True) base data descriptor with validate() and to_python() hooks; subclasses IntField(min=None, max=None), StrField(max_length=None, pattern=None), BoolField(), and ChoiceField(choices).
  • Model.__init_subclass__ builds cls._fields in definition order, including inherited fields.
  • Model(**kwargs) assigns each field (so validation runs), applies defaults, and raises TypeError for unknown or missing required fields. Errors from all fields are collected into one ValidationError with a .errors dict, not just the first one.
  • __repr__, __eq__, to_dict(), and Model.from_dict(d).
  • A class-level in-memory table: Model.objects.add(obj), .all(), and .filter(**criteria); also support .filter(Product.price > 100) by making class-level field access return the descriptor and overloading comparisons on it to build predicate objects.

Starting point

MISSING = object()

class Field:
    def __init__(self, default=MISSING, *, required=True):
        self.default, self.required = default, required

    def __set_name__(self, owner, name):
        self.name = name

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return obj.__dict__.get(self.name, self.default)

    def __set__(self, obj, value):
        obj.__dict__[self.name] = self.validate(self.to_python(value))

    def to_python(self, value):
        return value

    def validate(self, value):
        return value

class Model:
    _fields: dict[str, Field] = {}

    def __init_subclass__(cls, **kwargs):
        super().__init_subclass__(**kwargs)
        ...  # collect Field instances from the MRO into cls._fields

Acceptance criteria

  • class Product(Model): name = StrField(max_length=20); price = IntField(min=0) defines a model with Product._fields in order
  • Product(name="pen", price=-1) raises ValidationError naming price
  • Several invalid fields are all reported in ValidationError.errors
  • Assigning p.price = "x" after construction is also validated
  • Product.from_dict(p.to_dict()) == p
  • Product.objects.filter(Product.price > 100) returns the matching instances
  • A pytest suite with at least 12 tests passes via pixi run python -m pytest

Stretch goals

  • Read field types from annotations (price: int) using annotationlib.get_annotations(cls, format=Format.FORWARDREF), so forward references don't crash.
  • A ForeignKey(OtherModel) field that accepts an instance or an id.
  • Persist to sqlite3 by generating CREATE TABLE from _fields (see M40).
  • Compare memory with and without __slots__ on the model instances (and explain why slots complicate this design).

Quiz

Recap

Going further