Chapter 64 — Metaprogramming: Code That Writes and Inspects Code

Series A5 — Expert Python

NoteIn this chapter, you will:
  • Understand descriptors: the machinery behind property, methods, and many Python “magic tricks”
  • Use __init_subclass__ and metaclasses to control how classes are made
  • Inspect a program from the inside with the inspect module (introspection)
  • Read and change code as a tree with the ast module
  • Build a mini schema framework for field data: the Series A5 project

All scripts for this chapter are in the kode/ch64/ folder. On the web version, you can also download them all at once (zip).

By now you have built a complete system, from the field to AI. Series A5 takes you down to the engine room. Picture a foreman on a farm. An ordinary foreman plants and harvests. An expert foreman also writes planting rules: “every new seedling must be recorded”, “every plot must carry a name board”. That is metaprogramming: code that controls, inspects, or writes other code. You already use it every day: @property, @dataclass, and Pydantic models all live on the techniques in this chapter. All behaviour below was run on Python 3.14.8; the attribute lookup rules were checked against the official guide (docs.python.org, Descriptor HowTo, read 2026-10-10).

Descriptors: a gatekeeper for attributes

A descriptor is an object that has a __get__, __set__, or __delete__ method. When such an object is placed as an attribute of a class, Python calls its methods every time that attribute is read or assigned. Think of a gatekeeper at a warehouse: everything that goes in or out is checked first.

Two chains worth knowing: the attribute lookup order and the class chain.
01_descriptor_basics.py
# Chapter 64 - Descriptors: a gatekeeper for attributes
class Positive:
    """Descriptor: the value must be a number greater than zero."""

    def __set_name__(self, cls, name):
        self.name = "_" + name          # where the value is stored on the object

    def __get__(self, obj, cls=None):
        if obj is None:                 # accessed through the class: Tree.diameter_cm
            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 a positive number, not {value!r}")
        setattr(obj, self.name, value)


class Tree:
    diameter_cm = Positive()
    height_m = Positive()

    def __init__(self, species, diameter_cm, height_m):
        self.species = species
        self.diameter_cm = diameter_cm   # triggers Positive.__set__
        self.height_m = height_m


teak = Tree("Teak", 32.5, 18.0)
print(teak.species, teak.diameter_cm, teak.height_m)

try:
    teak.diameter_cm = -5
except ValueError as e:
    print("Rejected:", e)

try:
    Tree("Meranti", 45.2, "height")
except ValueError as e:
    print("Rejected:", e)

print(type(Tree.diameter_cm).__name__)    # through the class, we get the descriptor itself
print(sorted(vars(teak)))                 # data is stored under names starting with _
Teak 32.5 18.0
Rejected: diameter_cm must be a positive number, not -5
Rejected: height_m must be a positive number, not 'height'
Positive
['_diameter_cm', '_height_m', 'species']
  • __set_name__ is called once when the class is made and tells the descriptor its attribute name (diameter_cm), so one Positive descriptor can serve many attributes.
  • __set__ checks the value. That is why teak.diameter_cm = -5 is rejected, and also when the initial value comes from __init__.
  • __get__ receives obj=None when the attribute is accessed through the class (Tree.diameter_cm), so we return the descriptor itself; that is why the second line from the bottom prints Positive.
  • Data is stored on the object under names starting with _: the descriptor lives on the class and is shared by all objects, so per-object values must be kept on the object.

Functions and properties are descriptors too

02_functions_are_descriptors.py
# Chapter 64 - Functions and properties are descriptors too
class Pond:
    def __init__(self, fish_count, avg_weight_g):
        self.fish_count = fish_count
        self.avg_weight_g = avg_weight_g

    def biomass_kg(self):
        return self.fish_count * self.avg_weight_g / 1000

    @property
    def stocking_density(self):
        return self.fish_count / 100        # assume the pond is 100 m2


pond = Pond(800, 540.5)

# 1. A function inside a class has __get__: that is what makes a "bound method"
function = Pond.__dict__["biomass_kg"]
print(type(function).__name__, hasattr(function, "__get__"))
bound = function.__get__(pond, Pond)        # exactly what pond.biomass_kg does
print(type(bound).__name__, bound())

# 2. property is a data descriptor (it has __set__ even if you did not fill it in)
print(type(Pond.__dict__["stocking_density"]).__name__, pond.stocking_density)
try:
    pond.stocking_density = 5
