Skip to content

Commit 0d03f1c

Browse files
rustyconoverclaude
andcommitted
Initial commit: vgi-google — query Google APIs from SQL
A VGI worker (Python) exposing Google APIs as DuckDB table functions: - Discovery-backed client factory (google-api-python-client + google-auth), building a client for any Google REST API from its discovery document, with a generic dotted-method caller, per-call timeout, and bounded retry/backoff on 429/5xx and rateLimitExceeded/quotaExceeded. - Curated READ adapters as table functions: google_sheet, google_drive, google_calendar, google_youtube — each mapping the API response to a clean, typed Arrow schema (TIMESTAMPTZ / LIST<VARCHAR> / JSON-tagged columns). - Generic escape hatch google_call(api, version, method, params_json) and the discovery helpers google_apis() / google_methods(api, version). - Service-account-first auth (API key for public APIs) via the VGI secret provider, never inline; least-privilege scopes declared per adapter. - Pagination via Google's nextPageToken carried as serializable scan state, one page per process tick; paged functions pinned to a single worker so a parallel scan cannot re-emit and duplicate rows. Tested with pytest (parsers + auth + discovery + mock E2E) and a haybarn SQL E2E driven against a canned, paginated transport (no live Google in the CI gate). The centerpiece asserts the pageToken scan-state round-trips across a batch boundary (every row exactly once), with a companion test showing why the single-worker pin prevents duplication. ruff + mypy clean. v1 is READ-only; Sheets write and the OAuth-user flow are documented follow-ups. Worker MIT; google-api-python-client / google-auth are Apache-2.0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
0 parents  commit 0d03f1c

33 files changed

Lines changed: 3894 additions & 0 deletions

.github/workflows/ci.yml

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
paths-ignore: ['README.md']
7+
pull_request:
8+
branches: [main]
9+
paths-ignore: ['README.md']
10+
workflow_dispatch:
11+
12+
permissions:
13+
contents: read
14+
15+
jobs:
16+
test:
17+
name: Python ${{ matrix.python-version }} on ${{ matrix.os }}
18+
runs-on: ${{ matrix.os }}
19+
strategy:
20+
fail-fast: false
21+
matrix:
22+
os: [ubuntu-latest, macos-latest]
23+
python-version: ["3.13"]
24+
25+
steps:
26+
- uses: actions/checkout@v4
27+
28+
- name: Install uv
29+
uses: astral-sh/setup-uv@v3
30+
31+
- name: Set up Python ${{ matrix.python-version }}
32+
run: uv python install ${{ matrix.python-version }}
33+
34+
# Install from PyPI rather than the local ../vgi-python path used for
35+
# `uv run` during development, so CI is self-contained.
36+
- name: Install dependencies
37+
run: |
38+
uv venv
39+
uv pip install vgi-python "google-api-python-client>=2.100" "google-auth>=2.20" pyarrow pytest
40+
uv pip install -e . --no-deps
41+
42+
# Parser + auth + discovery + mock E2E; NO live Google API.
43+
- name: Run unit tests
44+
run: uv run --no-sync pytest
45+
46+
e2e:
47+
name: SQL end-to-end (haybarn, mock transport)
48+
runs-on: ubuntu-latest
49+
steps:
50+
- uses: actions/checkout@v4
51+
52+
- name: Install uv
53+
uses: astral-sh/setup-uv@v3
54+
55+
- name: Set up Python 3.13
56+
run: uv python install 3.13
57+
58+
# Self-contained: install runtime deps into a venv from PyPI (not the local
59+
# ../vgi-python dev path), so the worker can be launched as
60+
# `.venv/bin/python google_worker.py` (plain python ignores the PEP-723
61+
# header that pins the dev path).
62+
- name: Install worker dependencies
63+
run: |
64+
uv venv
65+
uv pip install vgi-python "google-api-python-client>=2.100" "google-auth>=2.20" pyarrow
66+
uv pip install -e . --no-deps
67+
68+
- name: Install haybarn-unittest
69+
run: |
70+
uv tool install haybarn-unittest
71+
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
72+
73+
# Driven against the canned mock transport (VGI_GOOGLE_MOCK=1) — no live API.
74+
- name: Run SQL end-to-end tests
75+
run: make test-sql WORKER_STDIO=".venv/bin/python google_worker.py"
76+
77+
lint:
78+
name: Lint
79+
runs-on: ubuntu-latest
80+
steps:
81+
- uses: actions/checkout@v4
82+
83+
- name: Install uv
84+
uses: astral-sh/setup-uv@v3
85+
86+
- name: Install tooling
87+
run: |
88+
uv venv
89+
uv pip install vgi-python "google-api-python-client>=2.100" "google-auth>=2.20" pyarrow ruff mypy
90+
uv pip install -e . --no-deps
91+
92+
- name: Check linting
93+
run: uv run --no-sync ruff check .
94+
95+
- name: Type check (mypy)
96+
run: uv run --no-sync mypy vgi_google

