"""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)