Files
hermes-agent/hermes_cli/config_version_stamp.py
2026-10-08 17:22:54 +02:00

76 lines
3.5 KiB
Python

"""The config schema-version stamp: raw reads of ``_config_version`` from config.yaml.
Split out of :mod:`hermes_cli.config` (code-health size ratchet); ``hermes_cli.config``
late-imports these and re-exports them, and :mod:`scripts.docker_config_migrate` reads the
raw stamp directly. ``_coerce_config_version`` stays module-private here.
"""
from __future__ import annotations
from typing import Optional, Tuple
from hermes_cli.config_defaults import DEFAULT_CONFIG
def _coerce_config_version(value) -> int:
"""Return a safe integer config version, treating invalid values as legacy."""
if isinstance(value, bool):
return 0
try:
version = int(value)
except (TypeError, ValueError):
return 0
return max(version, 0)
def read_config_version_stamp(*, raise_on_parse_error: bool = False) -> tuple[Optional[int], int]:
"""Single raw read behind ``check_config_version()``: ``(stamp, latest_version)`` where
*stamp* is ``None`` when config.yaml parsed but carries no ``_config_version`` key (a
never-stamped current-schema file, not an ancient install — ``migrate_config()`` gives it only
the legacy-key steps). A missing file, or malformed YAML under a tolerant caller, reads as
``latest`` exactly as ``check_config_version()`` always reported it."""
from hermes_cli.config import InvalidUserConfigError, _warn_config_parse_failure, fast_safe_load, get_config_path
latest = _coerce_config_version(DEFAULT_CONFIG.get("_config_version", 1)) or 1
config_path = get_config_path()
if not config_path.exists():
return latest, latest
try:
with open(config_path, encoding="utf-8-sig") as f:
config = fast_safe_load(f)
except Exception as e:
_warn_config_parse_failure(config_path, e)
if raise_on_parse_error:
raise InvalidUserConfigError(
f"Cannot inspect {config_path}: config.yaml is not valid YAML ({e})"
) from e
return latest, latest
if config is None:
config = {} # empty file / bare document: valid first-run state
if not isinstance(config, dict):
# A list/scalar root parses fine but is just as unusable as broken YAML: save_config()
# would refuse it later, after .env was already rewritten. Strict callers see it up front.
if raise_on_parse_error:
raise InvalidUserConfigError(
f"Cannot inspect {config_path}: config.yaml top-level value must be "
f"a mapping, got {type(config).__name__}"
)
config = {}
if "_config_version" not in config:
return None, latest
return _coerce_config_version(config.get("_config_version")), latest
def check_config_version(*, raise_on_parse_error: bool = False) -> tuple[int, int]:
"""Return ``(current_version, latest_version)`` from the raw on-disk config.
Reads the raw file rather than ``load_config()``: the deep-merge would make a file lacking
``_config_version`` inherit the latest version, hiding that the schema was never migrated.
Invalid YAML gets a parse warning, not an automatic schema rewrite. Tolerant runtime status
callers keep the historical latest/latest fallback for malformed YAML; mutation and explicit
validation paths set ``raise_on_parse_error`` so a parse failure or a non-mapping root cannot
be mistaken for an up-to-date config. A file with no version key reads as 0."""
stamp, latest = read_config_version_stamp(raise_on_parse_error=raise_on_parse_error)
return (0 if stamp is None else stamp), latest