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:
@ -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:
|
||||
|
||||
228
hermes_cli/managed_uv.py
Normal file
228
hermes_cli/managed_uv.py
Normal file
@ -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,
|
||||
)
|
||||
@ -452,12 +452,9 @@ class SessionDB:
|
||||
self._fts_unavailable_warned = True
|
||||
logger.warning(
|
||||
"SQLite FTS5 unavailable for %s; full-text session search "
|
||||
"disabled. This usually means Hermes is running on an "
|
||||
"unsupported install (e.g. a pip-installed or pip-managed "
|
||||
"Python whose bundled SQLite lacks FTS5) rather than a "
|
||||
"mainline install. Some features may be missing or behave "
|
||||
"differently. Install the supported way: "
|
||||
"https://hermes-agent.nousresearch.com (underlying error: %s)",
|
||||
"disabled. Run `hermes update` to rebuild the venv with a "
|
||||
"current Python (managed uv guarantees FTS5). "
|
||||
"(underlying error: %s)",
|
||||
self.db_path,
|
||||
exc,
|
||||
)
|
||||
|
||||
@ -289,78 +289,42 @@ function Install-AgentBrowser {
|
||||
# ============================================================================
|
||||
|
||||
function Install-Uv {
|
||||
Write-Info "Checking for uv package manager..."
|
||||
|
||||
# Check if uv is already available
|
||||
if (Get-Command uv -ErrorAction SilentlyContinue) {
|
||||
$version = uv --version
|
||||
$script:UvCmd = "uv"
|
||||
Write-Success "uv found ($version)"
|
||||
# Hermes owns its own uv at $HermesHome\bin\uv.exe. Always install there —
|
||||
# no PATH probing, no conda guards, no multi-location resolution chains.
|
||||
# The runtime update path (hermes_cli/managed_uv.py) looks in the same
|
||||
# place, so install.ps1 and `hermes update` stay in sync.
|
||||
$managedUv = Join-Path $HermesHome "bin\uv.exe"
|
||||
|
||||
if (Test-Path $managedUv) {
|
||||
$script:UvCmd = $managedUv
|
||||
$version = & $managedUv --version
|
||||
Write-Success "Managed uv found ($version)"
|
||||
return $true
|
||||
}
|
||||
|
||||
# Check common install locations
|
||||
$uvPaths = @(
|
||||
"$env:USERPROFILE\.local\bin\uv.exe",
|
||||
"$env:USERPROFILE\.cargo\bin\uv.exe"
|
||||
)
|
||||
foreach ($uvPath in $uvPaths) {
|
||||
if (Test-Path $uvPath) {
|
||||
$script:UvCmd = $uvPath
|
||||
$version = & $uvPath --version
|
||||
Write-Success "uv found at $uvPath ($version)"
|
||||
return $true
|
||||
}
|
||||
}
|
||||
|
||||
# Install uv
|
||||
Write-Info "Installing uv (fast Python package manager)..."
|
||||
# Capture EAP outside the try block so the catch's restore call always
|
||||
# has a meaningful value -- if the assignment lived inside try and the
|
||||
# try body threw before reaching it, the catch would see $prevEAP
|
||||
# unset and leave EAP at whatever the previous protected call set.
|
||||
|
||||
Write-Info "Installing managed uv into $HermesHome\bin ..."
|
||||
New-Item -ItemType Directory -Path (Join-Path $HermesHome "bin") -Force | Out-Null
|
||||
|
||||
# UV_INSTALL_DIR tells the astral installer to place the binary
|
||||
# directly into $HermesHome\bin instead of ~/.local/bin.
|
||||
$prevEAP = $ErrorActionPreference
|
||||
try {
|
||||
# Relax ErrorActionPreference around the nested astral installer.
|
||||
# The astral installer (a separate `powershell -c "irm ... | iex"`)
|
||||
# writes download progress to stderr. With $ErrorActionPreference
|
||||
# = "Stop" set at the top of this script, PowerShell wraps stderr
|
||||
# lines from native commands (which `powershell -c` is, from our
|
||||
# perspective) as ErrorRecord objects when captured via 2>&1, then
|
||||
# throws a terminating exception on the first one -- even though
|
||||
# uv installs successfully and the child exits 0. Same fix
|
||||
# pattern Test-Python uses for `uv python install`; verify success
|
||||
# via Test-Path on the expected binary afterwards, which is more
|
||||
# reliable than exit-code/stderr signal anyway.
|
||||
$ErrorActionPreference = "Continue"
|
||||
$env:UV_INSTALL_DIR = Join-Path $HermesHome "bin"
|
||||
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" 2>&1 | Out-Null
|
||||
$ErrorActionPreference = $prevEAP
|
||||
|
||||
# Find the installed binary
|
||||
$uvExe = "$env:USERPROFILE\.local\bin\uv.exe"
|
||||
if (-not (Test-Path $uvExe)) {
|
||||
$uvExe = "$env:USERPROFILE\.cargo\bin\uv.exe"
|
||||
}
|
||||
if (-not (Test-Path $uvExe)) {
|
||||
# Refresh PATH and try again
|
||||
$env:Path = [Environment]::GetEnvironmentVariable("Path", "User") + ";" + [Environment]::GetEnvironmentVariable("Path", "Machine")
|
||||
if (Get-Command uv -ErrorAction SilentlyContinue) {
|
||||
$uvExe = (Get-Command uv).Source
|
||||
}
|
||||
}
|
||||
|
||||
if (Test-Path $uvExe) {
|
||||
$script:UvCmd = $uvExe
|
||||
$version = & $uvExe --version
|
||||
Write-Success "uv installed ($version)"
|
||||
if (Test-Path $managedUv) {
|
||||
$script:UvCmd = $managedUv
|
||||
$version = & $managedUv --version
|
||||
Write-Success "Managed uv installed ($version)"
|
||||
return $true
|
||||
}
|
||||
|
||||
Write-Err "uv installed but not found on PATH"
|
||||
Write-Info "Try restarting your terminal and re-running"
|
||||
|
||||
Write-Err "uv installed but not found at $managedUv"
|
||||
Write-Info "Install manually: https://docs.astral.sh/uv/getting-started/installation/"
|
||||
return $false
|
||||
} catch {
|
||||
# Restore EAP in case the try block threw before the assignment
|
||||
if ($prevEAP) { $ErrorActionPreference = $prevEAP }
|
||||
Write-Err "Failed to install uv: $_"
|
||||
Write-Info "Install manually: https://docs.astral.sh/uv/getting-started/installation/"
|
||||
@ -385,11 +349,9 @@ function Sync-EnvPath {
|
||||
# in a fresh powershell process, so $script:UvCmd set by Install-Uv in a
|
||||
# prior process is not visible here. Later stages (Test-Python,
|
||||
# Install-Venv, Install-Dependencies, Install-PlatformSdks) call this
|
||||
# at the top to populate $script:UvCmd from PATH or known install paths.
|
||||
# Throws if uv is not findable -- the caller's stage then surfaces a
|
||||
# clean error via the stage-driver's try/catch. Fast path is a single
|
||||
# Get-Command call when uv is on PATH (the common case after Stage-Uv
|
||||
# ran path-modifying installs in a sibling process).
|
||||
# at the top to populate $script:UvCmd from the managed location.
|
||||
# Throws if uv is not findable — the caller's stage then surfaces a
|
||||
# clean error via the stage-driver's try/catch.
|
||||
function Resolve-UvCmd {
|
||||
# Already resolved (default invocation path: Install-Uv ran earlier
|
||||
# in the same process and set $script:UvCmd).
|
||||
@ -404,9 +366,15 @@ function Resolve-UvCmd {
|
||||
# Stale; fall through to re-discover.
|
||||
}
|
||||
|
||||
# Try PATH first (covers `winget install astral.uv`, manual installs,
|
||||
# and the post-Install-Uv state where uv.exe lives in
|
||||
# %USERPROFILE%\.local\bin which the installer added to PATH).
|
||||
# Check the managed location first — this is where Install-Uv puts it.
|
||||
$managedUv = Join-Path $HermesHome "bin\uv.exe"
|
||||
if (Test-Path $managedUv) {
|
||||
$script:UvCmd = $managedUv
|
||||
return
|
||||
}
|
||||
|
||||
# Fall back to PATH (covers edge cases where the installer ran in a
|
||||
# sibling process and HERMES_HOME wasn't propagated).
|
||||
if (Get-Command uv -ErrorAction SilentlyContinue) {
|
||||
$script:UvCmd = "uv"
|
||||
return
|
||||
@ -420,16 +388,7 @@ function Resolve-UvCmd {
|
||||
return
|
||||
}
|
||||
|
||||
# Check the well-known install locations the astral.sh installer drops
|
||||
# uv into. Mirrors the probe order Install-Uv uses.
|
||||
foreach ($uvPath in @("$env:USERPROFILE\.local\bin\uv.exe", "$env:USERPROFILE\.cargo\bin\uv.exe")) {
|
||||
if (Test-Path $uvPath) {
|
||||
$script:UvCmd = $uvPath
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
throw "uv is not installed or not on PATH. Run install.ps1 -Stage uv first."
|
||||
throw "uv is not installed. Run install.ps1 -Stage uv first."
|
||||
}
|
||||
|
||||
function Test-Python {
|
||||
|
||||
@ -475,39 +475,22 @@ install_uv() {
|
||||
return 0
|
||||
fi
|
||||
|
||||
log_info "Checking for uv package manager..."
|
||||
# Hermes owns its own uv at $HERMES_HOME/bin/uv. Always install there —
|
||||
# no PATH probing, no conda guards, no multi-location resolution chains.
|
||||
# The runtime update path (hermes_cli/managed_uv.py) looks in the same
|
||||
# place, so install.sh and `hermes update` stay in sync.
|
||||
local _managed_uv="$HERMES_HOME/bin/uv"
|
||||
|
||||
# Check common locations for uv
|
||||
if command -v uv &> /dev/null; then
|
||||
UV_CMD="uv"
|
||||
if [ -x "$_managed_uv" ]; then
|
||||
UV_CMD="$_managed_uv"
|
||||
UV_VERSION=$($UV_CMD --version 2>/dev/null)
|
||||
log_success "uv found ($UV_VERSION)"
|
||||
log_success "Managed uv found ($UV_VERSION)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Check ~/.local/bin (default uv install location) even if not on PATH yet
|
||||
if [ -x "$HOME/.local/bin/uv" ]; then
|
||||
UV_CMD="$HOME/.local/bin/uv"
|
||||
UV_VERSION=$($UV_CMD --version 2>/dev/null)
|
||||
log_success "uv found at ~/.local/bin ($UV_VERSION)"
|
||||
return 0
|
||||
fi
|
||||
log_info "Installing managed uv into $HERMES_HOME/bin ..."
|
||||
mkdir -p "$HERMES_HOME/bin"
|
||||
|
||||
# Check ~/.cargo/bin (alternative uv install location)
|
||||
if [ -x "$HOME/.cargo/bin/uv" ]; then
|
||||
UV_CMD="$HOME/.cargo/bin/uv"
|
||||
UV_VERSION=$($UV_CMD --version 2>/dev/null)
|
||||
log_success "uv found at ~/.cargo/bin ($UV_VERSION)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Install uv
|
||||
log_info "Installing uv (fast Python package manager)..."
|
||||
# Capture installer output so a failure shows the user WHY (network,
|
||||
# glibc mismatch on old distros, missing curl, ~/.local/bin not
|
||||
# writable, disk full, corp proxy / TLS interception, etc.) instead
|
||||
# of the previous "✗ Failed to install uv" with zero diagnostic.
|
||||
#
|
||||
# Two-stage: download the installer, then run it. Piping
|
||||
# `curl | sh` masks curl failures (sh exits 0 on empty stdin)
|
||||
# and conflates network errors with installer errors.
|
||||
@ -522,26 +505,22 @@ install_uv() {
|
||||
rm -f "$_uv_install_log" "$_uv_installer"
|
||||
exit 1
|
||||
fi
|
||||
if sh "$_uv_installer" >>"$_uv_install_log" 2>&1; then
|
||||
# UV_UNMANAGED_INSTALL tells the astral installer to place the binary
|
||||
# directly into $HERMES_HOME/bin instead of ~/.local/bin.
|
||||
if UV_UNMANAGED_INSTALL="$HERMES_HOME/bin" sh "$_uv_installer" >>"$_uv_install_log" 2>&1; then
|
||||
rm -f "$_uv_installer"
|
||||
# uv installs to ~/.local/bin by default
|
||||
if [ -x "$HOME/.local/bin/uv" ]; then
|
||||
UV_CMD="$HOME/.local/bin/uv"
|
||||
elif [ -x "$HOME/.cargo/bin/uv" ]; then
|
||||
UV_CMD="$HOME/.cargo/bin/uv"
|
||||
elif command -v uv &> /dev/null; then
|
||||
UV_CMD="uv"
|
||||
if [ -x "$_managed_uv" ]; then
|
||||
UV_CMD="$_managed_uv"
|
||||
else
|
||||
log_error "uv installer reported success but binary not found on PATH"
|
||||
log_error "uv installer reported success but binary not found at $_managed_uv"
|
||||
log_info "Installer output:"
|
||||
sed 's/^/ /' "$_uv_install_log" >&2
|
||||
log_info "Try adding ~/.local/bin to your PATH and re-running"
|
||||
rm -f "$_uv_install_log"
|
||||
exit 1
|
||||
fi
|
||||
rm -f "$_uv_install_log"
|
||||
UV_VERSION=$($UV_CMD --version 2>/dev/null)
|
||||
log_success "uv installed ($UV_VERSION)"
|
||||
log_success "Managed uv installed ($UV_VERSION)"
|
||||
else
|
||||
log_error "Failed to install uv"
|
||||
log_info "Installer output:"
|
||||
@ -579,7 +558,6 @@ check_python() {
|
||||
if PYTHON_PATH="$("$UV_CMD" python find "$PYTHON_VERSION" 2>/dev/null)"; then
|
||||
PYTHON_FOUND_VERSION="$("$PYTHON_PATH" --version 2>/dev/null)"
|
||||
log_success "Python found: $PYTHON_FOUND_VERSION"
|
||||
ensure_fts5
|
||||
return 0
|
||||
fi
|
||||
|
||||
@ -589,7 +567,6 @@ check_python() {
|
||||
PYTHON_PATH="$("$UV_CMD" python find "$PYTHON_VERSION")"
|
||||
PYTHON_FOUND_VERSION="$("$PYTHON_PATH" --version 2>/dev/null)"
|
||||
log_success "Python installed: $PYTHON_FOUND_VERSION"
|
||||
ensure_fts5
|
||||
else
|
||||
log_error "Failed to install Python $PYTHON_VERSION"
|
||||
log_info "Install Python $PYTHON_VERSION manually, then re-run this script"
|
||||
@ -597,104 +574,6 @@ check_python() {
|
||||
fi
|
||||
}
|
||||
|
||||
# Probe whether $1 (a python executable) links a SQLite with the FTS5
|
||||
# module compiled in. Hermes' session store (hermes_state.py) creates FTS5
|
||||
# virtual tables for full-text session search; a SQLite without FTS5 makes
|
||||
# the bundled-python path unusable for that feature. Returns 0 if FTS5 works.
|
||||
_python_has_fts5() {
|
||||
"$1" - <<'PY' 2>/dev/null
|
||||
import sqlite3, sys
|
||||
try:
|
||||
sqlite3.connect(":memory:").execute("CREATE VIRTUAL TABLE t USING fts5(x)")
|
||||
except Exception:
|
||||
sys.exit(1)
|
||||
PY
|
||||
}
|
||||
|
||||
# Reinstall $PYTHON_VERSION with the current uv and re-resolve PYTHON_PATH.
|
||||
# Returns 0 if the resulting interpreter ships FTS5.
|
||||
_reinstall_python_with_fts5() {
|
||||
local uv_bin="$1"
|
||||
"$uv_bin" python install "$PYTHON_VERSION" --reinstall >/dev/null 2>&1 || return 1
|
||||
PYTHON_PATH="$("$uv_bin" python find "$PYTHON_VERSION" 2>/dev/null)"
|
||||
PYTHON_FOUND_VERSION="$("$PYTHON_PATH" --version 2>/dev/null)"
|
||||
[ -n "${PYTHON_PATH:-}" ] && _python_has_fts5 "$PYTHON_PATH"
|
||||
}
|
||||
|
||||
_warn_no_fts5() {
|
||||
# Could not obtain an FTS5-capable interpreter (offline, pinned env, etc.).
|
||||
# Install proceeds — Hermes degrades gracefully and disables only full-text
|
||||
# session search — but warn so it isn't a silent gap.
|
||||
log_warn "Could not obtain an FTS5-capable Python. Hermes will run, but"
|
||||
log_warn "full-text session search will be disabled until FTS5 is present."
|
||||
}
|
||||
|
||||
# Guarantee the resolved uv-managed interpreter ships FTS5. uv's Python
|
||||
# distributions only gained FTS5 in mid-2025 (python-build-standalone #694),
|
||||
# but WHICH builds a given uv can install is baked into the uv binary's
|
||||
# download manifest — so a stale uv (e.g. `pip install uv==0.7.20`) only knows
|
||||
# about pre-FTS5 builds, and even `uv python install --reinstall` just pulls the
|
||||
# same FTS5-less interpreter. A plain reinstall with an old uv is therefore a
|
||||
# no-op for FTS5. To actually fix everyone's install, we escalate uv itself:
|
||||
#
|
||||
# 1. reinstall with the current $UV_CMD (handles a stale *interpreter* under
|
||||
# an already-current uv)
|
||||
# 2. if still no FTS5, bring uv up to date (`uv self update`) and reinstall —
|
||||
# this is what fixes a stale standalone uv
|
||||
# 3. if uv can't self-update (pip/apt/brew-managed uv refuses), install a
|
||||
# fresh standalone uv via the official installer into a temp dir and use
|
||||
# THAT to reinstall — this fixes package-manager-managed stale uv
|
||||
#
|
||||
# Pythons live in uv's shared store, so a fresh uv's --reinstall overwrites the
|
||||
# stale interpreter in place and the installer's later `uv python find` resolves
|
||||
# to it. Keeps session search working without bundling a second SQLite or asking
|
||||
# the user to do anything.
|
||||
ensure_fts5() {
|
||||
[ -n "${PYTHON_PATH:-}" ] || return 0
|
||||
if _python_has_fts5 "$PYTHON_PATH"; then
|
||||
return 0
|
||||
fi
|
||||
# Termux / non-uv installs have nothing to escalate.
|
||||
[ -n "${UV_CMD:-}" ] || { _warn_no_fts5; return 0; }
|
||||
|
||||
log_warn "Resolved Python's SQLite lacks the FTS5 module (session search needs it)."
|
||||
log_info "Reinstalling a current Python $PYTHON_VERSION with FTS5 via uv..."
|
||||
if _reinstall_python_with_fts5 "$UV_CMD"; then
|
||||
log_success "FTS5 available ($PYTHON_FOUND_VERSION)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Still no FTS5 — the uv binary itself is too old to know about FTS5-capable
|
||||
# Python builds. Try to update uv in place.
|
||||
log_info "uv is too old to provide an FTS5-capable Python — updating uv..."
|
||||
if "$UV_CMD" self update >/dev/null 2>&1; then
|
||||
if _reinstall_python_with_fts5 "$UV_CMD"; then
|
||||
log_success "FTS5 available ($PYTHON_FOUND_VERSION)"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# `uv self update` is unavailable on externally-managed uv (pip/apt/brew),
|
||||
# which is exactly the case the user hit (`pip install uv==0.7.20`). Install
|
||||
# a fresh standalone uv into a temp dir and use it just for the reinstall.
|
||||
log_info "Installing an up-to-date standalone uv to obtain an FTS5 Python..."
|
||||
local _tmp_uv_dir _fresh_uv
|
||||
_tmp_uv_dir="$(mktemp -d 2>/dev/null || echo "/tmp/hermes-fresh-uv.$$")"
|
||||
mkdir -p "$_tmp_uv_dir"
|
||||
if curl -LsSf https://astral.sh/uv/install.sh 2>/dev/null \
|
||||
| env UV_INSTALL_DIR="$_tmp_uv_dir" UV_UNMANAGED_INSTALL="$_tmp_uv_dir" sh >/dev/null 2>&1; then
|
||||
_fresh_uv="$_tmp_uv_dir/uv"
|
||||
if [ -x "$_fresh_uv" ] && _reinstall_python_with_fts5 "$_fresh_uv"; then
|
||||
log_success "FTS5 available ($PYTHON_FOUND_VERSION)"
|
||||
rm -rf "$_tmp_uv_dir"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
rm -rf "$_tmp_uv_dir"
|
||||
|
||||
_warn_no_fts5
|
||||
}
|
||||
|
||||
# Best-effort automatic git provisioning, mirroring install.ps1's Install-Git
|
||||
# (which downloads PortableGit on Windows). git is required to clone the repo,
|
||||
# and a fresh "normie" machine with no developer tools won't have it. Returns 0
|
||||
|
||||
@ -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."""
|
||||
|
||||
|
||||
205
tests/hermes_cli/test_managed_uv.py
Normal file
205
tests/hermes_cli/test_managed_uv.py
Normal file
@ -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