Lesson 7 · Emission

Emitting statements

Your renderer builds text with f-strings, and for a class header and an annotated assignment that is genuinely fine. A method body is where it stops being fine — and the reason is not aesthetic.


Three ways to produce output, and their names

String assemblyWhat renderer.py does. Output is text from the first moment.
Constructor callsBuild the output tree node by node: ast.FunctionDef(name=..., args=..., body=[...]). Correct, and unreadable past about four nodes.
QuasiquotationWrite the output in the target language, with holes, and substitute nodes into the holes.

Quasiquotation is the one you want, and it is old: Lisp's backquote, where `(if ,test 1 2) means “this expression, but with test's value spliced in”. , is unquote — substitute one node. ,@ is unquote-splicing — substitute a list of nodes into a position that holds a list. That distinction is the whole difficulty of emitting statements, because a body is a list.

Lisp / Scheme`(...) · ,x · ,@xs
Racket#'(...) and syntax-parse templates
Template Haskell[| ... |] · $(x)
Rustquote!{ ... } · #x · #(#xs)*
Pythonnothing. You build it — in about fifteen lines.

The fifteen lines

class Template(BaseModel):
    source: PythonSource

class HoleName(FrozenText): ...

def filled(template: Template, holes: dict[HoleName, ast.AST]) -> ast.Module:
    class Filler(ast.NodeTransformer):
        def visit_Name(self, node: ast.Name) -> ast.AST:
            return holes.get(HoleName(node.id), node)

        def visit_Expr(self, node: ast.Expr) -> ast.AST | list[ast.stmt]:
            named = node.value
            if isinstance(named, ast.Name) and HoleName(named.id) in holes:
                return holes[HoleName(named.id)]      # a list splices
            return self.generic_visit(node)

    return ast.fix_missing_locations(Filler().visit(ast.parse(template.source.root)))

Four facts in there worth holding:

Then ast.unparse once, at the very boundary, and ruff formats it — which you already pipe through, so unparse's indifferent formatting costs you nothing.


Exercise A — Break the indenter

The feature you want is a user-supplied method body, spliced into a generated class. Below, the same body emitted three ways. Column 1 interpolates once; column 2 indents every line, which is the fix everyone reaches for. Find a body for which column 2 emits valid Python that means something different from what the user wrote.

1 · one interpolation
2 · indent every line
3 · parse · splice · unparse

Column 3 does not indent anything. Indentation is not in a tree — it is computed from depth when the tree is printed. That is the difference between a representation that carries structure and one that carries its rendering.


The four things text cannot do

LayoutExercise A. Indentation is significant in Python and is a function of tree depth; text splicing has to reconstruct it, and reconstructing it inside a string literal changes the program's meaning with no syntax error to warn you.
PrecedenceSplice a or b into the template not _hole and text gives you not a or b — a different expression. ast.unparse parenthesises from the tree, so it cannot make this mistake. Note you already have one of these in flight: _widened builds f"{annotation} | {token}" by concatenation.
EscapingA default that is a string containing a quote, a backslash, or a newline. ast.Constant("a\"b") is one node; the f-string version is a bug waiting for a corpus entry.
InspectionThe one that actually matters. Which imports does this body need? is answerable by walking a tree for free names — bindings.py already does exactly that on the way in — and unanswerable over a string. Emitting a body you cannot inspect means emitting a file whose import block you cannot compute.

Keep the output structured until the last possible moment. Text is a serialisation, not a representation.


Exercise B — Recall


What changes in the repo

renderer.py splits in two. The first half becomes a lowering from your IR to ast nodes — Lesson 1's word, and now literally true: one target language, one node builder per IR node, no strings. The second half is a single ast.unparse at the edge, and PythonSource becomes a type that only exists after it. rendered_import becomes ast.ImportFrom(module=..., names=[ast.alias(...)]), which is duller and shorter than the three-branch string version it replaces.

The IR change is smaller than you'd guess. Does Model need statement nodes? Lesson 3's dial says: only if a pass looks inside a body. Today no pass does, so:

class Method(BaseModel):
    name: SymbolName
    body: MethodBody          # opaque: parsed once, carried verbatim
    imports: tuple[Import, ...]

The body stays opaque, and the front end — not a pass — computes its free names once and records the imports they need. That is the pattern to notice: a fact established at the boundary, so no pass has to interrogate the opaque node. Which works right up until the free names collide with the names your template introduced, and that is Lesson 8.

The counterweight. Having just been sold structure: do not build the whole output with constructors. Constructor soup is why quasiquotation was invented — a quote! template reads as the thing it emits, and a tree of ast.Call(func=ast.Attribute(value=ast.Name(...))) does not. Templates for shape, constructors for holes. And ast.unparse discards comments and original formatting: irrelevant here, because you generate whole files rather than editing them — but it is exactly why tools that edit Python reach for LibCST instead.

status · resources · mission