Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,296 @@
|
||||
"""Tests for ``hermes migrate xai`` — apply path with ruamel round-trip."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli.xai_retirement import (
|
||||
RetirementIssue,
|
||||
apply_migration,
|
||||
find_retired_xai_refs,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def trap_config(tmp_path: Path) -> Path:
|
||||
"""A config.yaml with retired models AND comments to verify round-trip."""
|
||||
p = tmp_path / "config.yaml"
|
||||
p.write_text(
|
||||
"# Hermes config (sample)\n"
|
||||
"principal:\n"
|
||||
" provider: xai # the main model\n"
|
||||
" model: grok-4-1-fast-non-reasoning # retiring May 15\n"
|
||||
" temperature: 0.5\n"
|
||||
"auxiliary:\n"
|
||||
" vision:\n"
|
||||
" provider: xai\n"
|
||||
" model: grok-4-fast-reasoning # retiring\n"
|
||||
" compression:\n"
|
||||
" provider: openai # not affected\n"
|
||||
" model: gpt-4o-mini\n"
|
||||
"delegation:\n"
|
||||
" model: grok-code-fast-1 # retiring\n"
|
||||
"plugins:\n"
|
||||
" image_gen:\n"
|
||||
" xai:\n"
|
||||
" model: grok-imagine-image-pro # retiring\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return p
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clean_config(tmp_path: Path) -> Path:
|
||||
p = tmp_path / "config.yaml"
|
||||
p.write_text(
|
||||
"principal:\n"
|
||||
" provider: xai\n"
|
||||
" model: grok-4.3\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return p
|
||||
|
||||
|
||||
def _parse(path: Path) -> dict:
|
||||
"""Load with ruamel for assertion convenience."""
|
||||
from ruamel.yaml import YAML
|
||||
yaml = YAML(typ="rt")
|
||||
with path.open("r", encoding="utf-8") as fh:
|
||||
return yaml.load(fh)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dry-run / no-op
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestNoOpPaths:
|
||||
def test_clean_config_returns_unchanged_result(self, clean_config: Path):
|
||||
issues = find_retired_xai_refs(_parse(clean_config))
|
||||
assert issues == []
|
||||
result = apply_migration(clean_config, issues)
|
||||
assert result.config_changed is False
|
||||
assert result.backup_path is None
|
||||
# File untouched
|
||||
assert "grok-4.3" in clean_config.read_text(encoding="utf-8")
|
||||
|
||||
def test_empty_issues_list_is_noop(self, trap_config: Path):
|
||||
original = trap_config.read_text(encoding="utf-8")
|
||||
result = apply_migration(trap_config, issues=[])
|
||||
assert result.config_changed is False
|
||||
assert trap_config.read_text(encoding="utf-8") == original
|
||||
|
||||
def test_missing_file_raises(self, tmp_path: Path):
|
||||
with pytest.raises(FileNotFoundError):
|
||||
apply_migration(tmp_path / "absent.yaml", issues=[
|
||||
RetirementIssue(
|
||||
config_path="principal.model",
|
||||
current_model="grok-3",
|
||||
replacement="grok-4.3",
|
||||
)
|
||||
])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Apply: surgical replacement
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestApplyReplacement:
|
||||
def test_replaces_principal_model(self, trap_config: Path):
|
||||
issues = find_retired_xai_refs(_parse(trap_config))
|
||||
result = apply_migration(trap_config, issues)
|
||||
assert result.config_changed is True
|
||||
cfg = _parse(trap_config)
|
||||
assert cfg["principal"]["model"] == "grok-4.3"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def test_does_not_touch_unrelated_slots(self, trap_config: Path):
|
||||
issues = find_retired_xai_refs(_parse(trap_config))
|
||||
apply_migration(trap_config, issues)
|
||||
cfg = _parse(trap_config)
|
||||
# auxiliary.compression was never xAI, must remain untouched
|
||||
assert cfg["auxiliary"]["compression"]["model"] == "gpt-4o-mini"
|
||||
assert cfg["auxiliary"]["compression"]["provider"] == "openai"
|
||||
# principal.temperature must survive
|
||||
assert cfg["principal"]["temperature"] == 0.5
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Round-trip preservation (the hard part)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRoundTripPreservation:
|
||||
|
||||
|
||||
def test_preserves_top_level_key_order(self, trap_config: Path):
|
||||
issues = find_retired_xai_refs(_parse(trap_config))
|
||||
apply_migration(trap_config, issues)
|
||||
text = trap_config.read_text(encoding="utf-8")
|
||||
order = [
|
||||
text.index("principal:"),
|
||||
text.index("auxiliary:"),
|
||||
text.index("delegation:"),
|
||||
text.index("plugins:"),
|
||||
]
|
||||
assert order == sorted(order)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Backup behaviour
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBackup:
|
||||
def test_backup_is_written_by_default(self, trap_config: Path):
|
||||
issues = find_retired_xai_refs(_parse(trap_config))
|
||||
original = trap_config.read_text(encoding="utf-8")
|
||||
result = apply_migration(trap_config, issues)
|
||||
assert result.backup_path is not None
|
||||
assert result.backup_path.exists()
|
||||
assert result.backup_path.read_text(encoding="utf-8") == original
|
||||
|
||||
|
||||
def test_no_backup_when_disabled(self, trap_config: Path):
|
||||
issues = find_retired_xai_refs(_parse(trap_config))
|
||||
result = apply_migration(trap_config, issues, backup=False)
|
||||
assert result.backup_path is None
|
||||
# No bak file in the directory
|
||||
assert not list(trap_config.parent.glob("*.bak-pre-migrate-xai-*"))
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Idempotence
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestIdempotence:
|
||||
def test_apply_twice_is_safe(self, trap_config: Path):
|
||||
# First pass: replace
|
||||
issues_1 = find_retired_xai_refs(_parse(trap_config))
|
||||
apply_migration(trap_config, issues_1)
|
||||
# Second pass: nothing to do
|
||||
issues_2 = find_retired_xai_refs(_parse(trap_config))
|
||||
assert issues_2 == []
|
||||
result_2 = apply_migration(trap_config, issues_2)
|
||||
assert result_2.config_changed is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fail-closed on unreadable existing config
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestUnreadableExistingConfig:
|
||||
def test_apply_refuses_to_overwrite_unreadable_config(self, trap_config: Path):
|
||||
"""apply_migration must not clobber an existing config.yaml it can't
|
||||
read. It reads the file first (which raises on an unreadable file), and
|
||||
the require_readable_config_before_write guard before the write is a
|
||||
belt-and-suspenders backstop for the read-then-write window. Either way
|
||||
the original bytes must survive."""
|
||||
import os
|
||||
|
||||
issues = find_retired_xai_refs(_parse(trap_config))
|
||||
assert issues # sanity: trap_config has retired refs
|
||||
original = trap_config.read_bytes()
|
||||
|
||||
os.chmod(trap_config, 0o000)
|
||||
try:
|
||||
with pytest.raises((PermissionError, RuntimeError, OSError)):
|
||||
apply_migration(trap_config, issues, backup=False)
|
||||
finally:
|
||||
os.chmod(trap_config, 0o644)
|
||||
|
||||
assert trap_config.read_bytes() == original
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Crash durability — the rewrite must be atomic
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCrashDurability:
|
||||
"""apply_migration() rewrites the whole config.yaml in place.
|
||||
|
||||
A bare ``open(path, "w")`` truncates the file *before* the dump runs, so an
|
||||
interruption (crash, SIGINT, ENOSPC) leaves config.yaml empty or
|
||||
half-written. Routing through ``utils.atomic_write_text`` means the target
|
||||
is only ever swapped in via an atomic rename after the temp file is fully
|
||||
written and fsynced.
|
||||
"""
|
||||
|
||||
def test_config_survives_an_interrupted_write(self, trap_config: Path):
|
||||
"""A failure mid-write must leave the original config.yaml untouched.
|
||||
|
||||
``--no-backup`` is a documented flag, so on that path the file being
|
||||
rewritten is the only copy in existence.
|
||||
"""
|
||||
import os
|
||||
|
||||
issues = find_retired_xai_refs(_parse(trap_config))
|
||||
assert issues # sanity: trap_config has retired refs
|
||||
original = trap_config.read_bytes()
|
||||
|
||||
def boom(fd):
|
||||
raise OSError("simulated crash mid-write")
|
||||
|
||||
with pytest.MonkeyPatch.context() as mp:
|
||||
mp.setattr(os, "fsync", boom)
|
||||
with pytest.raises(OSError):
|
||||
apply_migration(trap_config, issues, backup=False)
|
||||
|
||||
# The original bytes must survive verbatim...
|
||||
assert trap_config.read_bytes() == original
|
||||
# ...and the aborted write must not leave a temp file behind.
|
||||
assert list(trap_config.parent.glob("*.tmp")) == []
|
||||
|
||||
def test_symlinked_config_is_replaced_in_place(self, tmp_path: Path):
|
||||
"""A config.yaml symlinked into a dotfiles repo must stay a symlink."""
|
||||
real_dir = tmp_path / "dotfiles"
|
||||
real_dir.mkdir()
|
||||
real = real_dir / "hermes-config.yaml"
|
||||
real.write_text(
|
||||
"principal:\n"
|
||||
" provider: xai\n"
|
||||
" model: grok-3\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
link = tmp_path / "config.yaml"
|
||||
link.symlink_to(real)
|
||||
|
||||
issues = find_retired_xai_refs(_parse(link))
|
||||
assert issues
|
||||
apply_migration(link, issues, backup=False)
|
||||
|
||||
assert link.is_symlink(), "atomic replace detached the symlink"
|
||||
assert real.read_text(encoding="utf-8") == link.read_text(encoding="utf-8")
|
||||
assert "grok-4.3" in real.read_text(encoding="utf-8")
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX permission bits")
|
||||
def test_existing_file_mode_is_preserved(self, trap_config: Path):
|
||||
"""Managed (NixOS 0640) and container installs widen config.yaml
|
||||
deliberately; the migration must not silently tighten it to 0600."""
|
||||
import os
|
||||
import stat
|
||||
|
||||
os.chmod(trap_config, 0o640)
|
||||
issues = find_retired_xai_refs(_parse(trap_config))
|
||||
apply_migration(trap_config, issues, backup=False)
|
||||
|
||||
mode = stat.S_IMODE(trap_config.stat().st_mode)
|
||||
assert mode == 0o640, f"mode changed to {oct(mode)}"
|
||||
|
||||
def test_comments_survive_the_atomic_write(self, trap_config: Path):
|
||||
"""The ruamel round-trip must still run — serializing via a string
|
||||
buffer instead of the file handle must not drop comments."""
|
||||
issues = find_retired_xai_refs(_parse(trap_config))
|
||||
apply_migration(trap_config, issues, backup=False)
|
||||
|
||||
text = trap_config.read_text(encoding="utf-8")
|
||||
assert "# Hermes config (sample)" in text
|
||||
assert "# the main model" in text
|
||||
assert "# not affected" in text
|
||||
Reference in New Issue
Block a user