Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,385 @@
|
||||
"""Tests for the post-update *import* guard in ``hermes update``.
|
||||
|
||||
``_validate_critical_files_syntax`` only parses files, so it cannot detect a
|
||||
partially-updated tree: when one package is refreshed and a sibling is not,
|
||||
every file still parses but importing them together raises ``ImportError``.
|
||||
|
||||
Reference incident: a Windows user reported
|
||||
``ImportError: cannot import name 'TODO_INJECTION_HEADER' from
|
||||
'tools.todo_tool'`` on every startup after an update. ``agent/`` carried the
|
||||
new ``context_compressor.py`` (which imports that name at module level) while
|
||||
``tools/`` still held the pre-update ``todo_tool.py``. The ZIP-update path
|
||||
replaces top-level entries one at a time in ``os.listdir`` order, so an
|
||||
interruption between ``agent/`` and ``tools/`` produces exactly that skew --
|
||||
and the syntax guard reported the update as successful.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli import main as hermes_main
|
||||
from hermes_cli import update_cmd
|
||||
from hermes_constants import partial_update_hint
|
||||
|
||||
|
||||
def _write_skewed_tree(root: Path, *, skewed: bool) -> None:
|
||||
"""Build a tiny two-package tree that mimics the real failure.
|
||||
|
||||
``consumer`` imports a name from ``provider`` at module level. When
|
||||
``skewed`` is True the name is absent -- both files still parse.
|
||||
"""
|
||||
(root / "provider").mkdir(parents=True, exist_ok=True)
|
||||
(root / "provider" / "__init__.py").write_text("")
|
||||
(root / "provider" / "thing.py").write_text(
|
||||
"OTHER = 1\n" if skewed else "SHARED_NAME = 'x'\nOTHER = 1\n"
|
||||
)
|
||||
(root / "consumer.py").write_text("from provider.thing import SHARED_NAME\n")
|
||||
|
||||
|
||||
def test_syntax_guard_passes_but_import_guard_catches_skew(monkeypatch, tmp_path):
|
||||
"""The regression: a skewed tree parses cleanly but cannot be imported."""
|
||||
_write_skewed_tree(tmp_path, skewed=True)
|
||||
|
||||
# Both files are valid Python -- the syntax guard sees nothing wrong.
|
||||
# NOTE: patch update_cmd's global, not hermes_main's. Both modules expose
|
||||
# the name, but _validate_critical_files_syntax reads the one in its own
|
||||
# module. Patching the re-export leaves the real list in place, the stub
|
||||
# files are never looked at, and the guard returns a vacuous (True, None,
|
||||
# None) that would make this test pass no matter what the code did.
|
||||
monkeypatch.setattr(
|
||||
update_cmd, "_UPDATE_CRITICAL_FILES", ("consumer.py", "provider/thing.py")
|
||||
)
|
||||
syntax_ok, _, _ = hermes_main._validate_critical_files_syntax(tmp_path)
|
||||
assert syntax_ok, "sanity: the skewed tree must parse cleanly"
|
||||
|
||||
# The import guard catches it.
|
||||
monkeypatch.setattr(update_cmd, "_UPDATE_CRITICAL_MODULES", ("consumer",))
|
||||
ok, module, error = hermes_main._validate_critical_modules_import(tmp_path)
|
||||
assert ok is False
|
||||
assert module == "consumer"
|
||||
assert error is not None and "SHARED_NAME" in error
|
||||
|
||||
|
||||
def test_import_guard_passes_on_consistent_tree(monkeypatch, tmp_path):
|
||||
_write_skewed_tree(tmp_path, skewed=False)
|
||||
monkeypatch.setattr(update_cmd, "_UPDATE_CRITICAL_MODULES", ("consumer",))
|
||||
|
||||
assert hermes_main._validate_critical_modules_import(tmp_path) == (True, None, None)
|
||||
|
||||
|
||||
def test_import_guard_ignores_non_import_errors(monkeypatch, tmp_path):
|
||||
"""A module that raises at import time for config/env reasons is not
|
||||
update breakage -- the guard must not roll back a good update."""
|
||||
(tmp_path / "consumer.py").write_text(
|
||||
"raise RuntimeError('no API key configured')\n"
|
||||
)
|
||||
monkeypatch.setattr(update_cmd, "_UPDATE_CRITICAL_MODULES", ("consumer",))
|
||||
|
||||
ok, _, _ = hermes_main._validate_critical_modules_import(tmp_path)
|
||||
assert ok is True
|
||||
|
||||
|
||||
def test_import_guard_can_report_non_import_errors(monkeypatch, tmp_path):
|
||||
"""Stash restore can compare runtime failures before and after apply."""
|
||||
(tmp_path / "consumer.py").write_text("raise RuntimeError('broken config')\n")
|
||||
monkeypatch.setattr(update_cmd, "_UPDATE_CRITICAL_MODULES", ("consumer",))
|
||||
|
||||
ok, module, error = hermes_main._validate_critical_modules_import(
|
||||
tmp_path, report_runtime_errors=True
|
||||
)
|
||||
|
||||
assert ok is False
|
||||
assert module == "consumer"
|
||||
assert error == "broken config"
|
||||
|
||||
|
||||
def test_import_guard_can_report_missing_third_party_dependency(
|
||||
monkeypatch, tmp_path
|
||||
):
|
||||
"""Stash comparison must see newly introduced missing dependencies."""
|
||||
(tmp_path / "consumer.py").write_text("import totally_not_installed_pkg\n")
|
||||
monkeypatch.setattr(update_cmd, "_UPDATE_CRITICAL_MODULES", ("consumer",))
|
||||
|
||||
ok, module, error = hermes_main._validate_critical_modules_import(
|
||||
tmp_path, report_runtime_errors=True
|
||||
)
|
||||
|
||||
assert ok is False
|
||||
assert module == "consumer"
|
||||
assert error is not None and "totally_not_installed_pkg" in error
|
||||
|
||||
|
||||
def test_import_failure_comparison_preserves_exception_type(monkeypatch, tmp_path):
|
||||
source = tmp_path / "consumer.py"
|
||||
monkeypatch.setattr(update_cmd, "_UPDATE_CRITICAL_MODULES", ("consumer",))
|
||||
source.write_text("raise RuntimeError('stopped')\n")
|
||||
runtime_failure = update_cmd._critical_module_import_failures(
|
||||
tmp_path, report_runtime_errors=True
|
||||
)
|
||||
source.write_text("raise SystemExit('stopped')\n")
|
||||
terminating_failure = update_cmd._critical_module_import_failures(
|
||||
tmp_path, report_runtime_errors=True
|
||||
)
|
||||
|
||||
assert runtime_failure == {"consumer": ("RuntimeError", "stopped")}
|
||||
assert terminating_failure == {"consumer": ("SystemExit", "stopped")}
|
||||
|
||||
|
||||
def test_import_guard_reports_probe_termination_when_comparing_states(
|
||||
monkeypatch, tmp_path
|
||||
):
|
||||
"""A terminating import is unsafe when validating a restored stash."""
|
||||
(tmp_path / "consumer.py").write_text("import os\nos._exit(7)\n")
|
||||
monkeypatch.setattr(update_cmd, "_UPDATE_CRITICAL_MODULES", ("consumer",))
|
||||
|
||||
ok, module, error = hermes_main._validate_critical_modules_import(
|
||||
tmp_path, report_runtime_errors=True
|
||||
)
|
||||
|
||||
assert ok is False
|
||||
assert module == "critical-module probe"
|
||||
assert error == "terminated before reporting import health (exit code 7)"
|
||||
|
||||
|
||||
def test_import_guard_reports_probe_termination_by_default(monkeypatch, tmp_path):
|
||||
"""A missing health marker must not classify a terminated probe as healthy."""
|
||||
(tmp_path / "consumer.py").write_text("import os\nos._exit(9)\n")
|
||||
monkeypatch.setattr(update_cmd, "_UPDATE_CRITICAL_MODULES", ("consumer",))
|
||||
|
||||
ok, module, error = hermes_main._validate_critical_modules_import(tmp_path)
|
||||
|
||||
assert ok is False
|
||||
assert module == "critical-module probe"
|
||||
assert error == "terminated before reporting import health (exit code 9)"
|
||||
|
||||
|
||||
def test_import_guard_reports_system_exit_by_default(monkeypatch, tmp_path):
|
||||
"""Catchable terminating imports must not complete with a healthy marker."""
|
||||
(tmp_path / "consumer.py").write_text("raise SystemExit('stopped')\n")
|
||||
monkeypatch.setattr(update_cmd, "_UPDATE_CRITICAL_MODULES", ("consumer",))
|
||||
|
||||
ok, module, error = hermes_main._validate_critical_modules_import(tmp_path)
|
||||
|
||||
assert ok is False
|
||||
assert module == "consumer"
|
||||
assert error == "stopped"
|
||||
|
||||
|
||||
def test_import_guard_does_not_accept_forged_static_marker(monkeypatch, tmp_path):
|
||||
"""Imported stdout cannot impersonate the per-probe completion marker."""
|
||||
(tmp_path / "consumer.py").write_text(
|
||||
"import os, sys\n"
|
||||
"sys.stdout.write('__HERMES_IMPORT_HEALTH__[]')\n"
|
||||
"sys.stdout.flush()\n"
|
||||
"os._exit(7)\n"
|
||||
)
|
||||
monkeypatch.setattr(update_cmd, "_UPDATE_CRITICAL_MODULES", ("consumer",))
|
||||
|
||||
ok, module, error = hermes_main._validate_critical_modules_import(tmp_path)
|
||||
|
||||
assert ok is False
|
||||
assert module == "critical-module probe"
|
||||
assert error == "terminated before reporting import health (exit code 7)"
|
||||
|
||||
|
||||
def test_import_guard_rejects_malformed_health_payload(monkeypatch, tmp_path):
|
||||
class Result:
|
||||
returncode = 0
|
||||
stdout = ""
|
||||
|
||||
def malformed(cmd, **_kwargs):
|
||||
marker = cmd[-1].split("sys.stdout.write('\\n", 1)[1].split("'", 1)[0]
|
||||
Result.stdout = f"{marker}{{}}"
|
||||
return Result()
|
||||
|
||||
monkeypatch.setattr(update_cmd.subprocess, "run", malformed)
|
||||
|
||||
ok, module, error = hermes_main._validate_critical_modules_import(tmp_path)
|
||||
|
||||
assert ok is False
|
||||
assert module == "critical-module probe"
|
||||
assert error == "reported malformed import health data"
|
||||
|
||||
|
||||
def test_import_guard_reports_probe_timeout(monkeypatch, tmp_path):
|
||||
import subprocess
|
||||
|
||||
def timeout(*_args, **_kwargs):
|
||||
raise subprocess.TimeoutExpired(["python", "-c", "probe"], 120)
|
||||
|
||||
monkeypatch.setattr(update_cmd.subprocess, "run", timeout)
|
||||
|
||||
ok, module, error = hermes_main._validate_critical_modules_import(tmp_path)
|
||||
|
||||
assert ok is False
|
||||
assert module == "critical-module probe"
|
||||
assert error == "timed out before reporting import health"
|
||||
|
||||
|
||||
def test_untracked_enumeration_failure_is_visible(monkeypatch, tmp_path, capsys):
|
||||
class Result:
|
||||
returncode = 1
|
||||
stdout = ""
|
||||
|
||||
monkeypatch.setattr(update_cmd.subprocess, "run", lambda *_a, **_kw: Result())
|
||||
|
||||
assert update_cmd._git_untracked_paths(["git"], tmp_path) is None
|
||||
assert "Could not enumerate untracked files" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_import_guard_is_non_fatal_when_probe_cannot_run(monkeypatch, tmp_path):
|
||||
"""If we can't spawn the probe, don't block the user's update."""
|
||||
|
||||
def boom(*_a, **_kw):
|
||||
raise OSError("cannot spawn")
|
||||
|
||||
monkeypatch.setattr(update_cmd.subprocess, "run", boom)
|
||||
assert update_cmd._validate_critical_modules_import(tmp_path) == (True, None, None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# partial_update_hint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_hint_fires_for_first_party_import_error():
|
||||
exc = ImportError("cannot import name 'TODO_INJECTION_HEADER'")
|
||||
exc.name = "tools.todo_tool"
|
||||
|
||||
hint = partial_update_hint(exc)
|
||||
|
||||
assert hint, "expected recovery guidance for a first-party ImportError"
|
||||
assert any("hermes update" in line for line in hint)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"exc",
|
||||
[
|
||||
ModuleNotFoundError("No module named 'numpy'", name="numpy"),
|
||||
ValueError("unrelated"),
|
||||
ImportError("third-party broke"),
|
||||
],
|
||||
)
|
||||
def test_hint_stays_silent_for_unrelated_failures(exc):
|
||||
"""Missing third-party deps and non-import errors have different
|
||||
remediation -- claiming a partial update would misdirect the user."""
|
||||
if isinstance(exc, ImportError) and not isinstance(exc, ModuleNotFoundError):
|
||||
exc.name = "requests"
|
||||
assert partial_update_hint(exc) == []
|
||||
|
||||
|
||||
def test_import_guard_prefers_the_project_venv_interpreter(monkeypatch, tmp_path):
|
||||
"""``hermes update`` can run under a different Python than the install's.
|
||||
|
||||
Probing ``sys.executable`` would then validate a tree the user never
|
||||
actually runs -- the same reasoning behind ``_venv_core_imports_healthy``.
|
||||
On Windows (the platform this guard exists for) the driving interpreter
|
||||
and the venv interpreter routinely differ.
|
||||
"""
|
||||
bin_dir = "Scripts" if update_cmd._m()._is_windows() else "bin"
|
||||
name = "python.exe" if update_cmd._m()._is_windows() else "python"
|
||||
venv_python = tmp_path / "venv" / bin_dir / name
|
||||
venv_python.parent.mkdir(parents=True)
|
||||
venv_python.write_text("")
|
||||
|
||||
seen: dict = {}
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
seen["interpreter"] = cmd[0]
|
||||
|
||||
class R:
|
||||
returncode = 0
|
||||
stdout = ""
|
||||
stderr = ""
|
||||
|
||||
return R()
|
||||
|
||||
monkeypatch.setattr(update_cmd.subprocess, "run", fake_run)
|
||||
update_cmd._validate_critical_modules_import(tmp_path)
|
||||
|
||||
assert seen["interpreter"] == str(venv_python)
|
||||
|
||||
|
||||
def test_import_guard_ignores_missing_third_party_dependency(monkeypatch, tmp_path):
|
||||
"""A new third-party requirement is not a partially-updated tree.
|
||||
|
||||
On the git path this guard runs BEFORE the dependency sync, so a release
|
||||
that adds a dependency would otherwise look like breakage and trigger a
|
||||
spurious `git reset --hard` rollback of a perfectly good update.
|
||||
"""
|
||||
(tmp_path / "consumer.py").write_text("import totally_not_installed_pkg\n")
|
||||
monkeypatch.setattr(update_cmd, "_UPDATE_CRITICAL_MODULES", ("consumer",))
|
||||
|
||||
assert update_cmd._validate_critical_modules_import(tmp_path) == (True, None, None)
|
||||
|
||||
|
||||
def test_import_guard_flags_missing_first_party_module(monkeypatch, tmp_path):
|
||||
"""A missing *first-party* module IS skew — the update dropped a file."""
|
||||
(tmp_path / "consumer.py").write_text("import tools.nonexistent_module\n")
|
||||
monkeypatch.setattr(update_cmd, "_UPDATE_CRITICAL_MODULES", ("consumer",))
|
||||
|
||||
ok, module, error = update_cmd._validate_critical_modules_import(tmp_path)
|
||||
assert ok is False
|
||||
assert module == "consumer"
|
||||
assert error is not None and "tools.nonexistent_module" in error
|
||||
|
||||
|
||||
@pytest.mark.parametrize("modname", ["agents", "agentops", "toolsets_x", "hermesx"])
|
||||
def test_hint_does_not_claim_partial_update_for_lookalike_third_party(modname):
|
||||
"""``startswith`` would match third-party ``agents``/``agentops`` and blame
|
||||
our updater for someone else's import error."""
|
||||
exc = ImportError("boom")
|
||||
exc.name = modname
|
||||
assert partial_update_hint(exc) == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("modname", ["tools.todo_tool", "agent.context_compressor",
|
||||
"hermes_constants", "hermes_cli.config", "cli"])
|
||||
def test_hint_fires_for_each_first_party_root(modname):
|
||||
exc = ImportError("cannot import name 'X'")
|
||||
exc.name = modname
|
||||
assert partial_update_hint(exc), f"expected guidance for {modname}"
|
||||
|
||||
|
||||
def test_probe_and_hint_share_one_first_party_definition():
|
||||
"""The guard that BLOCKS and the hint that EXPLAINS must never disagree.
|
||||
|
||||
These started as two hand-maintained lists and immediately diverged:
|
||||
`cli` was first-party to the hint but not the probe, and `hermesx`
|
||||
(third-party) matched the probe's loose `startswith("hermes")`. A user
|
||||
could get a rollback with no explanation, or an explanation with no
|
||||
detection. Both now derive from FIRST_PARTY_MODULE_ROOTS; this test
|
||||
fails if either grows a private copy.
|
||||
"""
|
||||
from hermes_constants import FIRST_PARTY_MODULE_ROOTS, is_first_party_module
|
||||
|
||||
captured = {}
|
||||
|
||||
class _Result:
|
||||
returncode = 0
|
||||
stdout = ""
|
||||
stderr = ""
|
||||
|
||||
def capture(cmd, **_kw):
|
||||
captured["probe"] = cmd[-1]
|
||||
return _Result()
|
||||
|
||||
real_run = update_cmd.subprocess.run
|
||||
update_cmd.subprocess.run = capture
|
||||
try:
|
||||
update_cmd._validate_critical_modules_import("/tmp")
|
||||
finally:
|
||||
update_cmd.subprocess.run = real_run
|
||||
|
||||
probe_src = captured["probe"]
|
||||
# Every first-party root must appear in the probe's injected tuple.
|
||||
for root in FIRST_PARTY_MODULE_ROOTS:
|
||||
assert repr(root) in probe_src, f"{root} missing from the probe's root set"
|
||||
# And the hint must agree on each of them.
|
||||
for root in FIRST_PARTY_MODULE_ROOTS:
|
||||
assert is_first_party_module(f"{root}.anything")
|
||||
# Lookalikes stay out of both.
|
||||
for lookalike in ("agents", "agentops", "toolsets_x", "hermesx"):
|
||||
assert not is_first_party_module(lookalike)
|
||||
Reference in New Issue
Block a user