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.