except AttributeError as e:
    print("AttributeError:", e)

# 3. Lookup order: a DATA descriptor WINS over the object's __dict__,
#    while a plain function (non-data) LOSES to the object's __dict__.
pond.__dict__["biomass_kg"] = lambda: "from the object's __dict__"
print(pond.biomass_kg())                    # the object's __dict__ beats the function
pond.__dict__["stocking_density"] = 999
print(pond.stocking_density)                # the property still wins
function True
method 432.4
property 8.0
AttributeError: property 'stocking_density' of 'Pond' object has no setter
from the object's __dict__
8.0

This is the secret of “bound methods”. A plain function inside a class has __get__; when you write pond.biomass_kg, Python calls function.__get__(pond, Pond) and the result is a method that already “holds” pond as self (the line method 432.4). property turns out to be a full descriptor (it has __set__), so assigning to it raises the same AttributeError as the official message.

From here comes an important order rule: a data descriptor (has __set__) wins over the object’s __dict__; a non-data descriptor (only __get__, like a plain function) loses to the object’s __dict__. The last two output lines prove it: the biomass_kg function can be “overridden” through __dict__, but stocking_density (a property) still wins.

NoteREMEMBER

A descriptor must be placed on the class, not on the object. A descriptor put in self.x = Positive() inside __init__ is never called at all.

__init_subclass__: child classes register themselves

Often we want every child class to be registered automatically or checked. People used to use metaclasses for this; since Python 3.6 there is a much simpler way: the __init_subclass__ method on the parent class is called every time a subclass is made.

03_init_subclass.py
# Chapter 64 - __init_subclass__: child classes register themselves
class Saver:
    registry = {}                            # extension -> saver class

    def __init_subclass__(cls, extension=None, **kwargs):
        super().__init_subclass__(**kwargs)
        if extension is None:
            raise TypeError(f"{cls.__name__} must give extension=...")
        cls.extension = extension
        Saver.registry[extension] = cls      # registered automatically when the class is made

    @classmethod
    def for_file(cls, file_name):
        extension = file_name.rsplit(".", 1)[-1]
        return cls.registry[extension]()


class CsvSaver(Saver, extension="csv"):
    def save(self, rows):
        return "\n".join(",".join(str(x) for x in r) for r in rows)


class TsvSaver(Saver, extension="tsv"):
    def save(self, rows):
        return "\n".join("\t".join(str(x) for x in r) for r in rows)


print(sorted(Saver.registry))
data = [("Teak", 32.5), ("Meranti", 45.2)]
print(Saver.for_file("trees.csv").save(data))
print(repr(Saver.for_file("trees.tsv").save(data)))

try:
    class BrokenSaver(Saver):                # forgot to give an extension
        pass
except TypeError as e:
    print("TypeError:", e)
['csv', 'tsv']
Teak,32.5
Meranti,45.2
'Teak\t32.5\nMeranti\t45.2'
TypeError: BrokenSaver must give extension=...

Notice class CsvSaver(Saver, extension="csv"): the keyword argument on the class line is passed on to __init_subclass__. As soon as the class is finished, it is already in Saver.registry, without anyone calling a registration function. A class that forgot to give extension is rejected when it is defined, not when it is used. This “self-registering plugin” pattern is common for file formats, sensor types, or report types.

Metaclasses: a factory that makes classes

In Python, a class is also an object, and something makes it: type. It is called a metaclass, “the class of a class”.

The class chain: an object, its class, and the metaclass type.
04_metaclass.py
# Chapter 64 - Metaclass: a factory that makes classes
# 1. A class is also an object; its maker is called type
print(type(3).__name__, type(int).__name__, type(type).__name__)

# 2. type(name, bases, contents) makes a class dynamically
Cow = type("Cow", (), {"kind": "cow", "sound": lambda self: "moo"})
print(Cow.__name__, Cow().sound())


# 3. Your own metaclass: controls how ALL its subclasses are made
class RequireDocstring(type):
    def __new__(mcs, name, bases, body):
        for key, value in body.items():
            if callable(value) and not key.startswith("_") and not value.__doc__:
                raise TypeError(f"method {name}.{key} has no docstring yet")
        body["made_by"] = mcs.__name__
        return super().__new__(mcs, name, bases, body)


class Sensor(metaclass=RequireDocstring):
    def read(self):
        """Return one reading."""
        return 27.5


