Lesson 2 · The substrate

Values and expressions

Why your loader has two front ends, and why that is not the accident you think it is.


You said the dual front end — runtime reflection and source parsing — was accreted. This lesson argues it was forced, that you can't remove it, and that the useful move is to write down the rule it currently follows only in your head.

It rests on one distinction, which is the most important idea in this whole path:

A running program holds values. A generator must emit expressions. There is no general way back from one to the other.

Why repr is not the way back

It looks like it should be. repr's documented aspiration is to produce a string that eval turns back into an equal object. So why can't you read User.model_fields["id"].default off the runtime class and write repr of it into the file?

It fails in three tiers, and they matter in ascending order because each is quieter than the last.

Tier 1 — not source at all

<object object at 0x1028c4b20>
<function <lambda> at 0x1039419b0>
<_io.StringIO object at 0x103a81ab0>
<generator object <genexpr> at 0x103ae0450>

Angle brackets aren't Python. You'd notice within a minute. Harmless, because loud.

Tier 2 — valid syntax, unbound names

repr(datetime(2020, 1, 1))  →  'datetime.datetime(2020, 1, 1, 0, 0)'
repr(Tag(label="x"))        →  "Tag(label='x')"

Both are legal Python. Neither tells you that the file now needs import datetime or from tagging import Tag. A value carries no import list. This is precisely the job bindings.py does — and it can only do it because it is handed an expression to walk for free names. Hand it a value and there is nothing to walk. Still fairly loud: the generated file fails at import.

Tier 3 — valid syntax, bound names, wrong meaning

This is the answer to the question you passed on, and there's an instance of it sitting in your own test corpus. Consider:

class Tagged(BaseModel):
    tags: list[Tag] = Field(default_factory=list)

Ask the runtime for the default and you get []. Emit repr of it:

tags: list[Tag] = []

Valid syntax. Bound names. Imports fine. Passes a golden test if the golden file was recorded from the same wrong code. And it is a different program: the source says build a fresh list per instance, and the output says here is one list. You have silently converted a factory into a shared default — Lesson 5's Hazard 2, introduced by the generator rather than by the user, in a place where nothing raises.

Worse, the tell is invisible in the value. [] from a default_factory and [] written literally are the same object shape. The distinction exists only in the source text. Which is the general statement of tier 3:

Evaluation is lossy. It discards exactly the information a generator needs: how the value was going to be produced.

The same loss elsewhere: Field(default=3, gt=0) evaluates to 3 and the constraint goes somewhere else entirely; an enum member reprs as <Color.RED: 1>; a float computed as 1/3 reprs as a decimal literal that is a different expression with the same value. Every one of these is a place where “ask the object” and “read the source” disagree, and the source is right.


Exercise A — Pick the oracle

Six questions the loader has to answer. Which source of truth can answer it? Decide before clicking.


So: the rule your loader already follows

Work through Exercise A and the split falls out. It is not arbitrary:

Question shapeOracleBecause
What does this program mean?
fields after inheritance, MRO, decorators, which class declared what
runtime Pydantic has already done the work. Recomputing MRO resolution from an AST means reimplementing Python.
What did the programmer write?
default expressions, base expressions, annotation text
source Evaluation destroyed it. Nothing at runtime can reconstruct it.

And now the answer to the half of question 1 you skipped: where does the loader need both? Look at ModuleSource. It calls inspect.getsource to get the text and ast.parse to get the tree — then ast.get_source_segment(text, node) uses the node's line and column span to slice the original text. The tree is used only to locate; the text is what's kept.

That is deliberate and it is the third thing in this lesson worth remembering. ast.unparse(node) would give you an expression back, but a normalised one — reformatted, requoted, comments gone. By slicing the original text instead, the generated file reproduces what the programmer wrote, character for character. A parse tree used as an index into source, rather than as a replacement for it, is called keeping the source map. It's the same reason compilers carry spans around long after they've stopped needing to re-read the file.


Exercise B — The quiet one

class Preset(BaseModel):
    tags: list[Tag] = Field(default_factory=lambda: [Tag("new")])

A generator asks the runtime for the default and emits repr of it. Write what lands in the generated file, then say what breaks.


The lever you didn't have until 3.14

One column of that table just got a second oracle, and it's worth knowing before you decide what to do with the accretion.

PEP 649 (shipped in 3.14) makes annotations lazily computed via an __annotate__ method, and PEP 749 adds annotationlib, which can ask a class for its annotations as strings — from the runtime object, with no file access:

annotationlib.get_annotations(User, format=Format.STRING)
# {'id': 'UUID', 'tags': 'list[Tag]', 'at': 'datetime | None', 'ref': 'Tag | None'}

That is the annotation column of your source front end, for free. But measure before you believe it — I ran it, and it is normalised, not source-faithful:

writtenreturned
list[  int ]list[int]
Literal["x","y"]Literal['x', 'y']
"Annotated[int, Field(gt=0)]"Annotated[int, Field(gt=0)]
'dict[str,   int]'dict[str, int]

Read those last two rows again. The quotes are gone. A string forward reference comes back unquoted — which is the canonicalisation Lesson 3 wants, and it would have made the partial_none bug in Lesson 3 unreachable, for free, by construction.

So the honest assessment of the accretion: annotations could plausibly move to the runtime oracle and shrink module_source.py. Defaults and bases cannot — nothing normalises those back into expressions, because of everything above. The dual front end stays. What changes is that it stops being accidental.

Don't act on this yet. Normalisation cuts both ways: it also means you'd stop reproducing what the programmer wrote, and your corpus asserts on exact output. It's a real option with a real cost, and the way to evaluate it is a spike against the existing corpus — which is Lesson 4's subject. Noted here so it's a decision rather than a discovery.

Exercise C — Recall


status · resources · mission