mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-08 13:12:08 +00:00
refactor(setup): route dependency installs through the canonical uv→pip→ensurepip ladder
Replace the hand-rolled ensurepip bootstrap (and five other one-off pip-install code paths) with hermes_cli.tools_config._pip_install, which prefers the bundled uv (fast, needs no pip in the venv), falls back to python -m pip, and bootstraps pip via ensurepip only when missing. Sites unified: - hermes_cli/setup.py: _install_neutts_deps, _install_kittentts_deps, modal SDK install, daytona SDK install - hermes_cli/memory_setup.py: memory-plugin pip deps (previously dead-ended when uv AND pip binaries were both absent) - hermes_cli/dingtalk_auth.py: qrcode auto-install (previously invoked 'python -m uv' which is not how uv ships) - agent/lsp/install.py: --target LSP server installs - plugins/google_meet/cli.py, plugins/platforms/matrix/adapter.py, plugins/platforms/google_chat/oauth.py, plugins/memory/honcho/cli.py Tests updated to assert the ladder behavior (uv-first, pip fallback, ensurepip bootstrap) instead of the removed bespoke branches.
This commit is contained in:
parent
569b78c1f9
commit
ba865e4038
9 changed files with 107 additions and 165 deletions
|
|
@ -348,17 +348,15 @@ def _install_pip(pkg: str, bin_name: str) -> Optional[str]:
|
|||
pip_target.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
logger.info("[install] pip install --target %s %s", pip_target, pkg)
|
||||
proc = subprocess.run(
|
||||
[sys.executable, "-m", "pip", "install", "--target", str(pip_target), "--quiet", pkg],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
from hermes_cli.tools_config import _pip_install
|
||||
|
||||
proc = _pip_install(
|
||||
["--target", str(pip_target), "--quiet", pkg],
|
||||
timeout=300,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
logger.warning(
|
||||
"[install] pip install failed for %s: %s", pkg, proc.stderr.strip()[:500]
|
||||
"[install] pip install failed for %s: %s", pkg, (proc.stderr or "").strip()[:500]
|
||||
)
|
||||
return None
|
||||
except (subprocess.TimeoutExpired, OSError) as e:
|
||||
|
|
|
|||
|
|
@ -163,17 +163,15 @@ def _ensure_qrcode_installed() -> bool:
|
|||
|
||||
import subprocess
|
||||
|
||||
# Try uv first (Hermes convention), then pip
|
||||
for cmd in (
|
||||
[sys.executable, "-m", "uv", "pip", "install", "qrcode"],
|
||||
[sys.executable, "-m", "pip", "install", "-q", "qrcode"],
|
||||
):
|
||||
try:
|
||||
subprocess.check_call(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
from hermes_cli.tools_config import _pip_install
|
||||
|
||||
try:
|
||||
result = _pip_install(["-q", "qrcode"], timeout=120)
|
||||
if result.returncode == 0:
|
||||
import qrcode # noqa: F401,F811
|
||||
return True
|
||||
except (subprocess.CalledProcessError, ImportError, FileNotFoundError):
|
||||
continue
|
||||
except (subprocess.SubprocessError, ImportError, OSError):
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -122,36 +122,19 @@ def _install_dependencies(provider_name: str) -> None:
|
|||
|
||||
print(f"\n Installing dependencies: {', '.join(missing)}")
|
||||
|
||||
import shutil
|
||||
|
||||
uv_path = shutil.which("uv")
|
||||
if uv_path:
|
||||
install_cmd = [uv_path, "pip", "install", "--python", sys.executable, "--quiet"] + missing
|
||||
manual_cmd = f"uv pip install --python {sys.executable} {' '.join(missing)}"
|
||||
else:
|
||||
pip_cmd = shutil.which("pip3") or shutil.which("pip")
|
||||
if not pip_cmd:
|
||||
print(" ⚠ uv not found — cannot install dependencies")
|
||||
print(" Install uv: curl -LsSf https://astral.sh/uv/install.sh | sh")
|
||||
print(" Then re-run: hermes memory setup")
|
||||
return
|
||||
print(" ⚠ uv not found. Falling back to standard pip...")
|
||||
install_cmd = [sys.executable, "-m", "pip", "install", "--quiet"] + missing
|
||||
manual_cmd = f"{sys.executable} -m pip install {' '.join(missing)}"
|
||||
from hermes_cli.tools_config import _pip_install
|
||||
|
||||
manual_cmd = f"uv pip install {' '.join(missing)}"
|
||||
try:
|
||||
subprocess.run(
|
||||
install_cmd,
|
||||
check=True, timeout=120,
|
||||
capture_output=True,
|
||||
)
|
||||
print(f" ✓ Installed {', '.join(missing)}")
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f" ⚠ Failed to install {', '.join(missing)}")
|
||||
stderr = (e.stderr or b"").decode()[:200]
|
||||
if stderr:
|
||||
print(f" {stderr}")
|
||||
print(f" Run manually: {manual_cmd}")
|
||||
result = _pip_install(["--quiet"] + missing, timeout=120)
|
||||
if result.returncode == 0:
|
||||
print(f" ✓ Installed {', '.join(missing)}")
|
||||
else:
|
||||
print(f" ⚠ Failed to install {', '.join(missing)}")
|
||||
stderr = (result.stderr or "")[:200]
|
||||
if stderr:
|
||||
print(f" {stderr}")
|
||||
print(f" Run manually: {manual_cmd}")
|
||||
except Exception as e:
|
||||
print(f" ⚠ Install failed: {e}")
|
||||
print(f" Run manually: {manual_cmd}")
|
||||
|
|
|
|||
|
|
@ -829,29 +829,23 @@ def _install_neutts_deps() -> bool:
|
|||
print_info("This will also download the TTS model (~300MB) on first use.")
|
||||
print()
|
||||
|
||||
# Ensure pip is available — some venvs are created without pip (e.g. Ubuntu 25.10)
|
||||
try:
|
||||
import importlib
|
||||
if importlib.util.find_spec("pip") is None:
|
||||
print_info("pip not found in venv — bootstrapping with ensurepip...")
|
||||
subprocess.run([sys.executable, "-m", "ensurepip", "--upgrade"], check=True, timeout=30)
|
||||
print_success("pip bootstrapped successfully")
|
||||
except Exception as e:
|
||||
print_warning(f"Could not bootstrap pip: {e}")
|
||||
print_info("Try: python -m ensurepip --upgrade")
|
||||
return False
|
||||
# Route through the canonical uv → pip → ensurepip ladder so pip-less
|
||||
# venvs (Ubuntu 25.10 `python -m venv`, `uv venv`) work out of the box.
|
||||
from hermes_cli.tools_config import _pip_install
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
[sys.executable, "-m", "pip", "install", "-U", "neutts[all]", "--quiet"],
|
||||
check=True, timeout=300,
|
||||
)
|
||||
result = _pip_install(["-U", "neutts[all]", "--quiet"], timeout=300)
|
||||
except Exception as e:
|
||||
print_error(f"Failed to install neutts: {e}")
|
||||
print_info("Try manually: uv pip install -U 'neutts[all]'")
|
||||
return False
|
||||
if result.returncode == 0:
|
||||
print_success("neutts installed successfully")
|
||||
return True
|
||||
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
|
||||
print_error(f"Failed to install neutts: {e}")
|
||||
print_info("Try manually: python -m pip install -U neutts[all]")
|
||||
return False
|
||||
err = (result.stderr or "").strip()
|
||||
print_error(f"Failed to install neutts: {err[:300] if err else 'install failed'}")
|
||||
print_info("Try manually: uv pip install -U 'neutts[all]'")
|
||||
return False
|
||||
|
||||
|
||||
def _install_kittentts_deps() -> bool:
|
||||
|
|
@ -866,17 +860,22 @@ def _install_kittentts_deps() -> bool:
|
|||
print()
|
||||
print_info("Installing kittentts Python package (~25-80MB model downloaded on first use)...")
|
||||
print()
|
||||
|
||||
from hermes_cli.tools_config import _pip_install
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
[sys.executable, "-m", "pip", "install", "-U", wheel_url, "soundfile", "--quiet"],
|
||||
check=True, timeout=300,
|
||||
)
|
||||
result = _pip_install(["-U", wheel_url, "soundfile", "--quiet"], timeout=300)
|
||||
except Exception as e:
|
||||
print_error(f"Failed to install kittentts: {e}")
|
||||
print_info(f"Try manually: uv pip install -U '{wheel_url}' soundfile")
|
||||
return False
|
||||
if result.returncode == 0:
|
||||
print_success("kittentts installed successfully")
|
||||
return True
|
||||
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
|
||||
print_error(f"Failed to install kittentts: {e}")
|
||||
print_info(f"Try manually: python -m pip install -U '{wheel_url}' soundfile")
|
||||
return False
|
||||
err = (result.stderr or "").strip()
|
||||
print_error(f"Failed to install kittentts: {err[:300] if err else 'install failed'}")
|
||||
print_info(f"Try manually: uv pip install -U '{wheel_url}' soundfile")
|
||||
return False
|
||||
|
||||
|
||||
def _xai_oauth_logged_in_for_setup() -> bool:
|
||||
|
|
@ -1315,32 +1314,13 @@ def setup_terminal_backend(config: dict):
|
|||
__import__("modal")
|
||||
except ImportError:
|
||||
print_info("Installing modal SDK...")
|
||||
import subprocess
|
||||
from hermes_cli.tools_config import _pip_install
|
||||
|
||||
uv_bin = shutil.which("uv")
|
||||
if uv_bin:
|
||||
result = subprocess.run(
|
||||
[
|
||||
uv_bin,
|
||||
"pip",
|
||||
"install",
|
||||
"--python",
|
||||
sys.executable,
|
||||
"modal",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
else:
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "pip", "install", "modal"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
result = _pip_install(["modal"])
|
||||
if result.returncode == 0:
|
||||
print_success("modal SDK installed")
|
||||
else:
|
||||
print_warning("Install failed — run manually: pip install modal")
|
||||
print_warning("Install failed — run manually: uv pip install modal")
|
||||
|
||||
# Modal token
|
||||
print()
|
||||
|
|
@ -1375,25 +1355,13 @@ def setup_terminal_backend(config: dict):
|
|||
__import__("daytona")
|
||||
except ImportError:
|
||||
print_info("Installing daytona SDK...")
|
||||
import subprocess
|
||||
from hermes_cli.tools_config import _pip_install
|
||||
|
||||
uv_bin = shutil.which("uv")
|
||||
if uv_bin:
|
||||
result = subprocess.run(
|
||||
[uv_bin, "pip", "install", "--python", sys.executable, "daytona"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
else:
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "pip", "install", "daytona"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
result = _pip_install(["daytona"])
|
||||
if result.returncode == 0:
|
||||
print_success("daytona SDK installed")
|
||||
else:
|
||||
print_warning("Install failed — run manually: pip install daytona")
|
||||
print_warning("Install failed — run manually: uv pip install daytona")
|
||||
if result.stderr:
|
||||
print_info(f" Error: {result.stderr.strip().splitlines()[-1]}")
|
||||
|
||||
|
|
|
|||
|
|
@ -250,10 +250,9 @@ def _cmd_install(*, realtime: bool, assume_yes: bool) -> int:
|
|||
pip_pkgs = ["playwright", "websockets"]
|
||||
print(f"\n[1/3] pip install: {' '.join(pip_pkgs)}")
|
||||
try:
|
||||
res = _sp.run(
|
||||
[sys.executable, "-m", "pip", "install", "--upgrade", *pip_pkgs],
|
||||
check=False,
|
||||
)
|
||||
from hermes_cli.tools_config import _pip_install
|
||||
|
||||
res = _pip_install(["--upgrade", *pip_pkgs], capture_output=False)
|
||||
if res.returncode != 0:
|
||||
print(" pip install failed")
|
||||
return 1
|
||||
|
|
|
|||
|
|
@ -515,20 +515,16 @@ def _ensure_sdk_installed() -> bool:
|
|||
print(" Skipping install. Run: pip install 'honcho-ai>=2.0.1'\n")
|
||||
return False
|
||||
|
||||
import subprocess
|
||||
print(" Installing honcho-ai...", flush=True)
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "pip", "install", "honcho-ai>=2.0.1"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
from hermes_cli.tools_config import _pip_install
|
||||
|
||||
result = _pip_install(["honcho-ai>=2.0.1"])
|
||||
if result.returncode == 0:
|
||||
print(" Installed.\n")
|
||||
return True
|
||||
else:
|
||||
print(f" Install failed:\n{result.stderr.strip()}")
|
||||
print(" Run manually: pip install 'honcho-ai>=2.0.1'\n")
|
||||
print(f" Install failed:\n{(result.stderr or '').strip()}")
|
||||
print(" Run manually: uv pip install 'honcho-ai>=2.0.1'\n")
|
||||
return False
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -379,13 +379,14 @@ def install_deps() -> bool:
|
|||
|
||||
print("Installing Google Chat OAuth dependencies...")
|
||||
try:
|
||||
subprocess.check_call(
|
||||
[sys.executable, "-m", "pip", "install", "--quiet"] + _REQUIRED_PACKAGES,
|
||||
stdout=subprocess.DEVNULL,
|
||||
)
|
||||
from hermes_cli.tools_config import _pip_install
|
||||
|
||||
result = _pip_install(["--quiet"] + _REQUIRED_PACKAGES)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError((result.stderr or "install failed").strip()[:300])
|
||||
print("Dependencies installed.")
|
||||
return True
|
||||
except subprocess.CalledProcessError as exc:
|
||||
except Exception as exc:
|
||||
print(f"ERROR: Failed to install dependencies: {exc}")
|
||||
print("Or install via the optional extra:")
|
||||
print(" pip install 'hermes-agent[google_chat]'")
|
||||
|
|
|
|||
|
|
@ -4497,23 +4497,14 @@ def interactive_setup() -> None:
|
|||
__import__("mautrix")
|
||||
except ImportError:
|
||||
print_info(f"Installing {matrix_pkg}...")
|
||||
import subprocess
|
||||
uv_bin = shutil.which("uv")
|
||||
if uv_bin:
|
||||
result = subprocess.run(
|
||||
[uv_bin, "pip", "install", "--python", _sys.executable, matrix_pkg],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
else:
|
||||
result = subprocess.run(
|
||||
[_sys.executable, "-m", "pip", "install", matrix_pkg],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
from hermes_cli.tools_config import _pip_install
|
||||
|
||||
result = _pip_install([matrix_pkg])
|
||||
if result.returncode == 0:
|
||||
print_success(f"{matrix_pkg} installed")
|
||||
else:
|
||||
print_warning(
|
||||
f"Install failed — run manually: pip install "
|
||||
f"Install failed — run manually: uv pip install "
|
||||
f"'{matrix_pkg}' asyncpg aiosqlite Markdown aiohttp-socks"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -51,47 +51,55 @@ class TestMemorySetupProviderRouting:
|
|||
|
||||
|
||||
class TestInstallDependenciesRunner:
|
||||
"""`_install_dependencies` must install via `uv` when present and fall back
|
||||
to standard `pip` when `uv` is unavailable (e.g. slim containers / CI images
|
||||
that don't ship uv) instead of dead-ending with "cannot install"."""
|
||||
"""`_install_dependencies` must route through the canonical
|
||||
``_pip_install`` ladder (uv → pip → ensurepip): uv when present, standard
|
||||
pip when uv is unavailable, and an ensurepip bootstrap for pip-less venvs
|
||||
instead of dead-ending with "cannot install"."""
|
||||
|
||||
def _run_with_missing_dep(self, tmp_path, which_side_effect):
|
||||
def _run_with_missing_dep(self, tmp_path, which_side_effect, run_behavior=None):
|
||||
"""Drive _install_dependencies for a plugin that declares one missing
|
||||
pip dep, capturing the subprocess.run argv (or None if never called)."""
|
||||
pip dep, capturing every subprocess.run argv issued by the ladder."""
|
||||
import sys
|
||||
|
||||
(tmp_path / "plugin.yaml").write_text(
|
||||
"pip_dependencies:\n - definitely-not-installed-xyz\n", encoding="utf-8"
|
||||
)
|
||||
captured = {}
|
||||
calls = []
|
||||
|
||||
def fake_run(cmd, **kw):
|
||||
captured["cmd"] = cmd
|
||||
return SimpleNamespace()
|
||||
calls.append(cmd)
|
||||
if run_behavior:
|
||||
return run_behavior(cmd)
|
||||
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
|
||||
with patch("plugins.memory.find_provider_dir", return_value=tmp_path), \
|
||||
patch("shutil.which", side_effect=which_side_effect), \
|
||||
patch("subprocess.run", fake_run):
|
||||
patch("hermes_cli.tools_config.shutil.which", side_effect=which_side_effect), \
|
||||
patch("hermes_cli.tools_config.subprocess.run", fake_run):
|
||||
memory_setup._install_dependencies("x")
|
||||
return captured.get("cmd"), sys.executable
|
||||
return calls, sys.executable
|
||||
|
||||
def test_uses_uv_when_available(self, tmp_path):
|
||||
cmd, _ = self._run_with_missing_dep(
|
||||
calls, _ = self._run_with_missing_dep(
|
||||
tmp_path, lambda b: "/usr/bin/uv" if b == "uv" else None
|
||||
)
|
||||
assert cmd is not None
|
||||
assert cmd[:3] == ["/usr/bin/uv", "pip", "install"]
|
||||
assert calls
|
||||
assert calls[0][:3] == ["/usr/bin/uv", "pip", "install"]
|
||||
|
||||
def test_falls_back_to_pip_when_uv_missing(self, tmp_path, capsys):
|
||||
"""The salvaged behavior (#5954): no uv but pip present -> python -m pip."""
|
||||
cmd, py = self._run_with_missing_dep(
|
||||
tmp_path, lambda b: "/usr/bin/pip3" if b == "pip3" else None
|
||||
)
|
||||
assert cmd is not None
|
||||
assert cmd[:4] == [py, "-m", "pip", "install"]
|
||||
assert "Falling back to standard pip" in capsys.readouterr().out
|
||||
def test_falls_back_to_pip_when_uv_missing(self, tmp_path):
|
||||
"""No uv but pip importable -> python -m pip install."""
|
||||
calls, py = self._run_with_missing_dep(tmp_path, lambda b: None)
|
||||
assert calls
|
||||
# Ladder probes pip first, then installs with it.
|
||||
assert calls[0][:3] == [py, "-m", "pip"]
|
||||
assert calls[-1][:4] == [py, "-m", "pip", "install"]
|
||||
|
||||
def test_aborts_when_neither_uv_nor_pip(self, tmp_path, capsys):
|
||||
cmd, _ = self._run_with_missing_dep(tmp_path, lambda b: None)
|
||||
assert cmd is None # no install attempted
|
||||
assert "cannot install dependencies" in capsys.readouterr().out
|
||||
def test_bootstraps_pip_via_ensurepip_when_missing(self, tmp_path):
|
||||
"""Neither uv nor pip -> ensurepip bootstrap, then pip install."""
|
||||
def behavior(cmd):
|
||||
if cmd[-1] == "--version":
|
||||
return SimpleNamespace(returncode=1, stdout="", stderr="")
|
||||
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
|
||||
calls, py = self._run_with_missing_dep(tmp_path, lambda b: None, behavior)
|
||||
assert any("ensurepip" in c for c in calls)
|
||||
assert calls[-1][:4] == [py, "-m", "pip", "install"]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue