Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 33 additions & 7 deletions skills/productivity/google-workspace/scripts/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
import argparse
import json
import os
import shutil
import subprocess
import sys
from pathlib import Path
Expand Down Expand Up @@ -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,
Expand All @@ -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():
Expand Down
123 changes: 123 additions & 0 deletions tests/skills/test_google_oauth_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -322,3 +322,126 @@ def test_delegates_to_hermes_constants_when_available(self):
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
Loading