Two complementary fixes for a silent partial-install failure that bit
``hermes update`` in the wild: a fresh checkout pulled 145 commits,
``rebuild_venv`` failed to recreate the venv on Windows because
``shutil.rmtree(ignore_errors=True)`` couldn't delete files held open by
the running ``hermes.exe`` shim. ``uv venv`` then refused with
"A directory already exists at: venv" and the update fell back to
installing on top of the stale venv. The resulting partial install
missed exactly one newly-added base dep — ``pathspec==1.1.1`` — which
``hermes desktop --build-only`` imports at the top of its content-hash
check. The desktop rebuild died with ModuleNotFoundError and the parent
update only logged "⚠ Desktop build failed (non-fatal)". Same root cause
made the "default: sync failed" line in the skill-sync stage, because
that sync subprocess hit the same missing import.
Fix 1: ``rebuild_venv`` retries with ``--clear``
------------------------------------------------
If ``uv venv`` fails with "already exists" in stderr (which is what uv
prints, and what uv's own hint tells you to fix with --clear), retry
once with ``--clear``. Only this specific failure pattern triggers the
retry — disk-full / interpreter-download failures still surface as
before so we don't mask real problems.
Fix 2: post-install dep verification
------------------------------------
Belt-and-suspenders so future uv resolver quirks (or any other cause of
partial installs) surface immediately instead of hours later in a
downstream subprocess. After ``_install_python_dependencies_with_optional_fallback``
runs, ``_verify_core_dependencies_installed``:
1. Reads ``[project.dependencies]`` straight from pyproject.toml
(so we don't trust the venv's stale metadata).
2. Filters by environment markers via ``packaging.requirements.Requirement``
so cross-platform exclusions (``ptyprocess ; sys_platform != 'win32'``)
don't false-positive on Windows.
3. Runs ``importlib.metadata.version()`` for each remaining dep inside
the *target* venv interpreter (resolved from ``VIRTUAL_ENV``, not
``sys.executable``).
4. If anything is missing, reinstalls the base group with
``--reinstall`` to force re-resolution. If a second probe still
reports missing deps, force-installs each one with its pinned spec.
5. Treats final failure as a warning rather than a hard error — a
single broken-on-PyPI dep shouldn't block an otherwise-successful
update — but the message points at ``hermes update --force`` and
names the missing packages so the user knows what's wrong.
Tests
-----
- ``TestRebuildVenv::test_retries_with_clear_when_dir_already_exists`` —
simulates the rmtree-couldn't-delete-it failure mode and asserts the
``--clear`` retry path is taken and succeeds.
- ``TestRebuildVenv::test_does_not_retry_when_first_failure_is_not_dir_exists``
— guards against masking real failures (disk full, etc.).
- ``test_verify_core_dependencies.py`` — 7 tests covering the happy
path, the regression (missing pathspec triggers --reinstall), the
per-package fallback when --reinstall doesn't help, the platform-
marker filter so Windows doesn't try to install ptyprocess, the
missing-pyproject noop, and the VIRTUAL_ENV resolver.
Co-authored-by: Kyssta <218078013+kyssta-exe@users.noreply.github.com>
285 lines
13 KiB
Python
285 lines
13 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_removes_old_venv_and_creates_new(self, tmp_path):
|
|
venv_dir = tmp_path / "venv"
|
|
venv_dir.mkdir()
|
|
(venv_dir / "old_file").write_text("stale")
|
|
|
|
uv_bin = str(tmp_path / "bin" / "uv")
|
|
|
|
def fake_run(cmd, **kwargs):
|
|
m = MagicMock(returncode=0)
|
|
if cmd[1] == "venv":
|
|
# Simulate uv creating the venv dir
|
|
venv_dir.mkdir(exist_ok=True)
|
|
bin_dir = venv_dir / "bin"
|
|
bin_dir.mkdir(parents=True, exist_ok=True)
|
|
(bin_dir / "python").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), \
|
|
patch("hermes_cli.managed_uv.shutil.rmtree") as mock_rmtree:
|
|
from hermes_cli.managed_uv import rebuild_venv
|
|
result = rebuild_venv(uv_bin, venv_dir)
|
|
assert result is True
|
|
mock_rmtree.assert_called_once_with(venv_dir, ignore_errors=True)
|
|
|
|
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, \
|
|
patch("hermes_cli.managed_uv.shutil.rmtree"):
|
|
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_retries_with_clear_when_dir_already_exists(self, tmp_path):
|
|
"""On Windows, rmtree can silently fail when an open handle holds a
|
|
file in the venv (running hermes.exe, gateway, AV scanner). uv then
|
|
refuses with ``Caused by: A directory already exists at: venv``.
|
|
Make sure we don't give up — retry with ``--clear`` to force uv past
|
|
the stale directory and rebuild successfully."""
|
|
venv_dir = tmp_path / "venv"
|
|
venv_dir.mkdir()
|
|
(venv_dir / "stale_open_handle").write_text("rmtree couldn't delete me")
|
|
|
|
uv_bin = str(tmp_path / "bin" / "uv")
|
|
call_log: list[list[str]] = []
|
|
|
|
def fake_run(cmd, **kwargs):
|
|
call_log.append(list(cmd))
|
|
m = MagicMock()
|
|
if cmd[1] == "venv" and "--clear" not in cmd:
|
|
# First attempt: uv refuses because dir still exists
|
|
m.returncode = 1
|
|
m.stderr = (
|
|
"error: Failed to create virtual environment\n"
|
|
" Caused by: A directory already exists at: venv\n"
|
|
"hint: Use the `--clear` flag or set `UV_VENV_CLEAR=1` to replace the existing directory\n"
|
|
)
|
|
m.stdout = ""
|
|
return m
|
|
if cmd[1] == "venv" and "--clear" in cmd:
|
|
# Retry: succeeds. Simulate uv writing the python shim.
|
|
m.returncode = 0
|
|
m.stderr = ""
|
|
m.stdout = ""
|
|
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")
|
|
return m
|
|
if "--version" in cmd:
|
|
m.returncode = 0
|
|
m.stdout = "Python 3.11.0"
|
|
m.stderr = ""
|
|
return m
|
|
m.returncode = 0
|
|
return m
|
|
|
|
with patch("hermes_cli.managed_uv.subprocess.run", side_effect=fake_run), \
|
|
patch("hermes_cli.managed_uv.shutil.rmtree"):
|
|
from hermes_cli.managed_uv import rebuild_venv
|
|
result = rebuild_venv(uv_bin, venv_dir)
|
|
|
|
assert result is True, "rebuild should succeed after --clear retry"
|
|
# We expect exactly two ``uv venv`` calls: one without --clear, one with.
|
|
venv_calls = [c for c in call_log if len(c) >= 2 and c[1] == "venv"]
|
|
assert len(venv_calls) == 2, f"expected 2 venv calls, got {venv_calls}"
|
|
assert "--clear" not in venv_calls[0], "first call should not pass --clear"
|
|
assert "--clear" in venv_calls[1], "retry must pass --clear"
|
|
|
|
def test_does_not_retry_when_first_failure_is_not_dir_exists(self, tmp_path):
|
|
"""If uv venv fails for some other reason (e.g. interpreter download
|
|
failed, disk full), we should NOT silently retry with --clear —
|
|
that would mask a real problem. Just surface the original failure."""
|
|
venv_dir = tmp_path / "venv"
|
|
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=1, stderr="error: No space left on device", stdout="")
|
|
return m
|
|
|
|
with patch("hermes_cli.managed_uv.subprocess.run", side_effect=fake_run), \
|
|
patch("hermes_cli.managed_uv.shutil.rmtree"):
|
|
from hermes_cli.managed_uv import rebuild_venv
|
|
result = rebuild_venv(uv_bin, venv_dir)
|
|
|
|
assert result is False
|
|
venv_calls = [c for c in call_log if len(c) >= 2 and c[1] == "venv"]
|
|
assert len(venv_calls) == 1, "should not retry on non-dir-exists failures"
|
|
assert "--clear" not in venv_calls[0]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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")
|