From dec0ded7a8a22e0bef77a3ad8ec2f7a0319006b6 Mon Sep 17 00:00:00 2001 From: Joseph Ma Date: Thu, 18 Jun 2026 15:53:57 -0400 Subject: [PATCH 1/5] Feat: Sync SDK with v2 Platform API + add route-drift guard Brings the SDK back in line with compute/pkg/platform/v2/routes.go (26 routes): - Fix sandbox resource path /v2/sandboxes -> /v2/sandcastles (the backend renamed it with no alias; every sandbox call was 404ing). Verified live against staging. - Sandbox: add list/executions (cursor-paginated), optional sandbox_id on start, and tql_path/params/max_rows on query (exactly-one-of guard). - Connectors: add types/create/test/update/delete (config passed through as proto-JSON; types() is self-describing). - Bump 2.0.0 -> 2.1.0; CHANGELOG entry + backfilled 2.0.0 entry. Drift prevention (SDK half of Tier 1): - # v2:covers comment on all 26 methods + tests/test_route_coverage.py asserting SDK coverage == vendored tests/routes.manifest.json (byte-identical to the canonical manifest generated by the backend's TestRouteManifest golden test). - Scheduled route-sync workflow opens a PR when the backend manifest drifts. - respx unit tests for sandbox + connectors; live-staging coverage for the new read endpoints. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/route-sync.yml | 53 ++++++++++++ CHANGELOG.md | 17 ++++ src/textql/_version.py | 2 +- src/textql/resources/chat.py | 5 ++ src/textql/resources/connectors.py | 47 +++++++++++ src/textql/resources/playbooks.py | 7 ++ src/textql/resources/sandbox.py | 74 +++++++++++++++-- tests/routes.manifest.json | 28 +++++++ tests/test_connectors.py | 73 ++++++++++++++++ tests/test_integration.py | 15 ++++ tests/test_route_coverage.py | 26 ++++++ tests/test_sandbox.py | 128 +++++++++++++++++++++++++++++ 12 files changed, 465 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/route-sync.yml create mode 100644 tests/routes.manifest.json create mode 100644 tests/test_connectors.py create mode 100644 tests/test_route_coverage.py create mode 100644 tests/test_sandbox.py diff --git a/.github/workflows/route-sync.yml b/.github/workflows/route-sync.yml new file mode 100644 index 0000000..2918956 --- /dev/null +++ b/.github/workflows/route-sync.yml @@ -0,0 +1,53 @@ +name: Route Sync + +# Pulls the canonical v2 route manifest from the backend and opens a PR when it +# drifts from the vendored copy. The PR then fails test_route_coverage until the +# matching SDK methods + `# v2:covers` comments are added. Configure the repo +# variable BACKEND_MANIFEST_URL (raw URL of compute/pkg/platform/v2/routes.manifest.json) +# and, if the source is private, the BACKEND_REPO_TOKEN secret. + +on: + schedule: + - cron: "0 12 * * *" + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Fetch upstream manifest + id: fetch + env: + BACKEND_MANIFEST_URL: ${{ vars.BACKEND_MANIFEST_URL }} + BACKEND_REPO_TOKEN: ${{ secrets.BACKEND_REPO_TOKEN }} + run: | + if [ -z "$BACKEND_MANIFEST_URL" ]; then + echo "BACKEND_MANIFEST_URL not set; skipping" + echo "changed=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + auth=() + if [ -n "$BACKEND_REPO_TOKEN" ]; then auth=(-H "Authorization: Bearer $BACKEND_REPO_TOKEN"); fi + curl -fsSL "${auth[@]}" "$BACKEND_MANIFEST_URL" -o upstream.json + changed=$(python -c "import json,sys; a=set(json.load(open('upstream.json'))); b=set(json.load(open('tests/routes.manifest.json'))); print('true' if a!=b else 'false')") + if [ "$changed" = "true" ]; then + python -c "import json; json.dump(sorted(json.load(open('upstream.json'))), open('tests/routes.manifest.json','w'), indent=2); open('tests/routes.manifest.json','a').write('\n')" + fi + echo "changed=$changed" >> "$GITHUB_OUTPUT" + - name: Open PR + if: steps.fetch.outputs.changed == 'true' + uses: peter-evans/create-pull-request@v6 + with: + branch: route-sync + add-paths: tests/routes.manifest.json + commit-message: "chore: sync v2 route manifest from backend" + title: "chore: sync v2 route manifest from backend" + body: | + The backend `/v2` route set changed. Add/rename the matching SDK + methods and their `# v2:covers` comments until `test_route_coverage` + passes. diff --git a/CHANGELOG.md b/CHANGELOG.md index b70466c..001e72f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,23 @@ All notable changes to the `textql` Python SDK will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.1.0] — 2026-06-18 + +### Fixed +- Sandbox resource now targets `/v2/sandcastles`; the backend renamed the path from `/v2/sandboxes` with no alias, so every sandbox call was 404ing. + +### Added +- Sandbox: `list` and `executions` (both cursor-paginated), and an optional `sandbox_id` on `start` to restart a specific sandbox. +- Sandbox `query`: `tql_path` (run a saved library `.tql`), `params`, and `max_rows`; enforces exactly one of `query`/`tql_path` client-side. +- Connectors: `types`, `create`, `test`, `update`, `delete` — `config` is passed through as proto-JSON; call `connectors.types()` for per-type fields. +- Route-drift guard: `tests/test_route_coverage.py` + vendored `tests/routes.manifest.json`, and a scheduled `route-sync` workflow that opens a PR when the backend route set changes. + +## [2.0.0] — 2026-05-14 + +### Changed +- Version realigned to 2.x to track the v2 Platform API surface. +- Releases publish to PyPI via Trusted Publishing. + ## [0.2.0] — 2026-05-14 ### Added diff --git a/src/textql/_version.py b/src/textql/_version.py index 8c0d5d5..9aa3f90 100644 --- a/src/textql/_version.py +++ b/src/textql/_version.py @@ -1 +1 @@ -__version__ = "2.0.0" +__version__ = "2.1.0" diff --git a/src/textql/resources/chat.py b/src/textql/resources/chat.py index 66ece22..d276da7 100644 --- a/src/textql/resources/chat.py +++ b/src/textql/resources/chat.py @@ -14,6 +14,7 @@ class Chat: def __init__(self, client: TextQL) -> None: self._client = client + # v2:covers GET /v2/chats def list( self, *, @@ -36,6 +37,7 @@ def list( params["sort_direction"] = sort_direction return self._client.request("GET", "/v2/chats", params=params) + # v2:covers POST /v2/chats def create( self, question: str, @@ -83,9 +85,11 @@ def _create_multipart( return self._client.request("POST", "/v2/chats", data=fields, files=file_tuples) + # v2:covers GET /v2/chats/:id def get(self, chat_id: str) -> Any: return self._client.request("GET", f"/v2/chats/{chat_id}") + # v2:covers POST /v2/chats/stream def stream( self, question: str, @@ -103,5 +107,6 @@ def stream( body["tools"] = tools return self._client.stream_request("POST", "/v2/chats/stream", json=body) + # v2:covers POST /v2/chats/:id/cancel def cancel(self, chat_id: str) -> Any: return self._client.request("POST", f"/v2/chats/{chat_id}/cancel") diff --git a/src/textql/resources/connectors.py b/src/textql/resources/connectors.py index dcc2117..2943632 100644 --- a/src/textql/resources/connectors.py +++ b/src/textql/resources/connectors.py @@ -10,5 +10,52 @@ class Connectors: def __init__(self, client: TextQL) -> None: self._client = client + # v2:covers GET /v2/connectors def list(self) -> Any: return self._client.request("GET", "/v2/connectors") + + # v2:covers GET /v2/connectors/types + def types(self) -> Any: + return self._client.request("GET", "/v2/connectors/types") + + # v2:covers POST /v2/connectors + def create( + self, + config: dict[str, Any], + *, + allow_sql_write_operations: bool | None = None, + include_db_session_metadata: bool | None = None, + ) -> Any: + body: dict[str, Any] = {"config": config} + if allow_sql_write_operations is not None: + body["allow_sql_write_operations"] = allow_sql_write_operations + if include_db_session_metadata is not None: + body["include_db_session_metadata"] = include_db_session_metadata + return self._client.request("POST", "/v2/connectors", json=body) + + # v2:covers POST /v2/connectors/test + def test(self, config: dict[str, Any], *, connector_id: str | None = None) -> Any: + body: dict[str, Any] = {"config": config} + if connector_id is not None: + body["connector_id"] = connector_id + return self._client.request("POST", "/v2/connectors/test", json=body) + + # v2:covers PATCH /v2/connectors/:id + def update( + self, + connector_id: int, + config: dict[str, Any], + *, + allow_sql_write_operations: bool | None = None, + include_db_session_metadata: bool | None = None, + ) -> Any: + body: dict[str, Any] = {"config": config} + if allow_sql_write_operations is not None: + body["allow_sql_write_operations"] = allow_sql_write_operations + if include_db_session_metadata is not None: + body["include_db_session_metadata"] = include_db_session_metadata + return self._client.request("PATCH", f"/v2/connectors/{connector_id}", json=body) + + # v2:covers DELETE /v2/connectors/:id + def delete(self, connector_id: int) -> Any: + return self._client.request("DELETE", f"/v2/connectors/{connector_id}") diff --git a/src/textql/resources/playbooks.py b/src/textql/resources/playbooks.py index dd248b2..d4618b5 100644 --- a/src/textql/resources/playbooks.py +++ b/src/textql/resources/playbooks.py @@ -10,6 +10,7 @@ class Playbooks: def __init__(self, client: TextQL) -> None: self._client = client + # v2:covers GET /v2/playbooks def list( self, *, @@ -35,9 +36,11 @@ def list( params["status_filter"] = status_filter return self._client.request("GET", "/v2/playbooks", params=params) + # v2:covers POST /v2/playbooks def create(self) -> Any: return self._client.request("POST", "/v2/playbooks") + # v2:covers GET /v2/playbooks/:id def get( self, playbook_id: str, @@ -52,6 +55,7 @@ def get( params["offset"] = offset return self._client.request("GET", f"/v2/playbooks/{playbook_id}", params=params) + # v2:covers PATCH /v2/playbooks/:id def update( self, playbook_id: str, @@ -87,12 +91,15 @@ def update( body["selected_template_data_ids"] = selected_template_data_ids return self._client.request("PATCH", f"/v2/playbooks/{playbook_id}", json=body) + # v2:covers POST /v2/playbooks/:id/deploy def deploy(self, playbook_id: str) -> Any: return self._client.request("POST", f"/v2/playbooks/{playbook_id}/deploy") + # v2:covers DELETE /v2/playbooks/:id def delete(self, playbook_id: str) -> Any: return self._client.request("DELETE", f"/v2/playbooks/{playbook_id}") + # v2:covers POST /v2/playbooks/:id/run def run(self, playbook_id: str, *, dry_run: bool = False) -> Any: body: dict[str, Any] = {} if dry_run: diff --git a/src/textql/resources/sandbox.py b/src/textql/resources/sandbox.py index 961ae99..c3d78fa 100644 --- a/src/textql/resources/sandbox.py +++ b/src/textql/resources/sandbox.py @@ -13,33 +13,89 @@ class Sandbox: def __init__(self, client: TextQL) -> None: self._client = client - def start(self) -> Any: - return self._client.request("POST", "/v2/sandboxes") + # v2:covers POST /v2/sandcastles + def start(self, *, sandbox_id: str | None = None) -> Any: + body: dict[str, Any] = {} + if sandbox_id is not None: + body["sandbox_id"] = sandbox_id + return self._client.request("POST", "/v2/sandcastles", json=body or None) + # v2:covers GET /v2/sandcastles + def list( + self, + *, + status: str | None = None, + limit: int | None = None, + cursor: str | None = None, + ) -> Any: + params: dict[str, Any] = {} + if status is not None: + params["status"] = status + if limit is not None: + params["limit"] = limit + if cursor is not None: + params["cursor"] = cursor + return self._client.request("GET", "/v2/sandcastles", params=params) + + # v2:covers GET /v2/sandcastles/:id def status(self, sandbox_id: str) -> Any: - return self._client.request("GET", f"/v2/sandboxes/{sandbox_id}") + return self._client.request("GET", f"/v2/sandcastles/{sandbox_id}") + + # v2:covers GET /v2/sandcastles/:id/executions + def executions( + self, + sandbox_id: str, + *, + limit: int | None = None, + cursor: str | None = None, + ) -> Any: + params: dict[str, Any] = {} + if limit is not None: + params["limit"] = limit + if cursor is not None: + params["cursor"] = cursor + return self._client.request( + "GET", f"/v2/sandcastles/{sandbox_id}/executions", params=params + ) + # v2:covers DELETE /v2/sandcastles/:id def stop(self, sandbox_id: str) -> Any: - return self._client.request("DELETE", f"/v2/sandboxes/{sandbox_id}") + return self._client.request("DELETE", f"/v2/sandcastles/{sandbox_id}") + # v2:covers POST /v2/sandcastles/:id/execute def execute(self, sandbox_id: str, *, code: str) -> Any: return self._client.request( - "POST", f"/v2/sandboxes/{sandbox_id}/execute", json={"code": code} + "POST", f"/v2/sandcastles/{sandbox_id}/execute", json={"code": code} ) + # v2:covers POST /v2/sandcastles/:id/query def query( self, sandbox_id: str, *, connector_id: int, - query: str, + query: str | None = None, + tql_path: str | None = None, + params: dict[str, Any] | None = None, + max_rows: int | None = None, dataframe_name: str | None = None, ) -> Any: - body: dict[str, Any] = {"connector_id": connector_id, "query": query} + if (query is None) == (tql_path is None): + raise ValueError("exactly one of query or tql_path is required") + body: dict[str, Any] = {"connector_id": connector_id} + if query is not None: + body["query"] = query + if tql_path is not None: + body["tql_path"] = tql_path + if params is not None: + body["params"] = params + if max_rows is not None: + body["max_rows"] = max_rows if dataframe_name is not None: body["dataframe_name"] = dataframe_name - return self._client.request("POST", f"/v2/sandboxes/{sandbox_id}/query", json=body) + return self._client.request("POST", f"/v2/sandcastles/{sandbox_id}/query", json=body) + # v2:covers POST /v2/sandcastles/:id/files def upload_file(self, sandbox_id: str, file: FileInput) -> Any: if isinstance(file, tuple): name, content = file @@ -49,6 +105,6 @@ def upload_file(self, sandbox_id: str, file: FileInput) -> Any: content = p.read_bytes() return self._client.request( "POST", - f"/v2/sandboxes/{sandbox_id}/files", + f"/v2/sandcastles/{sandbox_id}/files", files=[("file", (name, content))], ) diff --git a/tests/routes.manifest.json b/tests/routes.manifest.json new file mode 100644 index 0000000..0c5ed3e --- /dev/null +++ b/tests/routes.manifest.json @@ -0,0 +1,28 @@ +[ + "DELETE /v2/connectors/:id", + "DELETE /v2/playbooks/:id", + "DELETE /v2/sandcastles/:id", + "GET /v2/chats", + "GET /v2/chats/:id", + "GET /v2/connectors", + "GET /v2/connectors/types", + "GET /v2/playbooks", + "GET /v2/playbooks/:id", + "GET /v2/sandcastles", + "GET /v2/sandcastles/:id", + "GET /v2/sandcastles/:id/executions", + "PATCH /v2/connectors/:id", + "PATCH /v2/playbooks/:id", + "POST /v2/chats", + "POST /v2/chats/:id/cancel", + "POST /v2/chats/stream", + "POST /v2/connectors", + "POST /v2/connectors/test", + "POST /v2/playbooks", + "POST /v2/playbooks/:id/deploy", + "POST /v2/playbooks/:id/run", + "POST /v2/sandcastles", + "POST /v2/sandcastles/:id/execute", + "POST /v2/sandcastles/:id/files", + "POST /v2/sandcastles/:id/query" +] diff --git a/tests/test_connectors.py b/tests/test_connectors.py new file mode 100644 index 0000000..3cd9a58 --- /dev/null +++ b/tests/test_connectors.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +import json + +import httpx +import respx + +from textql import TextQL + +BASE = "https://app.textql.com" + + +def _client() -> TextQL: + return TextQL(api_key="tql_test") + + +@respx.mock +def test_list_path() -> None: + route = respx.get(f"{BASE}/v2/connectors").mock(return_value=httpx.Response(200, json=[])) + _client().connectors.list() + assert route.called + + +@respx.mock +def test_types_path() -> None: + route = respx.get(f"{BASE}/v2/connectors/types").mock( + return_value=httpx.Response(200, json={"types": []}) + ) + _client().connectors.types() + assert route.called + + +@respx.mock +def test_create_body() -> None: + route = respx.post(f"{BASE}/v2/connectors").mock( + return_value=httpx.Response(201, json={"connector_id": 7}) + ) + cfg = {"connector_type": "POSTGRES", "name": "db", "postgres": {"host": "h"}} + _client().connectors.create(cfg, allow_sql_write_operations=True) + assert json.loads(route.calls.last.request.content) == { + "config": cfg, + "allow_sql_write_operations": True, + } + + +@respx.mock +def test_test_returns_success_false_without_raising() -> None: + respx.post(f"{BASE}/v2/connectors/test").mock( + return_value=httpx.Response(200, json={"success": False, "error": "nope"}) + ) + out = _client().connectors.test({"connector_type": "KDB", "name": "k", "kdb": {}}) + assert out == {"success": False, "error": "nope"} + + +@respx.mock +def test_update_path_and_body() -> None: + route = respx.patch(f"{BASE}/v2/connectors/9").mock( + return_value=httpx.Response(200, json={"id": 9}) + ) + _client().connectors.update(9, {"connector_type": "POSTGRES", "name": "db"}) + assert route.called + assert json.loads(route.calls.last.request.content) == { + "config": {"connector_type": "POSTGRES", "name": "db"} + } + + +@respx.mock +def test_delete_path() -> None: + route = respx.delete(f"{BASE}/v2/connectors/9").mock( + return_value=httpx.Response(200, json={"id": 9, "success": True}) + ) + _client().connectors.delete(9) + assert route.called diff --git a/tests/test_integration.py b/tests/test_integration.py index aa47d5e..9306939 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -36,6 +36,11 @@ def test_list(self, client): assert "name" in first assert "type" in first + def test_types(self, client): + result = client.connectors.types() + assert "types" in result + assert isinstance(result["types"], list) + class TestChat: def test_list(self, client): @@ -125,6 +130,11 @@ def test_lifecycle(self, client): st = client.sandbox.status(sb_id) assert st["status"] in ("running", "stopped") + # list (the running sandbox should appear) + listed = client.sandbox.list() + assert "sandboxes" in listed + assert any(s["sandbox_id"] == sb_id for s in listed["sandboxes"]) + # execute result = client.sandbox.execute(sb_id, code="print(2 + 2)") assert result["output"] == ["4"] @@ -147,6 +157,11 @@ def test_lifecycle(self, client): ) assert qr["dataframe_name"] == "test_df" assert qr["num_rows"] >= 1 + + # executions (the runs above should be recorded) + execs = client.sandbox.executions(sb_id) + assert "executions" in execs + assert isinstance(execs["executions"], list) finally: # stop (cleanup) stopped = client.sandbox.stop(sb_id) diff --git a/tests/test_route_coverage.py b/tests/test_route_coverage.py new file mode 100644 index 0000000..69e8457 --- /dev/null +++ b/tests/test_route_coverage.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +import json +import re +from pathlib import Path + +_COVERS = re.compile(r"#\s*v2:covers\s+(GET|POST|PUT|PATCH|DELETE)\s+(\S+)") +_RESOURCES = Path(__file__).resolve().parent.parent / "src" / "textql" / "resources" +_MANIFEST = Path(__file__).resolve().parent / "routes.manifest.json" + + +def _declared() -> set[str]: + out: set[str] = set() + for f in _RESOURCES.glob("*.py"): + for method, path in _COVERS.findall(f.read_text()): + out.add(f"{method} {path}") + return out + + +def test_sdk_covers_every_backend_route() -> None: + declared = _declared() + manifest = set(json.loads(_MANIFEST.read_text())) + assert declared == manifest, { + "missing_in_sdk": sorted(manifest - declared), + "stale_in_sdk": sorted(declared - manifest), + } diff --git a/tests/test_sandbox.py b/tests/test_sandbox.py new file mode 100644 index 0000000..df07b4d --- /dev/null +++ b/tests/test_sandbox.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +import json + +import httpx +import pytest +import respx + +from textql import TextQL + +BASE = "https://app.textql.com" + + +def _client() -> TextQL: + return TextQL(api_key="tql_test") + + +@respx.mock +def test_start_uses_sandcastles_path() -> None: + route = respx.post(f"{BASE}/v2/sandcastles").mock( + return_value=httpx.Response(201, json={"sandbox_id": "org-1", "created_at": "t"}) + ) + _client().sandbox.start() + assert route.called + assert route.calls.last.request.url.path == "/v2/sandcastles" + + +@respx.mock +def test_start_with_sandbox_id_sends_body() -> None: + route = respx.post(f"{BASE}/v2/sandcastles").mock(return_value=httpx.Response(201, json={})) + _client().sandbox.start(sandbox_id="org-abc") + assert json.loads(route.calls.last.request.content) == {"sandbox_id": "org-abc"} + + +@respx.mock +def test_status_path() -> None: + route = respx.get(f"{BASE}/v2/sandcastles/sb1").mock( + return_value=httpx.Response(200, json={"status": "running"}) + ) + _client().sandbox.status("sb1") + assert route.called + + +@respx.mock +def test_stop_path() -> None: + route = respx.delete(f"{BASE}/v2/sandcastles/sb1").mock( + return_value=httpx.Response(200, json={"success": True}) + ) + _client().sandbox.stop("sb1") + assert route.called + + +@respx.mock +def test_execute_path_and_body() -> None: + route = respx.post(f"{BASE}/v2/sandcastles/sb1/execute").mock( + return_value=httpx.Response(200, json={}) + ) + _client().sandbox.execute("sb1", code="print(1)") + assert json.loads(route.calls.last.request.content) == {"code": "print(1)"} + + +@respx.mock +def test_list_path_and_params() -> None: + route = respx.get(f"{BASE}/v2/sandcastles").mock( + return_value=httpx.Response(200, json={"sandboxes": []}) + ) + _client().sandbox.list(status="all", limit=10, cursor="c1") + assert dict(route.calls.last.request.url.params) == { + "status": "all", + "limit": "10", + "cursor": "c1", + } + + +@respx.mock +def test_executions_path_and_params() -> None: + route = respx.get(f"{BASE}/v2/sandcastles/sb1/executions").mock( + return_value=httpx.Response(200, json={"executions": []}) + ) + _client().sandbox.executions("sb1", limit=5) + assert route.called + assert dict(route.calls.last.request.url.params) == {"limit": "5"} + + +@respx.mock +def test_query_tql_path_branch() -> None: + route = respx.post(f"{BASE}/v2/sandcastles/sb1/query").mock( + return_value=httpx.Response(200, json={"preview": "x"}) + ) + _client().sandbox.query( + "sb1", connector_id=2, tql_path="portfolio/p.tql", params={"a": 1}, max_rows=50 + ) + assert json.loads(route.calls.last.request.content) == { + "connector_id": 2, + "tql_path": "portfolio/p.tql", + "params": {"a": 1}, + "max_rows": 50, + } + + +@respx.mock +def test_query_sql_branch() -> None: + route = respx.post(f"{BASE}/v2/sandcastles/sb1/query").mock( + return_value=httpx.Response(200, json={"preview": "x"}) + ) + _client().sandbox.query("sb1", connector_id=2, query="select 1", dataframe_name="df") + assert json.loads(route.calls.last.request.content) == { + "connector_id": 2, + "query": "select 1", + "dataframe_name": "df", + } + + +@respx.mock +def test_upload_file_path() -> None: + route = respx.post(f"{BASE}/v2/sandcastles/sb1/files").mock( + return_value=httpx.Response(200, json={"filename": "a.txt", "size_bytes": 3}) + ) + _client().sandbox.upload_file("sb1", ("a.txt", b"abc")) + assert route.called + + +def test_query_requires_exactly_one_of() -> None: + c = _client() + with pytest.raises(ValueError, match="exactly one of query or tql_path"): + c.sandbox.query("sb1", connector_id=2) + with pytest.raises(ValueError, match="exactly one of query or tql_path"): + c.sandbox.query("sb1", connector_id=2, query="x", tql_path="y") From effe84d57a1166a92ab3083dc7cbd47b9b52391f Mon Sep 17 00:00:00 2001 From: Joseph Ma Date: Thu, 18 Jun 2026 16:19:47 -0400 Subject: [PATCH 2/5] Fix: route-sync fetches private backend manifest via contents API raw.githubusercontent.com + a PAT bearer header is unreliable for private repos. Use `gh api .../contents/...` with Accept: application/vnd.github.raw, which authenticates with BACKEND_REPO_TOKEN. Repo/path/ref are overridable via repo vars (default TextQLLabs/demo2). Copy the canonical file verbatim instead of re-serializing, so the vendored copy stays byte-identical. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/route-sync.yml | 38 ++++++++++++++++++-------------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/.github/workflows/route-sync.yml b/.github/workflows/route-sync.yml index 2918956..9ae8518 100644 --- a/.github/workflows/route-sync.yml +++ b/.github/workflows/route-sync.yml @@ -1,10 +1,11 @@ name: Route Sync -# Pulls the canonical v2 route manifest from the backend and opens a PR when it -# drifts from the vendored copy. The PR then fails test_route_coverage until the -# matching SDK methods + `# v2:covers` comments are added. Configure the repo -# variable BACKEND_MANIFEST_URL (raw URL of compute/pkg/platform/v2/routes.manifest.json) -# and, if the source is private, the BACKEND_REPO_TOKEN secret. +# Opens a PR when the backend's canonical v2 route manifest drifts from the +# vendored copy; the PR then fails test_route_coverage until the matching SDK +# methods + `# v2:covers` comments are added. demo2 is private, so fetching its +# manifest needs a token with Contents:read on it — store it as the +# BACKEND_REPO_TOKEN secret (fine-grained PAT scoped to demo2, or a GitHub App +# installation token). on: schedule: @@ -18,31 +19,34 @@ permissions: jobs: sync: runs-on: ubuntu-latest + env: + GH_TOKEN: ${{ secrets.BACKEND_REPO_TOKEN }} + BACKEND_REPO: ${{ vars.BACKEND_REPO || 'TextQLLabs/demo2' }} + MANIFEST_PATH: ${{ vars.BACKEND_MANIFEST_PATH || 'compute/pkg/platform/v2/routes.manifest.json' }} + MANIFEST_REF: ${{ vars.BACKEND_MANIFEST_REF || 'main' }} steps: - uses: actions/checkout@v4 - name: Fetch upstream manifest id: fetch - env: - BACKEND_MANIFEST_URL: ${{ vars.BACKEND_MANIFEST_URL }} - BACKEND_REPO_TOKEN: ${{ secrets.BACKEND_REPO_TOKEN }} run: | - if [ -z "$BACKEND_MANIFEST_URL" ]; then - echo "BACKEND_MANIFEST_URL not set; skipping" + set -euo pipefail + if [ -z "${GH_TOKEN:-}" ]; then + echo "BACKEND_REPO_TOKEN not set; skipping" echo "changed=false" >> "$GITHUB_OUTPUT" exit 0 fi - auth=() - if [ -n "$BACKEND_REPO_TOKEN" ]; then auth=(-H "Authorization: Bearer $BACKEND_REPO_TOKEN"); fi - curl -fsSL "${auth[@]}" "$BACKEND_MANIFEST_URL" -o upstream.json - changed=$(python -c "import json,sys; a=set(json.load(open('upstream.json'))); b=set(json.load(open('tests/routes.manifest.json'))); print('true' if a!=b else 'false')") - if [ "$changed" = "true" ]; then - python -c "import json; json.dump(sorted(json.load(open('upstream.json'))), open('tests/routes.manifest.json','w'), indent=2); open('tests/routes.manifest.json','a').write('\n')" - fi + gh api "repos/${BACKEND_REPO}/contents/${MANIFEST_PATH}?ref=${MANIFEST_REF}" \ + -H "Accept: application/vnd.github.raw" > upstream.json + changed=$(python3 -c "import json; a=set(json.load(open('upstream.json'))); b=set(json.load(open('tests/routes.manifest.json'))); print('true' if a!=b else 'false')") + if [ "$changed" = "true" ]; then cp upstream.json tests/routes.manifest.json; fi echo "changed=$changed" >> "$GITHUB_OUTPUT" - name: Open PR if: steps.fetch.outputs.changed == 'true' uses: peter-evans/create-pull-request@v6 with: + # Default GITHUB_TOKEN-created PRs do NOT trigger CI; pass a PAT/App + # token here if you want test_route_coverage to run automatically. + token: ${{ secrets.ROUTE_SYNC_PR_TOKEN || secrets.GITHUB_TOKEN }} branch: route-sync add-paths: tests/routes.manifest.json commit-message: "chore: sync v2 route manifest from backend" From 11111119b535fe267eb77c81427d7120ab9ba68e Mon Sep 17 00:00:00 2001 From: Joseph Ma Date: Thu, 18 Jun 2026 16:23:39 -0400 Subject: [PATCH 3/5] Feat: route-sync uses a GitHub App token for fetch + CI-triggering PR A single App credential covers both halves: read the private backend manifest and open a PR here that actually triggers CI (default GITHUB_TOKEN PRs don't run checks, so the drift guard would never go red). Mint the installation token in-workflow via actions/create-github-app-token scoped to both repos; job no-ops until SYNC_APP_ID + SYNC_APP_PRIVATE_KEY are set. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/route-sync.yml | 41 +++++++++++++++++++++----------- 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/.github/workflows/route-sync.yml b/.github/workflows/route-sync.yml index 9ae8518..c1fb488 100644 --- a/.github/workflows/route-sync.yml +++ b/.github/workflows/route-sync.yml @@ -1,11 +1,16 @@ name: Route Sync # Opens a PR when the backend's canonical v2 route manifest drifts from the -# vendored copy; the PR then fails test_route_coverage until the matching SDK -# methods + `# v2:covers` comments are added. demo2 is private, so fetching its -# manifest needs a token with Contents:read on it — store it as the -# BACKEND_REPO_TOKEN secret (fine-grained PAT scoped to demo2, or a GitHub App -# installation token). +# vendored copy; that PR fails test_route_coverage until the matching SDK +# methods + `# v2:covers` comments are added. +# +# Auth: a GitHub App is the single credential for both halves — it reads the +# private backend repo's manifest AND opens a PR here that triggers CI (PRs from +# the default GITHUB_TOKEN do not run checks). Configure: +# - var SYNC_APP_ID, secret SYNC_APP_PRIVATE_KEY +# - install the App on the backend repo (Contents: read) and on this repo +# (Contents: read/write, Pull requests: read/write) +# Until configured the job no-ops. on: schedule: @@ -13,29 +18,39 @@ on: workflow_dispatch: permissions: - contents: write - pull-requests: write + contents: read jobs: sync: runs-on: ubuntu-latest env: - GH_TOKEN: ${{ secrets.BACKEND_REPO_TOKEN }} - BACKEND_REPO: ${{ vars.BACKEND_REPO || 'TextQLLabs/demo2' }} + BACKEND_REPO_NAME: ${{ vars.BACKEND_REPO_NAME || 'demo2' }} MANIFEST_PATH: ${{ vars.BACKEND_MANIFEST_PATH || 'compute/pkg/platform/v2/routes.manifest.json' }} MANIFEST_REF: ${{ vars.BACKEND_MANIFEST_REF || 'main' }} steps: - uses: actions/checkout@v4 + - name: Mint App token + id: app-token + if: ${{ vars.SYNC_APP_ID != '' }} + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ vars.SYNC_APP_ID }} + private-key: ${{ secrets.SYNC_APP_PRIVATE_KEY }} + repositories: | + ${{ vars.BACKEND_REPO_NAME || 'demo2' }} + ${{ github.event.repository.name }} - name: Fetch upstream manifest id: fetch + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} run: | set -euo pipefail if [ -z "${GH_TOKEN:-}" ]; then - echo "BACKEND_REPO_TOKEN not set; skipping" + echo "GitHub App not configured (set SYNC_APP_ID + SYNC_APP_PRIVATE_KEY); skipping" echo "changed=false" >> "$GITHUB_OUTPUT" exit 0 fi - gh api "repos/${BACKEND_REPO}/contents/${MANIFEST_PATH}?ref=${MANIFEST_REF}" \ + gh api "repos/${{ github.repository_owner }}/${BACKEND_REPO_NAME}/contents/${MANIFEST_PATH}?ref=${MANIFEST_REF}" \ -H "Accept: application/vnd.github.raw" > upstream.json changed=$(python3 -c "import json; a=set(json.load(open('upstream.json'))); b=set(json.load(open('tests/routes.manifest.json'))); print('true' if a!=b else 'false')") if [ "$changed" = "true" ]; then cp upstream.json tests/routes.manifest.json; fi @@ -44,9 +59,7 @@ jobs: if: steps.fetch.outputs.changed == 'true' uses: peter-evans/create-pull-request@v6 with: - # Default GITHUB_TOKEN-created PRs do NOT trigger CI; pass a PAT/App - # token here if you want test_route_coverage to run automatically. - token: ${{ secrets.ROUTE_SYNC_PR_TOKEN || secrets.GITHUB_TOKEN }} + token: ${{ steps.app-token.outputs.token }} branch: route-sync add-paths: tests/routes.manifest.json commit-message: "chore: sync v2 route manifest from backend" From 7777777ca81ab2c6a2e407c380dca750af7ed9b1 Mon Sep 17 00:00:00 2001 From: Joseph Ma Date: Thu, 18 Jun 2026 16:24:32 -0400 Subject: [PATCH 4/5] Feat: route-sync accepts a PAT or a GitHub App token Cross-repo credentials must be minted in the UI (gh can't create an App or a PAT), so support the lighter option too: resolve the token from a GitHub App (SYNC_APP_ID) when present, else fall back to a fine-grained PAT secret (BACKEND_REPO_TOKEN). Either triggers CI on the auto-PR; job no-ops with neither. Token is masked before use. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/route-sync.yml | 37 +++++++++++++++++++------------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/.github/workflows/route-sync.yml b/.github/workflows/route-sync.yml index c1fb488..74c773c 100644 --- a/.github/workflows/route-sync.yml +++ b/.github/workflows/route-sync.yml @@ -4,13 +4,14 @@ name: Route Sync # vendored copy; that PR fails test_route_coverage until the matching SDK # methods + `# v2:covers` comments are added. # -# Auth: a GitHub App is the single credential for both halves — it reads the -# private backend repo's manifest AND opens a PR here that triggers CI (PRs from -# the default GITHUB_TOKEN do not run checks). Configure: -# - var SYNC_APP_ID, secret SYNC_APP_PRIVATE_KEY -# - install the App on the backend repo (Contents: read) and on this repo -# (Contents: read/write, Pull requests: read/write) -# Until configured the job no-ops. +# Needs a cross-repo credential (the default GITHUB_TOKEN can't read the private +# backend repo, and PRs it opens don't trigger CI). Use EITHER: +# - a fine-grained PAT (owner TextQLLabs, repos demo2 + textql-python, +# Contents: read/write, Pull requests: read/write) as secret +# BACKEND_REPO_TOKEN; or +# - a GitHub App (var SYNC_APP_ID + secret SYNC_APP_PRIVATE_KEY) installed on +# both repos with the same permissions. +# The App is preferred (no expiry); the job no-ops until one is configured. on: schedule: @@ -39,27 +40,33 @@ jobs: repositories: | ${{ vars.BACKEND_REPO_NAME || 'demo2' }} ${{ github.event.repository.name }} + - name: Resolve token + id: token + env: + APP_TOKEN: ${{ steps.app-token.outputs.token }} + PAT: ${{ secrets.BACKEND_REPO_TOKEN }} + run: | + tok="${APP_TOKEN:-$PAT}" + if [ -z "$tok" ]; then echo "has_token=false" >> "$GITHUB_OUTPUT"; else echo "has_token=true" >> "$GITHUB_OUTPUT"; fi + echo "::add-mask::$tok" + echo "value=$tok" >> "$GITHUB_OUTPUT" - name: Fetch upstream manifest id: fetch + if: ${{ steps.token.outputs.has_token == 'true' }} env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} + GH_TOKEN: ${{ steps.token.outputs.value }} run: | set -euo pipefail - if [ -z "${GH_TOKEN:-}" ]; then - echo "GitHub App not configured (set SYNC_APP_ID + SYNC_APP_PRIVATE_KEY); skipping" - echo "changed=false" >> "$GITHUB_OUTPUT" - exit 0 - fi gh api "repos/${{ github.repository_owner }}/${BACKEND_REPO_NAME}/contents/${MANIFEST_PATH}?ref=${MANIFEST_REF}" \ -H "Accept: application/vnd.github.raw" > upstream.json changed=$(python3 -c "import json; a=set(json.load(open('upstream.json'))); b=set(json.load(open('tests/routes.manifest.json'))); print('true' if a!=b else 'false')") if [ "$changed" = "true" ]; then cp upstream.json tests/routes.manifest.json; fi echo "changed=$changed" >> "$GITHUB_OUTPUT" - name: Open PR - if: steps.fetch.outputs.changed == 'true' + if: ${{ steps.fetch.outputs.changed == 'true' }} uses: peter-evans/create-pull-request@v6 with: - token: ${{ steps.app-token.outputs.token }} + token: ${{ steps.token.outputs.value }} branch: route-sync add-paths: tests/routes.manifest.json commit-message: "chore: sync v2 route manifest from backend" From 5555555c28a0d69f6dfd748ac8686d2d91d2cbb2 Mon Sep 17 00:00:00 2001 From: Joseph Ma Date: Thu, 18 Jun 2026 19:24:05 -0400 Subject: [PATCH 5/5] Fix: route-sync tracks the prod (release) contract, not main (staging) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit demo2 main builds to staging; prod is the `release` branch. The SDK defaults to app.textql.com (prod), so syncing against main would advertise routes before prod serves them — the lead/lag break this guard exists to prevent. Default MANIFEST_REF to `release` and soft-skip when the manifest isn't on that ref yet (bootstrap), failing loudly only on real fetch errors. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/route-sync.yml | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/.github/workflows/route-sync.yml b/.github/workflows/route-sync.yml index 74c773c..6480db3 100644 --- a/.github/workflows/route-sync.yml +++ b/.github/workflows/route-sync.yml @@ -27,7 +27,10 @@ jobs: env: BACKEND_REPO_NAME: ${{ vars.BACKEND_REPO_NAME || 'demo2' }} MANIFEST_PATH: ${{ vars.BACKEND_MANIFEST_PATH || 'compute/pkg/platform/v2/routes.manifest.json' }} - MANIFEST_REF: ${{ vars.BACKEND_MANIFEST_REF || 'main' }} + # Track the PROD contract: the SDK defaults to app.textql.com, and demo2's + # `main` is staging — prod is the `release` branch. Tracking `main` would + # sync the SDK ahead of what prod serves. + MANIFEST_REF: ${{ vars.BACKEND_MANIFEST_REF || 'release' }} steps: - uses: actions/checkout@v4 - name: Mint App token @@ -57,8 +60,16 @@ jobs: GH_TOKEN: ${{ steps.token.outputs.value }} run: | set -euo pipefail - gh api "repos/${{ github.repository_owner }}/${BACKEND_REPO_NAME}/contents/${MANIFEST_PATH}?ref=${MANIFEST_REF}" \ - -H "Accept: application/vnd.github.raw" > upstream.json + if ! gh api "repos/${{ github.repository_owner }}/${BACKEND_REPO_NAME}/contents/${MANIFEST_PATH}?ref=${MANIFEST_REF}" \ + -H "Accept: application/vnd.github.raw" > upstream.json 2> fetch_err; then + if grep -qi "not found" fetch_err; then + echo "manifest not on '${MANIFEST_REF}' yet (or path moved); skipping" + echo "changed=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + cat fetch_err >&2 + exit 1 + fi changed=$(python3 -c "import json; a=set(json.load(open('upstream.json'))); b=set(json.load(open('tests/routes.manifest.json'))); print('true' if a!=b else 'false')") if [ "$changed" = "true" ]; then cp upstream.json tests/routes.manifest.json; fi echo "changed=$changed" >> "$GITHUB_OUTPUT"