dialgem 0.1.0b1__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.
- dialgem/__init__.py +70 -0
- dialgem/_base_client.py +493 -0
- dialgem/_client.py +153 -0
- dialgem/_errors.py +102 -0
- dialgem/_pagination.py +128 -0
- dialgem/_resource.py +30 -0
- dialgem/_version.py +1 -0
- dialgem/_webhooks.py +164 -0
- dialgem/aresources/__init__.py +15 -0
- dialgem/aresources/account.py +91 -0
- dialgem/aresources/agents.py +234 -0
- dialgem/aresources/calls.py +129 -0
- dialgem/aresources/catalog.py +135 -0
- dialgem/aresources/chats.py +101 -0
- dialgem/aresources/contacts.py +207 -0
- dialgem/aresources/flows.py +72 -0
- dialgem/aresources/llms.py +59 -0
- dialgem/aresources/phone_numbers.py +82 -0
- dialgem/aresources/simulations.py +123 -0
- dialgem/aresources/web_calls.py +44 -0
- dialgem/py.typed +0 -0
- dialgem/resources/__init__.py +15 -0
- dialgem/resources/account.py +89 -0
- dialgem/resources/agents.py +232 -0
- dialgem/resources/calls.py +127 -0
- dialgem/resources/catalog.py +133 -0
- dialgem/resources/chats.py +99 -0
- dialgem/resources/contacts.py +205 -0
- dialgem/resources/flows.py +70 -0
- dialgem/resources/llms.py +57 -0
- dialgem/resources/phone_numbers.py +80 -0
- dialgem/resources/simulations.py +121 -0
- dialgem/resources/web_calls.py +42 -0
- dialgem/types/__init__.py +0 -0
- dialgem/types/_base.py +26 -0
- dialgem/types/_shared.py +21 -0
- dialgem/types/agent.py +115 -0
- dialgem/types/agent_resources.py +91 -0
- dialgem/types/agent_version.py +64 -0
- dialgem/types/api_key.py +36 -0
- dialgem/types/call.py +159 -0
- dialgem/types/catalog.py +93 -0
- dialgem/types/chat.py +68 -0
- dialgem/types/contact.py +139 -0
- dialgem/types/flow.py +30 -0
- dialgem/types/llm.py +32 -0
- dialgem/types/params.py +452 -0
- dialgem/types/phone_number.py +80 -0
- dialgem/types/session.py +51 -0
- dialgem/types/simulation.py +132 -0
- dialgem/types/web_call.py +31 -0
- dialgem/types/webhook.py +145 -0
- dialgem-0.1.0b1.dist-info/METADATA +281 -0
- dialgem-0.1.0b1.dist-info/RECORD +56 -0
- dialgem-0.1.0b1.dist-info/WHEEL +4 -0
- dialgem-0.1.0b1.dist-info/licenses/LICENSE +202 -0
dialgem/__init__.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""The official Python SDK for the Dialgem voice AI API.
|
|
2
|
+
|
|
3
|
+
from dialgem import Dialgem
|
|
4
|
+
|
|
5
|
+
dialgem = Dialgem() # reads DIALGEM_API_KEY
|
|
6
|
+
agent = dialgem.agents.create(name="Front desk", system_prompt="You answer the phone.")
|
|
7
|
+
|
|
8
|
+
Server-side only. To let a visitor talk to an agent from a web page, create a call with
|
|
9
|
+
``dialgem.web_calls.create(...)`` and hand the token to ``@dialgem/client`` in the browser.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from dialgem._base_client import BASE_URL, RequestOptions
|
|
13
|
+
from dialgem._client import AsyncDialgem, Dialgem
|
|
14
|
+
from dialgem._errors import (
|
|
15
|
+
APIConnectionError,
|
|
16
|
+
APIError,
|
|
17
|
+
APITimeoutError,
|
|
18
|
+
AuthenticationError,
|
|
19
|
+
BadRequestError,
|
|
20
|
+
ConflictError,
|
|
21
|
+
DialgemError,
|
|
22
|
+
InternalServerError,
|
|
23
|
+
NotFoundError,
|
|
24
|
+
PermissionDeniedError,
|
|
25
|
+
RateLimitError,
|
|
26
|
+
UnprocessableEntityError,
|
|
27
|
+
WebhookVerificationError,
|
|
28
|
+
)
|
|
29
|
+
from dialgem._pagination import AsyncPage, AsyncPaginator, SyncPage
|
|
30
|
+
from dialgem._version import VERSION as __version__
|
|
31
|
+
from dialgem._webhooks import (
|
|
32
|
+
DEFAULT_SIGNATURE_TOLERANCE_MS,
|
|
33
|
+
WEBHOOK_DELIVERY_HEADER,
|
|
34
|
+
WEBHOOK_EVENT_HEADER,
|
|
35
|
+
WEBHOOK_PREVIOUS_SIGNATURE_HEADER,
|
|
36
|
+
WEBHOOK_SIGNATURE_HEADER,
|
|
37
|
+
Webhooks,
|
|
38
|
+
webhooks,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
"APIConnectionError",
|
|
43
|
+
"APIError",
|
|
44
|
+
"APITimeoutError",
|
|
45
|
+
"AsyncDialgem",
|
|
46
|
+
"AsyncPage",
|
|
47
|
+
"AsyncPaginator",
|
|
48
|
+
"AuthenticationError",
|
|
49
|
+
"BASE_URL",
|
|
50
|
+
"BadRequestError",
|
|
51
|
+
"ConflictError",
|
|
52
|
+
"DEFAULT_SIGNATURE_TOLERANCE_MS",
|
|
53
|
+
"Dialgem",
|
|
54
|
+
"DialgemError",
|
|
55
|
+
"InternalServerError",
|
|
56
|
+
"NotFoundError",
|
|
57
|
+
"PermissionDeniedError",
|
|
58
|
+
"RateLimitError",
|
|
59
|
+
"RequestOptions",
|
|
60
|
+
"SyncPage",
|
|
61
|
+
"UnprocessableEntityError",
|
|
62
|
+
"WEBHOOK_DELIVERY_HEADER",
|
|
63
|
+
"WEBHOOK_EVENT_HEADER",
|
|
64
|
+
"WEBHOOK_PREVIOUS_SIGNATURE_HEADER",
|
|
65
|
+
"WEBHOOK_SIGNATURE_HEADER",
|
|
66
|
+
"WebhookVerificationError",
|
|
67
|
+
"Webhooks",
|
|
68
|
+
"__version__",
|
|
69
|
+
"webhooks",
|
|
70
|
+
]
|
dialgem/_base_client.py
ADDED
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
"""The HTTP engine: auth, retries, timeouts, error mapping and response parsing.
|
|
2
|
+
|
|
3
|
+
Rules, all deliberate and shared with the TypeScript SDK:
|
|
4
|
+
|
|
5
|
+
* **Auth is** ``X-API-Key``. ``Authorization: Bearer`` is only tried as a session JWT by the API.
|
|
6
|
+
* **POST is never retried by default.** GET, PUT, PATCH and DELETE retry on 408/429/5xx and on
|
|
7
|
+
connection errors, honouring ``Retry-After``. A POST retried after an ambiguous 502 can dial a
|
|
8
|
+
person twice; opt in per request with ``request_options={"max_retries": n}``.
|
|
9
|
+
* **Redirects are never followed.** The API does not redirect; following one could re-send the
|
|
10
|
+
API key to wherever ``Location`` points. A 3xx raises :class:`APIError`.
|
|
11
|
+
* **A multipart upload is never retried**, even on request: its parts may be one-shot streams.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import asyncio
|
|
17
|
+
import email.utils
|
|
18
|
+
import json
|
|
19
|
+
import platform
|
|
20
|
+
import random
|
|
21
|
+
import time
|
|
22
|
+
import warnings
|
|
23
|
+
from typing import Any, Dict, List, Mapping, Optional, Set, Tuple, Type, TypeVar
|
|
24
|
+
|
|
25
|
+
import httpx
|
|
26
|
+
from pydantic import BaseModel, TypeAdapter, ValidationError
|
|
27
|
+
from typing_extensions import TypedDict
|
|
28
|
+
|
|
29
|
+
from dialgem._errors import APIConnectionError, APIError, APITimeoutError, DialgemError
|
|
30
|
+
from dialgem._pagination import AsyncPaginator, PageData, SyncPage
|
|
31
|
+
from dialgem._version import VERSION
|
|
32
|
+
|
|
33
|
+
BASE_URL = "https://api.dialgem.com"
|
|
34
|
+
DEFAULT_TIMEOUT = 60.0
|
|
35
|
+
DEFAULT_MAX_RETRIES = 2
|
|
36
|
+
MAX_RETRY_DELAY = 60.0
|
|
37
|
+
_RETRYABLE_METHODS = frozenset({"GET", "PUT", "PATCH", "DELETE"})
|
|
38
|
+
|
|
39
|
+
T = TypeVar("T")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class RequestOptions(TypedDict, total=False):
|
|
43
|
+
"""Per-request overrides, accepted as ``request_options=`` by every resource method."""
|
|
44
|
+
|
|
45
|
+
#: Seconds before an attempt is abandoned. Applies per attempt, not across retries.
|
|
46
|
+
timeout: float
|
|
47
|
+
#: Retries on 408/429/5xx and connection errors. POST defaults to 0; set it to opt in.
|
|
48
|
+
max_retries: int
|
|
49
|
+
#: Extra headers for this request only.
|
|
50
|
+
headers: Dict[str, str]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
Query = Mapping[str, Any]
|
|
54
|
+
CastTo = Any
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# --------------------------------------------------------------------------- shared helpers
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _user_agent() -> str:
|
|
61
|
+
return f"dialgem-sdk-python/{VERSION} python/{platform.python_version()}"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _build_headers(
|
|
65
|
+
api_key: str, default_headers: Mapping[str, str], extra: Optional[Mapping[str, str]], has_json_body: bool
|
|
66
|
+
) -> Dict[str, str]:
|
|
67
|
+
# Merged case-insensitively, later layers winning, so a caller's ``x-api-key`` replaces ours
|
|
68
|
+
# instead of being sent alongside it.
|
|
69
|
+
merged: Dict[str, Tuple[str, str]] = {}
|
|
70
|
+
|
|
71
|
+
def layer(headers: Optional[Mapping[str, str]]) -> None:
|
|
72
|
+
for name, value in (headers or {}).items():
|
|
73
|
+
merged[name.lower()] = (name, value)
|
|
74
|
+
|
|
75
|
+
layer({"Accept": "application/json", "X-API-Key": api_key, "User-Agent": _user_agent()})
|
|
76
|
+
layer(default_headers)
|
|
77
|
+
layer(extra)
|
|
78
|
+
if has_json_body:
|
|
79
|
+
layer({"Content-Type": "application/json"})
|
|
80
|
+
return dict(merged.values())
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _clean_query(query: Optional[Query]) -> Optional[List[Tuple[str, str]]]:
|
|
84
|
+
"""``None`` values are dropped; lists repeat the parameter; booleans are ``true``/``false``."""
|
|
85
|
+
if not query:
|
|
86
|
+
return None
|
|
87
|
+
pairs: List[Tuple[str, str]] = []
|
|
88
|
+
for key, value in query.items():
|
|
89
|
+
if value is None:
|
|
90
|
+
continue
|
|
91
|
+
for item in value if isinstance(value, (list, tuple)) else [value]:
|
|
92
|
+
pairs.append((key, "true" if item is True else "false" if item is False else str(item)))
|
|
93
|
+
return pairs
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _clean_body(body: Optional[Mapping[str, Any]]) -> Optional[Dict[str, Any]]:
|
|
97
|
+
return None if body is None else dict(body)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _is_retryable_status(status: int) -> bool:
|
|
101
|
+
# Not 409: the API's 409s are real conflicts, which a retry would only repeat.
|
|
102
|
+
return status in (408, 429) or status >= 500
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _backoff(attempt: int) -> float:
|
|
106
|
+
base = min(0.5 * 2**attempt, 8.0)
|
|
107
|
+
return base * (1 - random.random() * 0.25)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _retry_after(headers: Mapping[str, str]) -> Optional[float]:
|
|
111
|
+
ms = headers.get("retry-after-ms")
|
|
112
|
+
if ms:
|
|
113
|
+
try:
|
|
114
|
+
value = float(ms) / 1000
|
|
115
|
+
if value > 0:
|
|
116
|
+
return min(value, MAX_RETRY_DELAY)
|
|
117
|
+
except ValueError:
|
|
118
|
+
pass
|
|
119
|
+
raw = headers.get("retry-after")
|
|
120
|
+
if not raw:
|
|
121
|
+
return None
|
|
122
|
+
try:
|
|
123
|
+
seconds = float(raw)
|
|
124
|
+
if seconds >= 0:
|
|
125
|
+
return min(seconds, MAX_RETRY_DELAY)
|
|
126
|
+
except ValueError:
|
|
127
|
+
pass
|
|
128
|
+
parsed = email.utils.parsedate_to_datetime(raw) if raw else None
|
|
129
|
+
if parsed is not None:
|
|
130
|
+
return min(max(parsed.timestamp() - time.time(), 0.0), MAX_RETRY_DELAY)
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _validate_timeout(value: float) -> float:
|
|
135
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)) or value <= 0:
|
|
136
|
+
raise DialgemError(f"`timeout` must be a positive number of seconds, got {value!r}.")
|
|
137
|
+
return float(value)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _validate_retries(value: int) -> int:
|
|
141
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
|
142
|
+
raise DialgemError(f"`max_retries` must be a non-negative integer, got {value!r}.")
|
|
143
|
+
return value
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _parse_success(response: httpx.Response) -> Any:
|
|
147
|
+
text = response.text
|
|
148
|
+
if response.status_code == 204 or not text.strip():
|
|
149
|
+
return None
|
|
150
|
+
try:
|
|
151
|
+
return json.loads(text)
|
|
152
|
+
except ValueError as exc:
|
|
153
|
+
raise DialgemError(
|
|
154
|
+
f"Expected a JSON response (status {response.status_code}) but could not parse it: {exc}"
|
|
155
|
+
) from exc
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _parse_error_body(response: httpx.Response) -> Any:
|
|
159
|
+
text = response.text
|
|
160
|
+
if not text:
|
|
161
|
+
return None
|
|
162
|
+
try:
|
|
163
|
+
return json.loads(text)
|
|
164
|
+
except ValueError:
|
|
165
|
+
return text
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
_adapters: Dict[Any, TypeAdapter[Any]] = {}
|
|
169
|
+
_warned: Set[str] = set()
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _adapter(cast_to: Any) -> TypeAdapter[Any]:
|
|
173
|
+
try:
|
|
174
|
+
return _adapters[cast_to]
|
|
175
|
+
except KeyError:
|
|
176
|
+
return _adapters.setdefault(cast_to, TypeAdapter(cast_to))
|
|
177
|
+
except TypeError: # unhashable annotation
|
|
178
|
+
return TypeAdapter(cast_to)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def cast_response(data: Any, cast_to: CastTo) -> Any:
|
|
182
|
+
"""Turn parsed JSON into ``cast_to``.
|
|
183
|
+
|
|
184
|
+
The API adds fields over time, so a response that does not fit the model exactly (a field
|
|
185
|
+
changed type, a required one went missing) must not take a customer's integration down. On a
|
|
186
|
+
validation failure the data is returned *unvalidated* in the model (``model_construct``) and
|
|
187
|
+
a :class:`UserWarning` is issued once per model, so the drift is visible but not fatal.
|
|
188
|
+
"""
|
|
189
|
+
if cast_to is None or data is None:
|
|
190
|
+
return data
|
|
191
|
+
if cast_to is dict or cast_to is Any:
|
|
192
|
+
return data
|
|
193
|
+
try:
|
|
194
|
+
return _adapter(cast_to).validate_python(data)
|
|
195
|
+
except ValidationError as exc:
|
|
196
|
+
name = getattr(cast_to, "__name__", str(cast_to))
|
|
197
|
+
if name not in _warned:
|
|
198
|
+
_warned.add(name)
|
|
199
|
+
warnings.warn(
|
|
200
|
+
f"dialgem: a {name} response did not match the SDK's model ({exc.error_count()} problem(s)); "
|
|
201
|
+
"returning it unvalidated. Update the SDK, and report this if it persists.",
|
|
202
|
+
UserWarning,
|
|
203
|
+
stacklevel=2,
|
|
204
|
+
)
|
|
205
|
+
return _construct(cast_to, data)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _construct(cast_to: Any, data: Any) -> Any:
|
|
209
|
+
origin = getattr(cast_to, "__origin__", None)
|
|
210
|
+
if origin in (list, List) and isinstance(data, list):
|
|
211
|
+
(inner,) = cast_to.__args__
|
|
212
|
+
return [_construct(inner, item) for item in data]
|
|
213
|
+
if isinstance(cast_to, type) and issubclass(cast_to, BaseModel) and isinstance(data, dict):
|
|
214
|
+
values = dict(data)
|
|
215
|
+
for name, field in cast_to.model_fields.items():
|
|
216
|
+
key = field.alias or name
|
|
217
|
+
if key in values:
|
|
218
|
+
values[key] = _construct_field(field.annotation, values[key])
|
|
219
|
+
return cast_to.model_construct(**values)
|
|
220
|
+
return data
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _model_in(annotation: Any) -> Tuple[Any, bool]:
|
|
224
|
+
"""Find a response model inside ``Optional[M]`` / ``List[M]`` / ``Optional[List[M]]``: (model, is_list)."""
|
|
225
|
+
if isinstance(annotation, type) and issubclass(annotation, BaseModel):
|
|
226
|
+
return annotation, False
|
|
227
|
+
for arg in getattr(annotation, "__args__", ()) or ():
|
|
228
|
+
if arg is type(None):
|
|
229
|
+
continue
|
|
230
|
+
model, is_list = _model_in(arg)
|
|
231
|
+
if model is not None:
|
|
232
|
+
return model, is_list or getattr(annotation, "__origin__", None) in (list, List)
|
|
233
|
+
return None, False
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _construct_field(annotation: Any, value: Any) -> Any:
|
|
237
|
+
"""Give a nested object its model type even when the parent had to be built unvalidated."""
|
|
238
|
+
model, is_list = _model_in(annotation)
|
|
239
|
+
if model is None:
|
|
240
|
+
return value
|
|
241
|
+
if is_list and isinstance(value, list):
|
|
242
|
+
return [_build(model, item) for item in value]
|
|
243
|
+
return _build(model, value)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _build(model: Any, value: Any) -> Any:
|
|
247
|
+
if not isinstance(value, dict):
|
|
248
|
+
return value
|
|
249
|
+
try:
|
|
250
|
+
return model.model_validate(value)
|
|
251
|
+
except ValidationError:
|
|
252
|
+
return _construct(model, value)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
class _Config:
|
|
256
|
+
"""Everything both clients need to build a request."""
|
|
257
|
+
|
|
258
|
+
def __init__(
|
|
259
|
+
self,
|
|
260
|
+
api_key: str,
|
|
261
|
+
timeout: float,
|
|
262
|
+
max_retries: int,
|
|
263
|
+
default_headers: Optional[Mapping[str, str]],
|
|
264
|
+
) -> None:
|
|
265
|
+
self.api_key = api_key
|
|
266
|
+
self.timeout = timeout
|
|
267
|
+
self.max_retries = max_retries
|
|
268
|
+
self.default_headers = dict(default_headers or {})
|
|
269
|
+
self.base_url = BASE_URL
|
|
270
|
+
|
|
271
|
+
def url(self, path: str) -> str:
|
|
272
|
+
return f"{self.base_url}{path if path.startswith('/') else '/' + path}"
|
|
273
|
+
|
|
274
|
+
def retries_for(self, method: str, options: Optional[RequestOptions], is_form: bool) -> int:
|
|
275
|
+
if is_form:
|
|
276
|
+
return 0
|
|
277
|
+
if options and "max_retries" in options:
|
|
278
|
+
return _validate_retries(options["max_retries"])
|
|
279
|
+
return self.max_retries if method in _RETRYABLE_METHODS else 0
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _raise_for_status(response: httpx.Response) -> None:
|
|
283
|
+
raise APIError.from_response(
|
|
284
|
+
response.status_code, _parse_error_body(response), response.reason_phrase, response.headers
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _connection_error(exc: Exception, timeout: float) -> APIConnectionError:
|
|
289
|
+
if isinstance(exc, httpx.TimeoutException):
|
|
290
|
+
return APITimeoutError(f"Request timed out after {timeout:g}s.")
|
|
291
|
+
return APIConnectionError(f"Connection error: {exc}")
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
# --------------------------------------------------------------------------- sync
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
class SyncAPIClient:
|
|
298
|
+
def __init__(
|
|
299
|
+
self,
|
|
300
|
+
*,
|
|
301
|
+
api_key: str,
|
|
302
|
+
timeout: float,
|
|
303
|
+
max_retries: int,
|
|
304
|
+
default_headers: Optional[Mapping[str, str]],
|
|
305
|
+
http_client: Optional[httpx.Client],
|
|
306
|
+
) -> None:
|
|
307
|
+
self._cfg = _Config(api_key, timeout, max_retries, default_headers)
|
|
308
|
+
self._owns_client = http_client is None
|
|
309
|
+
self._http = http_client or httpx.Client(follow_redirects=False)
|
|
310
|
+
|
|
311
|
+
def close(self) -> None:
|
|
312
|
+
if self._owns_client:
|
|
313
|
+
self._http.close()
|
|
314
|
+
|
|
315
|
+
# verbs ------------------------------------------------------------------------------------
|
|
316
|
+
|
|
317
|
+
def get(self, path: str, *, query: Optional[Query] = None, cast_to: CastTo = None, options: Optional[RequestOptions] = None) -> Any:
|
|
318
|
+
return self.request("GET", path, query=query, cast_to=cast_to, options=options)
|
|
319
|
+
|
|
320
|
+
def post(self, path: str, *, body: Optional[Mapping[str, Any]] = None, cast_to: CastTo = None, options: Optional[RequestOptions] = None) -> Any:
|
|
321
|
+
return self.request("POST", path, body=body, cast_to=cast_to, options=options)
|
|
322
|
+
|
|
323
|
+
def post_form(self, path: str, *, data: Mapping[str, str], files: List[Tuple[str, Any]], cast_to: CastTo = None, options: Optional[RequestOptions] = None) -> Any:
|
|
324
|
+
return self.request("POST", path, form=(data, files), cast_to=cast_to, options=options)
|
|
325
|
+
|
|
326
|
+
def put(self, path: str, *, body: Optional[Mapping[str, Any]] = None, cast_to: CastTo = None, options: Optional[RequestOptions] = None) -> Any:
|
|
327
|
+
return self.request("PUT", path, body=body, cast_to=cast_to, options=options)
|
|
328
|
+
|
|
329
|
+
def patch(self, path: str, *, body: Optional[Mapping[str, Any]] = None, cast_to: CastTo = None, options: Optional[RequestOptions] = None) -> Any:
|
|
330
|
+
return self.request("PATCH", path, body=body, cast_to=cast_to, options=options)
|
|
331
|
+
|
|
332
|
+
def delete(self, path: str, *, cast_to: CastTo = None, options: Optional[RequestOptions] = None) -> Any:
|
|
333
|
+
return self.request("DELETE", path, cast_to=cast_to, options=options)
|
|
334
|
+
|
|
335
|
+
def paginate(
|
|
336
|
+
self,
|
|
337
|
+
path: str,
|
|
338
|
+
*,
|
|
339
|
+
query: Optional[Query],
|
|
340
|
+
cast_to: Type[Any],
|
|
341
|
+
items_key: str,
|
|
342
|
+
cursor_param: str,
|
|
343
|
+
next_key: str,
|
|
344
|
+
options: Optional[RequestOptions] = None,
|
|
345
|
+
) -> SyncPage[Any]:
|
|
346
|
+
def fetch(cursor: Optional[str]) -> PageData[Any]:
|
|
347
|
+
envelope = self.get(path, query={**(query or {}), cursor_param: cursor}, options=options)
|
|
348
|
+
items = [cast_response(item, cast_to) for item in envelope.get(items_key) or []]
|
|
349
|
+
token = envelope.get(next_key)
|
|
350
|
+
return PageData(items, bool(envelope.get("has_more", token is not None)), token)
|
|
351
|
+
|
|
352
|
+
return SyncPage(fetch(None), fetch)
|
|
353
|
+
|
|
354
|
+
# engine -----------------------------------------------------------------------------------
|
|
355
|
+
|
|
356
|
+
def request(
|
|
357
|
+
self,
|
|
358
|
+
method: str,
|
|
359
|
+
path: str,
|
|
360
|
+
*,
|
|
361
|
+
query: Optional[Query] = None,
|
|
362
|
+
body: Optional[Mapping[str, Any]] = None,
|
|
363
|
+
form: Optional[Tuple[Mapping[str, str], List[Tuple[str, Any]]]] = None,
|
|
364
|
+
cast_to: CastTo = None,
|
|
365
|
+
options: Optional[RequestOptions] = None,
|
|
366
|
+
) -> Any:
|
|
367
|
+
cfg = self._cfg
|
|
368
|
+
timeout = _validate_timeout((options or {}).get("timeout", cfg.timeout))
|
|
369
|
+
retries = cfg.retries_for(method, options, form is not None)
|
|
370
|
+
has_json = body is not None
|
|
371
|
+
headers = _build_headers(cfg.api_key, cfg.default_headers, (options or {}).get("headers"), has_json)
|
|
372
|
+
kwargs: Dict[str, Any] = {"params": _clean_query(query), "headers": headers}
|
|
373
|
+
if has_json:
|
|
374
|
+
kwargs["content"] = json.dumps(_clean_body(body)).encode("utf-8")
|
|
375
|
+
if form is not None:
|
|
376
|
+
kwargs["data"], kwargs["files"] = form
|
|
377
|
+
for attempt in range(retries + 1):
|
|
378
|
+
left = retries - attempt
|
|
379
|
+
try:
|
|
380
|
+
response = self._http.request(
|
|
381
|
+
method, cfg.url(path), timeout=httpx.Timeout(timeout), follow_redirects=False, **kwargs
|
|
382
|
+
)
|
|
383
|
+
except httpx.HTTPError as exc:
|
|
384
|
+
if left > 0:
|
|
385
|
+
time.sleep(_backoff(attempt))
|
|
386
|
+
continue
|
|
387
|
+
raise _connection_error(exc, timeout) from exc
|
|
388
|
+
if 200 <= response.status_code < 300:
|
|
389
|
+
return cast_response(_parse_success(response), cast_to)
|
|
390
|
+
if left > 0 and _is_retryable_status(response.status_code):
|
|
391
|
+
time.sleep(_retry_after(response.headers) or _backoff(attempt))
|
|
392
|
+
continue
|
|
393
|
+
_raise_for_status(response)
|
|
394
|
+
raise AssertionError("unreachable") # pragma: no cover
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
# --------------------------------------------------------------------------- async
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
class AsyncAPIClient:
|
|
401
|
+
def __init__(
|
|
402
|
+
self,
|
|
403
|
+
*,
|
|
404
|
+
api_key: str,
|
|
405
|
+
timeout: float,
|
|
406
|
+
max_retries: int,
|
|
407
|
+
default_headers: Optional[Mapping[str, str]],
|
|
408
|
+
http_client: Optional[httpx.AsyncClient],
|
|
409
|
+
) -> None:
|
|
410
|
+
self._cfg = _Config(api_key, timeout, max_retries, default_headers)
|
|
411
|
+
self._owns_client = http_client is None
|
|
412
|
+
self._http = http_client or httpx.AsyncClient(follow_redirects=False)
|
|
413
|
+
|
|
414
|
+
async def aclose(self) -> None:
|
|
415
|
+
if self._owns_client:
|
|
416
|
+
await self._http.aclose()
|
|
417
|
+
|
|
418
|
+
async def get(self, path: str, *, query: Optional[Query] = None, cast_to: CastTo = None, options: Optional[RequestOptions] = None) -> Any:
|
|
419
|
+
return await self.request("GET", path, query=query, cast_to=cast_to, options=options)
|
|
420
|
+
|
|
421
|
+
async def post(self, path: str, *, body: Optional[Mapping[str, Any]] = None, cast_to: CastTo = None, options: Optional[RequestOptions] = None) -> Any:
|
|
422
|
+
return await self.request("POST", path, body=body, cast_to=cast_to, options=options)
|
|
423
|
+
|
|
424
|
+
async def post_form(self, path: str, *, data: Mapping[str, str], files: List[Tuple[str, Any]], cast_to: CastTo = None, options: Optional[RequestOptions] = None) -> Any:
|
|
425
|
+
return await self.request("POST", path, form=(data, files), cast_to=cast_to, options=options)
|
|
426
|
+
|
|
427
|
+
async def put(self, path: str, *, body: Optional[Mapping[str, Any]] = None, cast_to: CastTo = None, options: Optional[RequestOptions] = None) -> Any:
|
|
428
|
+
return await self.request("PUT", path, body=body, cast_to=cast_to, options=options)
|
|
429
|
+
|
|
430
|
+
async def patch(self, path: str, *, body: Optional[Mapping[str, Any]] = None, cast_to: CastTo = None, options: Optional[RequestOptions] = None) -> Any:
|
|
431
|
+
return await self.request("PATCH", path, body=body, cast_to=cast_to, options=options)
|
|
432
|
+
|
|
433
|
+
async def delete(self, path: str, *, cast_to: CastTo = None, options: Optional[RequestOptions] = None) -> Any:
|
|
434
|
+
return await self.request("DELETE", path, cast_to=cast_to, options=options)
|
|
435
|
+
|
|
436
|
+
def paginate(
|
|
437
|
+
self,
|
|
438
|
+
path: str,
|
|
439
|
+
*,
|
|
440
|
+
query: Optional[Query],
|
|
441
|
+
cast_to: Type[Any],
|
|
442
|
+
items_key: str,
|
|
443
|
+
cursor_param: str,
|
|
444
|
+
next_key: str,
|
|
445
|
+
options: Optional[RequestOptions] = None,
|
|
446
|
+
) -> AsyncPaginator[Any]:
|
|
447
|
+
async def fetch(cursor: Optional[str]) -> PageData[Any]:
|
|
448
|
+
envelope = await self.get(path, query={**(query or {}), cursor_param: cursor}, options=options)
|
|
449
|
+
items = [cast_response(item, cast_to) for item in envelope.get(items_key) or []]
|
|
450
|
+
token = envelope.get(next_key)
|
|
451
|
+
return PageData(items, bool(envelope.get("has_more", token is not None)), token)
|
|
452
|
+
|
|
453
|
+
return AsyncPaginator(fetch)
|
|
454
|
+
|
|
455
|
+
async def request(
|
|
456
|
+
self,
|
|
457
|
+
method: str,
|
|
458
|
+
path: str,
|
|
459
|
+
*,
|
|
460
|
+
query: Optional[Query] = None,
|
|
461
|
+
body: Optional[Mapping[str, Any]] = None,
|
|
462
|
+
form: Optional[Tuple[Mapping[str, str], List[Tuple[str, Any]]]] = None,
|
|
463
|
+
cast_to: CastTo = None,
|
|
464
|
+
options: Optional[RequestOptions] = None,
|
|
465
|
+
) -> Any:
|
|
466
|
+
cfg = self._cfg
|
|
467
|
+
timeout = _validate_timeout((options or {}).get("timeout", cfg.timeout))
|
|
468
|
+
retries = cfg.retries_for(method, options, form is not None)
|
|
469
|
+
has_json = body is not None
|
|
470
|
+
headers = _build_headers(cfg.api_key, cfg.default_headers, (options or {}).get("headers"), has_json)
|
|
471
|
+
kwargs: Dict[str, Any] = {"params": _clean_query(query), "headers": headers}
|
|
472
|
+
if has_json:
|
|
473
|
+
kwargs["content"] = json.dumps(_clean_body(body)).encode("utf-8")
|
|
474
|
+
if form is not None:
|
|
475
|
+
kwargs["data"], kwargs["files"] = form
|
|
476
|
+
for attempt in range(retries + 1):
|
|
477
|
+
left = retries - attempt
|
|
478
|
+
try:
|
|
479
|
+
response = await self._http.request(
|
|
480
|
+
method, cfg.url(path), timeout=httpx.Timeout(timeout), follow_redirects=False, **kwargs
|
|
481
|
+
)
|
|
482
|
+
except httpx.HTTPError as exc:
|
|
483
|
+
if left > 0:
|
|
484
|
+
await asyncio.sleep(_backoff(attempt))
|
|
485
|
+
continue
|
|
486
|
+
raise _connection_error(exc, timeout) from exc
|
|
487
|
+
if 200 <= response.status_code < 300:
|
|
488
|
+
return cast_response(_parse_success(response), cast_to)
|
|
489
|
+
if left > 0 and _is_retryable_status(response.status_code):
|
|
490
|
+
await asyncio.sleep(_retry_after(response.headers) or _backoff(attempt))
|
|
491
|
+
continue
|
|
492
|
+
_raise_for_status(response)
|
|
493
|
+
raise AssertionError("unreachable") # pragma: no cover
|