hermes update can brick a Windows install. When 'hermes update --force' runs past the concurrent-process guard, rebuild_venv runs while the venv is still in use: shutil.rmtree(ignore_errors=True) deletes site-packages + certifi's cert bundle but can't remove the locked python.exe, leaving a half-gutted venv that uv venv then refuses to overwrite. Every later HTTPS call dies with FileNotFoundError for the missing cacert and there is no recovery. --clear alone (thec136eb4deretry path) does not fix the real lock case: when the locked interpreter is *inside* the venv being rebuilt, neither rmtree nor uv venv --clear can delete it. os.replace of the parent directory *is* allowed on Windows (a running .exe is tracked by handle, not path), so we move the old venv aside atomically to <venv>.old, rebuild with --clear in its place, and the still-running gateway/desktop keep using the moved-aside copy until they restart. If the venv genuinely can't be moved, we abort cleanly and leave it fully intact; if the rebuild fails, we restore the moved-aside copy. Folds in the call-site guards from #38511 (@f3rs3n): - rebuild_venv() returns False (and restores the backup) if uv exits 0 without producing an interpreter. - both hermes update venv-rebuild call sites abort with RuntimeError instead of continuing into dependency install when rebuild_venv() returns False. Also gitignore /venv.old/ so the update autostash (git stash --include-untracked) doesn't sweep the moved-aside venv on every run. Root-cause fix for #37881. Supersedes the --clear-only retry fromc136eb4de. Co-authored-by: f3rs3n <32328813+f3rs3n@users.noreply.github.com>
272 lines
12 KiB
Python
272 lines
12 KiB
Python
"""Tests for hermes_cli.managed_uv — one path, no guessing."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import stat
|
|
from pathlib import Path
|
|
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)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# managed_uv_path
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestManagedUvPath:
|
|
def test_posix(self, tmp_path):
|
|
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \
|
|
patch("hermes_cli.managed_uv.platform.system", return_value="Linux"):
|
|
from hermes_cli.managed_uv import managed_uv_path
|
|
assert managed_uv_path() == tmp_path / "bin" / "uv"
|
|
|
|
def test_windows(self, tmp_path):
|
|
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \
|
|
patch("hermes_cli.managed_uv.platform.system", return_value="Windows"):
|
|
from hermes_cli.managed_uv import managed_uv_path
|
|
assert managed_uv_path() == tmp_path / "bin" / "uv.exe"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# resolve_uv
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestResolveUv:
|
|
def test_missing_returns_none(self, tmp_path):
|
|
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
|
|
|
|
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_already_installed_no_bootstrap(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 ensure_uv
|
|
path, fresh = ensure_uv()
|
|
assert path == str(tmp_path / "bin" / "uv")
|
|
assert fresh is False
|
|
|
|
def test_installs_if_missing_sets_bootstrap_flag(self, tmp_path):
|
|
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \
|
|
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, fresh = ensure_uv()
|
|
assert path == str(tmp_path / "bin" / "uv")
|
|
assert fresh is True
|
|
mock_install.assert_called_once()
|
|
|
|
def test_install_failure_returns_none_false(self, tmp_path):
|
|
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")):
|
|
from hermes_cli.managed_uv import ensure_uv
|
|
path, fresh = ensure_uv()
|
|
assert path is None
|
|
assert fresh is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# rebuild_venv
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestRebuildVenv:
|
|
def test_moves_old_venv_aside_and_creates_new(self, tmp_path):
|
|
"""The old venv is moved aside to <venv>.old (never rmtree'd in place),
|
|
uv is invoked with --clear, the moved-aside backup is removed on
|
|
success, and the rebuilt interpreter is reported."""
|
|
venv_dir = tmp_path / "venv"
|
|
venv_dir.mkdir()
|
|
(venv_dir / "old_file").write_text("stale")
|
|
|
|
uv_bin = str(tmp_path / "bin" / "uv")
|
|
call_log: list[list[str]] = []
|
|
|
|
def fake_run(cmd, **kwargs):
|
|
call_log.append(list(cmd))
|
|
m = MagicMock(returncode=0, stderr="", stdout="")
|
|
if len(cmd) >= 2 and cmd[1] == "venv":
|
|
# Simulate uv creating the venv dir with a python interpreter
|
|
bin_dir = venv_dir / ("Scripts" if os.name == "nt" else "bin")
|
|
bin_dir.mkdir(parents=True, exist_ok=True)
|
|
python_name = "python.exe" if os.name == "nt" else "python"
|
|
(bin_dir / python_name).write_text("#!/bin/sh\necho Python 3.11.0")
|
|
elif "--version" in cmd:
|
|
m.stdout = "Python 3.11.0"
|
|
return m
|
|
|
|
with patch("hermes_cli.managed_uv.subprocess.run", side_effect=fake_run):
|
|
from hermes_cli.managed_uv import rebuild_venv
|
|
result = rebuild_venv(uv_bin, venv_dir)
|
|
|
|
assert result is True
|
|
# uv venv was invoked exactly once, always with --clear.
|
|
venv_calls = [c for c in call_log if len(c) >= 2 and c[1] == "venv"]
|
|
assert len(venv_calls) == 1, f"expected 1 venv call, got {venv_calls}"
|
|
assert "--clear" in venv_calls[0]
|
|
# The moved-aside backup is cleaned up after a successful rebuild.
|
|
assert not (tmp_path / "venv.old").exists()
|
|
|
|
def test_aborts_without_deleting_when_venv_in_use(self, tmp_path):
|
|
"""If os.replace fails (Windows file lock — venv in use), we must abort
|
|
cleanly WITHOUT deleting the venv and WITHOUT invoking uv."""
|
|
venv_dir = tmp_path / "venv"
|
|
venv_dir.mkdir()
|
|
(venv_dir / "locked") .write_text("held open")
|
|
uv_bin = str(tmp_path / "bin" / "uv")
|
|
call_log: list[list[str]] = []
|
|
|
|
def fake_run(cmd, **kwargs):
|
|
call_log.append(list(cmd))
|
|
return MagicMock(returncode=0, stderr="", stdout="")
|
|
|
|
with patch("hermes_cli.managed_uv.subprocess.run", side_effect=fake_run), \
|
|
patch("hermes_cli.managed_uv.os.replace", side_effect=OSError("in use")):
|
|
from hermes_cli.managed_uv import rebuild_venv
|
|
result = rebuild_venv(uv_bin, venv_dir)
|
|
|
|
assert result is False
|
|
# venv left fully intact, uv never invoked.
|
|
assert venv_dir.exists() and (venv_dir / "locked").exists()
|
|
assert [c for c in call_log if len(c) >= 2 and c[1] == "venv"] == []
|
|
|
|
def test_restores_backup_when_rebuild_fails(self, tmp_path):
|
|
"""If uv venv exits non-zero, the moved-aside venv is restored so we
|
|
never leave Hermes with no venv at all."""
|
|
venv_dir = tmp_path / "venv"
|
|
venv_dir.mkdir()
|
|
(venv_dir / "marker").write_text("original")
|
|
uv_bin = str(tmp_path / "bin" / "uv")
|
|
|
|
def fake_run(cmd, **kwargs):
|
|
return MagicMock(returncode=1, stderr="boom", stdout="")
|
|
|
|
with patch("hermes_cli.managed_uv.subprocess.run", side_effect=fake_run):
|
|
from hermes_cli.managed_uv import rebuild_venv
|
|
result = rebuild_venv(uv_bin, venv_dir)
|
|
|
|
assert result is False
|
|
# Original venv restored from the .old backup.
|
|
assert venv_dir.exists() and (venv_dir / "marker").read_text() == "original"
|
|
assert not (tmp_path / "venv.old").exists()
|
|
|
|
def test_rebuild_failure_returns_false(self, tmp_path):
|
|
venv_dir = tmp_path / "venv"
|
|
uv_bin = str(tmp_path / "bin" / "uv")
|
|
|
|
with patch("hermes_cli.managed_uv.subprocess.run") as mock_run:
|
|
mock_run.return_value = MagicMock(returncode=1, stderr="nope")
|
|
from hermes_cli.managed_uv import rebuild_venv
|
|
result = rebuild_venv(uv_bin, venv_dir)
|
|
assert result is False
|
|
|
|
def test_rebuild_success_without_python_returns_false(self, tmp_path):
|
|
"""uv can exit 0 yet leave no interpreter; that must not count as success
|
|
(guard adapted from #38511)."""
|
|
venv_dir = tmp_path / "venv"
|
|
uv_bin = str(tmp_path / "bin" / "uv")
|
|
|
|
with patch("hermes_cli.managed_uv.subprocess.run") as mock_run:
|
|
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
|
from hermes_cli.managed_uv import rebuild_venv
|
|
result = rebuild_venv(uv_bin, venv_dir)
|
|
assert result is False
|
|
# Returned before the `python --version` probe ran (only the uv venv call).
|
|
assert mock_run.call_count == 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# update_managed_uv
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestUpdateManagedUv:
|
|
def test_no_uv_returns_none(self, tmp_path):
|
|
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path):
|
|
from hermes_cli.managed_uv import update_managed_uv
|
|
assert update_managed_uv() is None
|
|
|
|
def test_self_update_success(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.subprocess.run") as mock_run:
|
|
# uv self update succeeds
|
|
mock_run.return_value = MagicMock(returncode=0, stdout="uv 0.2.0")
|
|
from hermes_cli.managed_uv import update_managed_uv
|
|
result = update_managed_uv()
|
|
assert result == str(tmp_path / "bin" / "uv")
|
|
# First call is self update, second is --version
|
|
assert mock_run.call_count == 2
|
|
assert mock_run.call_args_list[0][0][0] == [str(tmp_path / "bin" / "uv"), "self", "update"]
|
|
|
|
def test_self_update_failure_non_fatal(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.subprocess.run") as mock_run:
|
|
mock_run.return_value = MagicMock(returncode=1, stderr="nope")
|
|
from hermes_cli.managed_uv import update_managed_uv
|
|
result = update_managed_uv()
|
|
# Still returns the path — failure is non-fatal
|
|
assert result == str(tmp_path / "bin" / "uv")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _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")
|
|
|
|
def test_windows_sets_uv_install_dir(self, tmp_path):
|
|
target = tmp_path / "bin" / "uv.exe"
|
|
with patch("hermes_cli.managed_uv.platform.system", return_value="Windows"), \
|
|
patch("hermes_cli.managed_uv._install_uv_windows") as mock_windows:
|
|
from hermes_cli.managed_uv import _install_uv
|
|
_install_uv(target)
|
|
mock_windows.assert_called_once()
|
|
call_env = mock_windows.call_args[0][0]
|
|
assert call_env["UV_INSTALL_DIR"] == str(tmp_path / "bin")
|