Skip to content

cfgx

Keep configuration logic in regular Python modules. Start with a single dictionary, then scale into lazy computed values, inheritance chains, and CLI-friendly overrides without learning a new DSL.

Everything you write stays Python: functions, conditionals, list comprehensions, imports. cfgx focuses on loading, layering, and mutating dictionaries so you can drop the result into any workflow.

Highlights

  • Load any Python config module with load.
  • Compose configs via parents = [...] chains or by supplying multiple paths at once.
  • Compute values lazily with Lazy.
  • Update values from previous layers with Update.
  • Adjust values on the fly using apply_overrides and a compact CLI syntax.
  • Control merge behavior with Delete() and Replace(value).
  • Snapshot final dictionaries back to Python with dump, or pretty-print them with format.

Core workflow

Start with a config file, load it, and apply CLI-style tweaks.

Define a config

# configs/base.py
config = {
    "model": {"name": "resnet18"},
    "trainer": {"max_steps": 50_000},
}

Load with overrides

from cfgx import load

cfg = load("configs/base.py", overrides=["trainer.max_steps=12_000"])

The result is a plain dictionary you can serialize, log, or feed into factories.

Config modules

Each config file is just Python. The loader only pays attention to two attributes:

  • config: dictionary.
  • parents: string or list of strings pointing to other config files (paths resolved relative to the current file).

Parent chaining

# configs/finetune.py
parents = ["base.py", "schedules/cosine.py"]

config = {"trainer": {"max_steps": 10_000}}

You can also compose multiple files by passing a sequence of paths to load.

Multiple paths

from cfgx import load

cfg = load(
    [
        "configs/base.py",
        "configs/backbones/resnet.py",
        "configs/modes/eval.py",
    ]
)

Runtime overrides

Overrides can be passed to load or applied later with apply_overrides(config_dict, sequence_of_strings), which mutates the dictionary in place. Each string uses a compact syntax designed for CLI usage.

  • path=value → assign (dict keys or list indices)
  • path+=value → append to a list
  • path-=value → remove a matching element from a list
  • path!= → delete a key or remove a list index

Values are parsed with ast.literal_eval, so strings, numbers, booleans, lists, dictionaries, and None all work. If parsing fails, the raw string is used, so most string values do not need to be quoted. You can also use lazy: to define a Lazy expression and update: to define an Update expression from the CLI (see Lazy values and Update values).

Assignments create intermediate dicts and extend lists with None as needed. List indices follow Python semantics: negative indices are allowed when the list already exists and are in range (otherwise IndexError). Deletes and list removals are forgiving no-ops when the path is missing or out of range.

Override syntax

from cfgx import apply_overrides

apply_overrides(
    cfg,
    [
        "optimizer.lr=5e-4",
        "trainer.max_steps=10_000",
        "trainer.hooks+='wandb'",
        "trainer.hooks-='checkpoint'",
        "data.pipeline[0]!=",
        "trainer.warmup_steps=lazy:c.trainer.max_steps * 0.1",
        "trainer.max_steps=update:v + 1000",
    ],
)

Merge semantics

When configs are layered, cfgx walks the override dictionary and combines it with the base using:

  • Dicts merge recursively.
  • Delete() removes the key entirely.
  • Replace(value) uses value as-is without deeper merging.
  • Update(fn_or_expr) transforms the previous value at that path.
  • Otherwise the override value replaces the base.

Delete and Replace

from cfgx import Delete, Replace, merge

base = {
    "optimizer": {
        "lr": 3e-4,
        "weight_decay": 0.01,
        "schedule": {"type": "linear", "warmup": 1_000},
    },
    "trainer": {"hooks": ["progress", "checkpoint"]},
}

override = {
    "optimizer": {
        "weight_decay": Delete(),
        "schedule": Replace({"type": "cosine", "t_max": 20_000}),
    },
    "trainer": {"steps": 10_000, "hooks": ["progress"]},
}

merged = merge(base, override)

merge is exported in case you want to reuse the algorithm, but load already relies on it internally.

Update values

Use Update when a child layer should transform the previous value at the same path.

Update applies during merge:

  • If the previous value is concrete, the update runs immediately.
  • If the previous value is Lazy, cfgx composes a new Lazy and the update runs when that lazy resolves.

Update can be defined from a callable (lambda v: ...) or a string expression ("v + [1]"). String expressions get v and math.

Update from the previous value

from cfgx import Update

parents = ["base.py"]
config = {
    "trainer": {
        "hooks": Update(lambda v: [*v, "wandb"]),
        "max_steps": Update("v + 1_000"),
    },
}

Note

For missing keys, callable updates are invoked with no argument, so a callable default like lambda v=[]: v + ["x"] works. String updates require an existing value.

Lazy values

Use Lazy for values that should be computed from the merged config. A Lazy receives c, a read-only proxy for the config where dicts are Mappings and lists are Sequences. You can use attribute access (c.trainer.max_steps), string keys (c["trainer"]["max_steps"]), and list indices (c.trainer.stages[0].max_steps). String expressions can also use math and Python builtins. Lazy values are resolved in-place after loading (or when you call resolve_lazy) and only when they appear inside nested dict/list structures.

Warning

The proxy references the original config values. Avoid side effects inside Lazy functions and don't rely on any specific resolution order.

Lazy with a function

from cfgx import Lazy

config = {
    "trainer": {"max_steps": 50_000},
    "scheduler": {
        "warmup_steps": 1_000,
        "decay_steps": Lazy(
            lambda c: c.trainer.max_steps - c.scheduler.warmup_steps
        ),
    },
}

Lazy from an expression

from cfgx import Lazy

config = {
    "trainer": {"max_steps": 50_000},
    "warmup_steps": Lazy("c.trainer.max_steps * 0.1"),
}

Formatting and snapshots

Freeze the exact configuration you ran:

Format or dump configs

from pathlib import Path
from cfgx import dump, format

print(format(cfg))
with Path("runs/2026-01-12/config_snapshot.py").open("w") as f:
    dump(cfg, f, format="ruff")
  • format returns a string derived from repr(cfg); it defaults to pretty formatting and supports format="raw" for raw repr output or format="ruff" for Ruff formatting.
  • dump writes a loadable snapshot prefixed with config =; dumps returns the same snapshot string; both default to pretty formatting and accept format="raw" for raw output.
  • sort_keys=True sorts dict keys throughout nested dict/list structures, including dict subclasses.
  • These are best-effort snapshots: you're responsible for repr() being valid Python that can recreate the config. If it isn't, formatting can raise (for example, on a syntax error) or load may fail because required imports are missing.

Tips for structuring configs

  • Organize by concern: configs/base.py, configs/data/imagenet.py, configs/model/resnet.py.
  • Expose helper functions alongside config for reusable snippets.
  • Prefer Lazy for repeated derived values, e.g. a single base learning rate that feeds multiple param groups (backbone_lr = base_lr * 0.1).
  • Prefer Update when changing an inherited value at the same key, especially for lists and incremental numeric adjustments.