Skip to content

Commit dec0ded

Browse files
jlsajfjclaude
andcommitted
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) <noreply@anthropic.com>
1 parent 7777777 commit dec0ded

12 files changed

Lines changed: 465 additions & 10 deletions

File tree

.github/workflows/route-sync.yml

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
name: Route Sync
2+
3+
# Pulls the canonical v2 route manifest from the backend and opens a PR when it
4+
# drifts from the vendored copy. The PR then fails test_route_coverage until the
5+
# matching SDK methods + `# v2:covers` comments are added. Configure the repo
6+
# variable BACKEND_MANIFEST_URL (raw URL of compute/pkg/platform/v2/routes.manifest.json)
7+
# and, if the source is private, the BACKEND_REPO_TOKEN secret.
8+
9+
on:
10+
schedule:
11+
- cron: "0 12 * * *"
12+
workflow_dispatch:
13+
14+
permissions:
15+
contents: write
16+
pull-requests: write
17+
18+
jobs:
19+
sync:
20+
runs-on: ubuntu-latest
21+
steps:
22+
- uses: actions/checkout@v4
23+
- name: Fetch upstream manifest
24+
id: fetch
25+
env:
26+
BACKEND_MANIFEST_URL: ${{ vars.BACKEND_MANIFEST_URL }}
27+
BACKEND_REPO_TOKEN: ${{ secrets.BACKEND_REPO_TOKEN }}
28+
run: |
29+
if [ -z "$BACKEND_MANIFEST_URL" ]; then
30+
echo "BACKEND_MANIFEST_URL not set; skipping"
31+
echo "changed=false" >> "$GITHUB_OUTPUT"
32+
exit 0
33+
fi
34+
auth=()
35+
if [ -n "$BACKEND_REPO_TOKEN" ]; then auth=(-H "Authorization: Bearer $BACKEND_REPO_TOKEN"); fi
36+
curl -fsSL "${auth[@]}" "$BACKEND_MANIFEST_URL" -o upstream.json
37+
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')")
38+
if [ "$changed" = "true" ]; then
39+
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')"
40+
fi
41+
echo "changed=$changed" >> "$GITHUB_OUTPUT"
42+
- name: Open PR
43+
if: steps.fetch.outputs.changed == 'true'
44+
uses: peter-evans/create-pull-request@v6
45+
with:
46+
branch: route-sync
47+
add-paths: tests/routes.manifest.json
48+
commit-message: "chore: sync v2 route manifest from backend"
49+
title: "chore: sync v2 route manifest from backend"
50+
body: |
51+
The backend `/v2` route set changed. Add/rename the matching SDK
52+
methods and their `# v2:covers` comments until `test_route_coverage`
53+
passes.

CHANGELOG.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,23 @@ All notable changes to the `textql` Python SDK will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [2.1.0] — 2026-06-18
9+
10+
### Fixed
11+
- Sandbox resource now targets `/v2/sandcastles`; the backend renamed the path from `/v2/sandboxes` with no alias, so every sandbox call was 404ing.
12+
13+
### Added
14+
- Sandbox: `list` and `executions` (both cursor-paginated), and an optional `sandbox_id` on `start` to restart a specific sandbox.
15+
- Sandbox `query`: `tql_path` (run a saved library `.tql`), `params`, and `max_rows`; enforces exactly one of `query`/`tql_path` client-side.
16+
- Connectors: `types`, `create`, `test`, `update`, `delete``config` is passed through as proto-JSON; call `connectors.types()` for per-type fields.
17+
- 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.
18+
19+
## [2.0.0] — 2026-05-14
20+
21+
### Changed
22+
- Version realigned to 2.x to track the v2 Platform API surface.
23+
- Releases publish to PyPI via Trusted Publishing.
24+
825
## [0.2.0] — 2026-05-14
926

1027
### Added

src/textql/_version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "2.0.0"
1+
__version__ = "2.1.0"

src/textql/resources/chat.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ class Chat:
1414
def __init__(self, client: TextQL) -> None:
1515
self._client = client
1616