.gitignore

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
__pycache__/
2+
*.pyc
3+
.venv/
4+
.mypy_cache/
5+
.pytest_cache/
6+
.ruff_cache/
7+
.claude/
8+
vendor/
9+
duckdb_unittest_tempdir/
10+
*.coverage
11+
.coverage
12+
.cache/
13+
uv.lock

.python-version

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
3.13

CLAUDE.md

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
# CLAUDE.md — vgi-google
2+
3+
Guidance for working in this repo. vgi-google is a **VGI worker** (Python) that
4+
queries **Google APIs from DuckDB/SQL**: a discovery-driven core, curated table
5+
adapters (Sheets / Drive / Calendar / YouTube), and a generic `google_call`
6+
escape hatch. It is an **egress / commodity** connector — the value is upstream
7+
in Google's APIs (quotas/billing), not in the worker; the durable cleverness is
8+
the discovery-driven breadth, not a moat. Keep the README's honest framing
9+
intact.
10+
11+
## What this is
12+
13+
- A VGI worker on the `vgi-python` SDK, launched by DuckDB (`ATTACH ... (TYPE vgi,
14+
LOCATION '...')`) as a stdio subprocess, or over HTTP via `serve.py`.
15+
- The SQL surface is seven **table functions** in the `google` catalog assembled
16+
in `google_worker.py`: `google_sheet`, `google_drive`, `google_calendar`,
17+
`google_youtube`, `google_call`, `google_apis`, `google_methods`.
18+
- v1 is **READ only**. Sheets write and the OAuth-user flow are documented
19+
follow-ups, NOT built.
20+
21+
## Layout
22+
23+
- `vgi_google/discovery.py` — the discovery-backed client factory
24+
(`build_service` via `google-api-python-client`'s `build` / `build_from_document`),
25+
generic dotted-method traversal (`resolve_method`), `execute` (timeout +
26+
bounded 429/5xx/quota retry), and discovery introspection (`list_apis`,
27+
`fetch_discovery_doc`, `list_methods`).
28+
- `vgi_google/auth.py` — credential resolution (service-account-first, then API
29+
key) via the VGI secret provider; env hatches for tests only.
30+
- `vgi_google/client.py``build_client(api, version, secrets, scopes)` ties auth
31+
+ discovery together, and holds the **test seam** `set_http_factory`.
32+
- `vgi_google/adapters/*` — one curated adapter per API (`api`/`version`/`scopes`/
33+
`schema` + `fetch_page(service, args, cursor) -> Page`).
34+
- `vgi_google/tables.py` — the table functions; `_run_paged_tick` is the shared
35+
one-page-per-tick driver; `_ScanState` is the serializable pagination cursor.
36+
- `vgi_google/schema_utils.py``afield` / `json_field` / `rows_to_batch` and
37+
the pinned `TIMESTAMPTZ` / `LIST_VARCHAR` types + `parse_*` helpers.
38+
39+
## VGI conventions that matter here
40+
41+
- **Table functions take `name := value` named args; scalars are positional-only.**
42+
Every function here is a table function. Positional args CANNOT have a default
43+
(DuckDB always binds them) — `google_call`'s `params_json` is positional and
44+
required; everything optional is a named `Arg("name", default=...)`.
45+
- **LIST / TIMESTAMPTZ / JSON returns REQUIRE explicit `arrow_type`.** Pinned once
46+
in `schema_utils.py`. JSON columns are plain string Arrow fields tagged with
47+
`metadata={b"logical_type": b"JSON"}`; read them in SQL with the `json`
48+
extension's `->>` (the `.test` file `LOAD json;` first).
49+
- **Do NOT request secrets in `on_bind`.** Using `@bind_fixed_schema` AND calling
50+
`params.secrets.get(...)` in a custom `on_bind` triggers a two-phase bind retry
51+
that returns an EMPTY output schema (every batch comes back 0-row). Resolve
52+
secrets lazily in `process()` via `params.secrets` instead — `auth.resolve`
53+
tolerates a missing/empty accessor. (`google_call` keeps an `on_bind` only to
54+
validate `params_json`, and does not touch secrets there.)
55+
- **Scan state must be serializable.** `_ScanState` extends
56+
`ArrowSerializableDataclass`; the framework round-trips Google's
57+
`nextPageToken` across `process()` ticks (and HTTP requests). One page per
58+
tick; `count` caps total rows.
59+
- **Pin paged functions to one worker.** Every paged table function is
60+
`@init_single_worker`. Without it, parallel scan instances each re-emit the
61+
whole result and DUPLICATE rows (the bug that bit vgi-search / vgi-wikipedia).
62+
`tests/test_mock_e2e.py::test_single_worker_pin_prevents_duplication` documents
63+
why.
64+
- **Never crash the worker.** `discovery.execute` raises `GoogleApiError`;
65+
`auth.resolve` raises `AuthError`; `process()` converts both (and `ValueError`)
66+
into a clean `RuntimeError` → DuckDB error.
67+
68+
## The discovery / test seam
69+
70+
`build_from_document` builds a real client from a static discovery doc, and an
71+
injectable `httplib2`-style transport serves canned responses — so the SAME
72+
adapter code runs in tests as live. `client.set_http_factory(factory)` installs
73+
`(api, version) -> (http, discovery_doc)`; tests use `tests/mock_google.py`'s
74+
`http_factory`. The worker subprocess enables it via `VGI_GOOGLE_MOCK=1` (see
75+
`google_worker._maybe_install_mock`). `google_apis`/`google_methods` read static
76+
docs from `VGI_GOOGLE_DISCOVERY_DIR`.
77+
78+
Note: `execute` json-decodes any raw bytes/str body, so adapters get a dict even
79+
when a minimal discovery doc omits a method's `response` schema.
80+
81+
## Auth
82+
83+
Service-account-first, via `params.secrets` only — never inline. Secret types:
84+
`google_service_account` (JSON key; optional `subject` for delegation, `scopes`
85+
to narrow) and `google_api_key`. Each adapter declares least-privilege `scopes`
86+
(documented in the README table). Env hatches `VGI_GOOGLE_SERVICE_ACCOUNT_FILE` /
87+
`VGI_GOOGLE_API_KEY` are for tests/local only.
88+
89+
## Adding a curated adapter
90+
91+
1. `vgi_google/adapters/<name>.py`: a class with `api`/`version`/`scopes`/`schema`
92+
and `fetch_page(service, args, cursor) -> Page` mapping the API response to
93+
dict rows + `next_cursor` (Google's `nextPageToken`).
94+
2. Register it in `adapters/__init__.py`; add a `Google<Name>Function` in
95+
`tables.py` (`@init_single_worker @bind_fixed_schema`, a `_<Name>Args`
96+
dataclass, `process` delegating to `_run_paged_tick`) and to `TABLE_FUNCTIONS`.
97+
3. Add a discovery doc to `tests/mock_google.py` `DISCOVERY_DOCS` + canned
98+
responses, a parser test in `tests/test_adapters.py`, and `.test` coverage.
99+
100+
## Testing (NO live Google in the CI gate)
101+
102+
- `make test-unit` / `pytest` — parser tests (`test_adapters.py`), auth
103+
(`test_auth.py`), discovery (`test_discovery.py`), mock E2E (`test_mock_e2e.py`,
104+
incl. the pageToken round-trip + single-worker-pin proofs).
105+
- `make test-sql` — the real worker subprocess under DuckDB via `haybarn-unittest`,
106+
launched with `VGI_GOOGLE_MOCK=1` by `scripts/run_sql_e2e.py`. The `.test` file
107+
uses `LOAD vgi;` (NEVER `require vgi`), `require-env VGI_GOOGLE_WORKER`, ATTACHes
108+
`${VGI_GOOGLE_WORKER}`, and globs `test/sql/*`.
109+
- `make lint` (ruff) + `make typecheck` (mypy `vgi_google`) must be clean.
110+
- Live smoke (`tests/test_live_smoke.py`) is GATED by `VGI_GOOGLE_LIVE=1`, not in
111+
the CI gate.
112+
113+
When changing pagination or a schema, re-run BOTH `make test-unit` and
114+
`make test-sql` — the scan-state round-trip is asserted in both.
115+
116+
## Licensing
117+
118+
Worker code is MIT (`LICENSE`). `google-api-python-client` and `google-auth` are
119+
Apache-2.0; httplib2 / google-auth-httplib2 are permissive. No GPL/AGPL. Quotas,
120+
billing and Google's ToS are the user's responsibility.

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 Query Farm LLC https://query.farm
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

Makefile

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# vgi-google worker -- dev and test targets.
2+
#
3+
# Usage:
4+
# make test # unit (pytest) + SQL end-to-end (haybarn-unittest, mock transport)
5+
# make test-unit # pytest only (fixture parsers + mock E2E, no live network)
6+
# make test-sql # SQL end-to-end only (haybarn glob, driven against the mock transport)
7+
# make lint # ruff
8+
# make typecheck # mypy
9+
#
10+
# The SQL suite drives the worker as a real subprocess over stdio: haybarn-unittest
11+
# ATTACHes `${VGI_GOOGLE_WORKER}`, then runs the .test files in test/sql/. The
12+
# worker is launched with VGI_GOOGLE_MOCK=1 so its adapters build from static
13+
# discovery docs and are served canned, paginated responses (tests/mock_google.py),
14+
# so NOTHING in the CI gate touches a live Google API.
15+
16+
# Worker stdio command (overridable). The PEP-723 header in google_worker.py
17+
# pins the google client libs, so `uv run` gives the worker its dependencies.
18+
WORKER_STDIO ?= uv run --python 3.13 google_worker.py
19+
20+
# haybarn-unittest: the DuckDB sqllogictest runner (uv tool install haybarn-unittest).
21+
HAYBARN ?= haybarn-unittest
22+
TEST_DIR = .
23+
TEST_PATTERN = test/sql/*
24+
25+
.PHONY: test test-unit test-sql pytest lint typecheck
26+
27+
test: test-unit test-sql
28+
29+
test-unit: pytest
30+
31+
pytest:
32+
uv run --no-sync pytest -q
33+
34+
# End-to-end SQL: a tiny Python launcher enables the mock transport (and a temp
35+
# discovery dir for the discovery helpers), then runs the haybarn glob with the
36+
# worker command exported.
37+
test-sql:
38+
@command -v $(HAYBARN) >/dev/null 2>&1 || { \
39+
echo "ERROR: $(HAYBARN) not found. Install it with:" >&2; \
40+
echo " uv tool install haybarn-unittest" >&2; \
41+
echo " (then ensure ~/.local/bin is on PATH)" >&2; \
42+
exit 1; \
43+
}
44+
VGI_GOOGLE_WORKER="$(WORKER_STDIO)" \
45+
HAYBARN="$(HAYBARN)" TEST_DIR="$(TEST_DIR)" TEST_PATTERN="$(TEST_PATTERN)" \
46+
uv run --no-sync python scripts/run_sql_e2e.py
47+
48+
lint:
49+
uv run --no-sync ruff check .
50+
51+
typecheck:
52+
uv run --no-sync mypy vgi_google

0 commit comments

Comments
 (0)