slimconfig 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Zeyu Yang
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,168 @@
1
+ Metadata-Version: 2.4
2
+ Name: slimconfig
3
+ Version: 0.1.0
4
+ Summary: YAML configs onto typed dataclass schemas — a lightweight Hydra stand-in
5
+ Author: Zeyu Yang
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/zeyuyang8/slimconfig
8
+ Project-URL: Repository, https://github.com/zeyuyang8/slimconfig
9
+ Keywords: config,yaml,omegaconf,dataclass,hydra
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Classifier: Programming Language :: Python :: 3.13
13
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
14
+ Classifier: Typing :: Typed
15
+ Requires-Python: >=3.12
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Requires-Dist: omegaconf<2.4,>=2.3
19
+ Requires-Dist: pyyaml
20
+ Provides-Extra: dev
21
+ Requires-Dist: pytest; extra == "dev"
22
+ Requires-Dist: ruff; extra == "dev"
23
+ Dynamic: license-file
24
+
25
+ # slimconfig
26
+
27
+ YAML configs merged onto typed dataclass schemas — a lightweight Hydra stand-in in ~300 lines,
28
+ built on [OmegaConf](https://omegaconf.readthedocs.io).
29
+
30
+ Two rules, enforced at load time:
31
+
32
+ * **Every field is required.** A schema's leaves all default to `MISSING`, so a config has to set
33
+ each one explicitly — a nullable field that is "off" is still written out as `null`, an empty
34
+ collection as `[]`. Nothing is silently inherited.
35
+ * **Unknown keys are rejected.** A typo in a YAML key is an error, not a value that goes nowhere.
36
+
37
+ Plus what a research/experiment runner actually needs: Hydra-style `defaults:` composition, a
38
+ `mode` dispatcher, and a run folder that snapshots the exact config it ran with.
39
+
40
+ ## Install
41
+
42
+ ```bash
43
+ pip install slimconfig
44
+ ```
45
+
46
+ ## Load a config
47
+
48
+ ```python
49
+ # train.py
50
+ import sys
51
+ from dataclasses import dataclass, field
52
+ from omegaconf import MISSING
53
+ from slimconfig import load_config
54
+
55
+ @dataclass
56
+ class Optim:
57
+ lr: float = MISSING
58
+ warmup_steps: int = MISSING
59
+
60
+ @dataclass
61
+ class TrainConfig:
62
+ run_dir: str = MISSING
63
+ model: str = MISSING
64
+ optim: Optim = field(default_factory=Optim)
65
+ resume_from: str | None = MISSING # "off" must still be spelled `null`
66
+
67
+ cfg = load_config(TrainConfig, sys.argv[1:]) # -> a real TrainConfig instance
68
+ print(cfg.optim.lr)
69
+ ```
70
+
71
+ ```yaml
72
+ # configs/train.yaml
73
+ run_dir: runs/${now:%Y%m%d-%H%M%S}
74
+ model: llama-3-8b
75
+ optim:
76
+ lr: 2.0e-4
77
+ warmup_steps: 100
78
+ resume_from: null
79
+ ```
80
+
81
+ ```bash
82
+ python train.py configs/train.yaml # a file
83
+ python train.py configs/train.yaml optim.lr=1e-4 # ...plus dotted overrides, later wins
84
+ ```
85
+
86
+ Leave `warmup_steps` out and the load fails with
87
+ `TrainConfig is missing required field(s): optim.warmup_steps` — before anything runs.
88
+
89
+ ## Share configs with `defaults:`
90
+
91
+ Any YAML may carry a top-level `defaults:` list of paths. Listed files merge first, in order, and
92
+ the current file wins on top; composition is recursive, and cycles are caught.
93
+
94
+ ```yaml
95
+ # configs/train_7b.yaml
96
+ defaults: [configs/train.yaml, configs/optim/cosine.yaml]
97
+ model: llama-3-7b
98
+ ```
99
+
100
+ Paths resolve **relative to the current working directory** (the project root scripts are launched
101
+ from), so one path convention holds wherever the including file lives. Absolute paths work too.
102
+
103
+ ## Interpolation resolvers
104
+
105
+ On top of OmegaConf's own `${a.b}` interpolation, importing slimconfig registers:
106
+
107
+ | Resolver | Meaning |
108
+ | --- | --- |
109
+ | `${now:%Y%m%d-%H%M%S}` | the load time, `strftime`-formatted — one consistent stamp per process |
110
+ | `${from_yaml:configs/data.yaml,dataset.name}` | one value read out of *another* config, so a config can track a value another file owns without duplicating it |
111
+
112
+ ## Dispatch on `mode`
113
+
114
+ For a single entry point that fans out to several jobs, `dispatch` reads `mode`, opens and
115
+ snapshots `run_dir`, then calls the matching handler:
116
+
117
+ ```python
118
+ # run.py
119
+ import sys
120
+ from slimconfig import dispatch
121
+
122
+ MODES = {
123
+ "train": (TrainConfig, train), # load TrainConfig strictly, call train(cfg)
124
+ "eval": (EvalConfig, evaluate),
125
+ "sweep": run_sweep, # bare handler: gets the raw specs, loads its own schema
126
+ }
127
+ raise SystemExit(dispatch(MODES, sys.argv[1:]))
128
+ ```
129
+
130
+ `mode` and `run_dir` are ordinary config keys, so a schema loaded this way declares them itself
131
+ (unknown keys are rejected).
132
+
133
+ ## Run folders
134
+
135
+ `start_run(run_dir, config)` (called for you by `dispatch`) creates the folder and writes:
136
+
137
+ * `config.yaml` — the fully-resolved config, re-runnable as-is: `python run.py <run_dir>/config.yaml`
138
+ * `run_meta.json` — argv, cwd, git commit + dirty flag, start time, host
139
+
140
+ Everything a run produces goes in that same folder, so a result is never separated from the config
141
+ that made it. The snapshot is best-effort — provenance never aborts a run.
142
+
143
+ ## API
144
+
145
+ | | |
146
+ | --- | --- |
147
+ | `load_config(schema, specs)` | merge specs onto a dataclass schema → a populated instance |
148
+ | `merge_specs(specs)` | merge specs into one unvalidated `DictConfig` |
149
+ | `peek(specs, key)` | read one top-level key before choosing a schema |
150
+ | `dispatch(modes, specs)` | `mode` → handler, with the run folder opened and snapshotted |
151
+ | `start_run(run_dir, config)` | create the run folder, write `config.yaml` + `run_meta.json` |
152
+ | `load_mapping_yaml(path)` | one YAML → `DictConfig`, with `defaults:` composed |
153
+ | `load_yaml(path)` | one YAML → `dict`, plain PyYAML, no composition |
154
+
155
+ A *spec* is a YAML file path, a `dotted.key=value` string, or a ready-made mapping/`DictConfig` —
156
+ so a caller can merge values it computed at runtime under the same "later wins" rule.
157
+
158
+ ## Development
159
+
160
+ ```bash
161
+ pip install -e ".[dev]"
162
+ pytest
163
+ ruff check .
164
+ ```
165
+
166
+ ## License
167
+
168
+ MIT
@@ -0,0 +1,144 @@
1
+ # slimconfig
2
+
3
+ YAML configs merged onto typed dataclass schemas — a lightweight Hydra stand-in in ~300 lines,
4
+ built on [OmegaConf](https://omegaconf.readthedocs.io).
5
+
6
+ Two rules, enforced at load time:
7
+
8
+ * **Every field is required.** A schema's leaves all default to `MISSING`, so a config has to set
9
+ each one explicitly — a nullable field that is "off" is still written out as `null`, an empty
10
+ collection as `[]`. Nothing is silently inherited.
11
+ * **Unknown keys are rejected.** A typo in a YAML key is an error, not a value that goes nowhere.
12
+
13
+ Plus what a research/experiment runner actually needs: Hydra-style `defaults:` composition, a
14
+ `mode` dispatcher, and a run folder that snapshots the exact config it ran with.
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ pip install slimconfig
20
+ ```
21
+
22
+ ## Load a config
23
+
24
+ ```python
25
+ # train.py
26
+ import sys
27
+ from dataclasses import dataclass, field
28
+ from omegaconf import MISSING
29
+ from slimconfig import load_config
30
+
31
+ @dataclass
32
+ class Optim:
33
+ lr: float = MISSING
34
+ warmup_steps: int = MISSING
35
+
36
+ @dataclass
37
+ class TrainConfig:
38
+ run_dir: str = MISSING
39
+ model: str = MISSING
40
+ optim: Optim = field(default_factory=Optim)
41
+ resume_from: str | None = MISSING # "off" must still be spelled `null`
42
+
43
+ cfg = load_config(TrainConfig, sys.argv[1:]) # -> a real TrainConfig instance
44
+ print(cfg.optim.lr)
45
+ ```
46
+
47
+ ```yaml
48
+ # configs/train.yaml
49
+ run_dir: runs/${now:%Y%m%d-%H%M%S}
50
+ model: llama-3-8b
51
+ optim:
52
+ lr: 2.0e-4
53
+ warmup_steps: 100
54
+ resume_from: null
55
+ ```
56
+
57
+ ```bash
58
+ python train.py configs/train.yaml # a file
59
+ python train.py configs/train.yaml optim.lr=1e-4 # ...plus dotted overrides, later wins
60
+ ```
61
+
62
+ Leave `warmup_steps` out and the load fails with
63
+ `TrainConfig is missing required field(s): optim.warmup_steps` — before anything runs.
64
+
65
+ ## Share configs with `defaults:`
66
+
67
+ Any YAML may carry a top-level `defaults:` list of paths. Listed files merge first, in order, and
68
+ the current file wins on top; composition is recursive, and cycles are caught.
69
+
70
+ ```yaml
71
+ # configs/train_7b.yaml
72
+ defaults: [configs/train.yaml, configs/optim/cosine.yaml]
73
+ model: llama-3-7b
74
+ ```
75
+
76
+ Paths resolve **relative to the current working directory** (the project root scripts are launched
77
+ from), so one path convention holds wherever the including file lives. Absolute paths work too.
78
+
79
+ ## Interpolation resolvers
80
+
81
+ On top of OmegaConf's own `${a.b}` interpolation, importing slimconfig registers:
82
+
83
+ | Resolver | Meaning |
84
+ | --- | --- |
85
+ | `${now:%Y%m%d-%H%M%S}` | the load time, `strftime`-formatted — one consistent stamp per process |
86
+ | `${from_yaml:configs/data.yaml,dataset.name}` | one value read out of *another* config, so a config can track a value another file owns without duplicating it |
87
+
88
+ ## Dispatch on `mode`
89
+
90
+ For a single entry point that fans out to several jobs, `dispatch` reads `mode`, opens and
91
+ snapshots `run_dir`, then calls the matching handler:
92
+
93
+ ```python
94
+ # run.py
95
+ import sys
96
+ from slimconfig import dispatch
97
+
98
+ MODES = {
99
+ "train": (TrainConfig, train), # load TrainConfig strictly, call train(cfg)
100
+ "eval": (EvalConfig, evaluate),
101
+ "sweep": run_sweep, # bare handler: gets the raw specs, loads its own schema
102
+ }
103
+ raise SystemExit(dispatch(MODES, sys.argv[1:]))
104
+ ```
105
+
106
+ `mode` and `run_dir` are ordinary config keys, so a schema loaded this way declares them itself
107
+ (unknown keys are rejected).
108
+
109
+ ## Run folders
110
+
111
+ `start_run(run_dir, config)` (called for you by `dispatch`) creates the folder and writes:
112
+
113
+ * `config.yaml` — the fully-resolved config, re-runnable as-is: `python run.py <run_dir>/config.yaml`
114
+ * `run_meta.json` — argv, cwd, git commit + dirty flag, start time, host
115
+
116
+ Everything a run produces goes in that same folder, so a result is never separated from the config
117
+ that made it. The snapshot is best-effort — provenance never aborts a run.
118
+
119
+ ## API
120
+
121
+ | | |
122
+ | --- | --- |
123
+ | `load_config(schema, specs)` | merge specs onto a dataclass schema → a populated instance |
124
+ | `merge_specs(specs)` | merge specs into one unvalidated `DictConfig` |
125
+ | `peek(specs, key)` | read one top-level key before choosing a schema |
126
+ | `dispatch(modes, specs)` | `mode` → handler, with the run folder opened and snapshotted |
127
+ | `start_run(run_dir, config)` | create the run folder, write `config.yaml` + `run_meta.json` |
128
+ | `load_mapping_yaml(path)` | one YAML → `DictConfig`, with `defaults:` composed |
129
+ | `load_yaml(path)` | one YAML → `dict`, plain PyYAML, no composition |
130
+
131
+ A *spec* is a YAML file path, a `dotted.key=value` string, or a ready-made mapping/`DictConfig` —
132
+ so a caller can merge values it computed at runtime under the same "later wins" rule.
133
+
134
+ ## Development
135
+
136
+ ```bash
137
+ pip install -e ".[dev]"
138
+ pytest
139
+ ruff check .
140
+ ```
141
+
142
+ ## License
143
+
144
+ MIT
@@ -0,0 +1,61 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "slimconfig"
7
+ # One source of truth: slimconfig.__version__ (see [tool.setuptools.dynamic] below).
8
+ dynamic = ["version"]
9
+ description = "YAML configs onto typed dataclass schemas — a lightweight Hydra stand-in"
10
+ readme = "README.md"
11
+ license = {text = "MIT"}
12
+ # 3.12+: the loader uses PEP 695 syntax (`type Spec = ...`, `def load_config[T]`).
13
+ requires-python = ">=3.12"
14
+ dependencies = [
15
+ # Pinned below 2.4: 2.4.0.dev deprecates register_new_resolver, which config.py uses for the
16
+ # ${now:...} / ${from_yaml:...} resolvers — a stable 2.3.x keeps that API warning-free.
17
+ "omegaconf>=2.3,<2.4",
18
+ "pyyaml",
19
+ ]
20
+ authors = [
21
+ {name = "Zeyu Yang"},
22
+ ]
23
+ keywords = ["config", "yaml", "omegaconf", "dataclass", "hydra"]
24
+ classifiers = [
25
+ "License :: OSI Approved :: MIT License",
26
+ "Programming Language :: Python :: 3.12",
27
+ "Programming Language :: Python :: 3.13",
28
+ "Topic :: Software Development :: Libraries :: Python Modules",
29
+ "Typing :: Typed",
30
+ ]
31
+
32
+ [project.optional-dependencies]
33
+ dev = [
34
+ "pytest",
35
+ "ruff",
36
+ ]
37
+
38
+ [project.urls]
39
+ Homepage = "https://github.com/zeyuyang8/slimconfig"
40
+ Repository = "https://github.com/zeyuyang8/slimconfig"
41
+
42
+ [tool.setuptools.dynamic]
43
+ version = {attr = "slimconfig.__version__"}
44
+
45
+ [tool.setuptools.packages.find]
46
+ where = ["src"]
47
+ include = ["slimconfig*"]
48
+
49
+ [tool.setuptools.package-data]
50
+ slimconfig = ["py.typed"]
51
+
52
+ [tool.ruff]
53
+ line-length = 150
54
+
55
+ [tool.ruff.lint]
56
+ extend-ignore = ["E203"]
57
+
58
+ [tool.ruff.lint.per-file-ignores]
59
+ # Every config problem is reported as ValueError, including the isinstance guards (TRY004 wants
60
+ # TypeError there); the ${now:...} stamp is intentionally local time (DTZ005).
61
+ "src/slimconfig/config.py" = ["TRY004", "DTZ005"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,31 @@
1
+ # slimconfig — YAML configs onto typed dataclass schemas, a lightweight Hydra stand-in.
2
+ #
3
+ # from slimconfig import load_config
4
+ # cfg = load_config(MyConfig, sys.argv[1:]) # MyConfig: a dataclass of MISSING fields
5
+ #
6
+ # See config.py for the YAML loader + `defaults:` composition and the ${now:...} / ${from_yaml:...}
7
+ # resolvers, and structured.py for the typed, all-fields-required schema loader, the `mode` dispatcher,
8
+ # and the run-folder snapshot.
9
+
10
+ from .config import load_mapping_yaml, load_yaml
11
+ from .structured import (
12
+ Spec,
13
+ dispatch,
14
+ load_config,
15
+ merge_specs,
16
+ peek,
17
+ start_run,
18
+ )
19
+
20
+ __version__ = "0.1.0"
21
+
22
+ __all__ = [
23
+ "Spec",
24
+ "dispatch",
25
+ "load_config",
26
+ "load_mapping_yaml",
27
+ "load_yaml",
28
+ "merge_specs",
29
+ "peek",
30
+ "start_run",
31
+ ]
@@ -0,0 +1,105 @@
1
+ # slimconfig.config — the YAML layer: read a file, compose its `defaults:` chain.
2
+ #
3
+ # Read a YAML config into an OmegaConf DictConfig and compose any `defaults: [...]` chain:
4
+ # * load_mapping_yaml — load a YAML, require a top-level mapping, compose any `defaults:` chain
5
+ # (current file wins), and return a DictConfig.
6
+ # * load_yaml — plain PyYAML: read a YAML file into a dict (no `defaults:` composition).
7
+ # The typed, all-fields-required loader (load_config) lives in structured.py.
8
+ #
9
+ # Importing this module also registers two OmegaConf interpolation resolvers (once, process-wide):
10
+ # * ${now:<strftime>} — stamp a value with the load time.
11
+ # * ${from_yaml:<path>,<key>} — read one value OUT of another config file, so a config can track
12
+ # a value another owns without duplicating it.
13
+
14
+ from __future__ import annotations
15
+
16
+ from datetime import datetime
17
+ from pathlib import Path
18
+ from typing import Any, cast
19
+
20
+ import yaml
21
+ from omegaconf import DictConfig, ListConfig, OmegaConf
22
+
23
+ # ``${now:<strftime>}`` — interpolate the current time into any config value (Hydra-style). Registered
24
+ # at import so every OmegaConf-loaded config has it. ``replace=True`` keeps re-import idempotent;
25
+ # ``use_cache=True`` gives ONE consistent timestamp for the whole load (and per process).
26
+ OmegaConf.register_new_resolver(
27
+ "now", lambda fmt: datetime.now().strftime(fmt), replace=True, use_cache=True
28
+ )
29
+
30
+ # Sentinel telling OmegaConf.select "not found" apart from a real ``null`` value at the key.
31
+ _NOT_FOUND = object()
32
+
33
+
34
+ def _select_from_yaml(path: str, key: str) -> Any:
35
+ cfg = load_mapping_yaml(path.strip())
36
+ val = OmegaConf.select(cfg, key.strip(), default=_NOT_FOUND, throw_on_missing=True)
37
+ if val is _NOT_FOUND:
38
+ raise ValueError(f"${{from_yaml:{path},{key}}}: {path!r} has no key {key.strip()!r}")
39
+ return val
40
+
41
+
42
+ OmegaConf.register_new_resolver("from_yaml", _select_from_yaml, replace=True, use_cache=True)
43
+
44
+
45
+ def load_yaml(path: str | Path) -> dict[str, Any]:
46
+ with open(path, encoding="utf-8") as f:
47
+ try:
48
+ cfg = yaml.safe_load(f)
49
+ except yaml.YAMLError as e:
50
+ raise ValueError(f"Config {path} is not valid YAML: {e}") from e
51
+ if not isinstance(cfg, dict):
52
+ raise ValueError(f"Config {path} did not parse to a mapping (got {type(cfg).__name__})")
53
+ return cfg
54
+
55
+
56
+ def load_mapping_yaml(path: str) -> DictConfig:
57
+ return _compose(Path(path).resolve(), visiting=())
58
+
59
+
60
+ def _load_one(path: Path) -> DictConfig:
61
+ try:
62
+ loaded = OmegaConf.load(path)
63
+ except OSError as e:
64
+ # OmegaConf.load raises OSError("Invalid loaded object type: <type>") for a top-level SCALAR
65
+ # yaml (42 / 3.14 / true) before we can type-check it. Re-raise THAT as the same path-naming
66
+ # ValueError so a scalar fails like every other non-mapping shape. A genuine IO error
67
+ # (missing/unreadable file -> FileNotFoundError) is NOT a parse problem -> let it propagate.
68
+ if isinstance(e, FileNotFoundError) or "Invalid loaded object type" not in str(e):
69
+ raise
70
+ raise ValueError(
71
+ f"config file {str(path)!r} did not parse to a mapping (got {type(e).__name__}: {e})"
72
+ ) from e
73
+ if not isinstance(loaded, DictConfig):
74
+ raise ValueError(
75
+ f"config file {str(path)!r} did not parse to a mapping (got {type(loaded).__name__})"
76
+ )
77
+ return loaded
78
+
79
+
80
+ def _compose(path: Path, visiting: tuple[Path, ...]) -> DictConfig:
81
+ if path in visiting:
82
+ chain = " -> ".join(str(p) for p in (*visiting, path))
83
+ raise ValueError(f"`defaults` cycle detected: {chain}")
84
+ loaded = _load_one(path)
85
+ defaults = loaded.pop("defaults", None)
86
+ if defaults is None:
87
+ return loaded
88
+ if not isinstance(defaults, ListConfig):
89
+ raise ValueError(
90
+ f"config file {str(path)!r}: top-level `defaults` must be a list of yaml paths, "
91
+ f"got {type(defaults).__name__}"
92
+ )
93
+ merged: DictConfig = OmegaConf.create({}) # type: ignore[assignment]
94
+ for entry in defaults:
95
+ if not isinstance(entry, str):
96
+ raise ValueError(
97
+ f"config file {str(path)!r}: each `defaults` entry must be a string path, "
98
+ f"got {type(entry).__name__}: {entry!r}"
99
+ )
100
+ # Defaults paths resolve relative to the CWD — the project root every script is run from —
101
+ # so one config path convention holds across a repo, wherever the including file lives.
102
+ # (An absolute entry resolves to itself: Path("/abs") wins over the cwd join.)
103
+ entry_path = (Path.cwd() / entry).resolve()
104
+ merged = cast(DictConfig, OmegaConf.merge(merged, _compose(entry_path, (*visiting, path))))
105
+ return cast(DictConfig, OmegaConf.merge(merged, loaded))
File without changes
@@ -0,0 +1,168 @@
1
+ # Typed, all-fields-required config loading — merge YAML onto a dataclass schema.
2
+ #
3
+ # A schema is a @dataclass whose every leaf defaults to omegaconf.MISSING, so a config must set
4
+ # each field explicitly (nothing is silently filled in). load_config merges one or more specs (YAML
5
+ # files and/or dotted key=value overrides) onto that schema and returns a fully populated instance.
6
+ # Two rules, enforced at load time:
7
+ # * every leaf must end up set — an unset MISSING leaf raises (a nullable field that is "off" must
8
+ # still be present as null; an empty collection must be written out as []);
9
+ # * unknown keys are rejected (OmegaConf struct mode).
10
+ # YAML files are read via slimconfig.config.load_mapping_yaml, so each may carry a top-level
11
+ # `defaults: [<path>, ...]` list to compose shared configs (Hydra-style: listed files merge first,
12
+ # the current file wins on top).
13
+
14
+ from __future__ import annotations
15
+
16
+ import dataclasses
17
+ import json
18
+ import os
19
+ import socket
20
+ import subprocess
21
+ import sys
22
+ from collections.abc import Mapping
23
+ from datetime import UTC, datetime
24
+ from pathlib import Path
25
+ from typing import Any, cast
26
+
27
+ from omegaconf import DictConfig, OmegaConf
28
+
29
+ from .config import load_mapping_yaml
30
+
31
+ # One config source: a YAML file path, a `dotted.key=value` override, or a ready-made mapping.
32
+ type Spec = str | Mapping[str, Any] | DictConfig
33
+
34
+
35
+ # Dotted paths of every leaf field still unset (recurses into nested configs).
36
+ def _missing_fields(cfg: DictConfig, prefix: str = "") -> list[str]:
37
+ missing: list[str] = []
38
+ for raw_key in cfg:
39
+ key = str(raw_key)
40
+ if OmegaConf.is_missing(cfg, key):
41
+ missing.append(prefix + key)
42
+ continue
43
+ value = cfg[key]
44
+ if OmegaConf.is_dict(value): # recurse into nested configs only; lists are leaf values
45
+ missing.extend(_missing_fields(cast(DictConfig, value), prefix + key + "."))
46
+ return missing
47
+
48
+
49
+ # Merge YAML files, dotted key=value overrides, and already-built mappings into one unstructured
50
+ # config. A YAML file is loaded via load_mapping_yaml, so it may carry a top-level `defaults: [...]`
51
+ # list to compose others; a mapping spec lets a caller merge values it computed itself (e.g. one cell
52
+ # of a sweep matrix resolved at runtime) with the same precedence rule — later specs win.
53
+ def merge_specs(specs: list[Spec]) -> DictConfig:
54
+ merged = OmegaConf.create()
55
+ for spec in specs:
56
+ if isinstance(spec, Mapping | DictConfig):
57
+ merged = OmegaConf.merge(merged, spec)
58
+ elif Path(spec).is_file():
59
+ merged = OmegaConf.merge(merged, load_mapping_yaml(spec))
60
+ elif "=" in spec:
61
+ merged = OmegaConf.merge(merged, OmegaConf.from_dotlist([spec]))
62
+ else:
63
+ raise FileNotFoundError(
64
+ f"config spec {spec!r} is neither a file nor a key=value override"
65
+ )
66
+ return cast(DictConfig, merged)
67
+
68
+
69
+ # Merge `specs` (YAML files and/or dotted key=value overrides) onto `schema`, in order (list a file
70
+ # before the overrides that should win over it). Returns a fully-populated schema instance. Raises
71
+ # ValueError if any leaf is left unset, FileNotFoundError for a bad spec, and OmegaConf errors for
72
+ # unknown keys / type mismatches.
73
+ def load_config[T](schema: type[T], specs: list[Spec]) -> T:
74
+ merged = OmegaConf.merge(OmegaConf.structured(schema), merge_specs(specs))
75
+ missing = _missing_fields(cast(DictConfig, merged))
76
+ if missing:
77
+ raise ValueError(f"{schema.__name__} is missing required field(s): {', '.join(missing)}")
78
+ return cast(T, OmegaConf.to_object(merged))
79
+
80
+
81
+ # Return top-level `key` from the merged specs (or None), without validation — lets a caller pick a
82
+ # schema before strict structured loading (e.g. read `method` to choose which schema to load). Accepts
83
+ # the same specs as load_config (YAML file paths and/or dotted key=value overrides), so it works with
84
+ # the bare-file-path invocation entry points use. Unknown keys are tolerated (no struct check).
85
+ def peek(args: list[Spec], key: str) -> Any:
86
+ return merge_specs(args).get(key)
87
+
88
+
89
+ def _git(*args: str) -> subprocess.CompletedProcess:
90
+ return subprocess.run(["git", *args], capture_output=True, text=True, timeout=10, check=False)
91
+
92
+
93
+ def _git_head() -> dict[str, Any]:
94
+ try:
95
+ commit = _git("rev-parse", "HEAD")
96
+ if commit.returncode:
97
+ return {}
98
+ return {
99
+ "git_commit": commit.stdout.strip(),
100
+ "git_dirty": bool(_git("status", "--porcelain").stdout.strip()),
101
+ }
102
+ except (OSError, subprocess.SubprocessError):
103
+ return {}
104
+
105
+
106
+ # Turn whatever a caller has into the plain config mapping to snapshot: raw specs (the dispatch path —
107
+ # because every field must be set explicitly, the merged YAML already IS the full config), a DictConfig,
108
+ # or a dataclass instance a handler built at runtime (e.g. one cell of a sweep matrix).
109
+ def _as_dictconfig(config: Any) -> DictConfig:
110
+ if isinstance(config, list):
111
+ return merge_specs(cast(list[Spec], config))
112
+ if isinstance(config, DictConfig):
113
+ return config
114
+ if dataclasses.is_dataclass(config) or isinstance(config, Mapping):
115
+ return cast(DictConfig, OmegaConf.structured(config))
116
+ raise TypeError(f"cannot snapshot config of type {type(config).__name__}")
117
+
118
+
119
+ # Open the run's folder and record what produced it. Writes two files:
120
+ # config.yaml — the fully-resolved config, re-runnable as-is (`python run.py <run_dir>/config.yaml`)
121
+ # run_meta.json — argv / cwd / git commit / start time / host
122
+ # Everything the run produces goes in this same folder, so a result is never separated from its config.
123
+ # The folder itself must be creatable (the run needs somewhere to write); the snapshot is best-effort —
124
+ # provenance never aborts a run.
125
+ def start_run(run_dir: str, config: Any) -> str:
126
+ os.makedirs(run_dir, exist_ok=True)
127
+ try:
128
+ # Render BOTH payloads before touching a file: re-running a run from its own snapshot
129
+ # (`python run.py <run_dir>/config.yaml`) passes the very file we are about to overwrite, and
130
+ # opening it "w" first would truncate it out from under the read.
131
+ snapshot = OmegaConf.to_yaml(_as_dictconfig(config), resolve=True)
132
+ meta = json.dumps({
133
+ "argv": sys.argv,
134
+ "cwd": os.getcwd(),
135
+ "started": datetime.now(UTC).isoformat(timespec="seconds"),
136
+ "host": socket.gethostname(),
137
+ **_git_head(),
138
+ }, indent=2, sort_keys=True)
139
+ with open(os.path.join(run_dir, "config.yaml"), "w", encoding="utf-8") as f:
140
+ f.write(snapshot)
141
+ with open(os.path.join(run_dir, "run_meta.json"), "w", encoding="utf-8") as f:
142
+ f.write(meta)
143
+ except (OSError, TypeError, ValueError) as e:
144
+ print(f"[slimconfig] could not snapshot the config into {run_dir} ({e})")
145
+ return run_dir
146
+
147
+
148
+ # Run the handler the config's `mode` selects — the whole body of every entry point, so the mode
149
+ # contract (and its error message) is written once. `modes` maps a mode name to either
150
+ # (schema, handler) — load `schema` strictly, call handler(cfg); the usual case, or
151
+ # handler — call handler(specs) and let it load its own config, for the modes whose schema
152
+ # depends on another field (e.g. one schema per `method`).
153
+ # Both branches get their `run_dir` opened and snapshotted first (see start_run), so no handler has to
154
+ # remember to do it.
155
+ def dispatch(modes: Mapping[str, Any], specs: list[Spec]) -> int:
156
+ mode = peek(specs, "mode")
157
+ if mode not in modes:
158
+ raise SystemExit(f"config must set `mode` to one of {', '.join(modes)} (got {mode!r})")
159
+ run_dir = peek(specs, "run_dir")
160
+ if not run_dir:
161
+ raise SystemExit("config must set `run_dir` — every run owns a folder holding its config and results")
162
+ start_run(run_dir, specs)
163
+
164
+ entry = modes[mode]
165
+ if isinstance(entry, tuple):
166
+ schema, handler = entry
167
+ return handler(load_config(schema, specs))
168
+ return entry(specs)
@@ -0,0 +1,168 @@
1
+ Metadata-Version: 2.4
2
+ Name: slimconfig
3
+ Version: 0.1.0
4
+ Summary: YAML configs onto typed dataclass schemas — a lightweight Hydra stand-in
5
+ Author: Zeyu Yang
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/zeyuyang8/slimconfig
8
+ Project-URL: Repository, https://github.com/zeyuyang8/slimconfig
9
+ Keywords: config,yaml,omegaconf,dataclass,hydra
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Classifier: Programming Language :: Python :: 3.13
13
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
14
+ Classifier: Typing :: Typed
15
+ Requires-Python: >=3.12
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Requires-Dist: omegaconf<2.4,>=2.3
19
+ Requires-Dist: pyyaml
20
+ Provides-Extra: dev
21
+ Requires-Dist: pytest; extra == "dev"
22
+ Requires-Dist: ruff; extra == "dev"
23
+ Dynamic: license-file
24
+
25
+ # slimconfig
26
+
27
+ YAML configs merged onto typed dataclass schemas — a lightweight Hydra stand-in in ~300 lines,
28
+ built on [OmegaConf](https://omegaconf.readthedocs.io).
29
+
30
+ Two rules, enforced at load time:
31
+
32
+ * **Every field is required.** A schema's leaves all default to `MISSING`, so a config has to set
33
+ each one explicitly — a nullable field that is "off" is still written out as `null`, an empty
34
+ collection as `[]`. Nothing is silently inherited.
35
+ * **Unknown keys are rejected.** A typo in a YAML key is an error, not a value that goes nowhere.
36
+
37
+ Plus what a research/experiment runner actually needs: Hydra-style `defaults:` composition, a
38
+ `mode` dispatcher, and a run folder that snapshots the exact config it ran with.
39
+
40
+ ## Install
41
+
42
+ ```bash
43
+ pip install slimconfig
44
+ ```
45
+
46
+ ## Load a config
47
+
48
+ ```python
49
+ # train.py
50
+ import sys
51
+ from dataclasses import dataclass, field
52
+ from omegaconf import MISSING
53
+ from slimconfig import load_config
54
+
55
+ @dataclass
56
+ class Optim:
57
+ lr: float = MISSING
58
+ warmup_steps: int = MISSING
59
+
60
+ @dataclass
61
+ class TrainConfig:
62
+ run_dir: str = MISSING
63
+ model: str = MISSING
64
+ optim: Optim = field(default_factory=Optim)
65
+ resume_from: str | None = MISSING # "off" must still be spelled `null`
66
+
67
+ cfg = load_config(TrainConfig, sys.argv[1:]) # -> a real TrainConfig instance
68
+ print(cfg.optim.lr)
69
+ ```
70
+
71
+ ```yaml
72
+ # configs/train.yaml
73
+ run_dir: runs/${now:%Y%m%d-%H%M%S}
74
+ model: llama-3-8b
75
+ optim:
76
+ lr: 2.0e-4
77
+ warmup_steps: 100
78
+ resume_from: null
79
+ ```
80
+
81
+ ```bash
82
+ python train.py configs/train.yaml # a file
83
+ python train.py configs/train.yaml optim.lr=1e-4 # ...plus dotted overrides, later wins
84
+ ```
85
+
86
+ Leave `warmup_steps` out and the load fails with
87
+ `TrainConfig is missing required field(s): optim.warmup_steps` — before anything runs.
88
+
89
+ ## Share configs with `defaults:`
90
+
91
+ Any YAML may carry a top-level `defaults:` list of paths. Listed files merge first, in order, and
92
+ the current file wins on top; composition is recursive, and cycles are caught.
93
+
94
+ ```yaml
95
+ # configs/train_7b.yaml
96
+ defaults: [configs/train.yaml, configs/optim/cosine.yaml]
97
+ model: llama-3-7b
98
+ ```
99
+
100
+ Paths resolve **relative to the current working directory** (the project root scripts are launched
101
+ from), so one path convention holds wherever the including file lives. Absolute paths work too.
102
+
103
+ ## Interpolation resolvers
104
+
105
+ On top of OmegaConf's own `${a.b}` interpolation, importing slimconfig registers:
106
+
107
+ | Resolver | Meaning |
108
+ | --- | --- |
109
+ | `${now:%Y%m%d-%H%M%S}` | the load time, `strftime`-formatted — one consistent stamp per process |
110
+ | `${from_yaml:configs/data.yaml,dataset.name}` | one value read out of *another* config, so a config can track a value another file owns without duplicating it |
111
+
112
+ ## Dispatch on `mode`
113
+
114
+ For a single entry point that fans out to several jobs, `dispatch` reads `mode`, opens and
115
+ snapshots `run_dir`, then calls the matching handler:
116
+
117
+ ```python
118
+ # run.py
119
+ import sys
120
+ from slimconfig import dispatch
121
+
122
+ MODES = {
123
+ "train": (TrainConfig, train), # load TrainConfig strictly, call train(cfg)
124
+ "eval": (EvalConfig, evaluate),
125
+ "sweep": run_sweep, # bare handler: gets the raw specs, loads its own schema
126
+ }
127
+ raise SystemExit(dispatch(MODES, sys.argv[1:]))
128
+ ```
129
+
130
+ `mode` and `run_dir` are ordinary config keys, so a schema loaded this way declares them itself
131
+ (unknown keys are rejected).
132
+
133
+ ## Run folders
134
+
135
+ `start_run(run_dir, config)` (called for you by `dispatch`) creates the folder and writes:
136
+
137
+ * `config.yaml` — the fully-resolved config, re-runnable as-is: `python run.py <run_dir>/config.yaml`
138
+ * `run_meta.json` — argv, cwd, git commit + dirty flag, start time, host
139
+
140
+ Everything a run produces goes in that same folder, so a result is never separated from the config
141
+ that made it. The snapshot is best-effort — provenance never aborts a run.
142
+
143
+ ## API
144
+
145
+ | | |
146
+ | --- | --- |
147
+ | `load_config(schema, specs)` | merge specs onto a dataclass schema → a populated instance |
148
+ | `merge_specs(specs)` | merge specs into one unvalidated `DictConfig` |
149
+ | `peek(specs, key)` | read one top-level key before choosing a schema |
150
+ | `dispatch(modes, specs)` | `mode` → handler, with the run folder opened and snapshotted |
151
+ | `start_run(run_dir, config)` | create the run folder, write `config.yaml` + `run_meta.json` |
152
+ | `load_mapping_yaml(path)` | one YAML → `DictConfig`, with `defaults:` composed |
153
+ | `load_yaml(path)` | one YAML → `dict`, plain PyYAML, no composition |
154
+
155
+ A *spec* is a YAML file path, a `dotted.key=value` string, or a ready-made mapping/`DictConfig` —
156
+ so a caller can merge values it computed at runtime under the same "later wins" rule.
157
+
158
+ ## Development
159
+
160
+ ```bash
161
+ pip install -e ".[dev]"
162
+ pytest
163
+ ruff check .
164
+ ```
165
+
166
+ ## License
167
+
168
+ MIT
@@ -0,0 +1,14 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/slimconfig/__init__.py
5
+ src/slimconfig/config.py
6
+ src/slimconfig/py.typed
7
+ src/slimconfig/structured.py
8
+ src/slimconfig.egg-info/PKG-INFO
9
+ src/slimconfig.egg-info/SOURCES.txt
10
+ src/slimconfig.egg-info/dependency_links.txt
11
+ src/slimconfig.egg-info/requires.txt
12
+ src/slimconfig.egg-info/top_level.txt
13
+ tests/test_config.py
14
+ tests/test_structured.py
@@ -0,0 +1,6 @@
1
+ omegaconf<2.4,>=2.3
2
+ pyyaml
3
+
4
+ [dev]
5
+ pytest
6
+ ruff
@@ -0,0 +1 @@
1
+ slimconfig
@@ -0,0 +1,150 @@
1
+ # The YAML layer: load_yaml / load_mapping_yaml, `defaults:` composition, and the resolvers.
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+ from omegaconf import OmegaConf
7
+
8
+ from slimconfig import load_mapping_yaml, load_yaml
9
+
10
+
11
+ def write(path, text: str) -> str:
12
+ path.write_text(text, encoding="utf-8")
13
+ return str(path)
14
+
15
+
16
+ def test_load_yaml_returns_plain_dict(tmp_path):
17
+ path = write(tmp_path / "a.yaml", "a: 1\nb: [1, 2]\n")
18
+ assert load_yaml(path) == {"a": 1, "b": [1, 2]}
19
+
20
+
21
+ @pytest.mark.parametrize("body", ["- 1\n- 2\n", "42\n"])
22
+ def test_load_yaml_rejects_non_mapping(tmp_path, body):
23
+ path = write(tmp_path / "a.yaml", body)
24
+ with pytest.raises(ValueError, match="did not parse to a mapping"):
25
+ load_yaml(path)
26
+
27
+
28
+ def test_load_yaml_rejects_invalid_yaml(tmp_path):
29
+ path = write(tmp_path / "a.yaml", "a: [1,\n")
30
+ with pytest.raises(ValueError, match="not valid YAML"):
31
+ load_yaml(path)
32
+
33
+
34
+ def test_load_mapping_yaml_without_defaults(tmp_path):
35
+ path = write(tmp_path / "a.yaml", "a: 1\nnested: {x: 2}\n")
36
+ cfg = load_mapping_yaml(path)
37
+ assert cfg.a == 1
38
+ assert cfg.nested.x == 2
39
+
40
+
41
+ # (A bare string yaml is NOT in this list: OmegaConf parses `just a string` into the mapping
42
+ # {"just a string": None}, so it never reaches the type check.)
43
+ @pytest.mark.parametrize("body", ["- 1\n", "42\n"])
44
+ def test_load_mapping_yaml_rejects_non_mapping(tmp_path, body):
45
+ path = write(tmp_path / "a.yaml", body)
46
+ with pytest.raises(ValueError, match="did not parse to a mapping"):
47
+ load_mapping_yaml(path)
48
+
49
+
50
+ def test_load_mapping_yaml_missing_file_is_not_a_parse_error(tmp_path):
51
+ with pytest.raises(FileNotFoundError):
52
+ load_mapping_yaml(str(tmp_path / "nope.yaml"))
53
+
54
+
55
+ # ── `defaults:` composition ──────────────────────────────────────────────────
56
+
57
+
58
+ def test_defaults_current_file_wins_and_merges_deeply(tmp_path, monkeypatch):
59
+ monkeypatch.chdir(tmp_path)
60
+ write(tmp_path / "base.yaml", "a: 1\nb: 2\nnested: {x: 1, y: 1}\n")
61
+ child = write(tmp_path / "child.yaml", "defaults: [base.yaml]\nb: 20\nnested: {y: 20}\n")
62
+ cfg = load_mapping_yaml(child)
63
+ assert cfg.a == 1 # inherited
64
+ assert cfg.b == 20 # overridden
65
+ assert (cfg.nested.x, cfg.nested.y) == (1, 20) # deep merge, not replacement
66
+ assert "defaults" not in cfg
67
+
68
+
69
+ def test_defaults_later_entry_wins_over_earlier(tmp_path, monkeypatch):
70
+ monkeypatch.chdir(tmp_path)
71
+ write(tmp_path / "one.yaml", "a: 1\nshared: from_one\n")
72
+ write(tmp_path / "two.yaml", "b: 2\nshared: from_two\n")
73
+ child = write(tmp_path / "child.yaml", "defaults: [one.yaml, two.yaml]\n")
74
+ cfg = load_mapping_yaml(child)
75
+ assert (cfg.a, cfg.b, cfg.shared) == (1, 2, "from_two")
76
+
77
+
78
+ def test_defaults_compose_recursively(tmp_path, monkeypatch):
79
+ monkeypatch.chdir(tmp_path)
80
+ write(tmp_path / "grand.yaml", "a: 1\n")
81
+ write(tmp_path / "parent.yaml", "defaults: [grand.yaml]\nb: 2\n")
82
+ child = write(tmp_path / "child.yaml", "defaults: [parent.yaml]\nc: 3\n")
83
+ cfg = load_mapping_yaml(child)
84
+ assert (cfg.a, cfg.b, cfg.c) == (1, 2, 3)
85
+
86
+
87
+ def test_defaults_resolve_against_cwd_not_the_including_file(tmp_path, monkeypatch):
88
+ monkeypatch.chdir(tmp_path)
89
+ (tmp_path / "configs").mkdir()
90
+ write(tmp_path / "configs" / "base.yaml", "a: 1\n")
91
+ # The `defaults` entry is written from the project root, even though the file that carries it
92
+ # sits one directory down next to base.yaml.
93
+ child = write(tmp_path / "configs" / "child.yaml", "defaults: [configs/base.yaml]\nb: 2\n")
94
+ cfg = load_mapping_yaml(child)
95
+ assert (cfg.a, cfg.b) == (1, 2)
96
+
97
+
98
+ def test_defaults_cycle_is_detected(tmp_path, monkeypatch):
99
+ monkeypatch.chdir(tmp_path)
100
+ write(tmp_path / "a.yaml", "defaults: [b.yaml]\n")
101
+ write(tmp_path / "b.yaml", "defaults: [a.yaml]\n")
102
+ with pytest.raises(ValueError, match="`defaults` cycle detected"):
103
+ load_mapping_yaml(str(tmp_path / "a.yaml"))
104
+
105
+
106
+ @pytest.mark.parametrize(
107
+ ("body", "match"),
108
+ [
109
+ ("defaults: base.yaml\n", "must be a list of yaml paths"),
110
+ ("defaults: [{a: 1}]\n", "must be a string path"),
111
+ ],
112
+ )
113
+ def test_defaults_shape_is_validated(tmp_path, monkeypatch, body, match):
114
+ monkeypatch.chdir(tmp_path)
115
+ write(tmp_path / "base.yaml", "a: 1\n")
116
+ path = write(tmp_path / "child.yaml", body)
117
+ with pytest.raises(ValueError, match=match):
118
+ load_mapping_yaml(path)
119
+
120
+
121
+ # ── resolvers ────────────────────────────────────────────────────────────────
122
+
123
+
124
+ def test_now_resolver_stamps_a_value(tmp_path):
125
+ path = write(tmp_path / "a.yaml", "run_dir: runs/${now:%Y}\n")
126
+ cfg = load_mapping_yaml(path)
127
+ assert cfg.run_dir.startswith("runs/2")
128
+ assert len(cfg.run_dir) == len("runs/YYYY")
129
+
130
+
131
+ def test_now_resolver_is_one_consistent_stamp(tmp_path):
132
+ path = write(tmp_path / "a.yaml", "one: ${now:%Y%m%d-%H%M%S}\ntwo: ${now:%Y%m%d-%H%M%S}\n")
133
+ cfg = load_mapping_yaml(path)
134
+ assert cfg.one == cfg.two
135
+
136
+
137
+ def test_from_yaml_resolver_reads_another_config(tmp_path, monkeypatch):
138
+ monkeypatch.chdir(tmp_path)
139
+ write(tmp_path / "data.yaml", "dataset:\n name: wikitext\n")
140
+ path = write(tmp_path / "a.yaml", "tag: ${from_yaml:data.yaml,dataset.name}\n")
141
+ assert load_mapping_yaml(path).tag == "wikitext"
142
+
143
+
144
+ def test_from_yaml_resolver_rejects_a_missing_key(tmp_path, monkeypatch):
145
+ monkeypatch.chdir(tmp_path)
146
+ write(tmp_path / "data.yaml", "dataset:\n name: wikitext\n")
147
+ path = write(tmp_path / "a.yaml", "tag: ${from_yaml:data.yaml,dataset.nope}\n")
148
+ cfg = load_mapping_yaml(path)
149
+ with pytest.raises(Exception, match="has no key"): # wrapped by omegaconf on access
150
+ _ = OmegaConf.to_container(cfg, resolve=True)
@@ -0,0 +1,198 @@
1
+ # The typed layer: merge_specs / load_config / peek, plus start_run and dispatch.
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass, field
7
+
8
+ import pytest
9
+ from omegaconf import MISSING, OmegaConf
10
+
11
+ from slimconfig import dispatch, load_config, merge_specs, peek, start_run
12
+
13
+
14
+ @dataclass
15
+ class Optim:
16
+ lr: float = MISSING
17
+ warmup_steps: int = MISSING
18
+
19
+
20
+ @dataclass
21
+ class TrainConfig:
22
+ run_dir: str = MISSING
23
+ model: str = MISSING
24
+ tags: list[str] = MISSING
25
+ resume_from: str | None = MISSING
26
+ optim: Optim = field(default_factory=Optim)
27
+
28
+
29
+ FULL = """
30
+ run_dir: runs/demo
31
+ model: llama
32
+ tags: []
33
+ resume_from: null
34
+ optim:
35
+ lr: 0.0002
36
+ warmup_steps: 100
37
+ """
38
+
39
+
40
+ def write(path, text: str) -> str:
41
+ path.write_text(text, encoding="utf-8")
42
+ return str(path)
43
+
44
+
45
+ # ── merge_specs ──────────────────────────────────────────────────────────────
46
+
47
+
48
+ def test_merge_specs_later_spec_wins(tmp_path):
49
+ path = write(tmp_path / "a.yaml", "a: 1\nb: 2\n")
50
+ merged = merge_specs([path, "b=20", {"c": 30}])
51
+ assert (merged.a, merged.b, merged.c) == (1, 20, 30)
52
+
53
+
54
+ def test_merge_specs_parses_dotted_overrides(tmp_path):
55
+ path = write(tmp_path / "a.yaml", FULL)
56
+ merged = merge_specs([path, "optim.lr=0.5"])
57
+ assert merged.optim.lr == 0.5
58
+ assert merged.optim.warmup_steps == 100 # untouched
59
+
60
+
61
+ def test_merge_specs_rejects_a_spec_that_is_neither(tmp_path):
62
+ with pytest.raises(FileNotFoundError, match="neither a file nor a key=value override"):
63
+ merge_specs(["configs/typo.yaml"])
64
+
65
+
66
+ # ── load_config ──────────────────────────────────────────────────────────────
67
+
68
+
69
+ def test_load_config_returns_a_populated_instance(tmp_path):
70
+ cfg = load_config(TrainConfig, [write(tmp_path / "a.yaml", FULL)])
71
+ assert isinstance(cfg, TrainConfig)
72
+ assert isinstance(cfg.optim, Optim)
73
+ assert (cfg.model, cfg.tags, cfg.resume_from) == ("llama", [], None)
74
+ assert cfg.optim.lr == pytest.approx(2e-4)
75
+
76
+
77
+ def test_load_config_overrides_win_over_the_file(tmp_path):
78
+ path = write(tmp_path / "a.yaml", FULL)
79
+ cfg = load_config(TrainConfig, [path, "model=qwen", "optim.warmup_steps=7"])
80
+ assert (cfg.model, cfg.optim.warmup_steps) == ("qwen", 7)
81
+
82
+
83
+ def test_load_config_requires_every_leaf(tmp_path):
84
+ path = write(tmp_path / "a.yaml", "run_dir: runs/demo\nmodel: llama\ntags: []\n")
85
+ with pytest.raises(ValueError, match=r"missing required field\(s\): resume_from, optim.lr"):
86
+ load_config(TrainConfig, [path])
87
+
88
+
89
+ def test_load_config_rejects_unknown_keys(tmp_path):
90
+ path = write(tmp_path / "a.yaml", FULL + "typo_key: 1\n")
91
+ with pytest.raises(Exception, match="typo_key"):
92
+ load_config(TrainConfig, [path])
93
+
94
+
95
+ def test_load_config_rejects_a_wrongly_typed_value(tmp_path):
96
+ path = write(tmp_path / "a.yaml", FULL.replace("warmup_steps: 100", "warmup_steps: many"))
97
+ with pytest.raises(Exception, match="warmup_steps"):
98
+ load_config(TrainConfig, [path])
99
+
100
+
101
+ def test_load_config_composes_defaults(tmp_path, monkeypatch):
102
+ monkeypatch.chdir(tmp_path)
103
+ write(tmp_path / "base.yaml", FULL)
104
+ child = write(tmp_path / "child.yaml", "defaults: [base.yaml]\nmodel: qwen\n")
105
+ cfg = load_config(TrainConfig, [child])
106
+ assert (cfg.model, cfg.optim.warmup_steps) == ("qwen", 100)
107
+
108
+
109
+ # ── peek ─────────────────────────────────────────────────────────────────────
110
+
111
+
112
+ def test_peek_reads_a_top_level_key_without_validation(tmp_path):
113
+ path = write(tmp_path / "a.yaml", "mode: train\nunknown_key: 1\n")
114
+ assert peek([path], "mode") == "train"
115
+ assert peek([path], "absent") is None
116
+ assert peek([path, "mode=eval"], "mode") == "eval"
117
+
118
+
119
+ # ── start_run ────────────────────────────────────────────────────────────────
120
+
121
+
122
+ def test_start_run_writes_a_resolved_snapshot_and_meta(tmp_path):
123
+ spec = write(tmp_path / "a.yaml", FULL)
124
+ run_dir = start_run(str(tmp_path / "runs" / "demo"), [spec])
125
+ snapshot = OmegaConf.load(f"{run_dir}/config.yaml")
126
+ assert snapshot.model == "llama"
127
+ meta = json.loads((tmp_path / "runs" / "demo" / "run_meta.json").read_text())
128
+ assert {"argv", "cwd", "started", "host"} <= meta.keys()
129
+
130
+
131
+ def test_start_run_snapshot_is_rerunnable_in_place(tmp_path):
132
+ # Re-running a run from its own snapshot passes the very file start_run overwrites.
133
+ spec = write(tmp_path / "a.yaml", FULL)
134
+ run_dir = start_run(str(tmp_path / "run"), [spec])
135
+ snapshot = f"{run_dir}/config.yaml"
136
+ start_run(run_dir, [snapshot])
137
+ assert load_config(TrainConfig, [snapshot]).model == "llama"
138
+
139
+
140
+ def test_start_run_accepts_a_dataclass_instance(tmp_path):
141
+ cfg = load_config(TrainConfig, [write(tmp_path / "a.yaml", FULL)])
142
+ run_dir = start_run(str(tmp_path / "run"), cfg)
143
+ assert OmegaConf.load(f"{run_dir}/config.yaml").optim.warmup_steps == 100
144
+
145
+
146
+ def test_start_run_survives_an_unsnapshottable_config(tmp_path, capsys):
147
+ start_run(str(tmp_path / "run"), object()) # provenance never aborts a run
148
+ assert "could not snapshot" in capsys.readouterr().out
149
+ assert (tmp_path / "run").is_dir()
150
+
151
+
152
+ # ── dispatch ─────────────────────────────────────────────────────────────────
153
+
154
+
155
+ # A schema reached through dispatch declares `mode` itself — the key is part of the config, and
156
+ # unknown keys are rejected.
157
+ @dataclass
158
+ class JobConfig:
159
+ mode: str = MISSING
160
+ run_dir: str = MISSING
161
+ model: str = MISSING
162
+
163
+
164
+ def test_dispatch_loads_the_schema_for_a_tuple_entry(tmp_path):
165
+ seen = {}
166
+
167
+ def train(cfg: JobConfig) -> int:
168
+ seen["model"] = cfg.model
169
+ return 0
170
+
171
+ path = write(tmp_path / "a.yaml", f"mode: train\nrun_dir: {tmp_path / 'run'}\nmodel: llama\n")
172
+ assert dispatch({"train": (JobConfig, train)}, [path]) == 0
173
+ assert seen["model"] == "llama"
174
+ assert (tmp_path / "run" / "config.yaml").is_file()
175
+
176
+
177
+ def test_dispatch_passes_raw_specs_to_a_bare_handler(tmp_path):
178
+ seen = {}
179
+
180
+ def sweep(specs) -> int:
181
+ seen["specs"] = specs
182
+ return 3
183
+
184
+ path = write(tmp_path / "a.yaml", f"mode: sweep\nrun_dir: {tmp_path / 'run'}\n")
185
+ assert dispatch({"sweep": sweep}, [path]) == 3
186
+ assert seen["specs"] == [path]
187
+
188
+
189
+ def test_dispatch_rejects_an_unknown_mode(tmp_path):
190
+ path = write(tmp_path / "a.yaml", "mode: nope\nrun_dir: runs/x\n")
191
+ with pytest.raises(SystemExit, match="must set `mode` to one of train"):
192
+ dispatch({"train": (TrainConfig, lambda cfg: 0)}, [path])
193
+
194
+
195
+ def test_dispatch_requires_a_run_dir(tmp_path):
196
+ path = write(tmp_path / "a.yaml", "mode: train\n")
197
+ with pytest.raises(SystemExit, match="must set `run_dir`"):
198
+ dispatch({"train": (TrainConfig, lambda cfg: 0)}, [path])