| """Configuration loading with explicit path resolution and validation.""" |
|
|
| from __future__ import annotations |
|
|
| from dataclasses import dataclass |
| from pathlib import Path |
| from typing import Any |
|
|
| import yaml |
|
|
|
|
| @dataclass(frozen=True) |
| class LoadedConfig: |
| path: Path |
| values: dict[str, Any] |
|
|
| @property |
| def base_dir(self) -> Path: |
| return self.path.parent.parent |
|
|
| def resolve_path(self, section: str, key: str) -> Path: |
| value = self.values[section][key] |
| path = Path(value) |
| if not path.is_absolute(): |
| path = (self.base_dir / path).resolve() |
| return path |
|
|
|
|
| def _merge_values(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]: |
| merged = dict(base) |
| for key, value in override.items(): |
| if isinstance(value, dict) and isinstance(merged.get(key), dict): |
| merged[key] = _merge_values(merged[key], value) |
| else: |
| merged[key] = value |
| return merged |
|
|
|
|
| def _read_config(config_path: Path, seen: set[Path]) -> dict[str, Any]: |
| if config_path in seen: |
| raise ValueError(f"Configuration inheritance cycle at {config_path}") |
| with config_path.open("r", encoding="utf-8") as handle: |
| values = yaml.safe_load(handle) or {} |
| parent = values.pop("extends", None) |
| if parent is None: |
| return values |
| parent_path = Path(parent) |
| if not parent_path.is_absolute(): |
| parent_path = (config_path.parent / parent_path).resolve() |
| return _merge_values(_read_config(parent_path, seen | {config_path}), values) |
|
|
|
|
| def load_config(path: str | Path) -> LoadedConfig: |
| config_path = Path(path).resolve() |
| values = _read_config(config_path, set()) |
| required = {"project", "data", "model", "training", "evaluation"} |
| missing = required.difference(values) |
| if missing: |
| raise ValueError(f"Configuration is missing sections: {sorted(missing)}") |
| return LoadedConfig(path=config_path, values=values) |
|
|