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 argparse
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@ -103,6 +104,8 @@ def install_deps():
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
print("Installing Google API dependencies...")
|
print("Installing Google API dependencies...")
|
||||||
|
|
||||||
|
# First choice: pip in the current interpreter. Works for most installs.
|
||||||
try:
|
try:
|
||||||
subprocess.check_call(
|
subprocess.check_call(
|
||||||
[sys.executable, "-m", "pip", "install", "--quiet"] + REQUIRED_PACKAGES,
|
[sys.executable, "-m", "pip", "install", "--quiet"] + REQUIRED_PACKAGES,
|
||||||
@ -111,13 +114,36 @@ def install_deps():
|
|||||||
print("Dependencies installed.")
|
print("Dependencies installed.")
|
||||||
return True
|
return True
|
||||||
except subprocess.CalledProcessError as e:
|
except subprocess.CalledProcessError as e:
|
||||||
print(f"ERROR: Failed to install dependencies: {e}")
|
pip_error = e
|
||||||
print(
|
|
||||||
"On environments without pip (e.g. Nix), install the optional extra instead:"
|
# 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
|
||||||
print(" pip install 'hermes-agent[google]'")
|
# --python <interpreter>` installs into that exact interpreter without
|
||||||
print(f"Or manually: {sys.executable} -m pip install {' '.join(REQUIRED_PACKAGES)}")
|
# needing pip present. Targeting sys.executable keeps us on the venv the
|
||||||
return False
|
# 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():
|
def _ensure_deps():
|
||||||
|
|||||||
@ -322,3 +322,126 @@ class TestHermesConstantsFallback:
|
|||||||
import hermes_constants
|
import hermes_constants
|
||||||
assert module.get_hermes_home is hermes_constants.get_hermes_home
|
assert module.get_hermes_home is hermes_constants.get_hermes_home
|
||||||
assert module.display_hermes_home is hermes_constants.display_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