Skip to content

Commit 4444444

Browse files
committed
Feat: Platform V2 SDK
1 parent cc0f703 commit 4444444

10 files changed

Lines changed: 571 additions & 12 deletions

File tree

src/textql/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
RateLimitError,
1010
TextQLError,
1111
)
12+
from ._streaming import Stream
1213
from ._version import __version__
1314

1415
__all__ = [
@@ -19,6 +20,7 @@
1920
"NotFoundError",
2021
"PermissionDeniedError",
2122
"RateLimitError",
23+
"Stream",
2224
"TextQL",
2325
"TextQLError",
2426
"__version__",

src/textql/_client.py

Lines changed: 89 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,37 @@
11
from __future__ import annotations
22

33
import os
4-
from typing import Any
4+
from typing import Any, NoReturn
55

66
import httpx
77

8+
from ._exceptions import (
9+
APIConnectionError,
10+
APIError,
11+
APITimeoutError,
12+
AuthenticationError,
13+
NotFoundError,
14+
PermissionDeniedError,
15+
RateLimitError,
16+
)
17+
from ._streaming import Stream
818
from ._version import __version__
19+
from .resources.chat import Chat
20+
from .resources.connectors import Connectors
21+
from .resources.playbooks import Playbooks
22+
from .resources.sandbox import Sandbox
923

10-
DEFAULT_BASE_URL = "https://api.textql.com"
24+
DEFAULT_BASE_URL = "https://app.textql.com"
1125
DEFAULT_TIMEOUT = 60.0
1226

1327

1428
class TextQL:
15-
"""Synchronous client for the TextQL Platform API.
29+
"""Synchronous client for the TextQL v2 Platform API."""
1630

17-
Resource clients (`chat`, `playbooks`, `sandbox`, `connectors`) are stubs
18-
in v0.1.0 — they will be filled in as the v2 SDK takes shape.
19-
"""
31+
chat: Chat
32+
connectors: Connectors
33+
playbooks: Playbooks
34+
sandbox: Sandbox
2035

2136
def __init__(
2237
self,
@@ -28,26 +43,88 @@ def __init__(
2843
) -> None:
2944
key = api_key or os.environ.get("TEXTQL_API_KEY")
3045
if not key:
31-
raise ValueError(
32-
"No API key provided. Pass api_key=... or set TEXTQL_API_KEY in the environment."
33-
)
34-
46+
raise ValueError("No API key provided. Pass api_key=... or set TEXTQL_API_KEY.")
3547
self.api_key = key
36-
resolved_base = base_url or os.environ.get("TEXTQL_BASE_URL") or DEFAULT_BASE_URL
37-
self.base_url = resolved_base.rstrip("/")
48+
49+
resolved = base_url or os.environ.get("TEXTQL_BASE_URL") or DEFAULT_BASE_URL
50+
resolved = resolved.rstrip("/")
51+
if not resolved.startswith(("http://", "https://")):
52+
resolved = f"https://{resolved}"
53+
self.base_url = resolved
54+
3855
self._http = http_client or httpx.Client(
3956
base_url=self.base_url,
4057
timeout=timeout,
4158
headers=self._default_headers(),
4259
)
4360

61+
self.chat = Chat(self)
62+
self.connectors = Connectors(self)
63+
self.playbooks = Playbooks(self)
64+
self.sandbox = Sandbox(self)
65+
4466
def _default_headers(self) -> dict[str, str]:
4567
return {
4668
"Authorization": f"Bearer {self.api_key}",
4769
"User-Agent": f"textql-python/{__version__}",
4870
"Accept": "application/json",
4971
}
5072

73+
def _request(self, method: str, path: str, **kwargs: Any) -> Any:
74+
try:
75+
resp = self._http.request(method, path, **kwargs)
76+
except httpx.TimeoutException as e:
77+
raise APITimeoutError(str(e)) from e
78+
except httpx.ConnectError as e:
79+
raise APIConnectionError(str(e)) from e
80+
81+
if resp.status_code >= 400:
82+
self._raise_for_status(resp)
83+
84+
if not resp.content:
85+
return None
86+
return resp.json()
87+
88+
def _stream_request(self, method: str, path: str, **kwargs: Any) -> Stream:
89+
try:
90+
req = self._http.build_request(method, path, **kwargs)
91+
resp = self._http.send(req, stream=True)
92+
except httpx.TimeoutException as e:
93+
raise APITimeoutError(str(e)) from e
94+
except httpx.ConnectError as e:
95+
raise APIConnectionError(str(e)) from e
96+
97+
if resp.status_code >= 400:
98+
resp.read()
99+
resp.close()
100+
self._raise_for_status(resp)
101+
102+
return Stream(resp)
103+
104+
def _raise_for_status(self, response: httpx.Response) -> NoReturn:
105+
message = response.text
106+
request_id: str | None = None
107+
try:
108+
body = response.json()
109+
if isinstance(body, dict):
110+
error = body.get("error")
111+
if isinstance(error, dict):
112+
message = error.get("message", message)
113+
request_id = error.get("request")
114+
except Exception:
115+
pass
116+
117+
status = response.status_code
118+
if status == 401:
119+
raise AuthenticationError(message, status_code=status, request_id=request_id)
120+
if status == 403:
121+
raise PermissionDeniedError(message, status_code=status, request_id=request_id)
122+
if status == 404:
123+
raise NotFoundError(message, status_code=status, request_id=request_id)
124+
if status == 429:
125+
raise RateLimitError(message, status_code=status, request_id=request_id)
126+
raise APIError(message, status_code=status, request_id=request_id)
127+
51128
def close(self) -> None:
52129
self._http.close()
53130

src/textql/_streaming.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
from __future__ import annotations
2+
3+
import json
4+
from collections.abc import Iterator
5+
from typing import Any
6+
7+
8+
class Stream:
9+
"""Iterator over Server-Sent Events from a streaming response."""
10+
11+
def __init__(self, response: Any) -> None:
12+
self._response = response
13+
self._iterator = self._parse_sse()
14+
15+
def _parse_sse(self) -> Iterator[dict[str, Any]]:
16+
for line in self._response.iter_lines():
17+
if line.startswith("data: "):
18+
yield json.loads(line[6:])
19+
20+
def __iter__(self) -> Stream:
21+
return self
22+
23+
def __next__(self) -> dict[str, Any]:
24+
return next(self._iterator)
25+
26+
def close(self) -> None:
27+
self._response.close()
28+
29+
def __enter__(self) -> Stream:
30+
return self
31+
32+
def __exit__(self, *_args: Any) -> None:
33+
self.close()

src/textql/resources/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from .chat import Chat
2+
from .connectors import Connectors
3+
from .playbooks import Playbooks
4+
from .sandbox import Sandbox
5+
6+
__all__ = ["Chat", "Connectors", "Playbooks", "Sandbox"]

src/textql/resources/chat.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
from __future__ import annotations
2+
3+
from pathlib import Path
4+
from typing import TYPE_CHECKING, Any, Union
5+
6+
if TYPE_CHECKING:
7+
from .._client import TextQL
8+
from .._streaming import Stream
9+
10+
FileInput = Union[str, Path, "tuple[str, bytes]"]
11+
12+
13+
class Chat:
14+
def __init__(self, client: TextQL) -> None:
15+
self._client = client
16+
17+
def list(
18+
self,
19+
*,
20+
limit: int | None = None,
21+
offset: int | None = None,
22+
search_term: str | None = None,
23+
sort_by: str | None = None,
24+
sort_direction: str | None = None,
25+
) -> Any:
26+
params: dict[str, Any] = {}
27+
if limit is not None:
28+
params["limit"] = limit
29+
if offset is not None:
30+
params["offset"] = offset
31+
if search_term is not None:
32+
params["search_term"] = search_term
33+
if sort_by is not None:
34+
params["sort_by"] = sort_by
35+
if sort_direction is not None:
36+
params["sort_direction"] = sort_direction
37+
return self._client._request("GET", "/v2/chats", params=params)
38+
39+
def create(
40+
self,
41+
question: str,
42+
*,
43+
chat_id: str | None = None,
44+
connector_ids: list[int] | None = None,
45+
tools: dict[str, Any] | None = None,
46+
files: list[FileInput] | None = None,
47+
) -> Any:
48+
if files:
49+
return self._create_multipart(
50+
question, chat_id=chat_id, connector_ids=connector_ids, files=files
51+
)
52+
body: dict[str, Any] = {"question": question}
53+
if chat_id is not None:
54+
body["chat_id"] = chat_id
55+
if connector_ids is not None:
56+
body["connector_ids"] = connector_ids
57+
if tools is not None:
58+
body["tools"] = tools
59+
return self._client._request("POST", "/v2/chats", json=body)
60+
61+
def _create_multipart(
62+
self,
63+
question: str,
64+
*,
65+
chat_id: str | None = None,
66+
connector_ids: list[int] | None = None,
67+
files: list[FileInput],
68+
) -> Any:
69+
fields: list[tuple[str, str]] = [("question", question)]
70+
if chat_id is not None:
71+
fields.append(("chat_id", chat_id))
72+
if connector_ids is not None:
73+
for cid in connector_ids:
74+
fields.append(("connector_ids", str(cid)))
75+
76+
file_tuples: list[tuple[str, tuple[str, bytes]]] = []
77+
for f in files:
78+
if isinstance(f, tuple):
79+
file_tuples.append(("files", f))
80+
else:
81+
p = Path(f)
82+
file_tuples.append(("files", (p.name, p.read_bytes())))
83+
84+
return self._client._request("POST", "/v2/chats", data=fields, files=file_tuples)
85+
86+
def get(self, chat_id: str) -> Any:
87+
return self._client._request("GET", f"/v2/chats/{chat_id}")
88+
89+
def stream(
90+
self,
91+
question: str,
92+
*,
93+
chat_id: str | None = None,
94+
connector_ids: list[int] | None = None,
95+
tools: dict[str, Any] | None = None,
96+
) -> Stream:
97+
body: dict[str, Any] = {"question": question}
98+
if chat_id is not None:
99+
body["chat_id"] = chat_id
100+
if connector_ids is not None:
101+
body["connector_ids"] = connector_ids
102+
if tools is not None:
103+
body["tools"] = tools
104+
return self._client._stream_request("POST", "/v2/chats/stream", json=body)
105+
106+
def cancel(self, chat_id: str) -> Any:
107+
return self._client._request("POST", f"/v2/chats/{chat_id}/cancel")

src/textql/resources/connectors.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
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 Connectors:
10+
def __init__(self, client: TextQL) -> None:
11+
self._client = client
12+
13+
def list(self) -> Any:
14+
return self._client._request("GET", "/v2/connectors")

0 commit comments

Comments
 (0)