Skip to content

Commit bac0542

Browse files
committed
Support including TOML config files via an include key
Config files can now reference other TOML files via a root-level `include` array. Included files are deep-merged before the including file's own content, with paths resolved relative to the including file's directory. Cycle detection prevents infinite recursion. Both `config.toml` and `config.{name}.toml` override files support this mechanism. Signed-off-by: Gaëtan Lehmann <gaetan.lehmann@vates.tech>
1 parent 983a76d commit bac0542

2 files changed

Lines changed: 35 additions & 3 deletions

File tree

config-schema.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,12 @@
282282
}
283283
},
284284
"properties": {
285+
"include": {
286+
"type": "array",
287+
"items": { "type": "string" },
288+
"description": "List of TOML files to load and deep-merge before this file's content. Paths are relative to this file's directory.",
289+
"default": []
290+
},
285291
"objects_name_prefix": {
286292
"anyOf": [
287293
{

lib/config_loader.py

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,32 @@ def _load_toml_file(path: Path) -> dict[str, Any]:
182182
return tomllib.load(f)
183183

184184

185+
def _load_toml_with_includes(path: Path, _seen: set[Path] | None = None) -> dict[str, Any]:
186+
"""Load a TOML file and recursively merge its includes.
187+
188+
Files listed in the root-level ``include`` key (array of strings)
189+
are loaded and deep-merged before the file's own content.
190+
Paths are resolved relative to the including file's directory.
191+
"""
192+
if _seen is None:
193+
_seen = set()
194+
path = path.resolve()
195+
if path in _seen:
196+
raise ValueError(f"Cyclic include detected: {path}")
197+
_seen.add(path)
198+
199+
data = _load_toml_file(path)
200+
includes = data.pop("include", None) or []
201+
202+
result: dict[str, Any] = {}
203+
for inc in includes:
204+
inc_path = (path.parent / inc).resolve()
205+
included = _load_toml_with_includes(inc_path, _seen)
206+
result = _merge_dicts(result, included)
207+
208+
return _merge_dicts(result, data)
209+
210+
185211
def _merge_dicts(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
186212
"""Deep merge override into base (recursive)."""
187213
for key, value in override.items():
@@ -235,7 +261,7 @@ def load_config() -> Config:
235261
repo_root = Path(__file__).parent.parent
236262
base_config_path = repo_root / "config.toml"
237263
try:
238-
base_data = _load_toml_file(base_config_path)
264+
base_data = _load_toml_with_includes(base_config_path)
239265
except FileNotFoundError:
240266
print(f"FATAL: {base_config_path} not found", file=sys.stderr)
241267
sys.exit(1)
@@ -247,15 +273,15 @@ def apply_override(config_name: str) -> None:
247273
repo_root = Path(__file__).parent.parent
248274
base_config_path = repo_root / "config.toml"
249275
try:
250-
base_data = _load_toml_file(base_config_path)
276+
base_data = _load_toml_with_includes(base_config_path)
251277
except FileNotFoundError:
252278
print(f"FATAL: {base_config_path} not found", file=sys.stderr)
253279
sys.exit(1)
254280
override_path = repo_root / f"config.{config_name}.toml"
255281
if not override_path.exists():
256282
print(f"FATAL: {override_path} not found", file=sys.stderr)
257283
sys.exit(1)
258-
base_data = _merge_dicts(base_data, _load_toml_file(override_path))
284+
base_data = _merge_dicts(base_data, _load_toml_with_includes(override_path))
259285
new = _build_config(base_data)
260286
for field in new.model_fields:
261287
setattr(config, field, getattr(new, field))

0 commit comments

Comments
 (0)