print(Sensor().read(), Sensor.made_by, type(Sensor).__name__)

try:
    class LazySensor(metaclass=RequireDocstring):
        def read(self):
            return 0
except TypeError as e:
    print("TypeError:", e)
int type type
Cow moo
27.5 RequireDocstring RequireDocstring
TypeError: method LazySensor.read has no docstring yet
  • First line: type(3) is int, type(int) is type, and type(type) is type itself: the chain ends there.
  • type("Cow", (), {...}) makes a class while the program runs, without the word class.
  • RequireDocstring is a metaclass: its code runs every time a class that uses it is made, and may reject or change the class contents. Here it rejects public methods without a docstring and adds an attribute made_by.
TipTIP

Order of choosing tools: first use a class decorator or __init_subclass__; use a descriptor for per-attribute logic; only then a metaclass, when you must change how the class is made (for example, preparing the namespace before the class body runs). Metaclasses are powerful but make code hard to read and can clash when two libraries use different metaclasses.

Introspection: a program inspects itself

Introspection is a program’s ability to ask about its own structure: names, parameters, documentation, who called it. The inspect module is the toolbox.

05_introspection.py
# Chapter 64 - Introspection: a program inspects itself
import inspect


class Plant:
    """A garden plant with a harvest."""

    def __init__(self, commodity: str, yield_kg: float = 0.0):
        self.commodity = commodity
        self.yield_kg = yield_kg

    def harvest(self, extra_kg: float, *, note: str = "") -> float:
        """Add to the harvest and return the total."""
        self.yield_kg += extra_kg
        return self.yield_kg


# 1. The signature and the annotations
signature = inspect.signature(Plant.harvest)
print(signature)
for name, p in signature.parameters.items():
    print(f"  {name}: kind={p.kind.name}, default={p.default!r}")

# 2. What is inside the class
methods = [n for n, f in inspect.getmembers(Plant, inspect.isfunction)]
print(methods)
print(inspect.getdoc(Plant))

# 3. Source code (only when the file exists)
print(inspect.getsource(Plant.harvest).splitlines()[0].strip())

# 4. Binding arguments by the names read from the signature
bound = inspect.signature(Plant).bind("coffee", yield_kg=12.5)
print(bound.arguments)

# 5. Who is calling me? (the call stack frames)
def who_am_i():
    return inspect.stack()[0].function, inspect.stack()[1].function


def caller():
    return who_am_i()


print(caller())
(self, extra_kg: float, *, note: str = '') -> float
  self: kind=POSITIONAL_OR_KEYWORD, default=<class 'inspect._empty'>
  extra_kg: kind=POSITIONAL_OR_KEYWORD, default=<class 'inspect._empty'>
  note: kind=KEYWORD_ONLY, default=''
['__init__', 'harvest']
A garden plant with a harvest.
def harvest(self, extra_kg: float, *, note: str = "") -> float:
{'commodity': 'coffee', 'yield_kg': 12.5}
('who_am_i', 'caller')
  • inspect.signature gives the function’s signature: extra_kg can be given by position or by name, while note after the * can only be given by name (KEYWORD_ONLY).
  • getmembers with the isfunction filter finds __init__ and harvest; getdoc reads the class docstring.
  • getsource reads the source code from the file (it fails for code made with exec).
  • signature(...).bind matches arguments to parameter names. Libraries such as FastAPI use this technique to translate the content of a web request into function parameters.
  • inspect.stack() walks the call stack: who_am_i finds out that its caller is caller.
WarningWARNING

inspect.stack() is slow because it reads source files for every frame. Use it in debugging helpers, not inside a loop called millions of times.

AST: reading code as a tree

Before running, Python turns the code text into an Abstract Syntax Tree (AST): each operation becomes a node of the tree. The ast module opens that tree for us.

06_ast_reading.py
# Chapter 64 - AST: reading code as a tree
import ast

# 1. One small formula becomes a tree
tree = ast.parse("biomass = count * weight / 1000", mode="exec")
print(ast.dump(tree, indent=2))

# 2. Walking the tree: how many functions and calls are in some code?
code = '''
def compute(a, b):
    return average(a) + average(b)

def average(x):
    return sum(x) / len(x)

print(compute([1, 2], [3, 4]))
'''


