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
6 changes: 4 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,18 @@ 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
## [2.1.0] — 2026-06-22

### 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.
- Sandbox: `exec` (bash/python command → stdout/stderr/exit_code), `list_files`, `download_file` (raw bytes), `delete_file`, `library_diff`, and `create_library_patch`.
- Models resource: `list` (`GET /v2/models`) — the models a key may pass as the chat `model` field.
- 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.
- Route-drift guard: `tests/test_route_coverage.py` + vendored `tests/routes.manifest.json` (33 routes), and a scheduled `route-sync` workflow that opens a PR when the backend route set changes.

## [2.0.0] — 2026-05-14

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ dependencies = [
dev = [
"pytest >=8",
"pytest-asyncio >=0.23",
"ruff >=0.6",
"ruff ==0.15.18",
"pyright >=1.1.380",
"respx >=0.21",
]
Expand Down
7 changes: 6 additions & 1 deletion src/textql/_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from ._version import __version__
from .resources.chat import Chat
from .resources.connectors import Connectors
from .resources.models import Models
from .resources.playbooks import Playbooks
from .resources.sandbox import Sandbox

Expand All @@ -30,6 +31,7 @@ class TextQL:

chat: Chat
connectors: Connectors
models: Models
playbooks: Playbooks
sandbox: Sandbox

Expand Down Expand Up @@ -60,6 +62,7 @@ def __init__(

self.chat = Chat(self)
self.connectors = Connectors(self)
self.models = Models(self)
self.playbooks = Playbooks(self)
self.sandbox = Sandbox(self)

Expand All @@ -70,7 +73,7 @@ def _default_headers(self) -> dict[str, str]:
"Accept": "application/json",
}

def request(self, method: str, path: str, **kwargs: Any) -> Any:
def request(self, method: str, path: str, *, raw: bool = False, **kwargs: Any) -> Any:
try:
resp = self._http.request(method, path, **kwargs)
except httpx.TimeoutException as e:
Expand All @@ -81,6 +84,8 @@ def request(self, method: str, path: str, **kwargs: Any) -> Any:
if resp.status_code >= 400:
self._raise_for_status(resp)

if raw:
return resp.content
if not resp.content:
return None
return resp.json()
Expand Down
3 changes: 2 additions & 1 deletion src/textql/resources/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from .chat import Chat
from .connectors import Connectors
from .models import Models
from .playbooks import Playbooks
from .sandbox import Sandbox

__all__ = ["Chat", "Connectors", "Playbooks", "Sandbox"]
__all__ = ["Chat", "Connectors", "Models", "Playbooks", "Sandbox"]
15 changes: 15 additions & 0 deletions src/textql/resources/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
from __future__ import annotations

from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
from .._client import TextQL


class Models:
def __init__(self, client: TextQL) -> None:
self._client = client

# v2:covers GET /v2/models
def list(self) -> Any:
return self._client.request("GET", "/v2/models")
60 changes: 60 additions & 0 deletions src/textql/resources/sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,3 +108,63 @@ def upload_file(self, sandbox_id: str, file: FileInput) -> Any:
f"/v2/sandcastles/{sandbox_id}/files",
files=[("file", (name, content))],
)

# v2:covers POST /v2/sandcastles/:id/exec
def exec(
self,
sandbox_id: str,
*,
command: str,
kind: str | None = None,
env: dict[str, str] | None = None,
) -> Any:
body: dict[str, Any] = {"command": command}
if kind is not None:
body["kind"] = kind
if env is not None:
body["env"] = env
return self._client.request("POST", f"/v2/sandcastles/{sandbox_id}/exec", json=body)

# v2:covers GET /v2/sandcastles/:id/files
def list_files(self, sandbox_id: str, *, path: str | None = None) -> Any:
params: dict[str, Any] = {}
if path is not None:
params["path"] = path
return self._client.request("GET", f"/v2/sandcastles/{sandbox_id}/files", params=params)

# v2:covers GET /v2/sandcastles/:id/files/*
def download_file(self, sandbox_id: str, file_path: str) -> bytes:
rel = file_path.lstrip("/")
return self._client.request("GET", f"/v2/sandcastles/{sandbox_id}/files/{rel}", raw=True)

# v2:covers DELETE /v2/sandcastles/:id/files/*
def delete_file(self, sandbox_id: str, file_path: str) -> Any:
rel = file_path.lstrip("/")
return self._client.request("DELETE", f"/v2/sandcastles/{sandbox_id}/files/{rel}")

# v2:covers GET /v2/sandcastles/:id/library/diff
def library_diff(self, sandbox_id: str) -> Any:
return self._client.request("GET", f"/v2/sandcastles/{sandbox_id}/library/diff")

