Source code for regstack.config.loader

from __future__ import annotations

import os
import tomllib
from collections.abc import Mapping
from pathlib import Path
from typing import Any

from regstack.config.schema import RegStackConfig

_DEFAULT_TOML_NAMES = ("regstack.toml",)
_DEFAULT_SECRETS_NAMES = ("regstack.secrets.env",)


def _find_first(names: tuple[str, ...]) -> Path | None:
    cwd = Path.cwd()
    for name in names:
        candidate = cwd / name
        if candidate.is_file():
            return candidate
    return None


def _read_toml(path: Path) -> dict[str, Any]:
    with path.open("rb") as fh:
        return tomllib.load(fh)


def _parse_dotenv(path: Path) -> dict[str, str]:
    """Minimal .env parser — `KEY=value` per line, no shell features."""
    out: dict[str, str] = {}
    for raw in path.read_text().splitlines():
        line = raw.strip()
        if not line or line.startswith("#"):
            continue
        if "=" not in line:
            continue
        key, _, value = line.partition("=")
        key = key.strip()
        value = value.strip()
        if (value.startswith('"') and value.endswith('"')) or (
            value.startswith("'") and value.endswith("'")
        ):
            value = value[1:-1]
        out[key] = value
    return out


def _flatten_for_env(d: Mapping[str, Any], prefix: str = "REGSTACK_") -> dict[str, str]:
    out: dict[str, str] = {}
    for key, value in d.items():
        full_key = f"{prefix}{key.upper()}"
        if isinstance(value, Mapping):
            out.update(_flatten_for_env(value, prefix=f"{full_key}__"))
        elif isinstance(value, list):
            out[full_key] = ",".join(str(item) for item in value)
        elif isinstance(value, bool):
            out[full_key] = "true" if value else "false"
        elif value is None:
            continue
        else:
            out[full_key] = str(value)
    return out


[docs] def load_config( toml_path: Path | str | None = None, secrets_env_path: Path | str | None = None, **overrides: object, ) -> RegStackConfig: """Build a ``RegStackConfig`` by merging defaults, TOML, env, and kwargs. Highest priority wins: kwargs > os.environ > secrets.env > TOML > defaults. """ env_overlay: dict[str, str] = {} toml_candidate: Path | None if toml_path is not None: toml_candidate = Path(toml_path) elif (env_path := os.environ.get("REGSTACK_CONFIG")) is not None: toml_candidate = Path(env_path) else: toml_candidate = _find_first(_DEFAULT_TOML_NAMES) if toml_candidate is not None and toml_candidate.is_file(): env_overlay.update(_flatten_for_env(_read_toml(toml_candidate))) secrets_candidate: Path | None if secrets_env_path is not None: secrets_candidate = Path(secrets_env_path) else: secrets_candidate = _find_first(_DEFAULT_SECRETS_NAMES) if secrets_candidate is not None and secrets_candidate.is_file(): env_overlay.update(_parse_dotenv(secrets_candidate)) # Real environment wins over TOML and secrets file. for key, value in os.environ.items(): if key.startswith("REGSTACK_"): env_overlay[key] = value # pydantic-settings reads from os.environ; we apply our merged overlay # by patching it for the duration of construction. saved: dict[str, str | None] = {} try: for key, value in env_overlay.items(): saved[key] = os.environ.get(key) os.environ[key] = value return RegStackConfig(**overrides) # type: ignore[arg-type] finally: for key, prev in saved.items(): if prev is None: os.environ.pop(key, None) else: os.environ[key] = prev