Drafts and nested defaults

Explicit lifecycle

ConfigType(...) returns a validated final. ConfigType.config_draft() returns a mutable incomplete instance of the same static type:

class Job(C.Config):
    workers: int
    labels: list[str] = []


final = Job(workers=8)
work = Job.config_draft()
work.workers = 8

Draft creation does not run field validators, model validators, default factories, private factories, or model_post_init. Assignment stores an unvalidated value. Unknown attributes fail immediately.

work.config_finalize() crosses the Pydantic validation boundary and returns a fresh final. The original draft remains editable and can produce another variant. Calling config_finalize() on a final raises DraftError.

Required child spines

A missing required field whose direct annotation is one concrete Config type creates a child draft on first access:

class Leaf(C.Config):
    width: int


class Root(C.Config):
    leaf: Leaf


work = Root.config_draft()
work.leaf.width = 256

A required scalar does not invent a value. Reading it raises UnsetError until it is assigned. Recursive required Config spines stop with a clear error instead of recursing without a base case.

Replayable final defaults

A normally constructed Config final used as a default retains a minimal copy of its raw constructor input:

class Child(C.Config):
    width: int = 128


DEFAULT_CHILD = Child(width=256)


class Parent(C.Config):
    child: Child = DEFAULT_CHILD
    stages: list[Child] = [Child(width=512)]

On Parent.config_draft(), default-origin children become fresh drafts. Inline and named defaults follow the same rule. Projection recurses through:

  • direct concrete Config fields;

  • supported union branches;

  • list and tuple elements;

  • mapping values and TypedDict values.

Mapping keys and set or frozenset members stay final because drafts are unhashable. A final explicitly assigned to a draft also stays final:

chosen = Child(width=1024)
work = Parent.config_draft()
work.child = chosen
assert work.child is chosen

The recipe is raw input, not Python source or an AST. A final created without a normal constructor recipe, changed through unchecked copy updates, or mutated after recipe capture is rejected as a replayable default. This avoids guessing how to recreate executable configuration.

Pydantic may copy a declared default. nshconfig preserves the recipe across that normal copy, including for Config values with unhashable fields.

Parent-dependent default children

A direct Child() default first attempts ordinary validation while the parent class body executes. Missing parent context turns that constructor call into an inert unbound template:

class Child(C.Config):
    copied: int = C.interp(lambda context: context.parent(Parent).source)


class Parent(C.Config):
    source: int = 3
    child: Child = Child()

The template contains raw constructor input and binding diagnostics, not partial field values. Its recipe runs in the parent’s normal Pydantic field pipeline. Parent().child.copied is 3, and changing source on a parent draft changes the finalized child.

Fallback is narrow. At least one interpolation must lack an ancestor, typed root, or nearest enclosing model, or raise NameError; every other error must be an omitted required field. Missing fields alone do not create a template. Strict type errors, extras, validators, wrong current/parent types, later-field reads, and other failures remain normal validation errors. model_validate(), TypeAdapter, and JSON/string validation never create templates. A forward name may defer once, but a repeated NameError during binding fails normally.

Use direct Child() defaults for ordinary and parent-dependent children. A default factory returning a draft or unbound template is rejected, including C.Field(default_factory=Child.config_draft).

When a parent draft materializes an unbound template, it becomes a fresh editable child draft. Declared field access, mutation, deletion, iteration, finalization, model copying, and Pydantic/JSON serialization are invalid on the template itself. Use C.is_template() to inspect the state. repr, Treescope, normal shallow/deep copy, and trusted pickle/cloudpickle preserve the inert recipe.

Validators, factories, model_post_init, and model validators may run before constructor fallback and again during binding. Keep configuration hooks deterministic and side-effect-free.

Provisional defaults

Safe scalar and built-in container defaults may be read from a draft. Built-in containers are copied before exposure. The draft remembers a structural baseline:

  • an untouched value is omitted at finalization, so Pydantic recomputes the canonical default or factory result;

  • an in-place mutation is supplied as explicit input;

  • editing a projected child draft makes its enclosing default branch explicit.

Simply reading a nested child’s own default does not pin the branch. Built-in containers are ordinary Python values, not tracking subclasses.

Arbitrary mutable defaults are opaque and cannot be observed provisionally. Assign an explicit value or read the value from the final instead.

Copy, equality, and hashing

Draft copying is rejected. Finals support model_copy() with native Pydantic semantics, including unvalidated update= values. Use type(final).model_validate(final) to revalidate current concrete contents.

Draft and template equality is identity equality and both are unhashable. Templates support normal Python shallow/deep copy but not model_copy(). Finals use value equality and frozen-Pydantic field hashing. A final with a list or dictionary field is therefore naturally unhashable. Field freezing is shallow and does not freeze mutable contents.