mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-31 06:51:29 +00:00
Remove unused imports (F401) and duplicate/shadowed import redefinitions (F811) across the codebase using ruff's safe autofixes. No behavioral changes -- imports only. - ~1400 safe autofixes applied across 644 files (net -1072 lines) - __init__.py re-exports preserved (excluded from F401 removal so public re-export surfaces stay intact) - Re-exports that are imported or monkeypatched by tests but look unused in their defining module are kept with explicit # noqa: F401 (gateway/run.py load_dotenv; run_agent re-exports from agent.message_sanitization, agent.context_compressor, agent.retry_utils, agent.prompt_builder, agent.process_bootstrap, agent.codex_responses_adapter) - Unsafe F841 (unused-variable) fixes deliberately skipped -- those can change behavior when the RHS has side effects - ruff lints remain disabled in pyproject.toml (only PLW1514 is selected); this is a one-time cleanup, not a config change Verification: - python -m compileall: clean - pytest --collect-only: all 27161 tests collect (zero import errors) - core entry points import clean (run_agent, model_tools, cli, toolsets, hermes_state, batch_runner, gateway) - static scan: every name any test imports directly from an edited module still resolves
280 lines
9.7 KiB
Python
280 lines
9.7 KiB
Python
"""Tests for Google Workspace gws bridge and CLI wrapper."""
|
|
|
|
import importlib.util
|
|
import json
|
|
import subprocess
|
|
import sys
|
|
import types
|
|
from datetime import datetime, timedelta, timezone
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
|
|
BRIDGE_PATH = (
|
|
Path(__file__).resolve().parents[2]
|
|
/ "skills/productivity/google-workspace/scripts/gws_bridge.py"
|
|
)
|
|
API_PATH = (
|
|
Path(__file__).resolve().parents[2]
|
|
/ "skills/productivity/google-workspace/scripts/google_api.py"
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def bridge_module(monkeypatch, tmp_path):
|
|
hermes_home = tmp_path / ".hermes"
|
|
hermes_home.mkdir()
|
|
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
|
|
|
spec = importlib.util.spec_from_file_location("gws_bridge_test", BRIDGE_PATH)
|
|
module = importlib.util.module_from_spec(spec)
|
|
assert spec.loader is not None
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
|
|
|
|
@pytest.fixture
|
|
def api_module(monkeypatch, tmp_path):
|
|
hermes_home = tmp_path / ".hermes"
|
|
hermes_home.mkdir()
|
|
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
|
|
|
spec = importlib.util.spec_from_file_location("gws_api_test", API_PATH)
|
|
module = importlib.util.module_from_spec(spec)
|
|
assert spec.loader is not None
|
|
spec.loader.exec_module(module)
|
|
# Ensure the gws CLI code path is taken even when the binary isn't
|
|
# installed (CI). Without this, calendar_list() falls through to the
|
|
# Python SDK path which imports ``googleapiclient`` — not in deps.
|
|
module._gws_binary = lambda: "/usr/bin/gws"
|
|
# Bypass authentication check — no real token file in CI.
|
|
module._ensure_authenticated = lambda: None
|
|
return module
|
|
|
|
|
|
def _write_token(path: Path, *, token="ya29.test", expiry=None, **extra):
|
|
data = {
|
|
"token": token,
|
|
"refresh_token": "1//refresh",
|
|
"client_id": "123.apps.googleusercontent.com",
|
|
"client_secret": "secret",
|
|
"token_uri": "https://oauth2.googleapis.com/token",
|
|
**extra,
|
|
}
|
|
if expiry is not None:
|
|
data["expiry"] = expiry
|
|
path.write_text(json.dumps(data))
|
|
|
|
|
|
def test_bridge_returns_valid_token(bridge_module, tmp_path):
|
|
"""Non-expired token is returned without refresh."""
|
|
future = (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat()
|
|
token_path = bridge_module.get_token_path()
|
|
_write_token(token_path, token="ya29.valid", expiry=future)
|
|
|
|
result = bridge_module.get_valid_token()
|
|
assert result == "ya29.valid"
|
|
|
|
|
|
def test_bridge_refreshes_expired_token(bridge_module, tmp_path):
|
|
"""Expired token triggers a refresh via token_uri."""
|
|
past = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat()
|
|
token_path = bridge_module.get_token_path()
|
|
_write_token(token_path, token="ya29.old", expiry=past)
|
|
|
|
mock_resp = MagicMock()
|
|
mock_resp.read.return_value = json.dumps({
|
|
"access_token": "ya29.refreshed",
|
|
"expires_in": 3600,
|
|
}).encode()
|
|
mock_resp.__enter__ = lambda s: s
|
|
mock_resp.__exit__ = MagicMock(return_value=False)
|
|
|
|
with patch("urllib.request.urlopen", return_value=mock_resp):
|
|
result = bridge_module.get_valid_token()
|
|
|
|
assert result == "ya29.refreshed"
|
|
# Verify persisted
|
|
saved = json.loads(token_path.read_text())
|
|
assert saved["token"] == "ya29.refreshed"
|
|
assert saved["type"] == "authorized_user"
|
|
|
|
|
|
def test_bridge_refresh_passes_timeout_to_urlopen(bridge_module):
|
|
"""Token refresh must pass an explicit timeout so a hung Google endpoint
|
|
cannot block the agent turn indefinitely (no `timeout=` defaults to the
|
|
global socket timeout, which is unset)."""
|
|
past = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat()
|
|
token_path = bridge_module.get_token_path()
|
|
_write_token(token_path, token="ya29.old", expiry=past)
|
|
|
|
mock_resp = MagicMock()
|
|
mock_resp.read.return_value = json.dumps({
|
|
"access_token": "ya29.refreshed",
|
|
"expires_in": 3600,
|
|
}).encode()
|
|
mock_resp.__enter__ = lambda s: s
|
|
mock_resp.__exit__ = MagicMock(return_value=False)
|
|
|
|
with patch("urllib.request.urlopen", return_value=mock_resp) as mocked:
|
|
bridge_module.get_valid_token()
|
|
|
|
assert mocked.call_count == 1
|
|
_, kwargs = mocked.call_args
|
|
assert kwargs.get("timeout") is not None, (
|
|
"urlopen call must pass timeout= to avoid hanging on unreachable upstream"
|
|
)
|
|
|
|
|
|
def test_bridge_refresh_exits_cleanly_on_network_error(bridge_module):
|
|
"""URLError/timeout during refresh exits 1 with a readable message
|
|
instead of crashing with a raw traceback."""
|
|
import urllib.error
|
|
|
|
past = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat()
|
|
token_path = bridge_module.get_token_path()
|
|
_write_token(token_path, token="ya29.old", expiry=past)
|
|
|
|
with patch(
|
|
"urllib.request.urlopen",
|
|
side_effect=urllib.error.URLError("timed out"),
|
|
):
|
|
with pytest.raises(SystemExit) as exc_info:
|
|
bridge_module.get_valid_token()
|
|
|
|
assert exc_info.value.code == 1
|
|
|
|
|
|
def test_bridge_exits_on_missing_token(bridge_module):
|
|
"""Missing token file causes exit with code 1."""
|
|
with pytest.raises(SystemExit):
|
|
bridge_module.get_valid_token()
|
|
|
|
|
|
def test_bridge_main_injects_token_env(bridge_module, tmp_path):
|
|
"""main() sets GOOGLE_WORKSPACE_CLI_TOKEN in subprocess env."""
|
|
future = (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat()
|
|
token_path = bridge_module.get_token_path()
|
|
_write_token(token_path, token="ya29.injected", expiry=future)
|
|
|
|
captured = {}
|
|
|
|
def capture_run(cmd, **kwargs):
|
|
captured["cmd"] = cmd
|
|
captured["env"] = kwargs.get("env", {})
|
|
return MagicMock(returncode=0)
|
|
|
|
with patch.object(sys, "argv", ["gws_bridge.py", "gmail", "+triage"]):
|
|
with patch.object(subprocess, "run", side_effect=capture_run):
|
|
with pytest.raises(SystemExit):
|
|
bridge_module.main()
|
|
|
|
assert captured["env"]["GOOGLE_WORKSPACE_CLI_TOKEN"] == "ya29.injected"
|
|
assert captured["cmd"] == ["gws", "gmail", "+triage"]
|
|
|
|
|
|
def test_api_calendar_list_uses_events_list(api_module):
|
|
"""calendar_list calls _run_gws with events list + params."""
|
|
captured = {}
|
|
|
|
def capture_run(cmd, **kwargs):
|
|
captured["cmd"] = cmd
|
|
return MagicMock(returncode=0, stdout="{}", stderr="")
|
|
|
|
args = api_module.argparse.Namespace(
|
|
start="", end="", max=25, calendar="primary", func=api_module.calendar_list,
|
|
)
|
|
|
|
with patch.object(api_module.subprocess, "run", side_effect=capture_run):
|
|
api_module.calendar_list(args)
|
|
|
|
cmd = captured["cmd"]
|
|
# _gws_binary() returns "/usr/bin/gws", so cmd[0] is that binary
|
|
assert cmd[0] == "/usr/bin/gws"
|
|
assert "calendar" in cmd
|
|
assert "events" in cmd
|
|
assert "list" in cmd
|
|
assert "--params" in cmd
|
|
params = json.loads(cmd[cmd.index("--params") + 1])
|
|
assert "timeMin" in params
|
|
assert "timeMax" in params
|
|
assert params["calendarId"] == "primary"
|
|
|
|
|
|
def test_api_calendar_list_respects_date_range(api_module):
|
|
"""calendar list with --start/--end passes correct time bounds."""
|
|
captured = {}
|
|
|
|
def capture_run(cmd, **kwargs):
|
|
captured["cmd"] = cmd
|
|
return MagicMock(returncode=0, stdout="{}", stderr="")
|
|
|
|
args = api_module.argparse.Namespace(
|
|
start="2026-04-01T00:00:00Z",
|
|
end="2026-04-07T23:59:59Z",
|
|
max=25,
|
|
calendar="primary",
|
|
func=api_module.calendar_list,
|
|
)
|
|
|
|
with patch.object(api_module.subprocess, "run", side_effect=capture_run):
|
|
api_module.calendar_list(args)
|
|
|
|
cmd = captured["cmd"]
|
|
params_idx = cmd.index("--params")
|
|
params = json.loads(cmd[params_idx + 1])
|
|
assert params["timeMin"] == "2026-04-01T00:00:00Z"
|
|
assert params["timeMax"] == "2026-04-07T23:59:59Z"
|
|
|
|
|
|
def test_api_get_credentials_refresh_persists_authorized_user_type(api_module, monkeypatch):
|
|
token_path = api_module.TOKEN_PATH
|
|
_write_token(token_path, token="ya29.old")
|
|
|
|
class FakeCredentials:
|
|
def __init__(self):
|
|
self.expired = True
|
|
self.refresh_token = "1//refresh"
|
|
self.valid = True
|
|
|
|
def refresh(self, request):
|
|
self.expired = False
|
|
|
|
def to_json(self):
|
|
return json.dumps({
|
|
"token": "ya29.refreshed",
|
|
"refresh_token": "1//refresh",
|
|
"client_id": "123.apps.googleusercontent.com",
|
|
"client_secret": "secret",
|
|
"token_uri": "https://oauth2.googleapis.com/token",
|
|
})
|
|
|
|
class FakeCredentialsModule:
|
|
@staticmethod
|
|
def from_authorized_user_file(filename, scopes):
|
|
assert filename == str(token_path)
|
|
assert scopes == api_module.SCOPES
|
|
return FakeCredentials()
|
|
|
|
google_module = types.ModuleType("google")
|
|
oauth2_module = types.ModuleType("google.oauth2")
|
|
credentials_module = types.ModuleType("google.oauth2.credentials")
|
|
credentials_module.Credentials = FakeCredentialsModule
|
|
transport_module = types.ModuleType("google.auth.transport")
|
|
requests_module = types.ModuleType("google.auth.transport.requests")
|
|
requests_module.Request = lambda: object()
|
|
|
|
monkeypatch.setitem(sys.modules, "google", google_module)
|
|
monkeypatch.setitem(sys.modules, "google.oauth2", oauth2_module)
|
|
monkeypatch.setitem(sys.modules, "google.oauth2.credentials", credentials_module)
|
|
monkeypatch.setitem(sys.modules, "google.auth.transport", transport_module)
|
|
monkeypatch.setitem(sys.modules, "google.auth.transport.requests", requests_module)
|
|
|
|
creds = api_module.get_credentials()
|
|
|
|
saved = json.loads(token_path.read_text())
|
|
assert isinstance(creds, FakeCredentials)
|
|
assert saved["token"] == "ya29.refreshed"
|
|
assert saved["type"] == "authorized_user"
|