refactor(uv): single managed-uv path, delete fts5 installer escalation
Replace the multi-path UV resolution chain (PATH probing, conda guards,
5-location trust ordering, temp-dir fallback installs) with a single
managed uv binary at $HERMES_HOME/bin/uv. Every code path that needs
uv resolves it from that one location; if missing, ensure_uv()
bootstraps it via the official standalone installer.
Key changes:
- New hermes_cli/managed_uv.py: managed_uv_path(), resolve_uv(),
ensure_uv() (returns (path, freshly_bootstrapped) tuple),
update_managed_uv(), rebuild_venv(), installer internals.
- hermes_cli/main.py: replace all shutil.which('uv') with ensure_uv(),
add venv rebuild on first-time managed uv bootstrap, update_managed_uv
before dep install on all 3 update paths.
- scripts/install.sh: install_uv() always installs to
$HERMES_HOME/bin/uv; delete ensure_fts5, _python_has_fts5,
_reinstall_python_with_fts5, _warn_no_fts5 (61 lines).
Managed uv always installs current Python with FTS5.
- scripts/install.ps1: Install-Uv always installs to
$HermesHome\bin\uv.exe; Resolve-UvCmd checks managed location first.
- hermes_state.py: simplified FTS5 warning now suggests 'hermes update'
as the fix instead of blaming install method.
- tests: 15 tests in test_managed_uv.py, autouse _patch_managed_uv
fixture in test_cmd_update.py.
Closes #37605, Closes #37622
This commit is contained in:
@@ -39,6 +39,42 @@ def mock_args():
|
||||
return SimpleNamespace()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Managed-uv compatibility for tests that patch shutil.which
|
||||
# ---------------------------------------------------------------------------
|
||||
# The production code now uses ``ensure_uv()`` / ``update_managed_uv()``
|
||||
# instead of ``shutil.which("uv")``. Many tests in this file patch
|
||||
# ``shutil.which`` to control whether uv is "available" — these autouse
|
||||
# fixtures make the managed_uv functions delegate to the patched
|
||||
# ``shutil.which`` so the existing test setup keeps working without
|
||||
# per-test changes.
|
||||
@pytest.fixture(autouse=True)
|
||||
def _patch_managed_uv(request):
|
||||
"""Make managed_uv helpers follow shutil.which mocking in tests."""
|
||||
import shutil
|
||||
|
||||
# resolve_uv delegates to shutil.which("uv") so that test patches
|
||||
# on shutil.which flow through naturally.
|
||||
def _fake_resolve_uv():
|
||||
return shutil.which("uv")
|
||||
|
||||
def _fake_ensure_uv():
|
||||
path = shutil.which("uv")
|
||||
return (path, False) # never freshly bootstrapped in tests
|
||||
|
||||
def _fake_update_managed_uv():
|
||||
return None # never actually self-update in tests
|
||||
|
||||
def _fake_rebuild_venv(*args, **kwargs):
|
||||
return True # no-op in tests
|
||||
|
||||
with patch("hermes_cli.managed_uv.resolve_uv", side_effect=_fake_resolve_uv), \
|
||||
patch("hermes_cli.managed_uv.ensure_uv", side_effect=_fake_ensure_uv), \
|
||||
patch("hermes_cli.managed_uv.update_managed_uv", side_effect=_fake_update_managed_uv), \
|
||||
patch("hermes_cli.managed_uv.rebuild_venv", side_effect=_fake_rebuild_venv):
|
||||
yield
|
||||
|
||||
|
||||
class TestCmdUpdatePip:
|
||||
"""Regression tests for pip-install update flows."""
|
||||
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
"""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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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")
|
||||
Reference in New Issue
Block a user