Lesson 5 · Elaboration

Deriving a fake() constructor

Four hazards, in the order they will bite you. Then the design that dissolves three of them at once.


You want to generate, from class User, a constructor that fills every field with something plausible so a test can say fake_user(name="x") and not care about the other eleven fields. This pattern has names worth knowing: in test literature it's a test data builder or object mother; in the language world, generating it automatically from a type is deriving — the same mechanism as Haskell's deriving (Default) or Rust's #[derive(Default)].

The core of it is a function you have to write:

fake_value : TypeExpr → Expr

Given a type, produce source code for an expression of that type. That's it. Everything hard about this feature is hard about that one signature — and notice its input. It takes a TypeExpr, not an AnnotationText. You cannot write this pass against your current IR. Lesson 3 said structure is added in response to a pass that demands it; this is that pass. Your next feature is the one that pays for the IR change.

Hazard 1 — evaluation time

Python evaluates a default expression once, when the enclosing def or class body executes — which is at import. Not per call. So:

def fake_user(*, at: datetime = datetime.now()) -> User: ...

freezes at to the moment the module was imported, for the lifetime of the process. Every fake user in your suite shares a timestamp, and the timestamp is whenever the test runner happened to load the file. Verified: two calls 50 ms apart return at values that compare equal.

Hazard 2 — sharing

Because the expression runs once, the resulting object is shared by every call. For an int, invisible. For a list, the classic Python footgun — one test appends, the next test sees it.

Here is the part that will mislead you, and it's the reason this hazard is worth its own exercise: Pydantic silently protects you from some of it, in one of the two places a default can live. Pydantic deep-copies field defaults it recognises. It does not copy objects it doesn't recognise. And it has no involvement at all in a plain function signature.

as a model field defaultas a fake() parameter default
when evaluatedonce, at class-body execonce, at def exec
list/dict/BaseModel copied per instance — safeshared
any other object sharedshared

Both rows verified against Pydantic 2.13 / Python 3.14. The bottom-left cell is the nasty one: tags: list[str] = [] on a model is genuinely fine, so you learn that mutable defaults are safe here — and then a field holding some non-Pydantic object is silently shared. dataclasses made the opposite call: it raises on a mutable default and makes you say default_factory. Pydantic's convenience is what makes this a trap.


Exercise A — Predict

Five snippets. Say what happens before you click.


The mechanism that fixes both: a thunk

Hazards 1 and 2 have one cause — a value was computed at the wrong time — and therefore one fix: don't emit a value, emit a way to get a value. A nullary function standing in for a delayed computation is a thunk. default_factory is Python's admission that a default sometimes has to be one.

For a generated function, though, you don't need default_factory — you need to move the default expression out of the signature and into the body, guarded by a sentinel that means “caller said nothing”. Which you have already built, for a different reason:

from pydantic.experimental.missing_sentinel import MISSING

def fake_user(
    *,
    id: UUID | MISSING = MISSING,
    tags: list[str] | MISSING = MISSING,
    at: datetime | MISSING = MISSING,
) -> User:
    return User(
        id=UUID(int=0) if id is MISSING else id,
        tags=[] if tags is MISSING else tags,
        at=datetime.now() if at is MISSING else at,
    )

Verified: fresh list per call, fresh timestamp per call, overrides still work. The signature carries only the sentinel — an immutable singleton, so nothing is shared — and every real default expression is evaluated on each call, in the body.

Notice what just happened. partial_sentinel() exists in your codebase to express “this field of a PATCH payload was not supplied”. The exact same construct turns out to be the correct mechanism for “this argument of a generated constructor was not supplied”. That is not a coincidence — both are the problem of distinguishing absence from a legitimate value, and the sentinel is its general solution. You built the machinery already; this feature reuses it wholesale, including the _widened logic that Lesson 3 wants restructured.

Hazard 3 — recursion

fake_value is defined by recursion on the type. list[T] is [fake_value(T)] or []; Foo is fake_foo(). So what does it do here?

class Node(BaseModel):
    name: str
    parent: Node | None
    children: list[Node]

A naive fake_value recurses forever, at generation time or at run time depending on how you emit it. The type is recursive, so the derivation must be too, and recursion needs a base case. Three ways to get one, in increasing order of power:

Prefer the nullable arm For a union containing None, emit None. Free termination for parent, and it's what a reader expects. Doesn't help children.
Empty the containers list[T][], dict[K,V]{}. Terminates children. The cost: your fakes exercise no collection logic, ever.
Depth budget Thread a depth through the derivation; past the limit, collapse to the smallest terminating value. This is QuickCheck's sized, and it's the general answer — the only one that survives a non-nullable mutual cycle (A has a B, B has an A).

Recommendation: the first two now, and detect the case they don't cover — a cycle with no nullable or collection edge — and raise a new UnrepresentableError. That is exactly the move rejections.py already makes everywhere else: refuse loudly at the boundary of the supported fragment rather than emit something subtly wrong. Add the depth budget when a real model demands it.

Hazard 4 — definition order

fake_order() calls fake_user(). In a generated module, a name must exist before it is used at import time — so the emitted functions need a topological sort of the model dependency graph, and a cycle has no valid order.

Except that they don't, and this is the elegant bit: a call inside a function body is resolved when the function runs, not when it's defined. The sentinel design already put every default expression inside a body. So definition order stops mattering, and cycles stop being unorderable — laziness dissolves hazard 4 for the same reason it dissolved hazards 1 and 2.

Which is the generalisation worth taking away from this whole lesson:

Deferring a computation converts three separate structural problems — staleness, sharing, and ordering — into none.

This is not a Python quirk. It's why Haskell's laziness lets you define mutually recursive values, why default_factory exists, why React has useMemo's thunk form, and why build systems are DAGs of commands rather than of values. Whenever you're fighting order of evaluation, the question to ask is: can I emit a description of the work instead of the work?


Exercise B — Derive it by hand

Write the expression fake_value should emit. Just the expression, as it would appear in the generated file.


Two design forks to decide before you write code

Deterministic or random?

A canonical value (always UUID(int=0), always "") gives reproducible tests and readable failures. A generator — QuickCheck's Arbitrary, Hypothesis's strategies — explores the input space but needs a seed, a shrinker, and a very different API. They are different products. Take deterministic; it's what a test data builder is for, and Hypothesis already exists for the other job.

The catch you'll hit within a week: if every fake has id=UUID(int=0), two fakes in one test violate a uniqueness constraint. The standard escape is a monotonic counter — deterministic per process, distinct per call. Decide it now, because it changes the signature (fake_user(seq=...) or a module-level counter) and signatures are the expensive thing to change later.

What happens at TypeOpaque?

fake_value is a partial function over types — it will meet Annotated[str, SomeCustomValidator], a third-party NewType, an enum. Two exits: raise (consistent with rejections.py), or consult a user-supplied registry mapping a type to a factory. Hypothesis calls that register_type_strategy; Rust calls it writing impl Default by hand. You'll need the registry eventually. Its shape — where the recipe declares it — is the interesting design question, and it's the one I'd grill you on next.


Exercise C — Free recall

Close the lesson in your head. Name the four hazards, one per box. Order doesn't matter.


status · resources · mission