diff --git a/CHANGELOG.md b/CHANGELOG.md index 001e72f..23b354d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ 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. @@ -13,8 +13,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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 diff --git a/pyproject.toml b/pyproject.toml index 0a5fb7d..df4d605 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", ] diff --git a/src/textql/_client.py b/src/textql/_client.py index d854fab..6dc6293 100644 --- a/src/textql/_client.py +++ b/src/textql/_client.py @@ -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 @@ -30,6 +31,7 @@ class TextQL: chat: Chat connectors: Connectors + models: Models playbooks: Playbooks sandbox: Sandbox @@ -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) @@ -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: @@ -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() diff --git a/src/textql/resources/__init__.py b/src/textql/resources/__init__.py index 81d63a9..6340371 100644 --- a/src/textql/resources/__init__.py +++ b/src/textql/resources/__init__.py @@ -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"] diff --git a/src/textql/resources/models.py b/src/textql/resources/models.py new file mode 100644 index 0000000..6dbc807 --- /dev/null +++ b/src/textql/resources/models.py @@ -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") diff --git a/src/textql/resources/sandbox.py b/src/textql/resources/sandbox.py index c3d78fa..0096d53 100644 --- a/src/textql/resources/sandbox.py +++ b/src/textql/resources/sandbox.py @@ -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 + ) diff --git a/tests/routes.manifest.json b/tests/routes.manifest.json index 0c5ed3e..1bb7304 100644 --- a/tests/routes.manifest.json +++ b/tests/routes.manifest.json @@ -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", @@ -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" ] diff --git a/tests/test_integration.py b/tests/test_integration.py index 9306939..3fa25bb 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -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) @@ -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"] @@ -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) diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..81e6e22 --- /dev/null +++ b/tests/test_models.py @@ -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" diff --git a/tests/test_sandbox.py b/tests/test_sandbox.py index df07b4d..bad8fa3 100644 --- a/tests/test_sandbox.py +++ b/tests/test_sandbox.py @@ -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, + }