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 assembly | What
renderer.py does. Output is text from the first moment. |
| Constructor calls | Build the output tree node by node:
ast.FunctionDef(name=..., args=..., body=[...]). Correct, and unreadable past about four
nodes. |
| Quasiquotation | Write 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) |
| Rust | quote!{ ... } · #x · #(#xs)* |
| Python | nothing. 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:
- The template is parsed, not formatted. It is checked for syntactic validity once, at the moment you write it, rather than every time you render.
- A hole is just an identifier.
_hole_bodyin the template is aNamenode, and substituting it is a dictionary lookup — no string scanning, no delimiters to escape. NodeTransformerlets a visitor return a list when the visited node sits in a statement list, and CPython splices it. That is,@, for free.ast.fix_missing_locations. Nodes you construct carry nolineno, andcompile()raisesTypeError: required field "lineno" missing from stmtwithout it.ast.unparsedoes not care — which is why this bug hides until the first time something compiles the tree or asks it for a source span.
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.
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
| Layout | Exercise 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. |
| Precedence | Splice 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. |
| Escaping | A 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. |
| Inspection | The 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.
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.