Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions .github/workflows/route-sync.yml
Original file line number Diff line number Diff line change
@@ -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.
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/textql/_version.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "2.0.0"
__version__ = "2.1.0"
5 changes: 5 additions & 0 deletions src/textql/resources/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ class Chat:
def __init__(self, client: TextQL) -> None:
self._client = client

# v2:covers GET /v2/chats
def list(
self,
*,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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")
47 changes: 47 additions & 0 deletions src/textql/resources/connectors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
7 changes: 7 additions & 0 deletions src/textql/resources/playbooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ class Playbooks:
def __init__(self, client: TextQL) -> None:
self._client = client

# v2:covers GET /v2/playbooks
def list(
self,
*,
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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:
Expand Down
74 changes: 65 additions & 9 deletions src/textql/resources/sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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))],
)
28 changes: 28 additions & 0 deletions tests/routes.manifest.json
Original file line number Diff line number Diff line change
@@ -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"
]
Loading
Loading