# v2:covers POST /v2/sandcastles/:id/library/patches
def create_library_patch(
self,
sandbox_id: str,
*,
title: str | None = None,
description: str | None = None,
draft: bool | None = None,
patch_number: int | None = None,
) -> Any:
body: dict[str, Any] = {}
if title is not None:
body["title"] = title
if description is not None:
body["description"] = description
if draft is not None:
body["draft"] = draft
if patch_number is not None:
body["patch_number"] = patch_number
return self._client.request(
"POST", f"/v2/sandcastles/{sandbox_id}/library/patches", json=body
)
7 changes: 7 additions & 0 deletions tests/routes.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,20 @@
"DELETE /v2/connectors/:id",
"DELETE /v2/playbooks/:id",
"DELETE /v2/sandcastles/:id",
"DELETE /v2/sandcastles/:id/files/*",
"GET /v2/chats",
"GET /v2/chats/:id",
"GET /v2/connectors",
"GET /v2/connectors/types",
"GET /v2/models",
"GET /v2/playbooks",
"GET /v2/playbooks/:id",
"GET /v2/sandcastles",
"GET /v2/sandcastles/:id",
"GET /v2/sandcastles/:id/executions",
"GET /v2/sandcastles/:id/files",
"GET /v2/sandcastles/:id/files/*",
"GET /v2/sandcastles/:id/library/diff",
"PATCH /v2/connectors/:id",
"PATCH /v2/playbooks/:id",
"POST /v2/chats",
Expand All @@ -22,7 +27,9 @@
"POST /v2/playbooks/:id/deploy",
"POST /v2/playbooks/:id/run",
"POST /v2/sandcastles",
"POST /v2/sandcastles/:id/exec",
"POST /v2/sandcastles/:id/execute",
"POST /v2/sandcastles/:id/files",
"POST /v2/sandcastles/:id/library/patches",
"POST /v2/sandcastles/:id/query"
]
18 changes: 18 additions & 0 deletions tests/test_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,13 @@ def test_types(self, client):
assert isinstance(result["types"], list)


class TestModels:
def test_list(self, client):
result = client.models.list()
assert "models" in result
assert isinstance(result["models"], list)


class TestChat:
def test_list(self, client):
result = client.chat.list(limit=2)
Expand Down Expand Up @@ -135,6 +142,11 @@ def test_lifecycle(self, client):
assert "sandboxes" in listed
assert any(s["sandbox_id"] == sb_id for s in listed["sandboxes"])

# exec (bash)
ex = client.sandbox.exec(sb_id, command="echo hi")
assert ex["stdout"].strip() == "hi"
assert ex["exit_code"] == 0

# execute
result = client.sandbox.execute(sb_id, code="print(2 + 2)")
assert result["output"] == ["4"]
Expand Down Expand Up @@ -162,6 +174,12 @@ def test_lifecycle(self, client):
execs = client.sandbox.executions(sb_id)
assert "executions" in execs
assert isinstance(execs["executions"], list)

# files + library diff (read-only)
files = client.sandbox.list_files(sb_id)
assert "files" in files
diff = client.sandbox.library_diff(sb_id)
assert "has_changes" in diff
finally:
# stop (cleanup)
stopped = client.sandbox.stop(sb_id)
Expand Down
22 changes: 22 additions & 0 deletions tests/test_models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
from __future__ import annotations

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_models_list_path() -> None:
route = respx.get(f"{BASE}/v2/models").mock(
return_value=httpx.Response(200, json={"models": [{"id": "claude-opus-4-8"}]})
)
out = _client().models.list()
assert route.called
assert out["models"][0]["id"] == "claude-opus-4-8"
71 changes: 71 additions & 0 deletions tests/test_sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,3 +126,74 @@ def test_query_requires_exactly_one_of() -> None:
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")


@respx.mock
def test_exec_path_and_body() -> None:
route = respx.post(f"{BASE}/v2/sandcastles/sb1/exec").mock(
return_value=httpx.Response(200, json={"stdout": "hi\n", "exit_code": 0})
)
_client().sandbox.exec("sb1", command="echo hi", kind="bash", env={"X": "1"})
assert json.loads(route.calls.last.request.content) == {
"command": "echo hi",
"kind": "bash",
"env": {"X": "1"},
}


@respx.mock
def test_list_files_path_and_params() -> None:
route = respx.get(f"{BASE}/v2/sandcastles/sb1/files").mock(
return_value=httpx.Response(200, json={"files": []})
)
_client().sandbox.list_files("sb1", path="sub")
assert dict(route.calls.last.request.url.params) == {"path": "sub"}


@respx.mock
def test_download_file_returns_raw_bytes() -> None:
respx.get(f"{BASE}/v2/sandcastles/sb1/files/out/data.csv").mock(
return_value=httpx.Response(200, content=b"a,b\n1,2\n")
)
out = _client().sandbox.download_file("sb1", "out/data.csv")
assert out == b"a,b\n1,2\n"


@respx.mock
def test_download_file_strips_leading_slash() -> None:
route = respx.get(f"{BASE}/v2/sandcastles/sb1/files/x.txt").mock(
return_value=httpx.Response(200, content=b"x")
)
_client().sandbox.download_file("sb1", "/x.txt")
assert route.calls.last.request.url.path == "/v2/sandcastles/sb1/files/x.txt"


@respx.mock
def test_delete_file_path() -> None:
route = respx.delete(f"{BASE}/v2/sandcastles/sb1/files/x.txt").mock(
return_value=httpx.Response(200, json={"success": True})
)
_client().sandbox.delete_file("sb1", "x.txt")
assert route.called


@respx.mock
def test_library_diff_path() -> None:
route = respx.get(f"{BASE}/v2/sandcastles/sb1/library/diff").mock(
return_value=httpx.Response(200, json={"has_changes": False})
)
_client().sandbox.library_diff("sb1")
assert route.called


@respx.mock
def test_create_library_patch_body() -> None:
route = respx.post(f"{BASE}/v2/sandcastles/sb1/library/patches").mock(
return_value=httpx.Response(201, json={"patch_id": "p1"})
)
_client().sandbox.create_library_patch("sb1", title="t", description="d", draft=True)
assert json.loads(route.calls.last.request.content) == {
"title": "t",
"description": "d",
"draft": True,
}
Loading