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
229 lines
7.4 KiB
Python
229 lines
7.4 KiB
Python
"""Managed uv — one path, no guessing.
|
|
|
|
Hermes owns its own uv binary at ``$HERMES_HOME/bin/uv`` (or ``uv.exe`` on
|
|
Windows). Every code path that needs uv resolves it from that single location.
|
|
If the binary is missing, ``ensure_uv()`` bootstraps it via the official
|
|
standalone installer with ``UV_UNMANAGED_INSTALL`` / ``UV_INSTALL_DIR`` pointed
|
|
at ``$HERMES_HOME/bin`` so the installer writes directly there — no PATH
|
|
probing, no conda guards, no multi-location resolution chains.
|
|
|
|
When ``ensure_uv()`` bootstraps uv for the first time (i.e. there was no
|
|
managed uv before), it returns ``(path, True)`` instead of just ``path``.
|
|
Callers in the update path use that signal to nuke and recreate the venv
|
|
with the now-current managed uv, guaranteeing a Python with FTS5.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
import platform
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
from pathlib import Path
|
|
from typing import Optional, Tuple
|
|
|
|
from hermes_constants import get_hermes_home
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Public helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def managed_uv_path() -> Path:
|
|
"""Return the path where Hermes keeps *its* uv binary.
|
|
|
|
``$HERMES_HOME/bin/uv`` on POSIX, ``$HERMES_HOME\\bin\\uv.exe`` on
|
|
Windows. The directory may not exist yet — callers should use
|
|
``ensure_uv()`` to bootstrap it.
|
|
"""
|
|
home = get_hermes_home()
|
|
if platform.system() == "Windows":
|
|
return home / "bin" / "uv.exe"
|
|
return home / "bin" / "uv"
|
|
|
|
|
|
def resolve_uv() -> Optional[str]:
|
|
"""Return the managed uv path if it exists, else ``None``.
|
|
|
|
No side effects — pure lookup.
|
|
"""
|
|
p = managed_uv_path()
|
|
if p.is_file() and os.access(p, os.X_OK):
|
|
return str(p)
|
|
return None
|
|
|
|
|
|
def ensure_uv() -> Tuple[Optional[str], bool]:
|
|
"""Return the managed uv path, installing it first if necessary.
|
|
|
|
Returns ``(path, freshly_bootstrapped)`` where *freshly_bootstrapped* is
|
|
``True`` when we just installed managed uv for the first time (there was
|
|
no managed uv before this call). Callers can use that signal to rebuild
|
|
the venv so Python is guaranteed to have FTS5.
|
|
|
|
On failure returns ``(None, False)`` (never raises) so callers can fall
|
|
back to pip gracefully.
|
|
"""
|
|
existing = resolve_uv()
|
|
if existing:
|
|
return (existing, False)
|
|
|
|
target = managed_uv_path()
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
print(f" → Installing managed uv into {target.parent} ...")
|
|
|
|
try:
|
|
_install_uv(target)
|
|
except Exception as exc:
|
|
logger.warning("Managed uv install failed: %s", exc)
|
|
print(f" ✗ Failed to install managed uv: {exc}")
|
|
return (None, False)
|
|
|
|
# Verify
|
|
result = resolve_uv()
|
|
if result:
|
|
version = subprocess.run(
|
|
[result, "--version"],
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
).stdout.strip()
|
|
print(f" ✓ Managed uv installed ({version})")
|
|
else:
|
|
print(" ✗ Managed uv install appeared to succeed but binary not found")
|
|
return (result, result is not None)
|
|
|
|
|
|
def rebuild_venv(uv_bin: str, venv_dir: Path, python_version: str = "3.11") -> bool:
|
|
"""Nuke and recreate the venv with managed uv.
|
|
|
|
Called when managed uv is first bootstrapped on an existing install — the
|
|
old venv may point to a Python without FTS5, so we rebuild it with a
|
|
fresh interpreter from the current managed uv. Returns ``True`` on
|
|
success.
|
|
"""
|
|
if venv_dir.exists():
|
|
print(f" → Rebuilding venv (old Python may lack FTS5)...")
|
|
shutil.rmtree(venv_dir, ignore_errors=True)
|
|
|
|
result = subprocess.run(
|
|
[uv_bin, "venv", str(venv_dir), "--python", python_version],
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
if result.returncode == 0:
|
|
venv_python = venv_dir / ("Scripts" if platform.system() == "Windows" else "bin") / "python"
|
|
py_ver = subprocess.run(
|
|
[str(venv_python), "--version"],
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
).stdout.strip()
|
|
print(f" ✓ venv rebuilt ({py_ver})")
|
|
return True
|
|
else:
|
|
logger.warning("venv rebuild failed: %s", result.stderr)
|
|
print(f" ✗ venv rebuild failed: {result.stderr.strip()}")
|
|
return False
|
|
|
|
|
|
def update_managed_uv() -> Optional[str]:
|
|
"""Run ``uv self update`` on the managed uv binary.
|
|
|
|
Call this during ``hermes update`` so the managed copy stays current.
|
|
Returns the managed path on success, ``None`` if uv isn't available or
|
|
the self-update fails (non-fatal — the old version still works).
|
|
"""
|
|
existing = resolve_uv()
|
|
if not existing:
|
|
# Not installed yet — ensure_uv() will handle that elsewhere.
|
|
return None
|
|
|
|
result = subprocess.run(
|
|
[existing, "self", "update"],
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
if result.returncode == 0:
|
|
version = subprocess.run(
|
|
[existing, "--version"],
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
).stdout.strip()
|
|
print(f" ✓ Managed uv updated ({version})")
|
|
else:
|
|
# Non-fatal — old uv still works fine.
|
|
logger.debug("uv self update failed (rc=%d): %s", result.returncode, result.stderr)
|
|
return existing
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Installer internals
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _install_uv(target: Path) -> None:
|
|
"""Bootstrap uv into *target* using the official standalone installer.
|
|
|
|
Uses ``UV_UNMANAGED_INSTALL`` (POSIX) or ``UV_INSTALL_DIR`` (Windows)
|
|
so the astral installer writes the binary directly into
|
|
``$HERMES_HOME/bin/`` instead of ``~/.local/bin/``.
|
|
"""
|
|
system = platform.system()
|
|
env = {
|
|
**os.environ,
|
|
# Tell the astral installer to drop the binary in our dir, not
|
|
# ~/.local/bin. UV_UNMANAGED_INSTALL is the POSIX env var; Windows
|
|
# uses UV_INSTALL_DIR.
|
|
"UV_UNMANAGED_INSTALL": str(target.parent),
|
|
"UV_INSTALL_DIR": str(target.parent),
|
|
}
|
|
|
|
if system == "Windows":
|
|
_install_uv_windows(env)
|
|
else:
|
|
_install_uv_posix(env)
|
|
|
|
|
|
def _install_uv_posix(env: dict[str, str]) -> None:
|
|
"""Download + sh the POSIX installer (two-stage to avoid curl|sh pitfalls)."""
|
|
with tempfile.NamedTemporaryFile(suffix=".sh", delete=False) as f:
|
|
installer_path = f.name
|
|
|
|
try:
|
|
subprocess.run(
|
|
["curl", "-LsSf", "https://astral.sh/uv/install.sh", "-o", installer_path],
|
|
check=True,
|
|
capture_output=True,
|
|
)
|
|
subprocess.run(
|
|
["sh", installer_path],
|
|
env=env,
|
|
check=True,
|
|
capture_output=True,
|
|
)
|
|
finally:
|
|
try:
|
|
os.unlink(installer_path)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def _install_uv_windows(env: dict[str, str]) -> None:
|
|
"""Invoke the PowerShell installer."""
|
|
cmd = (
|
|
'irm https://astral.sh/uv/install.ps1 | iex'
|
|
)
|
|
subprocess.run(
|
|
["powershell", "-ExecutionPolicy", "Bypass", "-c", cmd],
|
|
env=env,
|
|
check=True,
|
|
capture_output=True,
|
|
)
|