Skip to content

Commit ace0ace

Browse files
jlsajfjclaude
andcommitted
Feat: Add models + sandbox file/exec/library routes (33/33 with prod)
Bundle the 7 routes prod gained after PR #1 so 2.1.0 ships in full sync: - Models resource: list (GET /v2/models) - Sandbox: exec (bash/python), list_files, download_file (raw bytes), delete_file, library_diff, create_library_patch - _client.request gains raw= for binary downloads - Vendored manifest bumped to 33; respx + live-staging coverage added Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 38a62fc commit ace0ace

9 files changed

Lines changed: 207 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,18 @@ 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
8+
## [2.1.0] — 2026-06-22
99

1010
### Fixed
1111
- Sandbox resource now targets `/v2/sandcastles`; the backend renamed the path from `/v2/sandboxes` with no alias, so every sandbox call was 404ing.
1212

1313
### Added
1414
- Sandbox: `list` and `executions` (both cursor-paginated), and an optional `sandbox_id` on `start` to restart a specific sandbox.
1515
- Sandbox `query`: `tql_path` (run a saved library `.tql`), `params`, and `max_rows`; enforces exactly one of `query`/`tql_path` client-side.
16+
- Sandbox: `exec` (bash/python command → stdout/stderr/exit_code), `list_files`, `download_file` (raw bytes), `delete_file`, `library_diff`, and `create_library_patch`.
17+
- Models resource: `list` (`GET /v2/models`) — the models a key may pass as the chat `model` field.
1618
- 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.
19+
- 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.
1820

1921
## [2.0.0] — 2026-05-14
2022

src/textql/_client.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from ._version import __version__
1919
from .resources.chat import Chat
2020
from .resources.connectors import Connectors
21+
from .resources.models import Models
2122
from .resources.playbooks import Playbooks
2223
from .resources.sandbox import Sandbox
2324

@@ -30,6 +31,7 @@ class TextQL:
3031

3132
chat: Chat
3233
connectors: Connectors
34+
models: Models
3335
playbooks: Playbooks
3436
sandbox: Sandbox
3537

@@ -60,6 +62,7 @@ def __init__(
6062

6163
self.chat = Chat(self)
6264
self.connectors = Connectors(self)
65+
self.models = Models(self)
6366
self.playbooks = Playbooks(self)
6467
self.sandbox = Sandbox(self)
6568

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

73-
def request(self, method: str, path: str, **kwargs: Any) -> Any:
76+
def request(self, method: str, path: str, *, raw: bool = False, **kwargs: Any) -> Any:
7477
try:
7578
resp = self._http.request(method, path, **kwargs)
7679
except httpx.TimeoutException as e:
@@ -81,6 +84,8 @@ def request(self, method: str, path: str, **kwargs: Any) -> Any:
8184
if resp.status_code >= 400:
8285
self._raise_for_status(resp)
8386

87+
if raw:
88+
return resp.content
8489
if not resp.content:
8590
return None
8691
return resp.json()

src/textql/resources/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from .chat import Chat
22
from .connectors import Connectors
3+
from .models import Models
34
from .playbooks import Playbooks
45
from .sandbox import Sandbox
56

6-
__all__ = ["Chat", "Connectors", "Playbooks", "Sandbox"]
7+
__all__ = ["Chat", "Connectors", "Models", "Playbooks", "Sandbox"]

src/textql/resources/models.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
from __future__ import annotations
2+
3+
from typing import TYPE_CHECKING, Any
4+
5+
if TYPE_CHECKING:
6+
from .._client import TextQL
7+
8+
9+
class Models:
10+
def __init__(self, client: TextQL) -> None:
11+
self._client = client
12+
13+
# v2:covers GET /v2/models
14+
def list(self) -> Any:
15+
return self._client.request("GET", "/v2/models")

src/textql/resources/sandbox.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,3 +108,65 @@ def upload_file(self, sandbox_id: str, file: FileInput) -> Any:
108108
f"/v2/sandcastles/{sandbox_id}/files",
109109
files=[("file", (name, content))],
110110
)
111+
112+
# v2:covers POST /v2/sandcastles/:id/exec
113+
def exec(
114+
self,
115+
sandbox_id: str,
116+
*,
117+
command: str,
118+
kind: str | None = None,
119+
env: dict[str, str] | None = None,
120+
) -> Any:
121+
body: dict[str, Any] = {"command": command}
122+
if kind is not None:
123+
body["kind"] = kind
124+
if env is not None:
125+
body["env"] = env
126+
return self._client.request("POST", f"/v2/sandcastles/{sandbox_id}/exec", json=body)
127+
128+
# v2:covers GET /v2/sandcastles/:id/files
129+
def list_files(self, sandbox_id: str, *, path: str | None = None) -> Any:
130+
params: dict[str, Any] = {}
131+
if path is not None:
132+
params["path"] = path
133+
return self._client.request("GET", f"/v2/sandcastles/{sandbox_id}/files", params=params)
134+
135+
# v2:covers GET /v2/sandcastles/:id/files/*
136+
def download_file(self, sandbox_id: str, file_path: str) -> bytes:
137+
rel = file_path.lstrip("/")
138+
return self._client.request(
139+
"GET", f"/v2/sandcastles/{sandbox_id}/files/{rel}", raw=True
140+
)
141+
142+
# v2:covers DELETE /v2/sandcastles/:id/files/*
143+
def delete_file(self, sandbox_id: str, file_path: str) -> Any:
144+
rel = file_path.lstrip("/")
145+
return self._client.request("DELETE", f"/v2/sandcastles/{sandbox_id}/files/{rel}")
146+
147+
# v2:covers GET /v2/sandcastles/:id/library/diff
148+
def library_diff(self, sandbox_id: str) -> Any:
149+
return self._client.request("GET", f"/v2/sandcastles/{sandbox_id}/library/diff")
150+
151+
# v2:covers POST /v2/sandcastles/:id/library/patches
152+
def create_library_patch(
153+
self,
154+
sandbox_id: str,
155+
*,
156+
title: str | None = None,
157+
description: str | None = None,
158+
draft: bool | None = None,
159+
patch_number: int | None = None,
160+
) -> Any:
161+
body: dict[str, Any] = {}
162+
if title is not None:
163+
body["title"] = title
164+
if description is not None:
165+
body["description"] = description
166+
if draft is not None:
167+
body["draft"] = draft
168+
if patch_number is not None:
169+
body["patch_number"] = patch_number
170+
return self._client.request(
171+
"POST", f"/v2/sandcastles/{sandbox_id}/library/patches", json=body
172+
)

