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:
+52
-9
@@ -7679,8 +7679,21 @@ def _update_via_zip(args):
|
||||
# individually so update does not silently strip working capabilities.
|
||||
print("→ Updating Python dependencies...")
|
||||
|
||||
from hermes_cli.managed_uv import ensure_uv, rebuild_venv, update_managed_uv
|
||||
|
||||
# Keep managed uv current — runs `uv self update` if we already have one.
|
||||
update_managed_uv()
|
||||
|
||||
uv_bin, fresh_bootstrap = ensure_uv()
|
||||
# First-time managed uv install on an existing checkout: the old venv
|
||||
# may point to a Python without FTS5. Rebuild it so the new managed
|
||||
# uv provides a fresh interpreter with FTS5 guaranteed.
|
||||
if fresh_bootstrap and uv_bin:
|
||||
rebuild_venv(uv_bin, PROJECT_ROOT / "venv")
|
||||
|
||||
pip_cmd = [sys.executable, "-m", "pip"]
|
||||
uv_bin = shutil.which("uv") or _ensure_uv_for_termux(pip_cmd)
|
||||
if not uv_bin:
|
||||
uv_bin = _ensure_uv_for_termux(pip_cmd)
|
||||
if uv_bin:
|
||||
uv_env = {**os.environ, "VIRTUAL_ENV": str(PROJECT_ROOT / "venv")}
|
||||
if _is_termux_env(uv_env):
|
||||
@@ -8780,16 +8793,27 @@ def _install_psutil_android_compat(
|
||||
|
||||
|
||||
def _ensure_uv_for_termux(pip_cmd: list[str]) -> str | None:
|
||||
"""Best-effort uv bootstrap on Termux for faster update installs."""
|
||||
uv_bin = shutil.which("uv")
|
||||
if uv_bin or not _is_termux_env():
|
||||
return uv_bin
|
||||
"""Best-effort uv bootstrap on Termux for faster update installs.
|
||||
|
||||
The normal path (``ensure_uv()`` in managed_uv) installs the managed
|
||||
standalone uv into ``$HERMES_HOME/bin/uv``, but on Termux the official
|
||||
installer may not work (glibc vs bionic). Fall back to ``pip install uv``
|
||||
which gets a Termux-compatible binary.
|
||||
"""
|
||||
from hermes_cli.managed_uv import resolve_uv
|
||||
|
||||
existing = resolve_uv()
|
||||
if existing:
|
||||
return existing
|
||||
if not _is_termux_env():
|
||||
return None
|
||||
try:
|
||||
print(" → Termux detected: trying to install uv for faster dependency updates...")
|
||||
subprocess.run(pip_cmd + ["install", "uv"], cwd=PROJECT_ROOT, check=False)
|
||||
except Exception:
|
||||
pass
|
||||
return shutil.which("uv")
|
||||
# After pip install, check managed path first, then PATH
|
||||
return resolve_uv() or shutil.which("uv")
|
||||
|
||||
|
||||
def _update_node_dependencies() -> None:
|
||||
@@ -9440,7 +9464,12 @@ def _cmd_update_pip(args):
|
||||
print(f"→ Current version: {__version__}")
|
||||
print("→ Checking PyPI for updates...")
|
||||
|
||||
uv = shutil.which("uv")
|
||||
from hermes_cli.managed_uv import ensure_uv, update_managed_uv
|
||||
|
||||
# Keep managed uv current before using it.
|
||||
update_managed_uv()
|
||||
|
||||
uv, _fresh_bootstrap = ensure_uv()
|
||||
in_venv = sys.prefix != sys.base_prefix
|
||||
# pipx-managed installs live under .../pipx/venvs/<name>/...
|
||||
pipx_managed = "pipx" in sys.prefix.split(os.sep)
|
||||
@@ -9455,7 +9484,8 @@ def _cmd_update_pip(args):
|
||||
|
||||
if is_uv_tool_install():
|
||||
if not uv:
|
||||
print("✗ Detected a uv-tool install but `uv` is not on PATH; install uv and retry.")
|
||||
print("✗ Detected a uv-tool install but managed uv install failed.")
|
||||
print(" Install uv manually: https://docs.astral.sh/uv/getting-started/installation/")
|
||||
sys.exit(1)
|
||||
cmd = [uv, "tool", "upgrade", "hermes-agent"]
|
||||
elif pipx_managed and pipx:
|
||||
@@ -9851,8 +9881,21 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
# breaks on this machine, keep base deps and reinstall the remaining extras
|
||||
# individually so update does not silently strip working capabilities.
|
||||
print("→ Updating Python dependencies...")
|
||||
from hermes_cli.managed_uv import ensure_uv, rebuild_venv, update_managed_uv
|
||||
|
||||
# Keep managed uv current — runs `uv self update` if we already have one.
|
||||
update_managed_uv()
|
||||
|
||||
uv_bin, fresh_bootstrap = ensure_uv()
|
||||
# First-time managed uv install on an existing checkout: the old venv
|
||||
# may point to a Python without FTS5. Rebuild it so the new managed
|
||||
# uv provides a fresh interpreter with FTS5 guaranteed.
|
||||
if fresh_bootstrap and uv_bin:
|
||||
rebuild_venv(uv_bin, PROJECT_ROOT / "venv")
|
||||
|
||||
pip_cmd = [sys.executable, "-m", "pip"]
|
||||
uv_bin = shutil.which("uv") or _ensure_uv_for_termux(pip_cmd)
|
||||
if not uv_bin:
|
||||
uv_bin = _ensure_uv_for_termux(pip_cmd)
|
||||
install_group = "all"
|
||||
|
||||
if uv_bin:
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
"""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,
|
||||
)
|
||||
Reference in New Issue
Block a user