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 default | as a fake() parameter default | |
|---|---|---|
| when evaluated | once, at class-body exec | once, at def exec |
list/dict/BaseModel |
copied per instance — safe | shared |
| any other object | shared | shared |
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.
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.