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.
Files changed (56) hide show
  1. dialgem/__init__.py +70 -0
  2. dialgem/_base_client.py +493 -0
  3. dialgem/_client.py +153 -0
  4. dialgem/_errors.py +102 -0
  5. dialgem/_pagination.py +128 -0
  6. dialgem/_resource.py +30 -0
  7. dialgem/_version.py +1 -0
  8. dialgem/_webhooks.py +164 -0
  9. dialgem/aresources/__init__.py +15 -0
  10. dialgem/aresources/account.py +91 -0
  11. dialgem/aresources/agents.py +234 -0
  12. dialgem/aresources/calls.py +129 -0
  13. dialgem/aresources/catalog.py +135 -0
  14. dialgem/aresources/chats.py +101 -0
  15. dialgem/aresources/contacts.py +207 -0
  16. dialgem/aresources/flows.py +72 -0
  17. dialgem/aresources/llms.py +59 -0
  18. dialgem/aresources/phone_numbers.py +82 -0
  19. dialgem/aresources/simulations.py +123 -0
  20. dialgem/aresources/web_calls.py +44 -0
  21. dialgem/py.typed +0 -0
  22. dialgem/resources/__init__.py +15 -0
  23. dialgem/resources/account.py +89 -0
  24. dialgem/resources/agents.py +232 -0
  25. dialgem/resources/calls.py +127 -0
  26. dialgem/resources/catalog.py +133 -0
  27. dialgem/resources/chats.py +99 -0
  28. dialgem/resources/contacts.py +205 -0
  29. dialgem/resources/flows.py +70 -0
  30. dialgem/resources/llms.py +57 -0
  31. dialgem/resources/phone_numbers.py +80 -0
  32. dialgem/resources/simulations.py +121 -0
  33. dialgem/resources/web_calls.py +42 -0
  34. dialgem/types/__init__.py +0 -0
  35. dialgem/types/_base.py +26 -0
  36. dialgem/types/_shared.py +21 -0
  37. dialgem/types/agent.py +115 -0
  38. dialgem/types/agent_resources.py +91 -0
  39. dialgem/types/agent_version.py +64 -0
  40. dialgem/types/api_key.py +36 -0
  41. dialgem/types/call.py +159 -0
  42. dialgem/types/catalog.py +93 -0
  43. dialgem/types/chat.py +68 -0
  44. dialgem/types/contact.py +139 -0
  45. dialgem/types/flow.py +30 -0
  46. dialgem/types/llm.py +32 -0
  47. dialgem/types/params.py +452 -0
  48. dialgem/types/phone_number.py +80 -0
  49. dialgem/types/session.py +51 -0
  50. dialgem/types/simulation.py +132 -0
  51. dialgem/types/web_call.py +31 -0
  52. dialgem/types/webhook.py +145 -0
  53. dialgem-0.1.0b1.dist-info/METADATA +281 -0
  54. dialgem-0.1.0b1.dist-info/RECORD +56 -0
  55. dialgem-0.1.0b1.dist-info/WHEEL +4 -0
  56. 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
+ ]
@@ -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