Project composition

nshconfig endorses one small convention without providing a loader.

Reusable mutators

Place reusable configuration builders under your package:

src/
  project/
    configs/
      models.py
      optimizers.py

Each builder mutates and returns the same typed draft:

def resnet50(cfg: ModelConfig, *, d_model: int = 256) -> ModelConfig:
    cfg.architecture = "resnet50"
    cfg.d_model = d_model
    return cfg

This is an ordinary Python function. It composes naturally with other builders and requires no decorator, registry, or wrapper type.

Root experiment files

Keep finalized experiment choices in a root configs/ directory:

configs/
  baseline.py
  large_model.py

Each file exports the same mutator contract through __config__:

from project.configs.models import resnet50


def __config__(cfg: TrainConfig) -> TrainConfig:
    resnet50(cfg.model, d_model=512)
    cfg.optimizer.learning_rate = 1e-4
    cfg.seed = 7
    return cfg

There is intentionally no semantic difference between a reusable helper and a root experiment configuration. Both are (draft, ...) -> draft functions.

Application boundary

The application decides how a file is imported and which root type it expects. At the boundary it should:

cfg = TrainConfig.config_draft()
returned = module.__config__(cfg)
if returned is not cfg:
    raise TypeError("__config__ must return the draft it received")
final = cfg.config_finalize()

Create one root draft and finalize exactly once after all mutators have run. The identity check catches helpers that accidentally construct or return a different object.

Keeping file loading in the application avoids global registries, import-time side effects, hidden schema selection, and a second configuration language. A project may choose any import mechanism or command-line interface without changing the nshconfig lifecycle.