|
| 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. |
0 commit comments