An update resolves the enabled plugin union against the NEW core. A plugin admitted against the old core can stop fitting when core moves (managed Python 3.13 -> 3.14 vs a member's requires-python <3.14, a requires_hermes upper bound, a bumped pin), and the whole update then died after the source swap with a non-resolver InstallError whose 'retry' hint failed the same way every time. Update syncs now pass evict_incompatible_plugins=True (update completion, historical takeover, launch-time completion, venv_sync, post-update drift). PM screens statically first (requires-python vs the target interpreter, manifest/requires_hermes), then, if the rest still fails, builds core alone to prove the plugins are the cause and re-adds members in config order, disabling each one that breaks the build. Misfits land in plugins.disabled (memory.provider cleared) in every home that enables them, published through the existing journaled change hook (the journal now carries several configs), and are reported on stderr + receipt warnings. Admission and ordinary syncs still refuse; only a core that cannot build on its own fails an update.
180 lines
6.1 KiB
Python
180 lines
6.1 KiB
Python
"""venv_sync must work on trees where the venv does not exist yet.
|
|
|
|
It is the stdlib-only-at-import pre-venv entry point: the installers call
|
|
it on a fresh clone before any dependency is importable, and post_update
|
|
calls it after a tree swap when the venv is not trustworthy. Its
|
|
behaviour is driven through PM's public client; package resolution and
|
|
publication belong to PM, not this CLI entry point.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import subprocess
|
|
import sys
|
|
import textwrap
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from hermes_cli import venv_sync
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
|
|
|
|
def _run_bare(snippet: str) -> subprocess.CompletedProcess:
|
|
program = f"import sys\nsys.path.insert(0, {str(REPO_ROOT)!r})\n" + textwrap.dedent(snippet)
|
|
return subprocess.run([sys.executable, '-I', '-S', '-c', program],
|
|
capture_output=True, text=True, cwd=REPO_ROOT, timeout=120)
|
|
|
|
|
|
def test_bare_import_and_passive_paths(tmp_path):
|
|
result = _run_bare(f"""
|
|
from pathlib import Path
|
|
from hermes_cli import venv_sync
|
|
assert 'pm' not in sys.modules
|
|
root = Path({str(tmp_path)!r})
|
|
assert venv_sync.sync(root, check=True)['state'] == 'failed'
|
|
(root / 'install-stamp.json').write_text('{{"updateMechanism":"external"}}')
|
|
assert venv_sync.sync(root) == {{'state': 'sealed', 'ok': True}}
|
|
assert venv_sync.prepare_launch(root, []) is None
|
|
assert 'pm.install' not in sys.modules
|
|
""")
|
|
assert result.returncode == 0, result.stderr
|
|
|
|
|
|
def test_bare_harness_rejects_third_party_import():
|
|
result = _run_bare('import requests')
|
|
assert result.returncode != 0 and 'ModuleNotFoundError' in result.stderr
|
|
|
|
|
|
def _checkout(tmp_path: Path, name: str = "co") -> Path:
|
|
root = tmp_path / name
|
|
(root / ".git").mkdir(parents=True)
|
|
(root / "pyproject.toml").write_text("[project]\nname='x'\n")
|
|
(root / "uv.lock").write_text("lock-v1\n")
|
|
return root
|
|
|
|
|
|
def _wire_pm(monkeypatch, *, current=False, error=None):
|
|
import pm
|
|
|
|
calls = []
|
|
monkeypatch.setattr(pm, "venv_is_current", lambda *, project_root: current)
|
|
|
|
def sync(*, explicit, project_root, evict_incompatible_plugins):
|
|
assert evict_incompatible_plugins, "an update sync must disable misfit plugins, not fail"
|
|
calls.append((project_root, explicit))
|
|
if error:
|
|
raise pm.InstallError("venv", error)
|
|
|
|
monkeypatch.setattr(pm, "sync_venv", sync)
|
|
return calls
|
|
|
|
|
|
class TestCheckoutSync:
|
|
@pytest.mark.parametrize("foreign", [False, True])
|
|
def test_each_root_uses_the_public_pm_transaction(self, tmp_path, monkeypatch, foreign):
|
|
root = _checkout(tmp_path)
|
|
monkeypatch.setattr(venv_sync, "_project_root", lambda: root)
|
|
calls = _wire_pm(monkeypatch)
|
|
assert venv_sync.sync(root if foreign else None) == {"state": "synced", "ok": True}
|
|
assert calls == [(root, True)]
|
|
|
|
|
|
def test_check_is_passive(self, tmp_path, monkeypatch):
|
|
root = _checkout(tmp_path)
|
|
calls = _wire_pm(monkeypatch)
|
|
before = set(tmp_path.rglob("*"))
|
|
assert venv_sync.sync(root, check=True) == {"state": "would-sync", "ok": True}
|
|
assert calls == []
|
|
assert set(tmp_path.rglob("*")) == before
|
|
|
|
def test_a_failed_sync_is_reported_and_retried(self, tmp_path, monkeypatch):
|
|
root = _checkout(tmp_path)
|
|
calls = _wire_pm(monkeypatch, error="resolution failed")
|
|
for _ in range(2):
|
|
out = venv_sync.sync(root)
|
|
assert out["state"] == "failed" and not out["ok"]
|
|
assert "resolution failed" in out["detail"]
|
|
assert calls == [(root, True), (root, True)]
|
|
|
|
|
|
class TestSealedTrees:
|
|
def test_a_sealed_tree_is_a_clean_noop(self, tmp_path, monkeypatch):
|
|
"""The desktop payload and nix bundle must not fail, must not sync."""
|
|
root = tmp_path / "sealed"
|
|
root.mkdir()
|
|
(root / "install-stamp.json").write_text(
|
|
json.dumps({"commit": "abc123", "payload": "full", "updateMechanism": "electron-updater"})
|
|
)
|
|
calls = _wire_pm(monkeypatch)
|
|
|
|
out = venv_sync.sync(root)
|
|
|
|
assert out == {"state": "sealed", "ok": True}
|
|
assert calls == []
|
|
|
|
def test_a_dev_tree_with_both_stamp_and_git_is_a_checkout(
|
|
self, tmp_path, monkeypatch
|
|
):
|
|
root = _checkout(tmp_path)
|
|
(root / "install-stamp.json").write_text(
|
|
json.dumps({"commit": "abc", "updateMechanism": "electron-updater"})
|
|
)
|
|
_wire_pm(monkeypatch)
|
|
|
|
assert venv_sync.sync(root)["state"] == "synced"
|
|
|
|
def test_a_stamp_without_update_mechanism_is_a_build_lane_bug(self, tmp_path):
|
|
root = tmp_path / "sealed"
|
|
root.mkdir()
|
|
(root / "install-stamp.json").write_text(json.dumps({"commit": "abc"}))
|
|
with pytest.raises(RuntimeError, match="updateMechanism"):
|
|
venv_sync.sync(root)
|
|
|
|
|
|
class TestCliContract:
|
|
def test_json_output_and_exit_codes(self, tmp_path):
|
|
"""post_update and the installers read exactly this."""
|
|
root = tmp_path / "sealed"
|
|
root.mkdir()
|
|
(root / "install-stamp.json").write_text(
|
|
json.dumps({"commit": "x", "updateMechanism": "electron-updater"})
|
|
)
|
|
|
|
proc = subprocess.run(
|
|
[
|
|
sys.executable,
|
|
"-m",
|
|
"hermes_cli.venv_sync",
|
|
"--project-root",
|
|
str(root),
|
|
"--json",
|
|
],
|
|
capture_output=True,
|
|
text=True,
|
|
cwd=REPO_ROOT,
|
|
)
|
|
|
|
assert proc.returncode == 0, proc.stderr
|
|
assert json.loads(proc.stdout) == {"state": "sealed", "ok": True}
|
|
|
|
def test_failure_exits_nonzero(self, tmp_path):
|
|
proc = subprocess.run(
|
|
[
|
|
sys.executable,
|
|
"-m",
|
|
"hermes_cli.venv_sync",
|
|
"--project-root",
|
|
str(tmp_path), # empty dir: no pyproject, no stamp
|
|
"--json",
|
|
],
|
|
capture_output=True,
|
|
text=True,
|
|
cwd=REPO_ROOT,
|
|
)
|
|
|
|
assert proc.returncode == 1
|
|
assert json.loads(proc.stdout)["state"] == "failed"
|