diff --git a/.github/workflows/route-sync.yml b/.github/workflows/route-sync.yml new file mode 100644 index 0000000..6480db3 --- /dev/null +++ b/.github/workflows/route-sync.yml @@ -0,0 +1,88 @@ +name: Route Sync + +# Opens a PR when the backend's canonical v2 route manifest drifts from the +# vendored copy; that PR fails test_route_coverage until the matching SDK +# methods + `# v2:covers` comments are added. +# +# 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: + - cron: "0 12 * * *" + workflow_dispatch: + +permissions: + contents: read + +jobs: + sync: + runs-on: ubuntu-latest + env: + BACKEND_REPO_NAME: ${{ vars.BACKEND_REPO_NAME || 'demo2' }} + MANIFEST_PATH: ${{ vars.BACKEND_MANIFEST_PATH || 'compute/pkg/platform/v2/routes.manifest.json' }} + # 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 + 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: 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.token.outputs.value }} + run: | + set -euo pipefail + 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" + - name: Open PR + if: ${{ steps.fetch.outputs.changed == 'true' }} + uses: peter-evans/create-pull-request@v6 + with: + token: ${{ steps.token.outputs.value }} + 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")