many tests patched sys.platform or a module's _IS_WINDOWS flag, then ran on linux ci. the patch selects the branch under test, but the host does not have the behavior the branch exists for. the test proves the patch, not the platform. some gated assertions never ran on any host. this commit adds three markers: linux_only, macos_only, windows_only. a conftest hook skips a marked test on the other hosts, with a clear reason. no test fakes a host now. two documented fakes remain (android/termux, freebsd) because no ci runner exists for them. each fake site got one of four treatments: - gate it: the real host supplies the platform; mocks cover real dependencies only, never host identity - patch the module's own probe when the subject is the probe's consumer - assert against the real host when the fake stood in for any non-x host - delete the patch when it set the value the host already has bare skipif(sys.platform != ...) guards became markers too. the lane model skips these on linux and never imports them on windows, so they ran on no host. platform parametrize tables are now one marked test per os. running on real hosts found real errors: a chrome-sandbox failure in test_gui_command that main hides, and two windows failures fixed here. the agents.md testing section now documents the policy.
1107 lines
46 KiB
Python
1107 lines
46 KiB
Python
"""Tests for hermes_cli.managed_uv — one path, no guessing."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import stat
|
|
import sys
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _make_executable(path: Path) -> None:
|
|
"""Create a minimal fake uv binary at *path*."""
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text("#!/bin/sh\necho uv 0.1.2\n")
|
|
path.chmod(path.stat().st_mode | stat.S_IEXEC)
|
|
|
|
|
|
def _runtime_info(
|
|
executable: Path,
|
|
sqlite_version: tuple[int, int, int],
|
|
):
|
|
from hermes_cli.sqlite_runtime import SQLiteRuntimeInfo
|
|
|
|
return SQLiteRuntimeInfo(
|
|
executable=executable,
|
|
base_prefix=executable.parent.parent,
|
|
python_version=(3, 11, 15),
|
|
sqlite_version=sqlite_version,
|
|
sqlite_version_string=".".join(str(part) for part in sqlite_version),
|
|
sqlite_source_id=f"source-{sqlite_version}",
|
|
)
|
|
|
|
|
|
def _RRR(status):
|
|
"""not-applicable RuntimeRepairResult for tests that neutralize the
|
|
repair hook. ensure_uv()/update_managed_uv() invoke runtime repair as a
|
|
side effect; unmocked, it probes the REAL checkout's venv/.venv — on CI
|
|
the repo .venv links vulnerable SQLite, so repair fires for real and
|
|
re-invokes _install_uv (uv-refresh retry), breaking call-count asserts.
|
|
"""
|
|
from hermes_cli.managed_uv import RuntimeRepairResult
|
|
|
|
return RuntimeRepairResult(status)
|
|
|
|
|
|
def _make_runtime_install(
|
|
tmp_path: Path,
|
|
*,
|
|
windows: bool = False,
|
|
) -> tuple[Path, Path, Path]:
|
|
root = tmp_path / "checkout"
|
|
root.mkdir()
|
|
(root / "pyproject.toml").write_text("[project]\n", encoding="utf-8")
|
|
live = root / "venv"
|
|
bin_dir = live / ("Scripts" if windows else "bin")
|
|
bin_dir.mkdir(parents=True)
|
|
python = bin_dir / ("python.exe" if windows else "python")
|
|
python.write_text("live interpreter", encoding="utf-8")
|
|
sentinel = live / "sentinel"
|
|
sentinel.write_text("live", encoding="utf-8")
|
|
return root, live, sentinel
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# managed_uv_path
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestManagedUvPath:
|
|
# POSIX arm of the name mapping; the Windows arm (uv.exe) is exercised
|
|
# for real by TestEnsureUvWindowsSafe on the Windows lane.
|
|
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX-only: bin/uv name")
|
|
def test_posix(self, tmp_path):
|
|
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path):
|
|
from hermes_cli.managed_uv import managed_uv_path
|
|
assert managed_uv_path() == tmp_path / "bin" / "uv"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# resolve_uv
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestResolveUv:
|
|
|
|
def test_existing_executable(self, tmp_path):
|
|
_make_executable(tmp_path / "bin" / "uv")
|
|
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path):
|
|
from hermes_cli.managed_uv import resolve_uv
|
|
result = resolve_uv()
|
|
assert result == str(tmp_path / "bin" / "uv")
|
|
|
|
def test_non_executable_file_returns_none(self, tmp_path):
|
|
uv = tmp_path / "bin" / "uv"
|
|
uv.parent.mkdir(parents=True)
|
|
uv.write_text("not a binary")
|
|
# Ensure no execute bit
|
|
uv.chmod(0o644)
|
|
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path):
|
|
from hermes_cli.managed_uv import resolve_uv
|
|
assert resolve_uv() is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ensure_uv
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestEnsureUv:
|
|
|
|
def test_installs_if_missing(self, tmp_path):
|
|
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \
|
|
patch("hermes_cli.managed_uv.repair_vulnerable_runtime", return_value=_RRR("not-applicable")), \
|
|
patch("hermes_cli.managed_uv._install_uv") as mock_install:
|
|
# Simulate the installer creating the binary
|
|
def fake_install(target):
|
|
_make_executable(target)
|
|
mock_install.side_effect = fake_install
|
|
|
|
from hermes_cli.managed_uv import ensure_uv
|
|
path = ensure_uv()
|
|
assert path == str(tmp_path / "bin" / "uv")
|
|
mock_install.assert_called_once()
|
|
|
|
def test_install_reports_runtime_repair_to_observer(self, tmp_path):
|
|
from hermes_cli.managed_uv import (
|
|
RuntimeRepairResult,
|
|
ensure_uv,
|
|
)
|
|
|
|
repair = RuntimeRepairResult(
|
|
"repaired",
|
|
sqlite_before="3.50.4",
|
|
sqlite_after="3.53.1",
|
|
)
|
|
|
|
def fake_install(target):
|
|
_make_executable(target)
|
|
|
|
observed = []
|
|
with patch(
|
|
"hermes_cli.managed_uv.get_hermes_home",
|
|
return_value=tmp_path,
|
|
), patch(
|
|
"hermes_cli.managed_uv._install_uv",
|
|
side_effect=fake_install,
|
|
), patch(
|
|
"hermes_cli.managed_uv.repair_vulnerable_runtime",
|
|
return_value=repair,
|
|
):
|
|
path = ensure_uv(repair_observer=observed.append)
|
|
|
|
assert path == str(tmp_path / "bin" / "uv")
|
|
assert observed == [repair]
|
|
|
|
|
|
@pytest.mark.skipif(sys.platform == "win32",
|
|
reason="POSIX-only: the _UvResult dual contract is not offered on Windows")
|
|
class TestEnsureUvUpdateBoundary:
|
|
"""``ensure_uv()`` must answer to both the single-value and the legacy
|
|
``(path, fresh_bootstrap)`` call conventions — **on POSIX**.
|
|
|
|
``hermes update`` runs the call site from the old, already-imported
|
|
``hermes_cli.main`` against the freshly pulled ``managed_uv``. A release
|
|
parked on a ``(path, fresh)`` tuple runs ``uv_bin, fresh = ensure_uv()``
|
|
against the single-value module; the path is an iterable ``str`` so the
|
|
2-target unpack walked its characters and raised
|
|
``ValueError: too many values to unpack (expected 2)`` (root cause behind
|
|
PR #39763), or ``TypeError`` on the ``None`` failure path. On POSIX the
|
|
result must therefore be usable as a bare path *and* unpackable as a
|
|
2-tuple, in both the success and failure cases.
|
|
|
|
The dual contract is intentionally **not** offered on Windows — see
|
|
``TestEnsureUvWindowsSafe`` for why — so these tests are POSIX-only: the
|
|
host's real ``platform.system()`` selects the wrapper branch, nothing is
|
|
faked.
|
|
"""
|
|
|
|
def test_success_usable_as_single_value(self, tmp_path):
|
|
_make_executable(tmp_path / "bin" / "uv")
|
|
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \
|
|
patch("hermes_cli.managed_uv.repair_vulnerable_runtime", return_value=_RRR("not-applicable")):
|
|
from hermes_cli.managed_uv import ensure_uv
|
|
uv_bin = ensure_uv()
|
|
assert uv_bin == str(tmp_path / "bin" / "uv")
|
|
assert bool(uv_bin) is True
|
|
|
|
def test_success_unpacks_as_legacy_two_tuple(self, tmp_path):
|
|
_make_executable(tmp_path / "bin" / "uv")
|
|
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \
|
|
patch("hermes_cli.managed_uv.repair_vulnerable_runtime", return_value=_RRR("not-applicable")):
|
|
from hermes_cli.managed_uv import ensure_uv
|
|
uv_bin, fresh = ensure_uv() # old: uv_bin, fresh_bootstrap = ensure_uv()
|
|
assert uv_bin == str(tmp_path / "bin" / "uv")
|
|
assert fresh is False
|
|
|
|
def test_failure_unpacks_without_raising(self, tmp_path):
|
|
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \
|
|
patch("hermes_cli.managed_uv.repair_vulnerable_runtime", return_value=_RRR("not-applicable")), \
|
|
patch("hermes_cli.managed_uv._install_uv", side_effect=RuntimeError("network down")):
|
|
from hermes_cli.managed_uv import ensure_uv
|
|
uv_bin, fresh = ensure_uv()
|
|
assert uv_bin is None
|
|
assert fresh is False
|
|
|
|
|
|
class TestEnsureUvWindowsSafe:
|
|
"""On Windows ``ensure_uv()`` must return a plain ``str``/``None``.
|
|
|
|
``subprocess`` on Windows serializes argv through
|
|
``subprocess.list2cmdline``, which iterates every entry *as a string*
|
|
(``for c in arg``). The dependency installer feeds uv straight into the
|
|
command list (``[uv_bin, "pip", "install", ...]``). A ``str`` subclass
|
|
whose ``__iter__`` yields ``(path, fresh_bootstrap)`` instead of characters
|
|
therefore injects the bool into the command line and crashes the install
|
|
with ``TypeError: sequence item 1: expected str instance, bool found``
|
|
(a real field report on a 10-commits-behind Windows install). A single
|
|
return value cannot serve both the legacy 2-tuple unpack and Windows
|
|
char-iteration — both use the iterator protocol — so Windows opts out of
|
|
the wrapper entirely.
|
|
"""
|
|
|
|
def test_uvresult_would_break_windows_list2cmdline(self):
|
|
# Canary: this is *why* the wrapper is gated off Windows. If a future
|
|
# change makes _UvResult char-iterable (and thus list2cmdline-safe),
|
|
# the gate may be revisited.
|
|
import subprocess
|
|
from hermes_cli.managed_uv import _UvResult
|
|
with pytest.raises(TypeError):
|
|
subprocess.list2cmdline([_UvResult("C:\\hermes\\uv.exe"), "pip"])
|
|
|
|
@pytest.mark.windows_only
|
|
def test_windows_returns_plain_str_safe_for_subprocess(self, tmp_path):
|
|
"""``windows_only``: the subject is the real Windows opt-out branch and
|
|
``subprocess.list2cmdline`` — the faked ``platform.system`` only ever
|
|
proved the branch existed, not that the field crash was fixed on the
|
|
host that reported it."""
|
|
import subprocess
|
|
# On Windows the managed binary is uv.exe.
|
|
_make_executable(tmp_path / "bin" / "uv.exe")
|
|
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \
|
|
patch("hermes_cli.managed_uv.repair_vulnerable_runtime", return_value=_RRR("not-applicable")):
|
|
from hermes_cli.managed_uv import _UvResult, ensure_uv
|
|
uv_bin = ensure_uv()
|
|
assert type(uv_bin) is str and not isinstance(uv_bin, _UvResult)
|
|
# The exact operation that crashed in the field must now succeed.
|
|
cmdline = subprocess.list2cmdline([uv_bin, "pip", "install", "-e", "."])
|
|
assert "pip" in cmdline and "install" in cmdline
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# update_managed_uv
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestUpdateManagedUv:
|
|
|
|
|
|
|
|
def test_fresh_stamp_skips_network_self_update_but_not_repair(self, tmp_path, monkeypatch):
|
|
"""A recent success stamp must skip `uv self update` entirely while the
|
|
vulnerable-runtime repair probe still runs (CVE repair is never gated)."""
|
|
from hermes_cli.managed_uv import RuntimeRepairResult, update_managed_uv
|
|
|
|
uv = tmp_path / "bin" / "uv"
|
|
_make_executable(uv)
|
|
# Fresh stamp under the isolated HERMES_HOME.
|
|
import hermes_constants
|
|
stamp = hermes_constants.get_hermes_home() / "cache" / ".uv_self_update_stamp"
|
|
stamp.parent.mkdir(parents=True, exist_ok=True)
|
|
stamp.touch()
|
|
|
|
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \
|
|
patch("hermes_cli.managed_uv.subprocess.run") as mock_run, \
|
|
patch(
|
|
"hermes_cli.managed_uv.repair_vulnerable_runtime",
|
|
return_value=RuntimeRepairResult("skipped"),
|
|
) as mock_repair:
|
|
result = update_managed_uv()
|
|
|
|
assert result == str(uv)
|
|
assert mock_run.call_count == 0, "fresh stamp must skip the network self-update"
|
|
mock_repair.assert_called_once_with(str(uv))
|
|
|
|
|
|
def test_stale_stamp_runs_self_update_and_refreshes_stamp(self, tmp_path):
|
|
import os as _os
|
|
import time as _time
|
|
|
|
from hermes_cli.managed_uv import UV_SELF_UPDATE_INTERVAL_SECONDS, update_managed_uv
|
|
|
|
uv = tmp_path / "bin" / "uv"
|
|
_make_executable(uv)
|
|
import hermes_constants
|
|
stamp = hermes_constants.get_hermes_home() / "cache" / ".uv_self_update_stamp"
|
|
stamp.parent.mkdir(parents=True, exist_ok=True)
|
|
stamp.touch()
|
|
old = _time.time() - UV_SELF_UPDATE_INTERVAL_SECONDS - 60
|
|
_os.utime(stamp, (old, old))
|
|
|
|
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \
|
|
patch("hermes_cli.managed_uv.repair_vulnerable_runtime", return_value=_RRR("not-applicable")), \
|
|
patch("hermes_cli.managed_uv.subprocess.run") as mock_run:
|
|
mock_run.return_value = MagicMock(returncode=0, stdout="uv 0.2.0")
|
|
update_managed_uv()
|
|
|
|
assert mock_run.call_args_list[0][0][0] == [str(uv), "self", "update"]
|
|
assert stamp.stat().st_mtime > old + 30, "successful self-update must refresh the stamp"
|
|
|
|
|
|
|
|
|
|
class TestManagedPythonStore:
|
|
def test_store_is_checkout_scoped_across_profiles(self, tmp_path, monkeypatch):
|
|
from hermes_cli.managed_uv import managed_python_install_dir
|
|
|
|
checkout = tmp_path / "checkout"
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "profiles" / "alpha"))
|
|
alpha = managed_python_install_dir(checkout)
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "profiles" / "beta"))
|
|
beta = managed_python_install_dir(checkout)
|
|
|
|
expected = checkout / ".hermes-runtime" / "python"
|
|
assert alpha == expected
|
|
assert beta == expected
|
|
|
|
def test_environment_is_private_and_sanitized(self, tmp_path):
|
|
from hermes_cli.managed_uv import managed_python_env
|
|
|
|
checkout = tmp_path / "checkout"
|
|
base_env = {
|
|
"KEEP_ME": "yes",
|
|
"CONDA_DEFAULT_ENV": "poison",
|
|
"CONDA_PREFIX": "/poison/conda",
|
|
"UV_PROJECT_ENVIRONMENT": "/poison/project",
|
|
"UV_NO_MANAGED_PYTHON": "1",
|
|
"UV_PYTHON": "/poison/python",
|
|
"UV_PYTHON_DOWNLOADS": "never",
|
|
"UV_SYSTEM_PYTHON": "1",
|
|
"VIRTUAL_ENV": "/poison/venv",
|
|
"PYTHONHOME": "/poison/home",
|
|
"PYTHONPATH": "/poison/path",
|
|
}
|
|
|
|
env = managed_python_env(checkout, base_env=base_env)
|
|
|
|
assert env["KEEP_ME"] == "yes"
|
|
assert env["UV_MANAGED_PYTHON"] == "1"
|
|
assert env["UV_NO_CONFIG"] == "1"
|
|
assert env["UV_PYTHON_INSTALL_BIN"] == "0"
|
|
assert env["UV_PYTHON_INSTALL_REGISTRY"] == "0"
|
|
assert env["UV_PYTHON_INSTALL_DIR"] == str(
|
|
checkout / ".hermes-runtime" / "python"
|
|
)
|
|
for key in (
|
|
"CONDA_DEFAULT_ENV",
|
|
"CONDA_PREFIX",
|
|
"UV_PROJECT_ENVIRONMENT",
|
|
"UV_NO_MANAGED_PYTHON",
|
|
"UV_PYTHON",
|
|
"UV_PYTHON_DOWNLOADS",
|
|
"UV_SYSTEM_PYTHON",
|
|
"VIRTUAL_ENV",
|
|
"PYTHONHOME",
|
|
"PYTHONPATH",
|
|
):
|
|
assert key not in env
|
|
assert base_env["PYTHONHOME"] == "/poison/home"
|
|
|
|
|
|
@pytest.mark.skipif(sys.platform == "win32",
|
|
reason="POSIX-only: fixtures build the bin/ (not Scripts/) venv layout")
|
|
class TestRuntimeRepair:
|
|
def test_safe_runtime_is_a_noop(self, tmp_path):
|
|
from hermes_cli.managed_uv import repair_vulnerable_runtime
|
|
|
|
root, live, sentinel = _make_runtime_install(tmp_path)
|
|
current = _runtime_info(live / "bin" / "python", (3, 53, 1))
|
|
with patch(
|
|
"hermes_cli.managed_uv.probe_sqlite_runtime",
|
|
return_value=current,
|
|
), \
|
|
patch(
|
|
"hermes_cli.managed_uv._install_safe_python_generation"
|
|
) as mock_install:
|
|
result = repair_vulnerable_runtime("uv", project_root=root)
|
|
|
|
assert result.status == "safe"
|
|
assert result.sqlite_before == "3.53.1"
|
|
assert result.sqlite_after == "3.53.1"
|
|
assert sentinel.read_text(encoding="utf-8") == "live"
|
|
assert not (root / ".hermes-runtime").exists()
|
|
mock_install.assert_not_called()
|
|
|
|
def test_stage_candidate_sync_keeps_uv_project_config(self, tmp_path):
|
|
from hermes_cli.managed_uv import _stage_candidate_venv
|
|
|
|
root = tmp_path / "checkout"
|
|
root.mkdir()
|
|
(root / "uv.lock").write_text("# lock\n", encoding="utf-8")
|
|
generation = root / ".hermes-runtime" / "python" / "gen"
|
|
python = generation / "bin" / "python"
|
|
python.parent.mkdir(parents=True)
|
|
python.write_text("py", encoding="utf-8")
|
|
|
|
calls = []
|
|
|
|
def fake_run(argv, **kwargs):
|
|
calls.append((list(argv), kwargs.get("env")))
|
|
return MagicMock(returncode=0)
|
|
|
|
with patch("hermes_cli.managed_uv.subprocess.run", side_effect=fake_run), \
|
|
patch(
|
|
"hermes_cli.managed_uv._smoke_candidate_venv",
|
|
return_value=(True, "", None),
|
|
):
|
|
candidate = _stage_candidate_venv(
|
|
"uv",
|
|
project_root=root,
|
|
generation=generation,
|
|
python=python,
|
|
)
|
|
|
|
assert candidate is not None
|
|
assert len(calls) == 2
|
|
venv_argv, venv_env = calls[0]
|
|
sync_argv, sync_env = calls[1]
|
|
assert venv_argv[:2] == ["uv", "venv"]
|
|
assert "--no-config" in venv_argv
|
|
assert venv_env.get("UV_NO_CONFIG") == "1"
|
|
assert sync_argv[:2] == ["uv", "sync"]
|
|
assert "--locked" in sync_argv
|
|
assert "--no-config" not in sync_argv
|
|
assert "UV_NO_CONFIG" not in sync_env
|
|
|
|
def test_failed_candidate_preserves_live_venv(self, tmp_path):
|
|
from hermes_cli.managed_uv import (
|
|
_acquire_repair_lock,
|
|
_release_repair_lock,
|
|
repair_vulnerable_runtime,
|
|
)
|
|
|
|
root, live, sentinel = _make_runtime_install(tmp_path)
|
|
current = _runtime_info(live / "bin" / "python", (3, 50, 4))
|
|
generation = root / ".hermes-runtime" / "python" / "generation-test"
|
|
candidate_python = generation / "bin" / "python"
|
|
candidate_python.parent.mkdir(parents=True)
|
|
candidate_python.write_text("candidate interpreter", encoding="utf-8")
|
|
fixed = _runtime_info(candidate_python, (3, 53, 1))
|
|
|
|
with patch(
|
|
"hermes_cli.managed_uv.probe_sqlite_runtime",
|
|
side_effect=[current, current],
|
|
), \
|
|
patch(
|
|
"hermes_cli.managed_uv._install_safe_python_generation",
|
|
return_value=(generation, candidate_python, fixed),
|
|
), \
|
|
patch(
|
|
"hermes_cli.managed_uv._stage_candidate_venv",
|
|
return_value=None,
|
|
):
|
|
result = repair_vulnerable_runtime("uv", project_root=root)
|
|
|
|
assert result.status == "failed"
|
|
assert "replacement environment" in result.detail
|
|
assert sentinel.read_text(encoding="utf-8") == "live"
|
|
assert (live / "bin" / "python").read_text(encoding="utf-8") == (
|
|
"live interpreter"
|
|
)
|
|
assert not generation.exists()
|
|
reacquired = _acquire_repair_lock(root / ".hermes-runtime")
|
|
assert reacquired is not None
|
|
_release_repair_lock(reacquired)
|
|
|
|
def test_safe_runtime_sweeps_old_stale_backups(self, tmp_path):
|
|
"""A fixed runtime reclaims aged venv.stale.runtime-* leftovers
|
|
(issue #73109) but leaves fresh ones (possible in-flight repair)."""
|
|
import os
|
|
import time as _time
|
|
|
|
from hermes_cli.managed_uv import repair_vulnerable_runtime
|
|
|
|
root, live, sentinel = _make_runtime_install(tmp_path)
|
|
old_backup = root / f"{live.name}.stale.runtime-1-2-aaaa"
|
|
(old_backup / "bin").mkdir(parents=True)
|
|
(old_backup / "bin" / "python").write_text("old", encoding="utf-8")
|
|
stale_mtime = _time.time() - 7200
|
|
os.utime(old_backup, (stale_mtime, stale_mtime))
|
|
|
|
fresh_backup = root / f"{live.name}.stale.runtime-9-9-bbbb"
|
|
(fresh_backup / "bin").mkdir(parents=True)
|
|
|
|
current = _runtime_info(live / "bin" / "python", (3, 53, 1))
|
|
with patch(
|
|
"hermes_cli.managed_uv.probe_sqlite_runtime",
|
|
return_value=current,
|
|
):
|
|
result = repair_vulnerable_runtime("uv", project_root=root)
|
|
|
|
assert result.status == "safe"
|
|
assert not old_backup.exists(), "aged stale backup must be reclaimed"
|
|
assert fresh_backup.exists(), "fresh backup may be an in-flight repair"
|
|
assert sentinel.read_text(encoding="utf-8") == "live"
|
|
|
|
def test_successful_repair_removes_parked_backup(self, tmp_path):
|
|
"""After a successful cutover the parked venv is removed instead of
|
|
leaking ~1 GB at the project root forever (issue #73109)."""
|
|
from hermes_cli.managed_uv import repair_vulnerable_runtime
|
|
|
|
root, live, sentinel = _make_runtime_install(tmp_path)
|
|
current = _runtime_info(live / "bin" / "python", (3, 50, 4))
|
|
generation = root / ".hermes-runtime" / "python" / "generation-test"
|
|
candidate_python = generation / "bin" / "python"
|
|
candidate_python.parent.mkdir(parents=True)
|
|
candidate_python.write_text("candidate interpreter", encoding="utf-8")
|
|
fixed = _runtime_info(candidate_python, (3, 53, 1))
|
|
candidate_venv = root / ".hermes-runtime" / "venv-candidate"
|
|
(candidate_venv / "bin").mkdir(parents=True)
|
|
(candidate_venv / "bin" / "python").write_text(
|
|
"candidate venv interpreter", encoding="utf-8"
|
|
)
|
|
|
|
with patch(
|
|
"hermes_cli.managed_uv.probe_sqlite_runtime",
|
|
side_effect=[current, current],
|
|
), \
|
|
patch(
|
|
"hermes_cli.managed_uv._install_safe_python_generation",
|
|
return_value=(generation, candidate_python, fixed),
|
|
), \
|
|
patch(
|
|
"hermes_cli.managed_uv._stage_candidate_venv",
|
|
return_value=candidate_venv,
|
|
), \
|
|
patch(
|
|
"hermes_cli.managed_uv._smoke_candidate_venv",
|
|
return_value=(True, "", fixed),
|
|
):
|
|
result = repair_vulnerable_runtime("uv", project_root=root)
|
|
|
|
assert result.status == "repaired"
|
|
assert result.backup_venv is not None
|
|
assert not result.backup_venv.exists(), (
|
|
"parked venv must be removed after a successful repair"
|
|
)
|
|
leftovers = list(root.glob(f"{live.name}.stale.runtime-*"))
|
|
assert leftovers == [], f"no stale markers may remain: {leftovers}"
|
|
|
|
|
|
class TestRuntimeCutover:
|
|
def test_os_lock_blocks_concurrent_repair_and_releases(self, tmp_path):
|
|
from hermes_cli.managed_uv import _acquire_repair_lock, _release_repair_lock
|
|
|
|
runtime_root = tmp_path / ".hermes-runtime"
|
|
first = _acquire_repair_lock(runtime_root)
|
|
assert first is not None
|
|
assert _acquire_repair_lock(runtime_root) is None
|
|
|
|
_release_repair_lock(first)
|
|
second = _acquire_repair_lock(runtime_root)
|
|
assert second is not None
|
|
_release_repair_lock(second)
|
|
|
|
|
|
|
|
def test_post_swap_smoke_failure_rolls_back_live_venv(self, tmp_path):
|
|
from hermes_cli.managed_uv import _cut_over_candidate
|
|
|
|
root, live, sentinel = _make_runtime_install(tmp_path)
|
|
runtime_root = root / ".hermes-runtime"
|
|
candidate = runtime_root / "venv-candidate-test"
|
|
candidate.mkdir(parents=True)
|
|
(candidate / "sentinel").write_text("candidate", encoding="utf-8")
|
|
rejected_info = _runtime_info(candidate / "bin" / "python", (3, 50, 4))
|
|
|
|
with patch(
|
|
"hermes_cli.managed_uv._smoke_candidate_venv",
|
|
return_value=(False, "core import smoke failed", rejected_info),
|
|
):
|
|
ok, backup, info, detail = _cut_over_candidate(
|
|
candidate,
|
|
project_root=root,
|
|
)
|
|
|
|
assert ok is False
|
|
assert backup is None
|
|
assert info == rejected_info
|
|
assert "post-cutover smoke failed" in detail
|
|
assert sentinel.read_text(encoding="utf-8") == "live"
|
|
assert (live / "bin" / "python").read_text(encoding="utf-8") == (
|
|
"live interpreter"
|
|
)
|
|
assert not candidate.exists()
|
|
assert not list(runtime_root.glob("venv-rejected-*"))
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _install_uv internals
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestInstallUvInternals:
|
|
def test_posix_sets_uv_unmanaged_install(self, tmp_path):
|
|
target = tmp_path / "bin" / "uv"
|
|
with patch("hermes_cli.managed_uv._install_uv_posix") as mock_posix:
|
|
from hermes_cli.managed_uv import _install_uv
|
|
_install_uv(target)
|
|
mock_posix.assert_called_once()
|
|
call_env = mock_posix.call_args[0][0]
|
|
assert call_env["UV_UNMANAGED_INSTALL"] == str(tmp_path / "bin")
|
|
|
|
|
|
class TestRuntimeRequestMinorLine:
|
|
"""The repair must request the CPython minor line, not the exact patch.
|
|
|
|
Real-world constraint (verified live, July 2026): every published
|
|
python-build-standalone artifact for 3.11.14 links vulnerable SQLite
|
|
3.50.4 — even with --reinstall. The fixed SQLite (3.53.1) only exists
|
|
from 3.11.15. An exact-patch pin makes the repair permanently
|
|
impossible on such installs.
|
|
"""
|
|
|
|
def test_requests_minor_line(self):
|
|
from hermes_cli.managed_uv import _runtime_request
|
|
|
|
info = _runtime_info(Path("/venv/bin/python"), (3, 50, 4))
|
|
assert _runtime_request(info) == "3.11"
|
|
|
|
@staticmethod
|
|
def _run_generation(tmp_path, monkeypatch, current_version, candidate_version):
|
|
"""Drive _install_safe_python_generation with fakes; return result."""
|
|
import hermes_cli.managed_uv as managed_uv
|
|
from hermes_cli.sqlite_runtime import SQLiteRuntimeInfo
|
|
|
|
state = {}
|
|
|
|
def fake_run(cmd, **kwargs):
|
|
if "install" in cmd:
|
|
state["generation"] = Path(kwargs["env"]["UV_PYTHON_INSTALL_DIR"])
|
|
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
|
# uv python find → a path inside the generation dir
|
|
python = state["generation"] / "cpython" / "bin" / "python3"
|
|
python.parent.mkdir(parents=True, exist_ok=True)
|
|
python.touch()
|
|
return SimpleNamespace(returncode=0, stdout=str(python), stderr="")
|
|
|
|
def fake_probe(python, **kwargs):
|
|
return SQLiteRuntimeInfo(
|
|
executable=Path(python),
|
|
base_prefix=Path(python).parent.parent,
|
|
python_version=candidate_version,
|
|
sqlite_version=(3, 53, 1),
|
|
sqlite_version_string="3.53.1",
|
|
sqlite_source_id="fixed",
|
|
)
|
|
|
|
current = SQLiteRuntimeInfo(
|
|
executable=Path("/venv/bin/python"),
|
|
base_prefix=Path("/venv"),
|
|
python_version=current_version,
|
|
sqlite_version=(3, 50, 4),
|
|
sqlite_version_string="3.50.4",
|
|
sqlite_source_id="old",
|
|
)
|
|
monkeypatch.setattr(managed_uv.subprocess, "run", fake_run)
|
|
monkeypatch.setattr(managed_uv, "probe_sqlite_runtime", fake_probe)
|
|
return managed_uv._install_safe_python_generation(
|
|
"uv", project_root=tmp_path, current=current
|
|
)
|
|
|
|
def test_accepts_newer_patch_same_minor(self, tmp_path, monkeypatch):
|
|
result = self._run_generation(
|
|
tmp_path, monkeypatch, (3, 11, 14), (3, 11, 15)
|
|
)
|
|
assert result is not None
|
|
_, _, candidate = result
|
|
assert candidate.python_version == (3, 11, 15)
|
|
|
|
|
|
class TestPatchRetryOnVulnerableCandidate:
|
|
"""Regression tests for issue #71250: when the bare minor-line request
|
|
(e.g. "3.11") resolves to a candidate that's still vulnerable -- because
|
|
uv's default resolution for that host picked an older cached/indexed
|
|
patch even though a newer non-vulnerable one is available -- the
|
|
provisioner must query the available patches and retry with explicit
|
|
newer versions, rather than giving up after the first attempt.
|
|
"""
|
|
|
|
@staticmethod
|
|
def _versioned_probe_run(vulnerable_versions, sqlite_fixed=(3, 53, 1)):
|
|
"""Build a fake subprocess.run where the install/find/probe cycle
|
|
resolves to a DIFFERENT candidate Python version depending on which
|
|
exact version string was requested, so retries with explicit
|
|
patches can be distinguished from the initial bare-minor attempt."""
|
|
import hermes_cli.managed_uv as managed_uv
|
|
from hermes_cli.sqlite_runtime import SQLiteRuntimeInfo
|
|
|
|
state = {"requested": None}
|
|
|
|
def fake_run(cmd, **kwargs):
|
|
if "install" in cmd:
|
|
# cmd = [uv, "python", "install", <request>, ...]
|
|
state["requested"] = cmd[3]
|
|
state["generation"] = Path(kwargs["env"]["UV_PYTHON_INSTALL_DIR"])
|
|
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
|
if "list" in cmd:
|
|
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
|
# uv python find → a path inside the generation dir, tagged with
|
|
# which request produced it so the probe below can look it up.
|
|
python = state["generation"] / "cpython" / "bin" / "python3"
|
|
python.parent.mkdir(parents=True, exist_ok=True)
|
|
python.write_text(state["requested"] or "")
|
|
return SimpleNamespace(returncode=0, stdout=str(python), stderr="")
|
|
|
|
def fake_probe(python, **kwargs):
|
|
requested = Path(python).read_text()
|
|
# Bare minor request ("3.11") always resolves to the FIRST
|
|
# (worst-case / already-known-vulnerable) version in the list.
|
|
if requested in vulnerable_versions or requested == "3.11":
|
|
version = (3, 11, 14) if requested == "3.11" else tuple(
|
|
int(p) for p in requested.split(".")
|
|
)
|
|
return SQLiteRuntimeInfo(
|
|
executable=Path(python), base_prefix=Path(python).parent.parent,
|
|
python_version=version, sqlite_version=(3, 50, 4),
|
|
sqlite_version_string="3.50.4", sqlite_source_id="vulnerable",
|
|
)
|
|
version = tuple(int(p) for p in requested.split("."))
|
|
return SQLiteRuntimeInfo(
|
|
executable=Path(python), base_prefix=Path(python).parent.parent,
|
|
python_version=version, sqlite_version=sqlite_fixed,
|
|
sqlite_version_string=".".join(str(p) for p in sqlite_fixed),
|
|
sqlite_source_id="fixed",
|
|
)
|
|
|
|
return fake_run, fake_probe
|
|
|
|
def _run(self, tmp_path, monkeypatch, *, vulnerable_versions, patch_list):
|
|
import hermes_cli.managed_uv as managed_uv
|
|
from hermes_cli.sqlite_runtime import SQLiteRuntimeInfo
|
|
|
|
fake_run, fake_probe = self._versioned_probe_run(vulnerable_versions)
|
|
current = SQLiteRuntimeInfo(
|
|
executable=Path("/venv/bin/python"), base_prefix=Path("/venv"),
|
|
python_version=(3, 11, 14), sqlite_version=(3, 50, 4),
|
|
sqlite_version_string="3.50.4", sqlite_source_id="old",
|
|
)
|
|
monkeypatch.setattr(managed_uv.subprocess, "run", fake_run)
|
|
monkeypatch.setattr(managed_uv, "probe_sqlite_runtime", fake_probe)
|
|
monkeypatch.setattr(
|
|
managed_uv, "_list_available_patches", lambda *a, **kw: patch_list
|
|
)
|
|
return managed_uv._install_safe_python_generation(
|
|
"uv", project_root=tmp_path, current=current
|
|
)
|
|
|
|
def test_retries_and_succeeds_with_explicit_newer_patch(self, tmp_path, monkeypatch):
|
|
"""The exact #71250 scenario: bare '3.11' resolves to vulnerable
|
|
3.11.14, but 3.11.15 (fixed) is available and gets tried explicitly."""
|
|
result = self._run(
|
|
tmp_path, monkeypatch,
|
|
vulnerable_versions={"3.11"},
|
|
patch_list=[(3, 11, 15), (3, 11, 14), (3, 11, 13), (3, 11, 12)],
|
|
)
|
|
assert result is not None, "Must recover via explicit-patch retry"
|
|
_, _, candidate = result
|
|
assert candidate.python_version == (3, 11, 15)
|
|
assert not candidate.wal_reset_vulnerable
|
|
|
|
|
|
|
|
|
|
|
|
def test_retry_is_bounded_by_max_retries_constant(self, tmp_path, monkeypatch):
|
|
"""A very long patch list must not result in unbounded retries --
|
|
capped at _MAX_PATCH_RETRIES attempts."""
|
|
import hermes_cli.managed_uv as managed_uv
|
|
|
|
install_calls = []
|
|
fake_run, fake_probe = self._versioned_probe_run({"3.11"})
|
|
original_fake_run = fake_run
|
|
|
|
def counting_fake_run(cmd, **kwargs):
|
|
if "install" in cmd:
|
|
install_calls.append(cmd[3])
|
|
return original_fake_run(cmd, **kwargs)
|
|
|
|
from hermes_cli.sqlite_runtime import SQLiteRuntimeInfo
|
|
current = SQLiteRuntimeInfo(
|
|
executable=Path("/venv/bin/python"), base_prefix=Path("/venv"),
|
|
python_version=(3, 11, 14), sqlite_version=(3, 50, 4),
|
|
sqlite_version_string="3.50.4", sqlite_source_id="old",
|
|
)
|
|
# 20 vulnerable patches -- far more than _MAX_PATCH_RETRIES.
|
|
huge_patch_list = [(3, 11, v) for v in range(30, 10, -1)]
|
|
all_vulnerable = {f"3.11.{v}" for v in range(30, 10, -1)} | {"3.11"}
|
|
fake_run2, fake_probe2 = self._versioned_probe_run(all_vulnerable)
|
|
|
|
monkeypatch.setattr(managed_uv.subprocess, "run", fake_run2)
|
|
monkeypatch.setattr(managed_uv, "probe_sqlite_runtime", fake_probe2)
|
|
monkeypatch.setattr(
|
|
managed_uv, "_list_available_patches", lambda *a, **kw: huge_patch_list
|
|
)
|
|
result = managed_uv._install_safe_python_generation(
|
|
"uv", project_root=tmp_path, current=current
|
|
)
|
|
assert result is None
|
|
# 1 initial bare-minor attempt + at most _MAX_PATCH_RETRIES retries.
|
|
assert managed_uv._MAX_PATCH_RETRIES <= 5, (
|
|
"sanity: constant should stay small since each attempt is a "
|
|
"real download+install+probe cycle"
|
|
)
|
|
|
|
|
|
class TestListAvailablePatches:
|
|
"""Direct unit tests for _list_available_patches()'s JSON parsing,
|
|
against realistic `uv python list --all-versions --output-format json`
|
|
output (captured from a real uv 0.11.7 invocation)."""
|
|
|
|
SAMPLE_OUTPUT = (
|
|
'[{"key":"cpython-3.11.15-linux-x86_64-gnu","version":"3.11.15",'
|
|
'"version_parts":{"major":3,"minor":11,"patch":15},"path":null,'
|
|
'"symlink":null,"url":"https://example/cpython-3.11.15.tar.gz",'
|
|
'"os":"linux","variant":"default","implementation":"cpython",'
|
|
'"arch":"x86_64","libc":"gnu"},'
|
|
'{"key":"cpython-3.11.14-linux-x86_64-gnu","version":"3.11.14",'
|
|
'"version_parts":{"major":3,"minor":11,"patch":14},"path":null,'
|
|
'"symlink":null,"url":"https://example/cpython-3.11.14.tar.gz",'
|
|
'"os":"linux","variant":"default","implementation":"cpython",'
|
|
'"arch":"x86_64","libc":"gnu"},'
|
|
'{"key":"pypy-3.11.15-linux-x86_64-gnu","version":"3.11.15",'
|
|
'"version_parts":{"major":3,"minor":11,"patch":15},"path":null,'
|
|
'"symlink":null,"url":"https://example/pypy-3.11.15.tar.gz",'
|
|
'"os":"linux","variant":"default","implementation":"pypy",'
|
|
'"arch":"x86_64","libc":"gnu"}]'
|
|
)
|
|
|
|
def test_parses_and_sorts_newest_first(self, tmp_path, monkeypatch):
|
|
import hermes_cli.managed_uv as managed_uv
|
|
|
|
def fake_run(cmd, **kwargs):
|
|
return SimpleNamespace(returncode=0, stdout=self.SAMPLE_OUTPUT, stderr="")
|
|
|
|
monkeypatch.setattr(managed_uv.subprocess, "run", fake_run)
|
|
result = managed_uv._list_available_patches(
|
|
"uv", "3.11", cwd=tmp_path, env={}
|
|
)
|
|
assert result == [(3, 11, 15), (3, 11, 14)]
|
|
|
|
|
|
def test_subprocess_exception_returns_empty_list(self, tmp_path, monkeypatch):
|
|
import hermes_cli.managed_uv as managed_uv
|
|
|
|
def fake_run(cmd, **kwargs):
|
|
raise OSError("uv binary not found")
|
|
|
|
monkeypatch.setattr(managed_uv.subprocess, "run", fake_run)
|
|
assert managed_uv._list_available_patches("uv", "3.11", cwd=tmp_path, env={}) == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _refresh_managed_uv_catalog + provisioning retry (issue #72093)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestRefreshManagedUvCatalog:
|
|
"""The managed uv is UV_UNMANAGED_INSTALL'd, so `uv self update` is
|
|
disabled and its python-build-standalone catalog freezes at bootstrap
|
|
age. python-build-standalone re-releases the same patch versions with
|
|
fixed SQLite, so a stale catalog makes provisioning fail forever with
|
|
no newer patch number to retry (issue #72093)."""
|
|
|
|
|
|
def test_version_change_reports_true(self, tmp_path):
|
|
import hermes_cli.managed_uv as managed_uv
|
|
|
|
versions = iter(["uv 0.1.0", "uv 0.2.0"])
|
|
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \
|
|
patch("hermes_cli.managed_uv._install_uv"), \
|
|
patch(
|
|
"hermes_cli.managed_uv._uv_version_string",
|
|
side_effect=lambda _uv: next(versions),
|
|
):
|
|
# Host-native path: the refresh only acts on the managed binary,
|
|
# so the fixture must live at the real host's managed_uv_path()
|
|
# (uv on POSIX, uv.exe on Windows) — no platform fake needed.
|
|
uv_path = managed_uv.managed_uv_path()
|
|
_make_executable(uv_path)
|
|
assert managed_uv._refresh_managed_uv_catalog(str(uv_path)) is True
|
|
|
|
|
|
def test_installer_failure_reports_false(self, tmp_path):
|
|
import hermes_cli.managed_uv as managed_uv
|
|
|
|
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \
|
|
patch(
|
|
"hermes_cli.managed_uv._install_uv",
|
|
side_effect=RuntimeError("network down"),
|
|
):
|
|
uv_path = managed_uv.managed_uv_path()
|
|
_make_executable(uv_path)
|
|
assert managed_uv._refresh_managed_uv_catalog(str(uv_path)) is False
|
|
|
|
|
|
@pytest.mark.skipif(sys.platform == "win32",
|
|
reason="POSIX-only: fixtures build the bin/ (not Scripts/) venv layout")
|
|
class TestRepairRetriesAfterUvRefresh:
|
|
def _run_repair(self, tmp_path, *, refresh_result, second_attempt):
|
|
"""Drive repair with the first provisioning attempt failing."""
|
|
from hermes_cli.managed_uv import repair_vulnerable_runtime
|
|
|
|
root, live, sentinel = _make_runtime_install(tmp_path)
|
|
current = _runtime_info(live / "bin" / "python", (3, 50, 4))
|
|
|
|
attempts = []
|
|
|
|
def fake_install(uv_bin, *, project_root, current):
|
|
attempts.append(uv_bin)
|
|
if len(attempts) == 1:
|
|
return None
|
|
return second_attempt(project_root)
|
|
|
|
with patch(
|
|
"hermes_cli.managed_uv.probe_sqlite_runtime",
|
|
return_value=current,
|
|
), \
|
|
patch(
|
|
"hermes_cli.managed_uv._install_safe_python_generation",
|
|
side_effect=fake_install,
|
|
), \
|
|
patch(
|
|
"hermes_cli.managed_uv._refresh_managed_uv_catalog",
|
|
return_value=refresh_result,
|
|
) as mock_refresh, \
|
|
patch(
|
|
"hermes_cli.managed_uv._stage_candidate_venv",
|
|
return_value=None,
|
|
):
|
|
result = repair_vulnerable_runtime("uv", project_root=root)
|
|
return result, attempts, mock_refresh, sentinel
|
|
|
|
def test_no_retry_when_refresh_did_not_change_uv(self, tmp_path):
|
|
result, attempts, mock_refresh, sentinel = self._run_repair(
|
|
tmp_path,
|
|
refresh_result=False,
|
|
second_attempt=lambda root: None,
|
|
)
|
|
assert result.status == "failed"
|
|
assert len(attempts) == 1
|
|
mock_refresh.assert_called_once_with("uv")
|
|
assert sentinel.read_text(encoding="utf-8") == "live"
|
|
|
|
def test_retries_once_after_successful_refresh(self, tmp_path):
|
|
result, attempts, mock_refresh, sentinel = self._run_repair(
|
|
tmp_path,
|
|
refresh_result=True,
|
|
second_attempt=lambda root: None,
|
|
)
|
|
# Second attempt ran (and also failed) — exactly one retry, no loop.
|
|
assert result.status == "failed"
|
|
assert len(attempts) == 2
|
|
mock_refresh.assert_called_once_with("uv")
|
|
assert sentinel.read_text(encoding="utf-8") == "live"
|
|
|
|
def test_retry_success_proceeds_to_staging(self, tmp_path):
|
|
def second_attempt(root):
|
|
generation = root / ".hermes-runtime" / "python" / "generation-retry"
|
|
candidate_python = generation / "bin" / "python"
|
|
candidate_python.parent.mkdir(parents=True)
|
|
candidate_python.write_text("candidate", encoding="utf-8")
|
|
return generation, candidate_python, _runtime_info(
|
|
candidate_python, (3, 53, 1)
|
|
)
|
|
|
|
result, attempts, mock_refresh, sentinel = self._run_repair(
|
|
tmp_path,
|
|
refresh_result=True,
|
|
second_attempt=second_attempt,
|
|
)
|
|
# Provisioning succeeded on retry; staging (mocked to None) is what
|
|
# failed — proving the retry result flows into the normal pipeline.
|
|
assert result.status == "failed"
|
|
assert "replacement environment" in result.detail
|
|
assert len(attempts) == 2
|
|
assert sentinel.read_text(encoding="utf-8") == "live"
|
|
|
|
class TestDefaultLiveVenv:
|
|
"""_default_live_venv() must cover BOTH install layouts (venv/ and .venv/).
|
|
|
|
Historically repair hardcoded venv/, so uv-default/.venv checkouts got
|
|
'not-applicable' on every hermes update and stayed on journal_mode=DELETE
|
|
(2,600x slower state.db appends) while the WAL warning promised repair.
|
|
"""
|
|
|
|
def _checkout(self, tmp_path, *dirs):
|
|
root = tmp_path / "checkout"
|
|
root.mkdir()
|
|
(root / "pyproject.toml").write_text("[project]\n", encoding="utf-8")
|
|
for d in dirs:
|
|
bin_dir = root / d / "bin"
|
|
bin_dir.mkdir(parents=True)
|
|
(bin_dir / "python").write_text("py", encoding="utf-8")
|
|
return root
|
|
|
|
def test_dot_venv_only_is_targeted(self, tmp_path):
|
|
from hermes_cli.managed_uv import _default_live_venv
|
|
|
|
root = self._checkout(tmp_path, ".venv")
|
|
assert _default_live_venv(root) == root / ".venv"
|
|
|
|
def test_managed_venv_takes_precedence(self, tmp_path):
|
|
from hermes_cli.managed_uv import _default_live_venv
|
|
|
|
root = self._checkout(tmp_path, "venv", ".venv")
|
|
assert _default_live_venv(root) == root / "venv"
|
|
|
|
def test_neither_layout_keeps_not_applicable(self, tmp_path):
|
|
from hermes_cli.managed_uv import (
|
|
_default_live_venv,
|
|
repair_vulnerable_runtime,
|
|
)
|
|
|
|
root = self._checkout(tmp_path)
|
|
# Neither venv nor .venv has an interpreter -> repair is not applicable.
|
|
assert _default_live_venv(root) == root / "venv"
|
|
result = repair_vulnerable_runtime("uv", project_root=root)
|
|
assert result.status == "not-applicable"
|
|
|
|
|
|
class TestVenvPythonUpdateBoundary:
|
|
"""``_venv_python`` must survive a hermes_constants predating its symbol.
|
|
|
|
``hermes update`` imports hermes_constants from the OLD checkout, ``git
|
|
pull`` replaces that file, and the freshly-pulled managed_uv then runs its
|
|
lazy ``from hermes_constants import venv_python_path`` against the module
|
|
object already cached in ``sys.modules``. That cached module has no such
|
|
symbol, so the import raises — while naming the NEW file on disk, which
|
|
plainly contains it, which is what made the error so confusing:
|
|
|
|
cannot import name 'venv_python_path' from 'hermes_constants'
|
|
(~/.hermes/hermes-agent/hermes_constants.py)
|
|
|
|
It aborted the managed-Python runtime repair on the first update from any
|
|
release older than the symbol. Same class as the ``ensure_uv()`` arity skew
|
|
documented on ``_UvResult``.
|
|
"""
|
|
|
|
def test_recovers_when_the_cached_module_predates_the_symbol(self, monkeypatch):
|
|
import hermes_constants
|
|
|
|
from hermes_cli.managed_uv import _venv_python
|
|
|
|
# The stale in-memory module: the symbol the new code wants is absent,
|
|
# exactly as on an install that booted the pre-upgrade checkout. The
|
|
# file on disk is the current one, so a reload recovers the real helper.
|
|
monkeypatch.delattr(hermes_constants, "venv_python_path", raising=False)
|
|
|
|
# Host-native: the subject is the reload-recovery seam, not the
|
|
# bin/Scripts mapping — assert whatever layout the real host resolves.
|
|
expected = Path("/opt/hermes/venv/Scripts/python.exe") \
|
|
if sys.platform == "win32" else Path("/opt/hermes/venv/bin/python")
|
|
assert _venv_python(Path("/opt/hermes/venv")) == expected
|
|
|
|
def test_recovery_uses_the_shared_helper_not_a_second_copy(self, monkeypatch):
|
|
"""The reload must resolve through hermes_constants, not open-code it.
|
|
|
|
Hand-rolling `Scripts`/`bin` here is what #76105 deduped away and what
|
|
`test_no_open_coded_venv_layout_remains_in_hermes_cli` bans.
|
|
"""
|
|
import hermes_constants
|
|
|
|
from hermes_cli.managed_uv import _venv_python
|
|
|
|
monkeypatch.delattr(hermes_constants, "venv_python_path", raising=False)
|
|
|
|
sentinel = Path("/sentinel/from/shared/helper")
|
|
real_reload = __import__("importlib").reload
|
|
|
|
def _reload_with_marker(module):
|
|
fresh = real_reload(module)
|
|
monkeypatch.setattr(
|
|
fresh, "venv_python_path", lambda *a, **k: sentinel, raising=False
|
|
)
|
|
return fresh
|
|
|
|
monkeypatch.setattr("importlib.reload", _reload_with_marker)
|
|
assert _venv_python(Path("/opt/hermes/venv")) == sentinel
|
|
|
|
def test_uses_the_real_helper_when_it_is_importable(self, monkeypatch):
|
|
"""The normal path never reloads — recovery stays a fallback."""
|
|
from hermes_cli.managed_uv import _venv_python
|
|
|
|
def _no_reload(module): # pragma: no cover - must not run
|
|
raise AssertionError("reload must not run when the import succeeds")
|
|
|
|
monkeypatch.setattr("importlib.reload", _no_reload)
|
|
|
|
expected = Path("/opt/hermes/venv/Scripts/python.exe") \
|
|
if sys.platform == "win32" else Path("/opt/hermes/venv/bin/python")
|
|
assert _venv_python(Path("/opt/hermes/venv")) == expected
|
|
|