tests/routes.manifest.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,20 @@
22
"DELETE /v2/connectors/:id",
33
"DELETE /v2/playbooks/:id",
44
"DELETE /v2/sandcastles/:id",
5+
"DELETE /v2/sandcastles/:id/files/*",
56
"GET /v2/chats",
67
"GET /v2/chats/:id",
78
"GET /v2/connectors",
89
"GET /v2/connectors/types",
10+
"GET /v2/models",
911
"GET /v2/playbooks",
1012
"GET /v2/playbooks/:id",
1113
"GET /v2/sandcastles",
1214
"GET /v2/sandcastles/:id",
1315
"GET /v2/sandcastles/:id/executions",
16+
"GET /v2/sandcastles/:id/files",
17+
"GET /v2/sandcastles/:id/files/*",
18+
"GET /v2/sandcastles/:id/library/diff",
1419
"PATCH /v2/connectors/:id",
1520
"PATCH /v2/playbooks/:id",
1621
"POST /v2/chats",
@@ -22,7 +27,9 @@
2227
"POST /v2/playbooks/:id/deploy",
2328
"POST /v2/playbooks/:id/run",
2429
"POST /v2/sandcastles",
30+
"POST /v2/sandcastles/:id/exec",
2531
"POST /v2/sandcastles/:id/execute",
2632
"POST /v2/sandcastles/:id/files",
33+
"POST /v2/sandcastles/:id/library/patches",
2734
"POST /v2/sandcastles/:id/query"
2835
]

tests/test_integration.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,13 @@ def test_types(self, client):
4242
assert isinstance(result["types"], list)
4343

4444

45+
class TestModels:
46+
def test_list(self, client):
47+
result = client.models.list()
48+
assert "models" in result
49+
assert isinstance(result["models"], list)
50+
51+
4552
class TestChat:
4653
def test_list(self, client):
4754
result = client.chat.list(limit=2)
@@ -135,6 +142,11 @@ def test_lifecycle(self, client):
135142
assert "sandboxes" in listed
136143
assert any(s["sandbox_id"] == sb_id for s in listed["sandboxes"])
137144

