fix(google-workspace): fall back to uv when venv has no pip (#39516)
The Hermes Docker image's venv is built with `uv sync`, which does not bootstrap pip into the venv. When the google-workspace setup script needs to install its deps and the running interpreter has no pip, `sys.executable -m pip install` dead-ends with "No module named pip" (reported via Discord support). install_deps() now falls back to `uv pip install --python <interpreter>` when the pip path fails and uv is on PATH. uv installs into the exact interpreter the script is running under without needing pip present, so the pip-less venv self-heals (e.g. a dep evicted on image update, or a build without the [google]/[all] extra). On environments with neither pip nor uv, the [google] extra hint is printed as before. Verified E2E against nousresearch/hermes-agent:latest: under the venv python with a missing dep, --install-deps now prints "Dependencies installed." and exits 0 instead of failing. Adds TestInstallDeps regression coverage: pip path, uv fallback, uv-not-consulted-when-pip-works control, and both no-installer-available and uv-also-fails failure cases.
This commit is contained in:
@ -26,6 +26,7 @@ from __future__ import annotations # allow PEP 604 `X | None` on Python 3.9+
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
@ -103,6 +104,8 @@ def install_deps():
|
||||
pass
|
||||
|
||||
print("Installing Google API dependencies...")
|
||||
|
||||
# First choice: pip in the current interpreter. Works for most installs.
|
||||
try:
|
||||
subprocess.check_call(
|
||||
[sys.executable, "-m", "pip", "install", "--quiet"] + REQUIRED_PACKAGES,
|
||||
@ -111,13 +114,36 @@ def install_deps():
|
||||
print("Dependencies installed.")
|
||||
return True
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"ERROR: Failed to install dependencies: {e}")
|
||||
print(
|
||||
"On environments without pip (e.g. Nix), install the optional extra instead:"
|
||||
)
|
||||
print(" pip install 'hermes-agent[google]'")
|
||||
print(f"Or manually: {sys.executable} -m pip install {' '.join(REQUIRED_PACKAGES)}")
|
||||
return False
|
||||
pip_error = e
|
||||
|
||||
# Fallback: the interpreter has no pip (the Hermes Docker image's venv is
|
||||
# built with `uv sync`, which does not bootstrap pip). `uv pip install
|
||||
# --python <interpreter>` installs into that exact interpreter without
|
||||
# needing pip present. Targeting sys.executable keeps us on the venv the
|
||||
# script is actually running under, rather than guessing.
|
||||
uv = shutil.which("uv")
|
||||
if uv:
|
||||
try:
|
||||
subprocess.check_call(
|
||||
[uv, "pip", "install", "--python", sys.executable, "--quiet"]
|
||||
+ REQUIRED_PACKAGES,
|
||||
stdout=subprocess.DEVNULL,
|
||||
)
|
||||
print("Dependencies installed.")
|
||||
return True
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"ERROR: Failed to install dependencies via uv: {e}")
|
||||
print(f"Manually: {uv} pip install --python {sys.executable} {' '.join(REQUIRED_PACKAGES)}")
|
||||
return False
|
||||
|
||||
print(f"ERROR: Failed to install dependencies: {pip_error}")
|
||||
print(
|
||||
"On environments without pip (e.g. Nix, or the Hermes Docker image's "
|
||||
"uv-managed venv), install the optional extra instead:"
|
||||
)
|
||||
print(" pip install 'hermes-agent[google]'")
|
||||
print(f"Or manually: {sys.executable} -m pip install {' '.join(REQUIRED_PACKAGES)}")
|
||||
return False
|
||||
|
||||
|
||||
def _ensure_deps():
|
||||
|
||||
@ -322,3 +322,126 @@ class TestHermesConstantsFallback:
|
||||
import hermes_constants
|
||||
assert module.get_hermes_home is hermes_constants.get_hermes_home
|
||||
assert module.display_hermes_home is hermes_constants.display_hermes_home
|
||||
|
||||
|
||||
def _load_setup_module(monkeypatch):
|
||||
"""Load setup.py without stubbing _ensure_deps (for install_deps tests)."""
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"google_workspace_setup_installdeps_test", SCRIPT_PATH
|
||||
)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
assert spec.loader is not None
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def _force_deps_missing(monkeypatch):
|
||||
"""Make `import googleapiclient` / `import google_auth_oauthlib` fail so
|
||||
install_deps() proceeds past its early-return short-circuit."""
|
||||
for name in ("googleapiclient", "google_auth_oauthlib"):
|
||||
monkeypatch.setitem(sys.modules, name, None)
|
||||
|
||||
|
||||
class TestInstallDeps:
|
||||
"""Tests for install_deps() interpreter/installer selection.
|
||||
|
||||
Regression coverage for the Hermes Docker image, whose venv is built with
|
||||
`uv sync` and ships without pip — `sys.executable -m pip install` fails
|
||||
with `No module named pip`, so install_deps() must fall back to uv.
|
||||
"""
|
||||
|
||||
def test_returns_early_when_already_installed(self, monkeypatch):
|
||||
"""If both libs import, no installer subprocess runs at all."""
|
||||
module = _load_setup_module(monkeypatch)
|
||||
# Don't force-missing: real test env has the libs importable. Guard
|
||||
# against any subprocess being spawned.
|
||||
calls = []
|
||||
monkeypatch.setattr(
|
||||
module.subprocess, "check_call", lambda *a, **k: calls.append(a)
|
||||
)
|
||||
# google_auth_oauthlib may not be installed in the test env; only run
|
||||
# this assertion when the early-return path is actually reachable.
|
||||
try:
|
||||
import googleapiclient # noqa: F401
|
||||
import google_auth_oauthlib # noqa: F401
|
||||
except ImportError:
|
||||
pytest.skip("Google libs not installed in test env")
|
||||
assert module.install_deps() is True
|
||||
assert calls == []
|
||||
|
||||
def test_uses_pip_when_available(self, monkeypatch):
|
||||
"""When pip works, install_deps succeeds via pip and never calls uv."""
|
||||
module = _load_setup_module(monkeypatch)
|
||||
_force_deps_missing(monkeypatch)
|
||||
|
||||
recorded = []
|
||||
|
||||
def fake_check_call(cmd, **kwargs):
|
||||
recorded.append(cmd)
|
||||
# pip path is the first attempt — succeed.
|
||||
return 0
|
||||
|
||||
which_calls = []
|
||||
monkeypatch.setattr(module.subprocess, "check_call", fake_check_call)
|
||||
monkeypatch.setattr(
|
||||
module.shutil, "which", lambda name: which_calls.append(name)
|
||||
)
|
||||
|
||||
assert module.install_deps() is True
|
||||
assert recorded[0][:3] == [module.sys.executable, "-m", "pip"]
|
||||
# Control: uv must NOT be consulted when pip succeeds.
|
||||
assert which_calls == []
|
||||
|
||||
def test_falls_back_to_uv_when_pip_missing(self, monkeypatch):
|
||||
"""No pip → uv pip install --python <interpreter> is used."""
|
||||
module = _load_setup_module(monkeypatch)
|
||||
_force_deps_missing(monkeypatch)
|
||||
|
||||
recorded = []
|
||||
|
||||
def fake_check_call(cmd, **kwargs):
|
||||
recorded.append(cmd)
|
||||
if cmd[:3] == [module.sys.executable, "-m", "pip"]:
|
||||
raise module.subprocess.CalledProcessError(1, cmd)
|
||||
return 0 # uv invocation succeeds
|
||||
|
||||
monkeypatch.setattr(module.subprocess, "check_call", fake_check_call)
|
||||
monkeypatch.setattr(module.shutil, "which", lambda name: "/usr/local/bin/uv")
|
||||
|
||||
assert module.install_deps() is True
|
||||
assert len(recorded) == 2
|
||||
uv_cmd = recorded[1]
|
||||
assert uv_cmd[0] == "/usr/local/bin/uv"
|
||||
assert uv_cmd[1:5] == ["pip", "install", "--python", module.sys.executable]
|
||||
for pkg in module.REQUIRED_PACKAGES:
|
||||
assert pkg in uv_cmd
|
||||
|
||||
def test_returns_false_when_no_pip_and_no_uv(self, monkeypatch, capsys):
|
||||
"""No pip AND no uv → failure, with the [google] extra hint printed."""
|
||||
module = _load_setup_module(monkeypatch)
|
||||
_force_deps_missing(monkeypatch)
|
||||
|
||||
def fake_check_call(cmd, **kwargs):
|
||||
raise module.subprocess.CalledProcessError(1, cmd)
|
||||
|
||||
monkeypatch.setattr(module.subprocess, "check_call", fake_check_call)
|
||||
monkeypatch.setattr(module.shutil, "which", lambda name: None)
|
||||
|
||||
assert module.install_deps() is False
|
||||
out = capsys.readouterr().out
|
||||
assert "hermes-agent[google]" in out
|
||||
|
||||
def test_returns_false_when_uv_fallback_also_fails(self, monkeypatch, capsys):
|
||||
"""uv present but its install fails → failure surfaced (not swallowed)."""
|
||||
module = _load_setup_module(monkeypatch)
|
||||
_force_deps_missing(monkeypatch)
|
||||
|
||||
def fake_check_call(cmd, **kwargs):
|
||||
raise module.subprocess.CalledProcessError(1, cmd)
|
||||
|
||||
monkeypatch.setattr(module.subprocess, "check_call", fake_check_call)
|
||||
monkeypatch.setattr(module.shutil, "which", lambda name: "/usr/local/bin/uv")
|
||||
|
||||
assert module.install_deps() is False
|
||||
out = capsys.readouterr().out
|
||||
assert "via uv" in out
|
||||
|
||||
Reference in New Issue
Block a user