class Counter(ast.NodeVisitor):
    def __init__(self):
        self.functions = []
        self.calls = []

    def visit_FunctionDef(self, node):
        self.functions.append(node.name)
        self.generic_visit(node)             # keep walking into the body

    def visit_Call(self, node):
        if isinstance(node.func, ast.Name):
            self.calls.append(node.func.id)
        self.generic_visit(node)


c = Counter()
c.visit(ast.parse(code))
print("functions:", c.functions)
print("calls    :", sorted(c.calls))
Module(
  body=[
    Assign(
      targets=[
        Name(id='biomass', ctx=Store())],
      value=BinOp(
        left=BinOp(
          left=Name(id='count', ctx=Load()),
          op=Mult(),
          right=Name(id='weight', ctx=Load())),
        op=Div(),
        right=Constant(value=1000)))])
functions: ['compute', 'average']
calls    : ['average', 'average', 'compute', 'len', 'print', 'sum']

The formula count * weight / 1000 becomes a tree: the root is the division (Div), its left child is the multiplication (Mult), its right child is the constant 1000. That tree shape is what explains why multiplication is done first. NodeVisitor walks the tree and calls visit_<NodeName> for each node; here it finds two functions and six calls (average twice, sum, len, compute, print). Tools such as ruff, pytest (which rewrites assert), and type checkers all work with the AST.

Changing code, and computing formulas safely

07_ast_changing_safely.py
# Chapter 64 - AST: changing code, and computing formulas safely
import ast
import operator

# 1. NodeTransformer: replace every number 1000 (grams to kg) with the name GRAMS_PER_KG
class ReplaceConstant(ast.NodeTransformer):
    def visit_Constant(self, node):
        if node.value == 1000:
            return ast.copy_location(ast.Name(id="GRAMS_PER_KG", ctx=ast.Load()), node)
        return node


tree = ast.parse("biomass = count * weight / 1000", mode="exec")
tree = ast.fix_missing_locations(ReplaceConstant().visit(tree))
print(ast.unparse(tree))

environment = {"count": 800, "weight": 540.5, "GRAMS_PER_KG": 1000}
exec(compile(tree, "<ast>", "exec"), environment)    # safe: we built the code ourselves
print(environment["biomass"])

# 2. Never eval() a formula from a user; use an allow-list of operations
OPERATIONS = {ast.Add: operator.add, ast.Sub: operator.sub,
              ast.Mult: operator.mul, ast.Div: operator.truediv}


def safe_calculate(text, variables):
    def evaluate(n):
        if isinstance(n, ast.Expression):
            return evaluate(n.body)
        if isinstance(n, ast.Constant) and isinstance(n.value, (int, float)):
            return n.value
        if isinstance(n, ast.Name) and n.id in variables:
            return variables[n.id]
        if isinstance(n, ast.BinOp) and type(n.op) in OPERATIONS:
            return OPERATIONS[type(n.op)](evaluate(n.left), evaluate(n.right))
        raise ValueError(f"not allowed: {type(n).__name__}")
    return evaluate(ast.parse(text, mode="eval"))


data = {"count": 800, "weight": 540.5}
print(safe_calculate("count * weight / 1000", data))
for nasty in ["__import__('os').getcwd()", "count ** 99999", "weight.real"]:
    try:
        print(safe_calculate(nasty, data))
    except ValueError as e:
        print("Rejected:", e)
biomass = count * weight / GRAMS_PER_KG
432.4
432.4
Rejected: not allowed: Call
Rejected: not allowed: BinOp
Rejected: not allowed: Attribute

The first part uses a NodeTransformer: a Constant node with value 1000 is replaced by Name("GRAMS_PER_KG"); ast.unparse turns the tree back into text, and compile + exec runs it (result 432.4, same as the original).

The second part matters for security. If users may type a formula (for example on a dashboard), do not use eval(): it runs any code. We read the formula as an AST and allow only nodes from an allow-list: numbers, the variable names we provide, and four arithmetic operations. Function calls (__import__(...)), exponentiation (**, which can eat memory), and attribute access are rejected.

WarningWARNING

The official ast documentation warns that parsing a very large or complex string can crash the interpreter because of stack limits, and that even ast.literal_eval is “not recommended” for untrusted data (source: docs.python.org/3/library/ast.html, read 2026-10-10). An allow-list alone is not enough if the text can be huge: limit the input length.

Running Project A5: a mini schema framework

