config.py 1.85 KB
from __future__ import annotations

import os
import re
from pathlib import Path
from typing import Any

import yaml
from dotenv import load_dotenv


_ENV_PATTERN = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}")


def _expand_env(value: Any) -> Any:
    if isinstance(value, dict):
        return {key: _expand_env(item) for key, item in value.items()}
    if isinstance(value, list):
        return [_expand_env(item) for item in value]
    if not isinstance(value, str):
        return value

    def replace(match: re.Match[str]) -> str:
        default = match.group(2) if match.group(2) is not None else ""
        return os.getenv(match.group(1), default)

    expanded = _ENV_PATTERN.sub(replace, value)
    return _coerce_scalar(expanded)


def _coerce_scalar(value: str) -> Any:
    lowered = value.lower()
    if lowered in {"true", "false"}:
        return lowered == "true"
    if lowered in {"none", "null"}:
        return None
    try:
        if "." not in value:
            return int(value)
        return float(value)
    except ValueError:
        return value


def load_config(path: str | Path = "configs/eval.yaml") -> dict[str, Any]:
    load_dotenv()
    config_path = Path(path)
    with config_path.open("r", encoding="utf-8") as file:
        raw = yaml.safe_load(file) or {}
    return _expand_env(raw)


def require_config(config: dict[str, Any], dotted_key: str) -> Any:
    current: Any = config
    for part in dotted_key.split("."):
        if not isinstance(current, dict) or part not in current:
            raise ValueError(f"Missing required config value: {dotted_key}")
        value = current[part]
        if value is None or value == "":
            raise ValueError(f"Missing required config value: {dotted_key}")
        current = value
    return current


def project_path(*parts: str) -> Path:
    return Path.cwd().joinpath(*parts)