17+
# v2:covers GET /v2/chats
1718
def list(
1819
self,
1920
*,
@@ -36,6 +37,7 @@ def list(
3637
params["sort_direction"] = sort_direction
3738
return self._client.request("GET", "/v2/chats", params=params)
3839

40+
# v2:covers POST /v2/chats
3941
def create(
4042
self,
4143
question: str,
@@ -83,9 +85,11 @@ def _create_multipart(
8385

8486
return self._client.request("POST", "/v2/chats", data=fields, files=file_tuples)
8587

88+
# v2:covers GET /v2/chats/:id
8689
def get(self, chat_id: str) -> Any:
8790
return self._client.request("GET", f"/v2/chats/{chat_id}")
8891

92+
# v2:covers POST /v2/chats/stream
8993
def stream(
9094
self,
9195
question: str,
@@ -103,5 +107,6 @@ def stream(
103107
body["tools"] = tools
104108
return self._client.stream_request("POST", "/v2/chats/stream", json=body)
105109

110+
# v2:covers POST /v2/chats/:id/cancel
106111
def cancel(self, chat_id: str) -> Any:
107112
return self._client.request("POST", f"/v2/chats/{chat_id}/cancel")

src/textql/resources/connectors.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,52 @@ class Connectors:
1010
def __init__(self, client: TextQL) -> None:
1111
self._client = client
1212

13+
# v2:covers GET /v2/connectors
1314
def list(self) -> Any:
1415
return self._client.request("GET", "/v2/connectors")
16+
17+
# v2:covers GET /v2/connectors/types
18+
def types(self) -> Any:
19+
return self._client.request("GET", "/v2/connectors/types")
20+
21+
# v2:covers POST /v2/connectors
22+
def create(
23+
self,
24+
config: dict[str, Any],
25+
*,
26+
allow_sql_write_operations: bool | None = None,
27+
include_db_session_metadata: bool | None = None,
28+
) -> Any:
29+
body: dict[str, Any] = {"config": config}
30+
if allow_sql_write_operations is not None:
31+
body["allow_sql_write_operations"] = allow_sql_write_operations
32+
if include_db_session_metadata is not None:
33+
body["include_db_session_metadata"] = include_db_session_metadata
34+
return self._client.request("POST", "/v2/connectors", json=body)
35+
36+
# v2:covers POST /v2/connectors/test
37+
def test(self, config: dict[str, Any], *, connector_id: str | None = None) -> Any:
38+
body: dict[str, Any] = {"config": config}
39+
if connector_id is not None:
40+
body["connector_id"] = connector_id
41+
return self._client.request("POST", "/v2/connectors/test", json=body)
42+
43+
# v2:covers PATCH /v2/connectors/:id
44+
def update(
45+
self,
46+
connector_id: int,
47+
config: dict[str, Any],
48+
*,
49+
allow_sql_write_operations: bool | None = None,
50+
include_db_session_metadata: bool | None = None,
51+
) -> Any:
52+
body: dict[str, Any] = {"config": config}
53+
if allow_sql_write_operations is not None:
54+
body["allow_sql_write_operations"] = allow_sql_write_operations
55+
if include_db_session_metadata is not None:
56+
body["include_db_session_metadata"] = include_db_session_metadata
57+
return self._client.request("PATCH", f"/v2/connectors/{connector_id}", json=body)
58+
59+
# v2:covers DELETE /v2/connectors/:id
60+
def delete(self, connector_id: int) -> Any:
61+
return self._client.request("DELETE", f"/v2/connectors/{connector_id}")

src/textql/resources/playbooks.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ class Playbooks:
1010
def __init__(self, client: TextQL) -> None:
1111
self._client = client
1212

13+
# v2:covers GET /v2/playbooks
1314
def list(
1415
self,
1516
*,
@@ -35,9 +36,11 @@ def list(
3536
params["status_filter"] = status_filter
3637
return self._client.request("GET", "/v2/playbooks", params=params)
3738

39+
# v2:covers POST /v2/playbooks
3840
def create(self) -> Any:
3941
return self._client.request("POST", "/v2/playbooks")
4042

43+
# v2:covers GET /v2/playbooks/:id
4144
def get(
4245
self,
4346
playbook_id: str,
@@ -52,6 +55,7 @@ def get(
5255
params["offset"] = offset
5356
return self._client.request("GET", f"/v2/playbooks/{playbook_id}", params=params)
5457

58+
# v2:covers PATCH /v2/playbooks/:id
5559
def update(
5660
self,
5761
playbook_id: str,
@@ -87,12 +91,15 @@ def update(
8791
body["selected_template_data_ids"] = selected_template_data_ids
8892
return self._client.request("PATCH", f"/v2/playbooks/{playbook_id}", json=body)
8993

94+
# v2:covers POST /v2/playbooks/:id/deploy
9095
def deploy(self, playbook_id: str) -> Any:
9196
return self._client.request("POST", f"/v2/playbooks/{playbook_id}/deploy")
9297

98+
# v2:covers DELETE /v2/playbooks/:id
9399
def delete(self, playbook_id: str) -> Any:
94100
return self._client.request("DELETE", f"/v2/playbooks/{playbook_id}")
95101

102+
# v2:covers POST /v2/playbooks/:id/run
96103
def run(self, playbook_id: str, *, dry_run: bool = False) -> Any:
97104
body: dict[str, Any] = {}
98105
if dry_run:

src/textql/resources/sandbox.py

Lines changed: 65 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,33 +13,89 @@ class Sandbox:
1313
def __init__(self, client: TextQL) -> None:
1414
self._client = client
1515

16-
def start(self) -> Any:
17-
return self._client.request("POST", "/v2/sandboxes")
16+
# v2:covers POST /v2/sandcastles
17+
def start(self, *, sandbox_id: str | None = None) -> Any:
18+
body: dict[str, Any] = {}
19+
if sandbox_id is not None:
20+
body["sandbox_id"] = sandbox_id
21+
return self._client.request("POST", "/v2/sandcastles", json=body or None)
1822

23+
# v2:covers GET /v2/sandcastles
24+
def list(
25+
self,
26+
*,
27+
status: str | None = None,
28+
limit: int | None = None,
29+
cursor: str | None = None,
30+
) -> Any:
31+
params: dict[str, Any] = {}
32+
if status is not None:
33+
params["status"] = status
34+
if limit is not None:
35+
params["limit"] = limit
36+
if cursor is not None:
37+
params["cursor"] = cursor
38+
return self._client.request("GET", "/v2/sandcastles", params=params)
39+
40+
# v2:covers GET /v2/sandcastles/:id
1941
def status(self, sandbox_id: str) -> Any:
20-
return self._client.request("GET", f"/v2/sandboxes/{sandbox_id}")
42+
return self._client.request("GET", f"/v2/sandcastles/{sandbox_id}")
43+
44+
# v2:covers GET /v2/sandcastles/:id/executions
45+
def executions(
46+
self,
47+
sandbox_id: str,
48+
*,
49+
limit: int | None = None,
50+
cursor: str | None = None,
51+
) -> Any:
52+
params: dict[str, Any] = {}
53+
if limit is not None:
54+
params["limit"] = limit
55+
if cursor is not None:
56+
params["cursor"] = cursor
57+
return self._client.request(
58+
"GET", f"/v2/sandcastles/{sandbox_id}/executions", params=params
59+
)
2160

61+
# v2:covers DELETE /v2/sandcastles/:id
2262
def stop(self, sandbox_id: str) -> Any:
23-
return self._client.request("DELETE", f"/v2/sandboxes/{sandbox_id}")
63+
return self._client.request("DELETE", f"/v2/sandcastles/{sandbox_id}")
2464

65+
# v2:covers POST /v2/sandcastles/:id/execute
2566
def execute(self, sandbox_id: str, *, code: str) -> Any:
2667
return self._client.request(
27-
"POST", f"/v2/sandboxes/{sandbox_id}/execute", json={"code": code}
68+
"POST", f"/v2/sandcastles/{sandbox_id}/execute", json={"code": code}
2869
)
2970

71+
# v2:covers POST /v2/sandcastles/:id/query
3072
def query(
3173
self,
3274
sandbox_id: str,
3375
*,
3476
connector_id: int,
35-
query: str,
77+
query: str | None = None,
78+
tql_path: str | None = None,
79+
params: dict[str, Any] | None = None,
80+
max_rows: int | None = None,
3681
dataframe_name: str | None = None,
3782
) -> Any:
38-
body: dict[str, Any] = {"connector_id": connector_id, "query": query}
83+
if (query is None) == (tql_path is None):
84+
raise ValueError("exactly one of query or tql_path is required")
85+
body: dict[str, Any] = {"connector_id": connector_id}
86+
if query is not None:
87+
body["query"] = query
88+
if tql_path is not None:
89+
body["tql_path"] = tql_path
90+
if params is not None:
91+
body["params"] = params
92+
if max_rows is not None:
93+
body["max_rows"] = max_rows
3994
if dataframe_name is not None:
4095
body["dataframe_name"] = dataframe_name
41-
return self._client.request("POST", f"/v2/sandboxes/{sandbox_id}/query", json=body)
96+
return self._client.request("POST", f"/v2/sandcastles/{sandbox_id}/query", json=body)
4297

98+
# v2:covers POST /v2/sandcastles/:id/files
4399
def upload_file(self, sandbox_id: str, file: FileInput) -> Any:
44100
if isinstance(file, tuple):
45101
name, content = file
@@ -49,6 +105,6 @@ def upload_file(self, sandbox_id: str, file: FileInput) -> Any:
49105
content = p.read_bytes()
50106
return self._client.request(
51107
"POST",
52-
f"/v2/sandboxes/{sandbox_id}/files",
108+
f"/v2/sandcastles/{sandbox_id}/files",
53109
files=[("file", (name, content))],
54110
)

tests/routes.manifest.json

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
[
2+
"DELETE /v2/connectors/:id",
3+
"DELETE /v2/playbooks/:id",
4+
"DELETE /v2/sandcastles/:id",
5+
"GET /v2/chats",
6+
"GET /v2/chats/:id",
7+
"GET /v2/connectors",
8+
"GET /v2/connectors/types",
9+
"GET /v2/playbooks",
10+
"GET /v2/playbooks/:id",
11+
"GET /v2/sandcastles",
12+
"GET /v2/sandcastles/:id",
13+
"GET /v2/sandcastles/:id/executions",
14+
"PATCH /v2/connectors/:id",
15+
"PATCH /v2/playbooks/:id",
16+
"POST /v2/chats",
17+
"POST /v2/chats/:id/cancel",
18+
"POST /v2/chats/stream",
19+
"POST /v2/connectors",
20+
"POST /v2/connectors/test",
21+
"POST /v2/playbooks",
22+
"POST /v2/playbooks/:id/deploy",
23+
"POST /v2/playbooks/:id/run",
24+
"POST /v2/sandcastles",
25+
"POST /v2/sandcastles/:id/execute",
26+
"POST /v2/sandcastles/:id/files",
27+
"POST /v2/sandcastles/:id/query"
28+
]

0 commit comments

Comments
 (0)