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 "