Keep staleness (edit seen on the next poll), missing-file pickup, parsed-once and the uncached-writer contracts; the per-file mutation-poison and cross-profile cases restate the copier.
87 lines
3.3 KiB
Python
87 lines
3.3 KiB
Python
"""``list_profiles`` re-reads a profile's YAML only when that file has changed.
|
|
|
|
The listing is the shared body of ``GET /api/profiles`` and JSON-RPC ``profiles.list``, which the
|
|
Bots roster polls every 5s per connection, and it parsed three YAML files per profile every time —
|
|
including an installer-seeded ``config.yaml`` (the annotated template, ~119KB) for two strings.
|
|
|
|
The memo must be invisible: every edit has to show up on the very next read, or `hermes profile
|
|
list` and the roster paint stale names and models. Only derived values are cached — the raw
|
|
readers keep their uncached contract, because callers write those documents back.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
import hermes_cli.profiles as profiles
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _clear_memo():
|
|
profiles._PROFILE_FILE_CACHE.clear()
|
|
yield
|
|
profiles._PROFILE_FILE_CACHE.clear()
|
|
|
|
|
|
@pytest.fixture
|
|
def profile_dir(tmp_path) -> Path:
|
|
p = tmp_path / "profiles" / "bob"
|
|
p.mkdir(parents=True)
|
|
(p / "config.yaml").write_text("model:\n provider: openai\n default: gpt-4o\n", encoding="utf-8")
|
|
(p / "profile.yaml").write_text("display_name: Bob\ndescription: first\n", encoding="utf-8")
|
|
(p / "distribution.yaml").write_text("name: starter\nversion: 1.0.0\nsource: registry\n", encoding="utf-8")
|
|
return p
|
|
|
|
|
|
def test_an_edited_config_model_is_seen_on_the_next_read(profile_dir):
|
|
assert profiles._read_config_model(profile_dir) == ("gpt-4o", "openai")
|
|
|
|
(profile_dir / "config.yaml").write_text(
|
|
"model:\n provider: anthropic\n default: claude-opus-4.6\n", encoding="utf-8")
|
|
|
|
assert profiles._read_config_model(profile_dir) == ("claude-opus-4.6", "anthropic")
|
|
|
|
|
|
def test_a_missing_file_is_not_cached_and_is_picked_up_when_created(tmp_path):
|
|
bare = tmp_path / "profiles" / "bare"
|
|
bare.mkdir(parents=True)
|
|
|
|
assert profiles._read_distribution_meta(bare) == (None, None, None)
|
|
assert not any(key[1] == "distribution" for key in profiles._PROFILE_FILE_CACHE)
|
|
|
|
(bare / "distribution.yaml").write_text("name: late\nversion: 9\nsource: registry\n", encoding="utf-8")
|
|
|
|
assert profiles._read_distribution_meta(bare) == ("late", 9, "registry")
|
|
|
|
|
|
def test_an_unchanged_file_is_parsed_once_across_repeated_reads(profile_dir, monkeypatch):
|
|
"""The saving itself — the roster poll stops re-parsing files that have not moved."""
|
|
parsed: list = []
|
|
real = profiles._load_yaml_dict
|
|
|
|
def _counting(path):
|
|
parsed.append(Path(path).name)
|
|
return real(path)
|
|
|
|
monkeypatch.setattr(profiles, "_load_yaml_dict", _counting)
|
|
|
|
for _ in range(3):
|
|
profiles.read_profile_meta(profile_dir)
|
|
profiles._read_distribution_meta(profile_dir)
|
|
|
|
assert parsed.count("profile.yaml") == 1
|
|
assert parsed.count("distribution.yaml") == 1
|
|
|
|
|
|
def test_the_raw_config_reader_keeps_its_uncached_contract(profile_dir):
|
|
"""``read_user_config_raw`` feeds write-back round-trips, so it must NOT be memoised here."""
|
|
from hermes_cli.config import read_user_config_raw
|
|
|
|
config_path = profile_dir / "config.yaml"
|
|
assert read_user_config_raw(config_path)["model"]["provider"] == "openai"
|
|
|
|
config_path.write_text("model:\n provider: xai\n default: grok-4.6\n", encoding="utf-8")
|
|
|
|
assert read_user_config_raw(config_path)["model"]["provider"] == "xai"
|