Skip to content

Commit 5f93fa3

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 6782813 commit 5f93fa3

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
@@ -293,6 +293,12 @@
293293
}
294294
},
295295
"properties": {
296+
"include": {
297+
"type": "array",
298+
"items": { "type": "string" },
299+
"description": "List of TOML files to load and deep-merge before this file's content. Paths are relative to this file's directory.",
300+
"default": []
301+
},
296302
"objects_name_prefix": {
297303
"anyOf": [
298304
{

lib/config_loader.py

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

189189

190+
def _load_toml_with_includes(path: Path, _seen: set[Path] | None = None) -> dict[str, Any]:
191+
"""Load a TOML file and recursively merge its includes.
192+
193+
Files listed in the root-level ``include`` key (array of strings)
194+
are loaded and deep-merged before the file's own content.
195+
Paths are resolved relative to the including file's directory.
196+
"""
197+
if _seen is None:
198+
_seen = set()
199+
path = path.resolve()
200+
if path in _seen:
201+
raise ValueError(f"Cyclic include detected: {path}")
202+
_seen.add(path)
203+
204+
data = _load_toml_file(path)
205+
includes = data.pop("include", None) or []
206+
207+
result: dict[str, Any] = {}
208+
for inc in includes:
209+
inc_path = (path.parent / inc).resolve()
210+
included = _load_toml_with_includes(inc_path, _seen)
211+
result = _merge_dicts(result, included)
212+
213+
return _merge_dicts(result, data)
214+
215+
190216
def _merge_dicts(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
191217
"""Deep merge override into base (recursive)."""
192218
for key, value in override.items():
@@ -240,7 +266,7 @@ def load_config() -> Config:
240266
repo_root = Path(__file__).parent.parent
241267
base_config_path = repo_root / "config.toml"
242268
try:
243-
base_data = _load_toml_file(base_config_path)
269+
base_data = _load_toml_with_includes(base_config_path)
244270
except FileNotFoundError:
245271
print(f"FATAL: {base_config_path} not found", file=sys.stderr)
246272
sys.exit(1)
@@ -252,15 +278,15 @@ def apply_override(config_name: str) -> None:
252278
repo_root = Path(__file__).parent.parent
253279
base_config_path = repo_root / "config.toml"
254280
try:
255-
base_data = _load_toml_file(base_config_path)
281+
base_data = _load_toml_with_includes(base_config_path)
256282
except FileNotFoundError:
257283
print(f"FATAL: {base_config_path} not found", file=sys.stderr)
258284
sys.exit(1)
259285
override_path = repo_root / f"config.{config_name}.toml"
260286
if not override_path.exists():
261287
print(f"FATAL: {override_path} not found", file=sys.stderr)
262288
sys.exit(1)
263-
base_data = _merge_dicts(base_data, _load_toml_file(override_path))
289+
base_data = _merge_dicts(base_data, _load_toml_with_includes(override_path))
264290
new = _build_config(base_data)
265291
for field in new.model_fields:
266292
setattr(config, field, getattr(new, field))

0 commit comments

Comments
 (0)