Lesson 13 · The capstone
User-supplied methods, end to end
The feature that consumes lessons 5, 7, 8, 11 and 12 at once. It starts with a decision that removes most of the work, and the rest of the lesson is about the cases where you cannot take it.
The decision that comes first
“Arbitrary user code on generated classes” has three candidate designs, and they are not equally priced:
| Inherit delegate |
The user writes a normal class in their own module. You emit
class Invoice(_InvoiceGenerated, InvoiceMethods) and let Python's MRO do the composition.
The code never moves. |
| Move splice |
You lift the FunctionDef out of the user's source and emit it into the generated class
body. Lessons 7 and 8 in full. |
| Synthesize derive |
The user writes no body at all; they name a recipe and you generate the method from the fields.
fake() is this one. |
Take inherit wherever it fits, and notice why it is cheap rather than just convenient. When the code does not move, its free names still resolve in the module where they were written — so referential transparency, direction 2 of lesson 8's hygiene, holds by construction. There is nothing to resolve, no import to emit, no alias to invent. And direction 1 is nearly free too, because the user's binders live in their own function in their own file and cannot collide with your template's.
Hygiene is a tax on moving code. The cheapest way to pay it is to not move the code.
So when must you move? One criterion, and it is worth memorising because it decides this class of question generally:
Move a body only when it depends on information only the generator has.
A method that mentions every field by name — fake(), to_dict(), a projection
constructor — cannot be written by the user, because the field list is a fact about the generated type. A
method that says return self.total * 1.25 can be, and every line you spend moving it buys
nothing. That is also the honest reading of your own fake() requirement: it is
synthesize, and the general “arbitrary user methods” ask that grew out of it is mostly
inherit.
Exercise A — Inherit, move, or reject
Seven asks. One verdict each. Inherit — a mixin does it. Move — it depends on generator-only information, so lift the body. Reject — it cannot be done correctly at all, and the right answer is a diagnostic.
The pipeline, for the bodies you do move
Six stages, and every one of them is a lesson you have already done. This is the payoff of the phase map from lesson 1 — the feature is not new machinery, it is a path through existing machinery:
| Load | Find the FunctionDef in the user's
module AST. Keep the node, not the text — and record its ModelSpan, because every diagnostic
below has to point into their file, not your generated one. L11 |
| Scan | Compute the body's free names. This is the one analysis you cannot skip, and it sets the granularity: the body stays opaque as a statement list, but its free-name set is transparent, because that is the only thing a later pass needs to decide anything. L3 |
| Resolve | Look each free name up in the defining module's bindings and carry the import that binds it. Unresolvable → diagnostic, not a guess. L8, L12 |
| Rename | Gensym the binders your template introduces around the spliced body. Not the ones the body refers to — that distinction is snippet 1 below. L8 |
| Lower | Quasiquote the wrapper, unquote-splice the body's statement list into it. Structured nodes throughout; no text until the end. L7 |
| Render | ast.unparse, plus the aliased imports the resolve
stage collected. L7 |
The IR addition is small, which is the sign the earlier lessons were right:
@dataclass(frozen=True)
class Method:
name: MethodName
params: tuple[Param, ...]
body: tuple[ast.stmt, ...] # opaque: transport, never inspected
free: frozenset[Name] # transparent: what passes decide on
decorators: tuple[Decorator, ...]
origin: Origin
Note free and decorators are separate fields rather than things you re-derive
from body. Both are answers to questions passes ask; a pass that has to walk an opaque node to
answer a question is a pass telling you the node is at the wrong granularity — lesson 3's dial, set by the
same single question.
Four Python hazards that only bite when code moves
These are the ones that make “just splice the body in” wrong, and none of them is detectable by looking at the output — the output parses and runs.
1. Zero-argument super()
Legal only inside a class body, because the compiler notices the word and adds a hidden
__class__ cell pointing at the enclosing class. Move the body into a different class and it
recompiles against a different __class__, so super() now walks the
generated class's MRO instead of the original's. It does not crash; it dispatches somewhere else. Reject
it, or rewrite to the explicit two-argument super(TheirClass, self) and emit the import —
which is the resolve stage doing its job on an implicit name.
2. Closure capture
A body whose free names are module-level are all resolvable by emitting an import. A body that closes
over a local — defined inside a factory function, capturing its argument — is not: there is no
import that reconstructs a cell. This is the hard boundary of the whole feature, and it is worth stating
as the rule rather than the symptom: you can move code whose free names are module-global, and
only that. Everything else is UnresolvableNameError with a span into their
file.
3. Decorators, and their own free names
@property, @cached_property, @field_validator("total") — each is
a name that has to resolve, in order, and dropping the list silently changes a property into a method.
Silently, because obj.total then returns a bound method, which is truthy, prints plausibly,
and fails a comparison much later.
4. Definition order — and the contrast worth holding
Lesson 5's hazard was that defaults are evaluated at class-definition time, so a default mentioning a name defined below it fails at import. Method bodies are the opposite: names in a body are resolved when the method is called, so two methods may reference each other in any order, and a method may reference a class defined later in the file. Same file, two rules, and the discriminator is when the expression runs — which is exactly lesson 2's values-versus-expressions distinction appearing a third time.
Exercise B — Bug hunt
Five snippets. Click the offending line, or say no bug. One is clean.
The test that makes this shippable
Build inherit first even if you know you need move, because the inherit path is a differential oracle: the same method, composed the two ways, must behave identically. That is a metamorphic relation in lesson 9's exact terms — T is “move the body instead of inheriting it”, R is “same result on every input” — and it has the property the survey says to look for: no golden file, no hand-written expectation, and it stays true as the generator changes.
@given(st.data())
def test_moving_a_body_preserves_behaviour(data) -> None:
inherited = generate(recipe, strategy=Inherit)
moved = generate(recipe, strategy=Move)
args = data.draw(arguments_for(recipe.method))
assert call(moved, args) == call(inherited, args)
Two things about this test. First, it needs instances of the generated model to call the method on —
which is fake() from lesson 5, so the two features pay for each other rather than competing.
Second, watch it for vacuity: if arguments_for can only produce
inputs on which the method is trivial, the property passes and proves nothing. Instrument it — Hypothesis's
event() — and check that interesting bodies are actually being exercised.
The seeded-bug discipline from Hughes's How
to Specify It! is what tells you the property has power: introduce the four hazards above one at a
time, and check this test catches each. It will catch super() and the dropped decorator. It
will not catch a gensym collision that only triggers on a name the generator never draws — which
is precisely why lesson 8 said freshness must be guaranteed rather than checked.
Exercise C — Free recall
The whole path, interleaved. These are the terms that will let you read
the papers in RESOURCES.md and know which parts apply to you.
What changes in the repo
ir.py | Method as above, and
Model.methods. Also Model.bases — the inherit path needs to emit a base class
list, and nothing in the IR represents one today. |
loader.py | Lifts FunctionDef nodes from the module AST, computes
free names, records origins. This is the only place that touches the user's source for bodies, and it is
the reason lesson 2's AST front end is load-bearing rather than incidental: getattr on the
class object gives you a function object, and a function object cannot be rendered. |
gates.py | Two new checks, both rejections: a method name that collides with a field name, and a body with a non-global free name. Both are lesson 8's third family — the user owns both sides — and both need a span into their file. |
transformers.py | A pass that turns Method into the statements the
renderer emits, and the gensym pass that runs before it. Order matters here and it is not
commutative: rename, then splice. |
renderer.py | The MRO decision, and it is a real one: user methods must come
first in the base list to override generated ones, or last to be overridden by them.
Pick, write it down, and test the override direction — this is the kind of thing nobody notices until
someone's __str__ stops being called. |
Where this leaves the mission
The success criteria in MISSION.md were four. Three are now answerable in
one paragraph each: the shape of ir.py (lessons 1, 3, 12), the fake() hazards
(lesson 5, and definition order got a second pass above), and spotting a re.sub standing in
for a missing IR node (lesson 7). The fourth — reading a paper and mapping every term onto this project —
is the one to test directly rather than assume: take the nanopass paper, and for each of
language, pass, terminal, production, catamorphism, name the
thing in pydantic-codegen it corresponds to or say why it does not apply. What you cannot
place is the next lesson.