talqing 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- talqing/__init__.py +34 -0
- talqing/_transport.py +361 -0
- talqing/_version.py +3 -0
- talqing/client.py +280 -0
- talqing/errors.py +32 -0
- talqing/gen/__init__.py +10 -0
- talqing/gen/api.py +3583 -0
- talqing/gen/async_api.py +3595 -0
- talqing/gen/types.py +4605 -0
- talqing/py.typed +0 -0
- talqing-0.1.0.dist-info/METADATA +257 -0
- talqing-0.1.0.dist-info/RECORD +14 -0
- talqing-0.1.0.dist-info/WHEEL +4 -0
- talqing-0.1.0.dist-info/licenses/LICENSE +21 -0
talqing/__init__.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Python client for the Talqing API.
|
|
2
|
+
|
|
3
|
+
```python
|
|
4
|
+
from talqing import Talqing
|
|
5
|
+
|
|
6
|
+
with Talqing.from_env() as talqing:
|
|
7
|
+
agent = talqing.agents.get(agent_id)
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
The API surface under `talqing.agents`, `talqing.calls` and the rest is
|
|
11
|
+
generated from `openapi/openapi.json` — it is the same set of operations, under
|
|
12
|
+
the same names, as the TypeScript SDK. Every request and response shape is
|
|
13
|
+
exported from here too, as a TypedDict.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from ._transport import OMIT, AsyncStream, Stream
|
|
17
|
+
from ._version import __version__
|
|
18
|
+
from .client import AsyncTalqing, Talqing, paginate, paginate_async
|
|
19
|
+
from .errors import TalqingAPIError
|
|
20
|
+
from .gen.types import * # noqa: F403
|
|
21
|
+
from .gen.types import __all__ as _types
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"AsyncStream",
|
|
25
|
+
"AsyncTalqing",
|
|
26
|
+
"OMIT",
|
|
27
|
+
"Stream",
|
|
28
|
+
"Talqing",
|
|
29
|
+
"TalqingAPIError",
|
|
30
|
+
"__version__",
|
|
31
|
+
"paginate",
|
|
32
|
+
"paginate_async",
|
|
33
|
+
*_types,
|
|
34
|
+
]
|
talqing/_transport.py
ADDED
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
"""The half of the SDK the OpenAPI document cannot describe: the HTTP itself.
|
|
2
|
+
|
|
3
|
+
`gen/` says what every operation is; this says how one is sent. It carries the
|
|
4
|
+
credentials, turns a non-2xx into `TalqingAPIError`, reads an event stream, and
|
|
5
|
+
holds the sentinel that separates "leave this alone" from "set it to null".
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json as jsonlib
|
|
11
|
+
from types import TracebackType
|
|
12
|
+
from typing import Any, AsyncIterator, Generic, Iterator, Literal, Mapping, TypeVar
|
|
13
|
+
from urllib.parse import quote
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from ._version import __version__
|
|
18
|
+
from .errors import TalqingAPIError
|
|
19
|
+
|
|
20
|
+
T = TypeVar("T")
|
|
21
|
+
|
|
22
|
+
Expect = Literal["json", "none", "bytes"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class _Omit:
|
|
26
|
+
"""The absence of an argument, which is not the same as `None`.
|
|
27
|
+
|
|
28
|
+
Several PATCH bodies take `null` as a real value — `orgs.update(
|
|
29
|
+
retention_days=None)` means "keep everything forever" — so a default of
|
|
30
|
+
`None` would leave "leave the policy alone" unsayable. An argument still at
|
|
31
|
+
`OMIT` is not sent at all, and the server applies its own default.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
__slots__ = ()
|
|
35
|
+
|
|
36
|
+
def __repr__(self) -> str:
|
|
37
|
+
return "OMIT"
|
|
38
|
+
|
|
39
|
+
def __bool__(self) -> bool:
|
|
40
|
+
return False
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# Typed `Any` deliberately: this is the default of every optional argument in
|
|
44
|
+
# `gen/`, and typing it as itself would put `| _Omit` in three hundred
|
|
45
|
+
# signatures for no reader's benefit.
|
|
46
|
+
OMIT: Any = _Omit()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def encode_path(value: Any) -> str:
|
|
50
|
+
"""One path segment, escaped. `safe=""` because a path parameter is never a path."""
|
|
51
|
+
return quote(str(value), safe="")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def sent(values: Mapping[str, Any] | None) -> dict[str, Any] | None:
|
|
55
|
+
"""Drop the arguments the caller never passed."""
|
|
56
|
+
if values is None:
|
|
57
|
+
return None
|
|
58
|
+
return {key: value for key, value in values.items() if value is not OMIT}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def raise_for_status(response: httpx.Response) -> None:
|
|
62
|
+
if response.is_success:
|
|
63
|
+
return
|
|
64
|
+
# The contract is strict: the body is always {"detail": {"message", "errors"}}.
|
|
65
|
+
# The fallback below is for a failure that never reached the API at all — a
|
|
66
|
+
# proxy's HTML 502, a gateway timeout — not for a second response shape.
|
|
67
|
+
detail: Any
|
|
68
|
+
errors: list[str] = []
|
|
69
|
+
try:
|
|
70
|
+
detail = response.json()["detail"]
|
|
71
|
+
message = detail["message"]
|
|
72
|
+
errors = [str(error) for error in detail["errors"]]
|
|
73
|
+
except (ValueError, KeyError, TypeError):
|
|
74
|
+
detail = response.text or response.reason_phrase
|
|
75
|
+
message = detail
|
|
76
|
+
raise TalqingAPIError(
|
|
77
|
+
message,
|
|
78
|
+
status_code=response.status_code,
|
|
79
|
+
detail=detail,
|
|
80
|
+
errors=errors,
|
|
81
|
+
response=response,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class _Decoder:
|
|
86
|
+
"""The `text/event-stream` state machine, fed one line at a time.
|
|
87
|
+
|
|
88
|
+
A class rather than a generator because `async for` cannot drive a
|
|
89
|
+
synchronous one, and the parsing itself must not be written twice.
|
|
90
|
+
|
|
91
|
+
The `event:` line is not read. Every frame repeats its own name in the
|
|
92
|
+
JSON's `event` field — what the unions in `gen/types.py` discriminate on —
|
|
93
|
+
so reading it twice would only create somewhere for the two to disagree.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
def __init__(self) -> None:
|
|
97
|
+
self._data: list[str] = []
|
|
98
|
+
|
|
99
|
+
def feed(self, line: str) -> list[Any]:
|
|
100
|
+
"""The frames this line completed: none, or one. A list, so "not yet"
|
|
101
|
+
needs no sentinel to tell it from a frame that decoded to `None`."""
|
|
102
|
+
if line == "":
|
|
103
|
+
return self.flush()
|
|
104
|
+
if line.startswith(":"):
|
|
105
|
+
return [] # a keep-alive, sent every 15s so idle proxies hold on
|
|
106
|
+
field, _, value = line.partition(":")
|
|
107
|
+
if field == "data":
|
|
108
|
+
self._data.append(value[1:] if value.startswith(" ") else value)
|
|
109
|
+
return []
|
|
110
|
+
|
|
111
|
+
def flush(self) -> list[Any]:
|
|
112
|
+
if not self._data:
|
|
113
|
+
return []
|
|
114
|
+
frame = jsonlib.loads("\n".join(self._data))
|
|
115
|
+
self._data = []
|
|
116
|
+
return [frame]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _options(expect: Expect) -> dict[str, Any]:
|
|
120
|
+
"""The request options that depend on what is coming back.
|
|
121
|
+
|
|
122
|
+
The two recording endpoints answer `302` and the media is what the caller
|
|
123
|
+
asked for, so those follow the redirect. Nothing else in the API redirects,
|
|
124
|
+
and a POST that silently followed one would be a surprise worth avoiding.
|
|
125
|
+
httpx drops the Authorization header when a redirect leaves our origin,
|
|
126
|
+
which is what makes handing the request on to object storage safe.
|
|
127
|
+
"""
|
|
128
|
+
binary = expect == "bytes"
|
|
129
|
+
return {
|
|
130
|
+
"headers": {"Accept": "*/*" if binary else "application/json"},
|
|
131
|
+
"follow_redirects": binary,
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _decode(response: httpx.Response, expect: Expect) -> Any:
|
|
136
|
+
if expect == "none":
|
|
137
|
+
return None
|
|
138
|
+
if expect == "bytes":
|
|
139
|
+
return response.content
|
|
140
|
+
return response.json()
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _headers(token: str, extra: Mapping[str, str] | None) -> dict[str, str]:
|
|
144
|
+
headers = {
|
|
145
|
+
"Authorization": f"Bearer {token}",
|
|
146
|
+
"User-Agent": f"talqing-python/{__version__}",
|
|
147
|
+
}
|
|
148
|
+
headers.update(extra or {})
|
|
149
|
+
return headers
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class Stream(Generic[T]):
|
|
153
|
+
"""An open `text/event-stream`, as an iterator of decoded frames.
|
|
154
|
+
|
|
155
|
+
Iterating opens the connection and closes it when the stream ends or the
|
|
156
|
+
loop is left. Use it as a context manager when the loop may exit early::
|
|
157
|
+
|
|
158
|
+
with talqing.conversations.events(conversation_id) as events:
|
|
159
|
+
for event in events:
|
|
160
|
+
if event["event"] == "turn":
|
|
161
|
+
break
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
def __init__(
|
|
165
|
+
self, client: httpx.Client, url: str, params: Mapping[str, Any] | None
|
|
166
|
+
) -> None:
|
|
167
|
+
self._context = client.stream(
|
|
168
|
+
"GET", url, params=params, headers={"Accept": "text/event-stream"}
|
|
169
|
+
)
|
|
170
|
+
self._response: httpx.Response | None = None
|
|
171
|
+
self._spent = False
|
|
172
|
+
|
|
173
|
+
def __enter__(self) -> "Stream[T]":
|
|
174
|
+
self._open()
|
|
175
|
+
return self
|
|
176
|
+
|
|
177
|
+
def __exit__(
|
|
178
|
+
self,
|
|
179
|
+
kind: type[BaseException] | None,
|
|
180
|
+
error: BaseException | None,
|
|
181
|
+
trace: TracebackType | None,
|
|
182
|
+
) -> None:
|
|
183
|
+
self.close()
|
|
184
|
+
|
|
185
|
+
def __iter__(self) -> Iterator[T]:
|
|
186
|
+
self._open()
|
|
187
|
+
assert self._response is not None
|
|
188
|
+
decoder = _Decoder()
|
|
189
|
+
try:
|
|
190
|
+
for line in self._response.iter_lines():
|
|
191
|
+
yield from decoder.feed(line)
|
|
192
|
+
yield from decoder.flush()
|
|
193
|
+
finally:
|
|
194
|
+
self.close()
|
|
195
|
+
|
|
196
|
+
def _open(self) -> None:
|
|
197
|
+
if self._response is not None:
|
|
198
|
+
return
|
|
199
|
+
if self._spent:
|
|
200
|
+
raise RuntimeError("this stream is finished; open a new one to watch again")
|
|
201
|
+
self._response = self._context.__enter__()
|
|
202
|
+
if not self._response.is_success:
|
|
203
|
+
# Nothing has been read yet, so the error body is still on the wire.
|
|
204
|
+
self._response.read()
|
|
205
|
+
try:
|
|
206
|
+
raise_for_status(self._response)
|
|
207
|
+
finally:
|
|
208
|
+
self.close()
|
|
209
|
+
|
|
210
|
+
def close(self) -> None:
|
|
211
|
+
if self._response is None:
|
|
212
|
+
return
|
|
213
|
+
self._response = None
|
|
214
|
+
self._spent = True
|
|
215
|
+
self._context.__exit__(None, None, None)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class AsyncStream(Generic[T]):
|
|
219
|
+
"""`Stream`, awaited. See it for the shape; this differs only in `async for`."""
|
|
220
|
+
|
|
221
|
+
def __init__(
|
|
222
|
+
self, client: httpx.AsyncClient, url: str, params: Mapping[str, Any] | None
|
|
223
|
+
) -> None:
|
|
224
|
+
self._context = client.stream(
|
|
225
|
+
"GET", url, params=params, headers={"Accept": "text/event-stream"}
|
|
226
|
+
)
|
|
227
|
+
self._response: httpx.Response | None = None
|
|
228
|
+
self._spent = False
|
|
229
|
+
|
|
230
|
+
async def __aenter__(self) -> "AsyncStream[T]":
|
|
231
|
+
await self._open()
|
|
232
|
+
return self
|
|
233
|
+
|
|
234
|
+
async def __aexit__(
|
|
235
|
+
self,
|
|
236
|
+
kind: type[BaseException] | None,
|
|
237
|
+
error: BaseException | None,
|
|
238
|
+
trace: TracebackType | None,
|
|
239
|
+
) -> None:
|
|
240
|
+
await self.aclose()
|
|
241
|
+
|
|
242
|
+
async def __aiter__(self) -> AsyncIterator[T]:
|
|
243
|
+
await self._open()
|
|
244
|
+
assert self._response is not None
|
|
245
|
+
decoder = _Decoder()
|
|
246
|
+
try:
|
|
247
|
+
async for line in self._response.aiter_lines():
|
|
248
|
+
for frame in decoder.feed(line):
|
|
249
|
+
yield frame
|
|
250
|
+
for frame in decoder.flush():
|
|
251
|
+
yield frame
|
|
252
|
+
finally:
|
|
253
|
+
await self.aclose()
|
|
254
|
+
|
|
255
|
+
async def _open(self) -> None:
|
|
256
|
+
if self._response is not None:
|
|
257
|
+
return
|
|
258
|
+
if self._spent:
|
|
259
|
+
raise RuntimeError("this stream is finished; open a new one to watch again")
|
|
260
|
+
self._response = await self._context.__aenter__()
|
|
261
|
+
if not self._response.is_success:
|
|
262
|
+
await self._response.aread()
|
|
263
|
+
try:
|
|
264
|
+
raise_for_status(self._response)
|
|
265
|
+
finally:
|
|
266
|
+
await self.aclose()
|
|
267
|
+
|
|
268
|
+
async def aclose(self) -> None:
|
|
269
|
+
if self._response is None:
|
|
270
|
+
return
|
|
271
|
+
self._response = None
|
|
272
|
+
self._spent = True
|
|
273
|
+
await self._context.__aexit__(None, None, None)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
class Transport:
|
|
277
|
+
"""One configured `httpx.Client`, and the two things every method needs."""
|
|
278
|
+
|
|
279
|
+
def __init__(
|
|
280
|
+
self,
|
|
281
|
+
*,
|
|
282
|
+
base_url: str,
|
|
283
|
+
token: str,
|
|
284
|
+
timeout: float | httpx.Timeout,
|
|
285
|
+
headers: Mapping[str, str] | None,
|
|
286
|
+
transport: httpx.BaseTransport | None,
|
|
287
|
+
) -> None:
|
|
288
|
+
self.base_url = base_url
|
|
289
|
+
self.http = httpx.Client(
|
|
290
|
+
base_url=base_url,
|
|
291
|
+
timeout=timeout,
|
|
292
|
+
transport=transport,
|
|
293
|
+
headers=_headers(token, headers),
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
def request(
|
|
297
|
+
self,
|
|
298
|
+
method: str,
|
|
299
|
+
path: str,
|
|
300
|
+
*,
|
|
301
|
+
query: Mapping[str, Any] | None = None,
|
|
302
|
+
body: Mapping[str, Any] | None = None,
|
|
303
|
+
expect: Expect = "json",
|
|
304
|
+
) -> Any:
|
|
305
|
+
response = self.http.request(
|
|
306
|
+
method, path, params=sent(query), json=sent(body), **_options(expect)
|
|
307
|
+
)
|
|
308
|
+
raise_for_status(response)
|
|
309
|
+
return _decode(response, expect)
|
|
310
|
+
|
|
311
|
+
def stream(
|
|
312
|
+
self, path: str, *, query: Mapping[str, Any] | None = None
|
|
313
|
+
) -> Stream[Any]:
|
|
314
|
+
return Stream(self.http, path, sent(query))
|
|
315
|
+
|
|
316
|
+
def close(self) -> None:
|
|
317
|
+
self.http.close()
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
class AsyncTransport:
|
|
321
|
+
"""`Transport`, awaited."""
|
|
322
|
+
|
|
323
|
+
def __init__(
|
|
324
|
+
self,
|
|
325
|
+
*,
|
|
326
|
+
base_url: str,
|
|
327
|
+
token: str,
|
|
328
|
+
timeout: float | httpx.Timeout,
|
|
329
|
+
headers: Mapping[str, str] | None,
|
|
330
|
+
transport: httpx.AsyncBaseTransport | None,
|
|
331
|
+
) -> None:
|
|
332
|
+
self.base_url = base_url
|
|
333
|
+
self.http = httpx.AsyncClient(
|
|
334
|
+
base_url=base_url,
|
|
335
|
+
timeout=timeout,
|
|
336
|
+
transport=transport,
|
|
337
|
+
headers=_headers(token, headers),
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
async def request(
|
|
341
|
+
self,
|
|
342
|
+
method: str,
|
|
343
|
+
path: str,
|
|
344
|
+
*,
|
|
345
|
+
query: Mapping[str, Any] | None = None,
|
|
346
|
+
body: Mapping[str, Any] | None = None,
|
|
347
|
+
expect: Expect = "json",
|
|
348
|
+
) -> Any:
|
|
349
|
+
response = await self.http.request(
|
|
350
|
+
method, path, params=sent(query), json=sent(body), **_options(expect)
|
|
351
|
+
)
|
|
352
|
+
raise_for_status(response)
|
|
353
|
+
return _decode(response, expect)
|
|
354
|
+
|
|
355
|
+
def stream(
|
|
356
|
+
self, path: str, *, query: Mapping[str, Any] | None = None
|
|
357
|
+
) -> AsyncStream[Any]:
|
|
358
|
+
return AsyncStream(self.http, path, sent(query))
|
|
359
|
+
|
|
360
|
+
async def close(self) -> None:
|
|
361
|
+
await self.http.aclose()
|
talqing/_version.py
ADDED
talqing/client.py
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""The client itself: credentials, the two browser redirects, and pagination.
|
|
2
|
+
|
|
3
|
+
Every one of the API's operations is generated into `gen/` from
|
|
4
|
+
`openapi/openapi.json`. What is left here is only what the document cannot say.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
from types import TracebackType
|
|
11
|
+
from typing import Any, AsyncIterator, Awaitable, Callable, Iterator, Mapping, TypeVar
|
|
12
|
+
from urllib.parse import quote, urlencode
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from ._transport import AsyncTransport, Transport
|
|
17
|
+
from .gen.api import TalqingApi
|
|
18
|
+
from .gen.async_api import AsyncTalqingApi
|
|
19
|
+
from .gen.types import Page
|
|
20
|
+
|
|
21
|
+
T = TypeVar("T")
|
|
22
|
+
|
|
23
|
+
TOKEN_ENV = "TALQING_API_KEY"
|
|
24
|
+
BASE_URL_ENV = "TALQING_BASE_URL"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _token(value: str | None) -> str:
|
|
28
|
+
if value and value.strip():
|
|
29
|
+
return value.strip()
|
|
30
|
+
raise ValueError(
|
|
31
|
+
"a Talqing personal access token is required: pass token=..., or set "
|
|
32
|
+
f"{TOKEN_ENV} and use Talqing.from_env(). Create one on the dashboard's "
|
|
33
|
+
"Tokens page."
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _base_url(value: str | None) -> str:
|
|
38
|
+
if value and value.strip():
|
|
39
|
+
return value.strip().rstrip("/")
|
|
40
|
+
# No default, deliberately. A client that quietly points at localhost fails
|
|
41
|
+
# in production as a connection error three layers down; one that refuses to
|
|
42
|
+
# start says what is actually wrong.
|
|
43
|
+
raise ValueError(
|
|
44
|
+
"base_url is required: pass base_url='https://api.in.talqing.com', or "
|
|
45
|
+
f"set {BASE_URL_ENV} and use Talqing.from_env()."
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# Both routes are `include_in_schema=False` in the backend because they are
|
|
50
|
+
# browser redirects, not operations, so nothing generates them — which is also
|
|
51
|
+
# why the two clients must not each carry their own copy of the path.
|
|
52
|
+
def _google_login_url(base_url: str) -> str:
|
|
53
|
+
return f"{base_url}/v1/auth/google/start"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _oauth_start_url(base_url: str, provider: str, integration_id: str | None) -> str:
|
|
57
|
+
url = f"{base_url}/v1/integrations/oauth/{quote(provider, safe='')}/start"
|
|
58
|
+
if integration_id:
|
|
59
|
+
url += "?" + urlencode({"integration_id": integration_id})
|
|
60
|
+
return url
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _from_env(overrides: dict[str, Any]) -> dict[str, Any]:
|
|
64
|
+
return {
|
|
65
|
+
"token": os.getenv(TOKEN_ENV),
|
|
66
|
+
"base_url": os.getenv(BASE_URL_ENV),
|
|
67
|
+
**overrides,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class Talqing(TalqingApi):
|
|
72
|
+
"""A synchronous client for the Talqing API.
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
with Talqing(token=..., base_url="https://api.in.talqing.com") as talqing:
|
|
76
|
+
agent = talqing.agents.get(agent_id)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Operations are reached by resource, the way `backend/api/sdk_surface.py`
|
|
80
|
+
names them: `talqing.agents.versions.rollback(agent_id, 3)`. Every one of
|
|
81
|
+
them raises `TalqingAPIError` on a non-2xx and returns the decoded body
|
|
82
|
+
otherwise.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
def __init__(
|
|
86
|
+
self,
|
|
87
|
+
token: str | None = None,
|
|
88
|
+
*,
|
|
89
|
+
base_url: str | None = None,
|
|
90
|
+
timeout: float | httpx.Timeout = 30.0,
|
|
91
|
+
headers: Mapping[str, str] | None = None,
|
|
92
|
+
transport: httpx.BaseTransport | None = None,
|
|
93
|
+
) -> None:
|
|
94
|
+
super().__init__(
|
|
95
|
+
Transport(
|
|
96
|
+
base_url=_base_url(base_url),
|
|
97
|
+
token=_token(token),
|
|
98
|
+
timeout=timeout,
|
|
99
|
+
headers=headers,
|
|
100
|
+
transport=transport,
|
|
101
|
+
)
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
@classmethod
|
|
105
|
+
def from_env(cls, **overrides: Any) -> "Talqing":
|
|
106
|
+
"""Build a client from `TALQING_API_KEY` and `TALQING_BASE_URL`.
|
|
107
|
+
|
|
108
|
+
Anything passed here wins over the environment.
|
|
109
|
+
"""
|
|
110
|
+
return cls(**_from_env(overrides))
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def base_url(self) -> str:
|
|
114
|
+
return self._t.base_url
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def http(self) -> httpx.Client:
|
|
118
|
+
"""The underlying `httpx.Client`, already carrying the base URL and token."""
|
|
119
|
+
return self._t.http
|
|
120
|
+
|
|
121
|
+
def request(
|
|
122
|
+
self,
|
|
123
|
+
method: str,
|
|
124
|
+
path: str,
|
|
125
|
+
*,
|
|
126
|
+
query: Mapping[str, Any] | None = None,
|
|
127
|
+
body: Mapping[str, Any] | None = None,
|
|
128
|
+
) -> Any:
|
|
129
|
+
"""Call a path directly, with this client's credentials and error handling.
|
|
130
|
+
|
|
131
|
+
The escape hatch for an endpoint that shipped since this SDK was
|
|
132
|
+
generated. Everything the document knows about already has a method.
|
|
133
|
+
"""
|
|
134
|
+
return self._t.request(method, path, query=query, body=body)
|
|
135
|
+
|
|
136
|
+
def google_login_url(self) -> str:
|
|
137
|
+
"""Where to send a browser to start Google sign-in."""
|
|
138
|
+
return _google_login_url(self.base_url)
|
|
139
|
+
|
|
140
|
+
def oauth_start_url(self, provider: str, integration_id: str | None = None) -> str:
|
|
141
|
+
"""Where to send a browser to authorize an integration."""
|
|
142
|
+
return _oauth_start_url(self.base_url, provider, integration_id)
|
|
143
|
+
|
|
144
|
+
def close(self) -> None:
|
|
145
|
+
self._t.close()
|
|
146
|
+
|
|
147
|
+
def __enter__(self) -> "Talqing":
|
|
148
|
+
return self
|
|
149
|
+
|
|
150
|
+
def __exit__(
|
|
151
|
+
self,
|
|
152
|
+
kind: type[BaseException] | None,
|
|
153
|
+
error: BaseException | None,
|
|
154
|
+
trace: TracebackType | None,
|
|
155
|
+
) -> None:
|
|
156
|
+
self.close()
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class AsyncTalqing(AsyncTalqingApi):
|
|
160
|
+
"""`Talqing`, awaited. See it for the shape; every operation here is a coroutine.
|
|
161
|
+
|
|
162
|
+
```python
|
|
163
|
+
async with AsyncTalqing.from_env() as talqing:
|
|
164
|
+
agents = await talqing.agents.list()
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
The exception is a stream, which is not a coroutine on either client:
|
|
168
|
+
`async for event in talqing.conversations.events(conversation_id)`.
|
|
169
|
+
"""
|
|
170
|
+
|
|
171
|
+
def __init__(
|
|
172
|
+
self,
|
|
173
|
+
token: str | None = None,
|
|
174
|
+
*,
|
|
175
|
+
base_url: str | None = None,
|
|
176
|
+
timeout: float | httpx.Timeout = 30.0,
|
|
177
|
+
headers: Mapping[str, str] | None = None,
|
|
178
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
179
|
+
) -> None:
|
|
180
|
+
super().__init__(
|
|
181
|
+
AsyncTransport(
|
|
182
|
+
base_url=_base_url(base_url),
|
|
183
|
+
token=_token(token),
|
|
184
|
+
timeout=timeout,
|
|
185
|
+
headers=headers,
|
|
186
|
+
transport=transport,
|
|
187
|
+
)
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
@classmethod
|
|
191
|
+
def from_env(cls, **overrides: Any) -> "AsyncTalqing":
|
|
192
|
+
"""Build a client from `TALQING_API_KEY` and `TALQING_BASE_URL`."""
|
|
193
|
+
return cls(**_from_env(overrides))
|
|
194
|
+
|
|
195
|
+
@property
|
|
196
|
+
def base_url(self) -> str:
|
|
197
|
+
return self._t.base_url
|
|
198
|
+
|
|
199
|
+
@property
|
|
200
|
+
def http(self) -> httpx.AsyncClient:
|
|
201
|
+
"""The underlying `httpx.AsyncClient`, already carrying the base URL and token."""
|
|
202
|
+
return self._t.http
|
|
203
|
+
|
|
204
|
+
async def request(
|
|
205
|
+
self,
|
|
206
|
+
method: str,
|
|
207
|
+
path: str,
|
|
208
|
+
*,
|
|
209
|
+
query: Mapping[str, Any] | None = None,
|
|
210
|
+
body: Mapping[str, Any] | None = None,
|
|
211
|
+
) -> Any:
|
|
212
|
+
"""Call a path directly, with this client's credentials and error handling."""
|
|
213
|
+
return await self._t.request(method, path, query=query, body=body)
|
|
214
|
+
|
|
215
|
+
def google_login_url(self) -> str:
|
|
216
|
+
"""Where to send a browser to start Google sign-in."""
|
|
217
|
+
return _google_login_url(self.base_url)
|
|
218
|
+
|
|
219
|
+
def oauth_start_url(self, provider: str, integration_id: str | None = None) -> str:
|
|
220
|
+
"""Where to send a browser to authorize an integration."""
|
|
221
|
+
return _oauth_start_url(self.base_url, provider, integration_id)
|
|
222
|
+
|
|
223
|
+
async def close(self) -> None:
|
|
224
|
+
await self._t.close()
|
|
225
|
+
|
|
226
|
+
async def __aenter__(self) -> "AsyncTalqing":
|
|
227
|
+
return self
|
|
228
|
+
|
|
229
|
+
async def __aexit__(
|
|
230
|
+
self,
|
|
231
|
+
kind: type[BaseException] | None,
|
|
232
|
+
error: BaseException | None,
|
|
233
|
+
trace: TracebackType | None,
|
|
234
|
+
) -> None:
|
|
235
|
+
await self.close()
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def paginate(
|
|
239
|
+
page: Callable[..., Page[T]], *, limit: int = 200, **filters: Any
|
|
240
|
+
) -> Iterator[T]:
|
|
241
|
+
"""Walk every page of a list endpoint.
|
|
242
|
+
|
|
243
|
+
```python
|
|
244
|
+
for agent in paginate(talqing.agents.list):
|
|
245
|
+
...
|
|
246
|
+
for call in paginate(talqing.calls.list, agent_id=agent_id):
|
|
247
|
+
...
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
One helper rather than a `list_all_*` per endpoint: all 28 list endpoints
|
|
251
|
+
page the same way, so this does too. Anything else the endpoint filters on
|
|
252
|
+
is passed straight through.
|
|
253
|
+
"""
|
|
254
|
+
offset = 0
|
|
255
|
+
while True:
|
|
256
|
+
result = page(limit=limit, offset=offset, **filters)
|
|
257
|
+
yield from result["items"]
|
|
258
|
+
if not result["has_more"]:
|
|
259
|
+
return
|
|
260
|
+
offset += limit
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
async def paginate_async(
|
|
264
|
+
page: Callable[..., Awaitable[Page[T]]], *, limit: int = 200, **filters: Any
|
|
265
|
+
) -> AsyncIterator[T]:
|
|
266
|
+
"""`paginate`, awaited.
|
|
267
|
+
|
|
268
|
+
```python
|
|
269
|
+
async for agent in paginate_async(talqing.agents.list):
|
|
270
|
+
...
|
|
271
|
+
```
|
|
272
|
+
"""
|
|
273
|
+
offset = 0
|
|
274
|
+
while True:
|
|
275
|
+
result = await page(limit=limit, offset=offset, **filters)
|
|
276
|
+
for item in result["items"]:
|
|
277
|
+
yield item
|
|
278
|
+
if not result["has_more"]:
|
|
279
|
+
return
|
|
280
|
+
offset += limit
|
talqing/errors.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""The one exception this SDK raises."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TalqingAPIError(Exception):
|
|
11
|
+
"""Every error the API returns, in the one shape it returns them in.
|
|
12
|
+
|
|
13
|
+
The wire body is always ``{"detail": {"message", "errors"}}``, so there is
|
|
14
|
+
nothing to branch on: ``str(exc)`` is the message, ``exc.errors`` lists the
|
|
15
|
+
per-field problems when the failure had more than one, and ``exc.detail`` is
|
|
16
|
+
the decoded body for anything else you want off it.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
message: str,
|
|
22
|
+
*,
|
|
23
|
+
status_code: int,
|
|
24
|
+
detail: Any = None,
|
|
25
|
+
errors: Optional[list[str]] = None,
|
|
26
|
+
response: Optional[httpx.Response] = None,
|
|
27
|
+
) -> None:
|
|
28
|
+
super().__init__(message)
|
|
29
|
+
self.status_code = status_code
|
|
30
|
+
self.detail = detail
|
|
31
|
+
self.errors = errors or []
|
|
32
|
+
self.response = response
|