145+
# exec (bash)
146+
ex = client.sandbox.exec(sb_id, command="echo hi")
147+
assert ex["stdout"].strip() == "hi"
148+
assert ex["exit_code"] == 0
149+
138150
# execute
139151
result = client.sandbox.execute(sb_id, code="print(2 + 2)")
140152
assert result["output"] == ["4"]
@@ -162,6 +174,12 @@ def test_lifecycle(self, client):
162174
execs = client.sandbox.executions(sb_id)
163175
assert "executions" in execs
164176
assert isinstance(execs["executions"], list)
177+
178+
# files + library diff (read-only)
179+
files = client.sandbox.list_files(sb_id)
180+
assert "files" in files
181+
diff = client.sandbox.library_diff(sb_id)
182+
assert "has_changes" in diff
165183
finally:
166184
# stop (cleanup)
167185
stopped = client.sandbox.stop(sb_id)

tests/test_models.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
from __future__ import annotations
2+
3+
import httpx
4+
import respx
5+
6+
from textql import TextQL
7+
8+
BASE = "https://app.textql.com"
9+
10+
11+
def _client() -> TextQL:
12+
return TextQL(api_key="tql_test")
13+
14+
15+
@respx.mock
16+
def test_models_list_path() -> None:
17+
route = respx.get(f"{BASE}/v2/models").mock(
18+
return_value=httpx.Response(200, json={"models": [{"id": "claude-opus-4-8"}]})
19+
)
20+
out = _client().models.list()
21+
assert route.called
22+
assert out["models"][0]["id"] == "claude-opus-4-8"

tests/test_sandbox.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,3 +126,74 @@ def test_query_requires_exactly_one_of() -> None:
126126
c.sandbox.query("sb1", connector_id=2)
127127
with pytest.raises(ValueError, match="exactly one of query or tql_path"):
128128
c.sandbox.query("sb1", connector_id=2, query="x", tql_path="y")
129+
130+
131+
@respx.mock
132+
def test_exec_path_and_body() -> None:
133+
route = respx.post(f"{BASE}/v2/sandcastles/sb1/exec").mock(
134+
return_value=httpx.Response(200, json={"stdout": "hi\n", "exit_code": 0})
135+
)
136+
_client().sandbox.exec("sb1", command="echo hi", kind="bash", env={"X": "1"})
137+
assert json.loads(route.calls.last.request.content) == {
138+
"command": "echo hi",
139+
"kind": "bash",
140+
"env": {"X": "1"},
141+
}
142+
143+
144+
@respx.mock
145+
def test_list_files_path_and_params() -> None:
146+
route = respx.get(f"{BASE}/v2/sandcastles/sb1/files").mock(
147+
return_value=httpx.Response(200, json={"files": []})
148+
)
149+
_client().sandbox.list_files("sb1", path="sub")
150+
assert dict(route.calls.last.request.url.params) == {"path": "sub"}
151+
152+
153+
@respx.mock
154+
def test_download_file_returns_raw_bytes() -> None:
155+
respx.get(f"{BASE}/v2/sandcastles/sb1/files/out/data.csv").mock(
156+
return_value=httpx.Response(200, content=b"a,b\n1,2\n")
157+
)
158+
out = _client().sandbox.download_file("sb1", "out/data.csv")
159+
assert out == b"a,b\n1,2\n"
160+
161+
162+
@respx.mock
163+
def test_download_file_strips_leading_slash() -> None:
164+
route = respx.get(f"{BASE}/v2/sandcastles/sb1/files/x.txt").mock(
165+
return_value=httpx.Response(200, content=b"x")
166+
)
167+
_client().sandbox.download_file("sb1", "/x.txt")
168+
assert route.calls.last.request.url.path == "/v2/sandcastles/sb1/files/x.txt"
169+
170+
171+
@respx.mock
172+
def test_delete_file_path() -> None:
173+
route = respx.delete(f"{BASE}/v2/sandcastles/sb1/files/x.txt").mock(
174+
return_value=httpx.Response(200, json={"success": True})
175+
)
176+
_client().sandbox.delete_file("sb1", "x.txt")
177+
assert route.called
178+
179+
180+
@respx.mock
181+
def test_library_diff_path() -> None:
182+
route = respx.get(f"{BASE}/v2/sandcastles/sb1/library/diff").mock(
183+
return_value=httpx.Response(200, json={"has_changes": False})
184+
)
185+
_client().sandbox.library_diff("sb1")
186+
assert route.called
187+
188+
189+
@respx.mock
190+
def test_create_library_patch_body() -> None:
191+
route = respx.post(f"{BASE}/v2/sandcastles/sb1/library/patches").mock(
192+
return_value=httpx.Response(201, json={"patch_id": "p1"})
193+
)
194+
_client().sandbox.create_library_patch("sb1", title="t", description="d", draft=True)
195+
assert json.loads(route.calls.last.request.content) == {
196+
"title": "t",
197+
"description": "d",
198+
"draft": True,
199+
}

0 commit comments

Comments
 (0)