diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..00266cb --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: CI + +# Lint + test the plugin, host-free. Runs on the org's Namespace Linux profile; +# override the NSC_RUNNER repo variable to fall back to a hosted runner on a fork. +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +jobs: + test: + runs-on: ${{ vars.NSC_RUNNER || 'namespace-profile-protolabs-linux' }} + steps: + - uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dev deps + run: pip install -r requirements-dev.txt ruff + + - name: Lint + run: ruff check . && ruff format --check tests/ + + - name: Test + run: pytest -q diff --git a/.gitignore b/.gitignore index 75c6182..2d28832 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ __pycache__/ *.pyc .pytest_cache/ +.ruff_cache/ +.venv/ diff --git a/README.md b/README.md index 17e129e..8524626 100644 --- a/README.md +++ b/README.md @@ -22,8 +22,9 @@ python -m server plugin install https://github.com/protoLabsAI/google-plugin ``` ## Test +Host-free — no protoAgent checkout needed: ```bash -cd /path/to/protoAgent && uv run --frozen python -m pytest /path/to/google-plugin/tests -q +pip install -r requirements-dev.txt && pytest -q ``` Ported from the `gmail_*` / `calendar_*` tools of protoWorkstacean's Ava agent. diff --git a/__init__.py b/__init__.py index c010525..63c80cc 100644 --- a/__init__.py +++ b/__init__.py @@ -2,7 +2,7 @@ Ports Ava's Google tools from protoWorkstacean. Deliberately NOT pigeonholed as "Gmail + Calendar": a service-agnostic auth core (``auth.py``) backs one module per -service (``gmail.py``, ``calendar.py`` — Drive / Docs / Sheets drop in the same way), +service (``gmail.py``, ``gcal.py`` — Drive / Docs / Sheets drop in the same way), so expanding to the wider Workspace is new tool functions, not a re-architecture. Pull-mode posture: the agent lists, searches, reads, and DRAFTS — it never sends or @@ -10,6 +10,10 @@ config (``google.*``) with env fallbacks (``GOOGLE_CLIENT_ID`` / ``GOOGLE_CLIENT_SECRET`` / ``GOOGLE_REFRESH_TOKEN``). Mint the refresh token with the full scope set you intend to grow into so adding a service needs no re-consent. + +NOTE: no top-level relative imports here — pytest imports a repo-root ``__init__.py`` +as a nameless top-level module during package setup, where ``from . import x`` +explodes. Service imports live inside the functions that use them (pinned by a test). """ from __future__ import annotations @@ -17,24 +21,31 @@ import json import logging import os +from typing import TYPE_CHECKING from langchain_core.tools import tool -from . import calendar as cal -from . import gmail -from .auth import Creds, GoogleError +if TYPE_CHECKING: + from .auth import Creds log = logging.getLogger("protoagent.plugins.google") -_CREDS = Creds("", "", "") +_CREDS: Creds | None = None def _creds() -> Creds: + global _CREDS + if _CREDS is None: + from .auth import Creds + + _CREDS = Creds("", "", "") return _CREDS def _run(fn, *args, **kwargs): """Call a service fn, turning config/API errors into a readable tool string.""" + from .auth import GoogleError + if not _creds().configured(): return ("Google isn't configured. Set google.client_id / client_secret / refresh_token " "(or GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET / GOOGLE_REFRESH_TOKEN).") @@ -57,6 +68,8 @@ def gmail_list_unread(label: str = "INBOX", max: int = 20) -> str: label: Gmail label name (e.g. INBOX, "Personal/Work"). max: max messages (default 20, cap 100). """ + from . import gmail + out = _run(gmail.list_messages, _creds(), f"label:{label} is:unread", max) return out if isinstance(out, str) else json.dumps({"label": label, "count": len(out), "messages": out}, indent=2) @@ -69,6 +82,8 @@ def gmail_search(query: str, max: int = 20) -> str: query: a Gmail search query. max: max messages (default 20, cap 100). """ + from . import gmail + out = _run(gmail.list_messages, _creds(), query, max) return out if isinstance(out, str) else json.dumps({"query": query, "count": len(out), "messages": out}, indent=2) @@ -80,6 +95,8 @@ def gmail_get_thread(thread_id: str) -> str: Args: thread_id: the Gmail thread id. """ + from . import gmail + out = _run(gmail.get_thread, _creds(), thread_id) return out if isinstance(out, str) else json.dumps({"threadId": thread_id, "count": len(out), "messages": out}, indent=2) @@ -100,6 +117,8 @@ def gmail_create_draft(body: str, thread_id: str = "", to: str = "", subject: st in_reply_to: Message-ID being replied to (optional). references: References header (optional). """ + from . import gmail + if not thread_id and not (to and subject): return "Provide either thread_id (to reply) or both to + subject (for a new draft)." out = _run(gmail.create_draft, _creds(), body, thread_id, to, subject, in_reply_to, references) @@ -118,7 +137,9 @@ def calendar_list_upcoming(days: int = 7, calendar_id: str = "primary") -> str: days: lookahead window in days (default 7, cap 90). calendar_id: calendar id (default "primary"). """ - out = _run(cal.list_upcoming, _creds(), days, calendar_id) + from . import gcal + + out = _run(gcal.list_upcoming, _creds(), days, calendar_id) return out if isinstance(out, str) else json.dumps({"calendarId": calendar_id, "days": days, "count": len(out), "events": out}, indent=2) @@ -130,7 +151,9 @@ def calendar_event_detail(event_id: str, calendar_id: str = "primary") -> str: event_id: the event id. calendar_id: calendar id (default "primary"). """ - out = _run(cal.event_detail, _creds(), event_id, calendar_id) + from . import gcal + + out = _run(gcal.event_detail, _creds(), event_id, calendar_id) return out if isinstance(out, str) else json.dumps(out, indent=2) @@ -144,6 +167,8 @@ def calendar_event_detail(event_id: str, calendar_id: str = "primary") -> str: def register(registry) -> None: """Entry point — called once per graph build with the live config.""" global _CREDS + from .auth import Creds + cfg = registry.config or {} _CREDS = Creds( client_id=cfg.get("client_id") or os.environ.get("GOOGLE_CLIENT_ID", ""), @@ -155,10 +180,10 @@ def register(registry) -> None: # Console view: public page (/plugins/google/view) + gated data (/api/plugins/google/*). try: - from . import calendar as cal - from . import gmail + from . import gcal, gmail from .view import build_router - page, data = build_router(_creds, gmail, cal) + + page, data = build_router(_creds, gmail, gcal) registry.register_router(page) # default prefix /plugins/google (public via public_paths) registry.register_router(data, prefix="/api/plugins/google") except Exception: # noqa: BLE001 — view is best-effort diff --git a/calendar.py b/gcal.py similarity index 88% rename from calendar.py rename to gcal.py index bc4a1ef..71ee028 100644 --- a/calendar.py +++ b/gcal.py @@ -1,13 +1,15 @@ -"""Calendar service — read-only. Built on the shared auth core.""" +"""Calendar service — read-only. Built on the shared auth core. + +Named ``gcal`` (not ``calendar``) so it can never shadow the stdlib ``calendar`` +module — stdlib ``email`` imports ``calendar``, so a root-level ``calendar.py`` +breaks any Python started from this directory. +""" from __future__ import annotations from datetime import datetime, timedelta, timezone -try: - from .auth import Creds, request -except ImportError: # pragma: no cover — flat import path for unit tests - from auth import Creds, request +from .auth import Creds, request BASE = "https://www.googleapis.com/calendar/v3/calendars" diff --git a/gmail.py b/gmail.py index 89ff749..42516b2 100644 --- a/gmail.py +++ b/gmail.py @@ -8,11 +8,7 @@ import base64 from email.utils import formatdate -# Dual import: relative at runtime (loaded as the ``google`` package), flat in tests. -try: - from .auth import Creds, request -except ImportError: # pragma: no cover — flat import path for unit tests - from auth import Creds, request +from .auth import Creds, request BASE = "https://gmail.googleapis.com/gmail/v1/users/me" diff --git a/protoagent.plugin.yaml b/protoagent.plugin.yaml index c0c0422..6d7bed5 100644 --- a/protoagent.plugin.yaml +++ b/protoagent.plugin.yaml @@ -11,6 +11,7 @@ description: >- mint it with the full scope set you intend to grow into (e.g. gmail.modify, calendar, drive, documents, spreadsheets). enabled: false +min_protoagent_version: "0.71.0" # manifest public_paths (#1345) capabilities: network: [oauth2.googleapis.com, gmail.googleapis.com, www.googleapis.com] filesystem: none diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4a4be92 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,23 @@ +[project] +name = "google-plugin" +version = "0.1.0" +description = "Google Workspace plugin for protoAgent — Gmail (read + draft) + Calendar (read) over a service-agnostic OAuth/REST core, built to grow." +requires-python = ">=3.11" + +# Runtime deps come from the protoAgent host (langchain-core, fastapi, httpx) — +# this plugin adds none, so the manifest's requires_pip stays empty. + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "--import-mode=importlib" + +[tool.ruff] +line-length = 120 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "W"] +ignore = ["E402", "E501", "E731", "E741"] + +[tool.ruff.lint.per-file-ignores] +"**/__init__.py" = ["F401"] diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..2c8a6d0 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,7 @@ +# Test-only deps. At runtime langchain-core + fastapi + httpx are provided by the +# host (protoAgent); these let the suite run standalone in CI with no host. +fastapi>=0.110 +langchain-core>=0.2 +pyyaml>=6 +httpx>=0.27 +pytest>=8 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..0c8bb03 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,46 @@ +"""Test bootstrap — make the plugin importable with NO protoAgent host. + +The host loads the plugin as a package; the suite registers the same shape as a +synthetic package (``google_plugin`` — NOT ``google``, which is a real namespace +package owned by protobuf/googleapis) so the modules' relative imports +(``from .auth import …``) resolve standalone. Executing ``__init__.py`` needs only +langchain-core + fastapi from requirements-dev.txt; host-only imports stay lazy. +""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parent.parent +PKG = "google_plugin" + +if PKG not in sys.modules: + _spec = importlib.util.spec_from_file_location(PKG, ROOT / "__init__.py", submodule_search_locations=[str(ROOT)]) + assert _spec and _spec.loader + _mod = importlib.util.module_from_spec(_spec) + sys.modules[PKG] = _mod + _spec.loader.exec_module(_mod) + + +class FakeRegistry: + """Just enough registry to smoke-test register() with no host.""" + + def __init__(self, config: dict | None = None): + self.config = config or {} + self.tools: list = [] + self.routers: list = [] # (router, prefix) + + def register_tool(self, t): + self.tools.append(t) + + def register_router(self, router, prefix: str | None = None): + self.routers.append((router, prefix)) + + +@pytest.fixture +def registry(): + return FakeRegistry() diff --git a/tests/pytest.ini b/tests/pytest.ini deleted file mode 100644 index cec35f7..0000000 --- a/tests/pytest.ini +++ /dev/null @@ -1,6 +0,0 @@ -[pytest] -# Lives in tests/ so rootdir is tests/, not the plugin package root — pytest then -# never imports the plugin's __init__.py (whose relative imports resolve only at -# runtime). importlib mode imports test files by path. Tests load the service -# modules by file path; see test_google.py. -addopts = --import-mode=importlib diff --git a/tests/test_plugin.py b/tests/test_plugin.py new file mode 100644 index 0000000..b671a8a --- /dev/null +++ b/tests/test_plugin.py @@ -0,0 +1,131 @@ +"""Plugin-level tests — manifest coherence, register() wiring, tool wrappers, view rules. + +These test the ACTUAL registered surface: routers are mounted exactly as the host +mounts them (page at /plugins/google, data at /api/plugins/google) and the manifest's +views[].path is asserted against what the page router really serves. +""" + +from __future__ import annotations + +import ast +import sys +import tomllib +from pathlib import Path + +import google_plugin as plugin +import yaml +from fastapi import FastAPI +from fastapi.testclient import TestClient +from google_plugin import gcal, gmail, view +from google_plugin.auth import Creds + +ROOT = Path(__file__).resolve().parent.parent +MANIFEST = yaml.safe_load((ROOT / "protoagent.plugin.yaml").read_text()) +PYPROJECT = tomllib.loads((ROOT / "pyproject.toml").read_text()) + + +# ── Manifest coherence ──────────────────────────────────────────────────────── + + +def test_manifest_version_matches_pyproject(): + assert MANIFEST["version"] == PYPROJECT["project"]["version"] + + +def test_manifest_basics(): + assert MANIFEST["id"] == "google" + assert MANIFEST["enabled"] is False # enabling is the operator's trust decision + assert isinstance(MANIFEST["config_section"], str) + assert set(MANIFEST["secrets"]) <= set(MANIFEST["config"]) + assert MANIFEST["min_protoagent_version"] + + +def test_init_has_no_top_level_relative_imports(): + # pytest imports a repo-root __init__.py as a nameless top-level module during + # package setup — a top-level `from . import x` breaks the whole suite there. + # Relative imports in __init__.py must live inside functions. + tree = ast.parse((ROOT / "__init__.py").read_text()) + offenders = [n.lineno for n in tree.body if isinstance(n, ast.ImportFrom) and n.level > 0] + assert not offenders, f"top-level relative imports in __init__.py at lines {offenders}" + + +def test_no_stdlib_shadowing_module_names(): + # A root-level calendar.py breaks stdlib email (which imports calendar) for any + # Python started from this directory. Keep service modules off stdlib names. + stdlib = set(sys.stdlib_module_names) + for p in ROOT.glob("*.py"): + assert p.stem == "__init__" or p.stem not in stdlib, f"{p.name} shadows a stdlib module" + + +# ── register() wiring ───────────────────────────────────────────────────────── + + +def test_register_wires_tools_and_routers(registry): + registry.config = {"client_id": "cid", "client_secret": "cs", "refresh_token": "rt"} + plugin.register(registry) + assert {t.name for t in registry.tools} == { + "gmail_list_unread", + "gmail_search", + "gmail_get_thread", + "gmail_create_draft", + "calendar_list_upcoming", + "calendar_event_detail", + } + assert [p for _, p in registry.routers] == [None, "/api/plugins/google"] + assert plugin._creds().configured() + + +def test_every_tool_has_a_description(): + # An f-string "docstring" leaves __doc__ None → the tool ships undescribed. + for t in plugin.TOOLS: + assert t.description and len(t.description) > 20, t.name + + +def test_tools_hint_when_unconfigured(monkeypatch): + monkeypatch.setattr(plugin, "_CREDS", Creds("", "", "")) + out = plugin.gmail_list_unread.invoke({}) + assert "isn't configured" in out and "GOOGLE_CLIENT_ID" in out + + +def test_draft_tool_requires_target(monkeypatch): + monkeypatch.setattr(plugin, "_CREDS", Creds("c", "s", "r")) + out = plugin.gmail_create_draft.invoke({"body": "hi"}) + assert "thread_id" in out and "subject" in out + + +# ── Console view (the four rules) ───────────────────────────────────────────── + + +def _mounted_app() -> FastAPI: + """Mount the routers exactly as the host does for register()'s two calls.""" + page, data = view.build_router(plugin._creds, gmail, gcal) + app = FastAPI() + app.include_router(page, prefix="/plugins/google") + app.include_router(data, prefix="/api/plugins/google") + return app + + +def test_manifest_view_path_is_served_and_public(): + c = TestClient(_mounted_app()) + (view_decl,) = MANIFEST["views"] + assert view_decl["path"] in MANIFEST["public_paths"] + r = c.get(view_decl["path"]) + assert r.status_code == 200 and "Google Workspace" in r.text + # The page must NOT be served under the gated /api prefix. + assert c.get("/api" + view_decl["path"]).status_code == 404 + + +def test_data_routes_are_gated_prefix_and_degrade_unconfigured(monkeypatch): + monkeypatch.setattr(plugin, "_CREDS", Creds("", "", "")) + c = TestClient(_mounted_app()) + assert c.get("/api/plugins/google/status").json() == {"configured": False} + assert c.get("/api/plugins/google/unread").json() == {"messages": []} + assert c.get("/api/plugins/google/upcoming").json() == {"events": []} + + +def test_page_is_four_rules_compliant(): + page = view.PAGE + assert 'location.pathname.split("/plugins/")[0]' in page # slug-aware base + assert "/_ds/plugin-kit.css" in page and "/_ds/plugin-kit.js" in page # DS kit + assert "apiFetch" in page # authed fetch via the kit + assert ":root{" not in page and ":root {" not in page # no hand-rolled theme map + assert 'addEventListener("message"' not in page # kit owns the handshake diff --git a/tests/test_google.py b/tests/test_services.py similarity index 50% rename from tests/test_google.py rename to tests/test_services.py index bf3aaf8..67ad9cb 100644 --- a/tests/test_google.py +++ b/tests/test_services.py @@ -1,38 +1,18 @@ """Unit tests for the Google Workspace service layer — mocked httpx, no network. - cd ~/dev/protoAgent && uv run --frozen python -m pytest ~/dev/google-plugin -q - -Tests auth.py / gmail.py / calendar.py via flat imports (the modules' dual-import -fallback). The thin @tool wrappers in __init__.py are smoke-tested live after install. +Host-free: run `pip install -r requirements-dev.txt && pytest -q` from the repo root. +Modules import through the synthetic ``google_plugin`` package (see conftest.py). """ from __future__ import annotations import base64 -import importlib.util -import sys -from pathlib import Path import httpx import pytest -PLUGIN_DIR = Path(__file__).resolve().parent.parent - - -def _load(name: str, filename: str): - """Load a plugin module straight from its file — no package/relative-import - machinery, and no stdlib `calendar` shadow. `auth` must register under that - exact name so gmail/calendar's dual-import fallback (`from auth import …`) finds it.""" - spec = importlib.util.spec_from_file_location(name, PLUGIN_DIR / filename) - mod = importlib.util.module_from_spec(spec) - sys.modules[name] = mod - spec.loader.exec_module(mod) - return mod - +from google_plugin import auth, gcal, gmail -auth = _load("auth", "auth.py") -gmail = _load("gmail", "gmail.py") -cal = _load("calendar_svc", "calendar.py") Creds, GoogleError = auth.Creds, auth.GoogleError CREDS = Creds("cid", "csecret", "rtok") @@ -50,35 +30,75 @@ def _handler(request: httpx.Request) -> httpx.Response: return httpx.Response(200, json={"messages": [{"id": "m1"}, {"id": "m2"}]}) if path.endswith("/messages/m1") or path.endswith("/messages/m2"): mid = path.rsplit("/", 1)[-1] - return httpx.Response(200, json={ - "id": mid, "threadId": "t1", "snippet": f"snip-{mid}", - "payload": {"headers": [{"name": "From", "value": "alice@x.com"}, - {"name": "Subject", "value": f"Subj {mid}"}, - {"name": "Date", "value": "Wed, 25 Jun 2026 10:00:00 -0700"}]}, - }) + return httpx.Response( + 200, + json={ + "id": mid, + "threadId": "t1", + "snippet": f"snip-{mid}", + "payload": { + "headers": [ + {"name": "From", "value": "alice@x.com"}, + {"name": "Subject", "value": f"Subj {mid}"}, + {"name": "Date", "value": "Wed, 25 Jun 2026 10:00:00 -0700"}, + ] + }, + }, + ) if "/threads/" in path: - return httpx.Response(200, json={"messages": [{ - "id": "m1", "threadId": "t1", "snippet": "hello", - "payload": {"headers": [{"name": "From", "value": "bob@x.com"}, - {"name": "Subject", "value": "Re: hi"}, - {"name": "Message-ID", "value": ""}], - "parts": [{"mimeType": "text/plain", "body": {"data": _b64url("PLAIN BODY")}}]}, - }]}) + return httpx.Response( + 200, + json={ + "messages": [ + { + "id": "m1", + "threadId": "t1", + "snippet": "hello", + "payload": { + "headers": [ + {"name": "From", "value": "bob@x.com"}, + {"name": "Subject", "value": "Re: hi"}, + {"name": "Message-ID", "value": ""}, + ], + "parts": [{"mimeType": "text/plain", "body": {"data": _b64url("PLAIN BODY")}}], + }, + } + ] + }, + ) if path.endswith("/drafts"): return httpx.Response(200, json={"id": "d1", "message": {"id": "msg1", "threadId": "t1"}}) if path.endswith("/events"): - return httpx.Response(200, json={"items": [{ - "id": "e1", "summary": "Standup", - "start": {"dateTime": "2026-06-26T09:00:00-07:00"}, - "end": {"dateTime": "2026-06-26T09:15:00-07:00"}, - "attendees": [{"email": "a@x.com"}], "location": "Zoom", "htmlLink": "http://cal/e1"}]}) + return httpx.Response( + 200, + json={ + "items": [ + { + "id": "e1", + "summary": "Standup", + "start": {"dateTime": "2026-06-26T09:00:00-07:00"}, + "end": {"dateTime": "2026-06-26T09:15:00-07:00"}, + "attendees": [{"email": "a@x.com"}], + "location": "Zoom", + "htmlLink": "http://cal/e1", + } + ] + }, + ) if "/events/" in path: - return httpx.Response(200, json={ - "id": "e1", "summary": "Standup", "description": "daily", - "start": {"dateTime": "2026-06-26T09:00:00-07:00"}, - "end": {"dateTime": "2026-06-26T09:15:00-07:00"}, - "attendees": [{"email": "a@x.com", "displayName": "Al", "responseStatus": "accepted"}], - "organizer": {"email": "o@x.com", "displayName": "Org"}, "htmlLink": "http://cal/e1"}) + return httpx.Response( + 200, + json={ + "id": "e1", + "summary": "Standup", + "description": "daily", + "start": {"dateTime": "2026-06-26T09:00:00-07:00"}, + "end": {"dateTime": "2026-06-26T09:15:00-07:00"}, + "attendees": [{"email": "a@x.com", "displayName": "Al", "responseStatus": "accepted"}], + "organizer": {"email": "o@x.com", "displayName": "Org"}, + "htmlLink": "http://cal/e1", + }, + ) return httpx.Response(404, json={"error": path}) @@ -114,7 +134,9 @@ def test_gmail_thread_extracts_plain_body(client): def test_build_draft_raw_roundtrips_headers_and_body(): - decoded = base64.urlsafe_b64decode(gmail.build_draft_raw("hi there", to="x@y.com", subject="Hello").encode()).decode() + decoded = base64.urlsafe_b64decode( + gmail.build_draft_raw("hi there", to="x@y.com", subject="Hello").encode() + ).decode() assert "To: x@y.com" in decoded and "Subject: Hello" in decoded assert decoded.endswith("hi there") and "Content-Type: text/plain" in decoded @@ -126,9 +148,9 @@ def test_create_draft_resolves_thread_headers(client): def test_calendar_list_and_detail(client): - events = cal.list_upcoming(CREDS, days=7, client=client, now_iso="2026-06-25T00:00:00+00:00") + events = gcal.list_upcoming(CREDS, days=7, client=client, now_iso="2026-06-25T00:00:00+00:00") assert events[0]["title"] == "Standup" and events[0]["attendees"] == ["a@x.com"] - detail = cal.event_detail(CREDS, "e1", client=client) + detail = gcal.event_detail(CREDS, "e1", client=client) assert detail["description"] == "daily" assert detail["attendees"][0] == {"email": "a@x.com", "name": "Al", "status": "accepted"} assert detail["organizer"]["email"] == "o@x.com"