Source code for exlab_wizard.config.loader

"""config.yaml round-trip loader. Backend Spec §9.

Uses ruamel.yaml in round-trip mode so saving back to disk preserves
operator-readable comments and key order. The Settings UI's Save action
goes through this module so config.yaml stays human-friendly across
edit cycles.
"""

from __future__ import annotations

import io
from pathlib import Path
from typing import Any

from pydantic import ValidationError
from ruamel.yaml import YAML

from exlab_wizard.config.models import Config
from exlab_wizard.errors import ConfigError
from exlab_wizard.io import atomic_write_bytes
from exlab_wizard.logging import get_logger

_log = get_logger(__name__)


def _yaml() -> YAML:
    """Build a configured ruamel.yaml instance.

    typ='rt' is round-trip mode (preserves comments, anchors, key order).
    indent settings match the §9 example layout.
    """
    yaml = YAML(typ="rt")
    yaml.preserve_quotes = True
    yaml.indent(mapping=2, sequence=4, offset=2)
    yaml.width = 120
    return yaml


[docs] def load_config(path: Path) -> Config: """Load a config.yaml from disk and validate against the Pydantic model. Raises ConfigError on filesystem error, YAML parse error, or model validation failure. The original ValidationError is chained as the cause so the caller can introspect per-field errors. """ try: text = path.read_text(encoding="utf-8") except FileNotFoundError as exc: raise ConfigError(f"config.yaml not found at {path}") from exc except OSError as exc: raise ConfigError(f"could not read config.yaml at {path}: {exc}") from exc return load_config_from_text(text)
[docs] def load_config_from_text(text: str) -> Config: """Load a config from YAML text. Same semantics as load_config but for in-memory input.""" try: data = _yaml().load(text) or {} except Exception as exc: # ruamel.yaml has multiple exception types; catch broadly raise ConfigError(f"config.yaml is not valid YAML: {exc}") from exc if not isinstance(data, dict): raise ConfigError("config.yaml top level must be a mapping") try: return Config.model_validate(data) except ValidationError as exc: raise ConfigError(f"config.yaml failed validation:\n{exc}") from exc
[docs] def save_config(path: Path, config: Config, *, original_text: str | None = None) -> None: """Atomically write `config` back to `path`. If `original_text` is supplied, ruamel.yaml round-trips it so existing comments and key order are preserved; only the modified values change. If `original_text` is None, write a fresh document with no preserved formatting. Atomicity: write to ``<path>.tmp``, fsync, then ``Path.replace`` to ``<path>``. """ yaml = _yaml() new_dict = config.model_dump(mode="python", exclude_none=False) if original_text is not None: # Round-trip merge: load original, mutate values in place, dump. original = yaml.load(original_text) or {} _deep_merge(original, new_dict) out = original else: out = new_dict text_buf = io.StringIO() yaml.dump(out, text_buf) encoded = text_buf.getvalue().encode("utf-8") path.parent.mkdir(parents=True, exist_ok=True) atomic_write_bytes(path, encoded) _log.info("saved config.yaml [path=%s] [keys=%d]", str(path), len(out))
def _deep_merge(target: Any, source: dict[str, Any]) -> None: """In-place deep-merge source into target. Used by save_config to overlay new values onto a ruamel-loaded document so comments/key order survive. Lists are replaced wholesale (the Settings UI hands us the full list to write). """ for key, value in source.items(): if isinstance(value, dict) and isinstance(target.get(key), dict): _deep_merge(target[key], value) else: target[key] = value
[docs] def dump_config(config: Config) -> str: """Serialize config to a YAML string. No comment preservation; tests use this.""" yaml = _yaml() buf = io.StringIO() yaml.dump(config.model_dump(mode="python", exclude_none=False), buf) return buf.getvalue()