In Series A5 our project gets faster, more distributed, and finally summarised as a portfolio. The first step: understand how a validation library such as Pydantic (Chapter 34) works by building your own mini version from everything you just learned. Descriptors (Number, Choice) check each field; __init_subclass__ collects the fields of each model; no metaclass needed.

08_project_schema.py
# Chapter 64 - Project A5: a mini schema framework for field data
class Field:
    """Base descriptor: stores the value and checks it through check()."""

    def __init__(self, required=True):
        self.required = required

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

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

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

    def check(self, value):
        return value


class Number(Field):
    def __init__(self, minimum=None, maximum=None, **kw):
        super().__init__(**kw)
        self.minimum, self.maximum = minimum, maximum

    def check(self, value):
        if isinstance(value, bool) or not isinstance(value, (int, float)):
            raise ValueError(f"{self.name}: must be a number, not {value!r}")
        if self.minimum is not None and value < self.minimum:
            raise ValueError(f"{self.name}: {value} is below {self.minimum}")
        if self.maximum is not None and value > self.maximum:
            raise ValueError(f"{self.name}: {value} is above {self.maximum}")
        return value


class Choice(Field):
    def __init__(self, *choices, **kw):
        super().__init__(**kw)
        self.choices = choices

    def check(self, value):
        if value not in self.choices:
            raise ValueError(f"{self.name}: {value!r} is not one of {list(self.choices)}")
        return value


class Model:
    fields = {}

    def __init_subclass__(cls, **kw):
        super().__init_subclass__(**kw)
        cls.fields = {n: f for n, f in vars(cls).items() if isinstance(f, Field)}

    def __init__(self, **data):
        for name, f in self.fields.items():
            if name in data:
                setattr(self, name, data.pop(name))
            elif f.required:
                raise ValueError(f"{name}: is required")
        if data:
            raise ValueError(f"unknown fields: {sorted(data)}")

    def to_dict(self):
        return {n: getattr(self, n) for n in self.fields}


class Tree(Model):
    species = Choice("Teak", "Meranti", "Mahogany", "Sengon")
    diameter_cm = Number(minimum=1, maximum=300)
    height_m = Number(minimum=0.5, maximum=80, required=False)


print(list(Tree.fields))
print(Tree(species="Teak", diameter_cm=32.5).to_dict())
for broken in [dict(species="Pine", diameter_cm=20), dict(species="Teak", diameter_cm=900),
               dict(species="Teak"), dict(species="Teak", diameter_cm=20, color="green")]:
    try:
        Tree(**broken)
    except ValueError as e:
        print("Rejected:", e)
['species', 'diameter_cm', 'height_m']
{'species': 'Teak', 'diameter_cm': 32.5, 'height_m': None}
Rejected: species: 'Pine' is not one of ['Teak', 'Meranti', 'Mahogany', 'Sengon']
Rejected: diameter_cm: 900 is above 300
Rejected: diameter_cm: is required
Rejected: unknown fields: ['color']

Tree holds only three lines of fields, and we get: type and range validation, required vs optional fields (height_m is None when not given), and rejection of unknown fields. The error messages name the field thanks to __set_name__. Pydantic is far more complete (type conversion, JSON, speed), but the basic idea is the same.

Exercises

  1. Make a descriptor Text(max_length) that rejects text longer than the limit and trims spaces at the ends. Attach it to the field name of a Plot class.
  2. Write an __init_subclass__ on a Report class that rejects subclasses without a summarise method. (Hint: hasattr(cls, "summarise").)
  3. With ast.NodeVisitor, count how many times print is called in a .py file of your choice, and show the line numbers (node.lineno).
  4. Challenge: add a Date field to the project framework that accepts the text "2026-10-10" and stores it as a datetime.date, plus a Model.from_dict(data) method that creates an object from a dictionary.

Chapter Summary

  • Descriptors (__get__/__set__/__set_name__) control attribute access; functions, property, and classmethod are descriptors.
  • Data descriptors beat the object’s __dict__; non-data descriptors lose to it.
  • __init_subclass__ runs code whenever a subclass is made; enough for most needs that once used metaclasses.
  • Metaclasses (type and its subclasses) control the making of classes; use them when simpler tools are not enough.
  • inspect reads signatures, members, source, and the call stack.
  • ast turns code into a tree that can be read and changed; user formulas are computed with a node allow-list, not eval().

Next, in Chapter 65: we look into the CPython engine room: how objects are stored, what bytecode is, when memory garbage is cleaned, and the fate of the GIL.