From 7c00ffd92c4135365f395224d359a760f8838cce Mon Sep 17 00:00:00 2001 From: Ben Barclay Date: Fri, 5 Jun 2026 13:30:02 +1000 Subject: [PATCH] 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 ` 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. --- .../google-workspace/scripts/setup.py | 40 +++++- tests/skills/test_google_oauth_setup.py | 123 ++++++++++++++++++ 2 files changed, 156 insertions(+), 7 deletions(-) diff --git a/skills/productivity/google-workspace/scripts/setup.py b/skills/productivity/google-workspace/scripts/setup.py index d09085fe7..f5300e9e6 100644 --- a/skills/productivity/google-workspace/scripts/setup.py +++ b/skills/productivity/google-workspace/scripts/setup.py @@ -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 ` 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(): diff --git a/tests/skills/test_google_oauth_setup.py b/tests/skills/test_google_oauth_setup.py index a7908bd76..1b7b0e17d 100644 --- a/tests/skills/test_google_oauth_setup.py +++ b/tests/skills/test_google_oauth_setup.py @@ -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 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