Merge fallout (my resolution errors, all caught by CI): - hermes_cli/backup.py + gateway.py: `theirs` on those hunks re-imported clusters HEAD had already moved to backup_restore.py / kept in the facade. backup.py loses the 349-line duplicate (main's #110179 fix is ported into backup_restore._import_db_member); the systemd service-unit cluster returns to gateway.py (PM's _prepare_service_launcher / _pm_managed_node_dirs / _systemd_command have no home in main's extraction) with main's utf-8-sig read. gateway_service_unit.py is dropped. - gateway/run.py: main's plugin-update chore is not profile-scoped (the housekeeping ordering test pins the scope/drain sequence). - pyproject + 30 test files: `import yaml` -> `import hermes_yaml as yaml` (pm-clean has no pyyaml); gateway/config._bundled_platform_manifest_name reads through hermes_yaml. - tests re-seamed onto pm-clean's shape: residency admission (installed_engine), supervisor child env (binary is a constructor argument), update import guard (update_cmd_deps is gone; our probe already scrubs PYTHONPATH — both #115032 invariants pass), shallow-count git responses (stash path asks `status --porcelain -z`); dropped tests for retired code (_run_node_bootstrap/_ensure_tui_node, Windows resume demotion). - tests/tools/test_local_env_blocklist.py: restore the two helpers the suite-reduction commit dropped and the blocklist import. Real fixes: - pm: classify_uv_failure/ResolutionConflict move beside the uv runner (pm.environment, stdlib-only). pm.workspace imports tomllib at module level and cannot load on the 3.10 bootstrap python that streams uv output in the Docker arm64 image. - tools/browser_tool.warm_agent_browser_npx_cache: back as a permanent definition — it is on the frozen old-updater surface, and the revert-scheduled compat pointer does not count. - hermes_cli/memory_setup: the dashboard's pip row uses pm.environments. running_from_selected_environment for installed vs restart_required. - scripts/windows-build-deps.ps1: export DISTUTILS_USE_SDK/MSSdk so setuptools trusts the primed MSVC environment instead of asking vswhere (`env -i` test runner on win32-arm64 compiling ruamel-yaml-clib); run_tests.sh forwards them. - tests/pm/test_windows_build_deps.py: start the protocol test from a parent env without the toolchain variables the runner job already exports. - tests/conftest.py scrubs HERMES_BUNDLED_PLUGINS (Nix-wrapped hermes on the dev host); tests/home_io_guard.py treats sys.path site-packages under the real home as the interpreter's installation (PM-activated developer shell). - tests-js: four `curly` lint errors from main's new scripts.
87 lines
4.3 KiB
Python
87 lines
4.3 KiB
Python
"""Config migrations survive malformed nested values (#116593).
|
|
|
|
Hand-edited or legacy config.yaml files hold scalars where a migration step expects a
|
|
mapping. Each step must guard the shapes it indexes, and ``run_migrations`` must isolate
|
|
a step that still raises so one bad value cannot wedge ``hermes config migrate`` /
|
|
``hermes update`` and leave the config unversioned.
|
|
"""
|
|
|
|
import logging
|
|
import os
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
import hermes_yaml as yaml
|
|
|
|
|
|
def _write_config(tmp_path, config):
|
|
(tmp_path / "config.yaml").write_text(yaml.safe_dump(config), encoding="utf-8")
|
|
|
|
|
|
def _read_config(tmp_path):
|
|
return yaml.safe_load((tmp_path / "config.yaml").read_text(encoding="utf-8"))
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("current_ver", "config", "path", "expected"),
|
|
[
|
|
# _migrate_to_12: a non-string custom_providers name falls back to the hostname key.
|
|
(11, {"custom_providers": [{"name": 5, "base_url": "https://api.example.com/v1"}]},
|
|
("providers", "api-example-com", "api"), "https://api.example.com/v1"),
|
|
# _migrate_to_14: a mapping stt.provider is treated as the "local" default.
|
|
(13, {"stt": {"model": "tiny", "provider": {"nested": True}}},
|
|
("stt", "local", "model"), "tiny"),
|
|
# _migrate_to_14: a scalar stt.<section> is replaced, not indexed.
|
|
(13, {"stt": {"model": "base", "provider": "openai", "openai": 5}},
|
|
("stt", "openai", "model"), "base"),
|
|
# _migrate_to_16: a scalar display.platforms.<plat> slot is replaced, not indexed.
|
|
(15, {"display": {"tool_progress_overrides": {"telegram": "all"}, "platforms": {"telegram": 5}}},
|
|
("display", "platforms", "telegram", "tool_progress"), "all"),
|
|
# _migrate_to_17: a scalar auxiliary / auxiliary.compression is rebuilt as a mapping.
|
|
(16, {"compression": {"summary_model": "fast-model"}, "auxiliary": 5},
|
|
("auxiliary", "compression", "model"), "fast-model"),
|
|
(16, {"compression": {"summary_model": "fast-model"}, "auxiliary": {"compression": "x"}},
|
|
("auxiliary", "compression", "model"), "fast-model"),
|
|
],
|
|
ids=["v12-name-int", "v14-provider-map", "v14-section-scalar", "v16-platform-scalar",
|
|
"v17-auxiliary-scalar", "v17-compression-scalar"],
|
|
)
|
|
def test_malformed_nested_value_is_migrated_not_crashed(tmp_path, current_ver, config, path, expected):
|
|
"""Each cited step replaces the malformed slot and still lands the migrated value."""
|
|
from hermes_cli.config_migrations import run_migrations
|
|
|
|
_write_config(tmp_path, {"_config_version": current_ver, **config})
|
|
results = {"env_added": [], "config_added": [], "warnings": []}
|
|
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
|
|
run_migrations(current_ver, results, quiet=True)
|
|
|
|
node = _read_config(tmp_path)
|
|
for key in path:
|
|
node = node[key]
|
|
assert node == expected
|
|
assert not results["warnings"], "a guarded shape must migrate cleanly, not be skipped"
|
|
|
|
|
|
def test_failing_step_is_skipped_with_warning_and_config_still_migrates(tmp_path, caplog):
|
|
"""``migrate_config`` (the ``hermes config migrate`` / ``hermes update`` path) keeps going
|
|
past a raising step, records the skip in ``warnings`` and stamps the latest version. The
|
|
quiet path (profile creation, unattended update) discards ``results``, so the skip must
|
|
also reach the log or it is silent and, once stamped, permanent."""
|
|
from hermes_cli import config_migrations
|
|
from hermes_cli.config import migrate_config
|
|
|
|
def _boom(results, quiet):
|
|
raise RuntimeError("boom")
|
|
|
|
_write_config(tmp_path, {"_config_version": 12, "model": {"default": "x/y"}})
|
|
ladder = tuple((v, _boom if v == 13 else fn) for v, fn in config_migrations.MIGRATIONS)
|
|
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}), \
|
|
patch.object(config_migrations, "MIGRATIONS", ladder), \
|
|
caplog.at_level(logging.WARNING, logger="hermes_cli.config_migrations"):
|
|
results = migrate_config(interactive=False, quiet=True)
|
|
|
|
assert any(w.startswith("config migration to v13 failed and was skipped") for w in results["warnings"])
|
|
assert _read_config(tmp_path)["_config_version"] == config_migrations.MIGRATIONS[-1][0]
|
|
assert any("config migration to v13 failed and was skipped" in r.getMessage()
|
|
and r.levelno == logging.WARNING for r in caplog.records)
|