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
29 changes: 29 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
__pycache__/
*.pyc
.pytest_cache/
.ruff_cache/
.venv/
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
45 changes: 35 additions & 10 deletions __init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,39 +2,50 @@

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
auto-replies; a human reviews drafts and sends them. Credentials come from plugin
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

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).")
Expand All @@ -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)

Expand All @@ -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)

Expand All @@ -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)

Expand All @@ -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)
Expand All @@ -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)


Expand All @@ -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)


Expand All @@ -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", ""),
Expand All @@ -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
Expand Down
12 changes: 7 additions & 5 deletions calendar.py → gcal.py
Original file line number Diff line number Diff line change
@@ -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"

Expand Down
6 changes: 1 addition & 5 deletions gmail.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
1 change: 1 addition & 0 deletions protoagent.plugin.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"]
7 changes: 7 additions & 0 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
@@ -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
46 changes: 46 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -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()
6 changes: 0 additions & 6 deletions tests/pytest.ini

This file was deleted.

Loading
Loading