pantech 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.
- pantech/__init__.py +71 -0
- pantech/_base_client.py +548 -0
- pantech/_client.py +105 -0
- pantech/_exceptions.py +270 -0
- pantech/_operations.py +346 -0
- pantech/_resource.py +234 -0
- pantech/_streaming.py +55 -0
- pantech/_types.py +38 -0
- pantech/_version.py +1 -0
- pantech/_webhooks.py +72 -0
- pantech/py.typed +0 -0
- pantech/resources/__init__.py +6 -0
- pantech/resources/_async.py +6082 -0
- pantech/resources/_sync.py +6082 -0
- pantech/types/__init__.py +10 -0
- pantech/types/_generated.py +2447 -0
- pantech-0.1.0.dist-info/METADATA +218 -0
- pantech-0.1.0.dist-info/RECORD +20 -0
- pantech-0.1.0.dist-info/WHEEL +4 -0
- pantech-0.1.0.dist-info/licenses/LICENSE +21 -0
pantech/__init__.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""The official Python SDK for the Pantech Dynamics public API.
|
|
2
|
+
|
|
3
|
+
import pantech
|
|
4
|
+
|
|
5
|
+
client = pantech.Pantech() # reads PANTECH_API_KEY
|
|
6
|
+
order = client.instances.create(
|
|
7
|
+
name="web-1", plan_slug="starter", image_slug="ubuntu-24-04", ssh_key_id="sshk_…"
|
|
8
|
+
)
|
|
9
|
+
client.instances.wait_for_order(order["order_id"])
|
|
10
|
+
|
|
11
|
+
Docs: https://docs.pantechdynamics.com/sdks/python
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from . import types
|
|
15
|
+
from ._base_client import DEFAULT_BASE_URL, DEFAULT_MAX_RETRIES, DEFAULT_TIMEOUT
|
|
16
|
+
from ._client import AsyncPantech, Pantech
|
|
17
|
+
from ._exceptions import (
|
|
18
|
+
APIConnectionError,
|
|
19
|
+
APIError,
|
|
20
|
+
APITimeoutError,
|
|
21
|
+
AuthenticationError,
|
|
22
|
+
BadRequestError,
|
|
23
|
+
ConflictError,
|
|
24
|
+
FieldError,
|
|
25
|
+
InternalServerError,
|
|
26
|
+
NotFoundError,
|
|
27
|
+
OperationFailedError,
|
|
28
|
+
OrderFailedError,
|
|
29
|
+
PantechError,
|
|
30
|
+
PaymentRequiredError,
|
|
31
|
+
PermissionDeniedError,
|
|
32
|
+
RateLimitError,
|
|
33
|
+
ValidationError,
|
|
34
|
+
WaitTimeoutError,
|
|
35
|
+
WebhookVerificationError,
|
|
36
|
+
)
|
|
37
|
+
from ._types import NOT_GIVEN, NotGiven
|
|
38
|
+
from ._version import __version__
|
|
39
|
+
from ._webhooks import SIGNATURE_HEADER, verify_webhook
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
"Pantech",
|
|
43
|
+
"AsyncPantech",
|
|
44
|
+
"verify_webhook",
|
|
45
|
+
"SIGNATURE_HEADER",
|
|
46
|
+
"types",
|
|
47
|
+
"NOT_GIVEN",
|
|
48
|
+
"NotGiven",
|
|
49
|
+
"DEFAULT_BASE_URL",
|
|
50
|
+
"DEFAULT_MAX_RETRIES",
|
|
51
|
+
"DEFAULT_TIMEOUT",
|
|
52
|
+
"PantechError",
|
|
53
|
+
"APIError",
|
|
54
|
+
"BadRequestError",
|
|
55
|
+
"AuthenticationError",
|
|
56
|
+
"PaymentRequiredError",
|
|
57
|
+
"PermissionDeniedError",
|
|
58
|
+
"NotFoundError",
|
|
59
|
+
"ConflictError",
|
|
60
|
+
"ValidationError",
|
|
61
|
+
"RateLimitError",
|
|
62
|
+
"InternalServerError",
|
|
63
|
+
"APIConnectionError",
|
|
64
|
+
"APITimeoutError",
|
|
65
|
+
"WaitTimeoutError",
|
|
66
|
+
"OperationFailedError",
|
|
67
|
+
"OrderFailedError",
|
|
68
|
+
"WebhookVerificationError",
|
|
69
|
+
"FieldError",
|
|
70
|
+
"__version__",
|
|
71
|
+
]
|
pantech/_base_client.py
ADDED
|
@@ -0,0 +1,548 @@
|
|
|
1
|
+
"""The HTTP core shared by ``Pantech`` and ``AsyncPantech``.
|
|
2
|
+
|
|
3
|
+
Everything that decides *what* is sent and *whether* to retry lives in
|
|
4
|
+
``BaseClient`` and is pure; ``SyncAPIClient`` and ``AsyncAPIClient`` only add
|
|
5
|
+
the I/O loop around it, so the two clients cannot drift apart.
|
|
6
|
+
|
|
7
|
+
The rules, from the API's conventions:
|
|
8
|
+
|
|
9
|
+
* ``Authorization: Bearer <key>`` on every request. The key is never logged
|
|
10
|
+
and never shown in ``repr``.
|
|
11
|
+
* Every POST, PUT, PATCH and DELETE carries an ``Idempotency-Key`` (a UUIDv4
|
|
12
|
+
unless the caller passes one). Retries resend the same key, so the API
|
|
13
|
+
returns the first attempt's result instead of repeating the change.
|
|
14
|
+
* Retries (default 3) on connection errors, timeouts, 429, 500, 502, 503 and
|
|
15
|
+
504, with jittered exponential backoff, or the server's ``Retry-After``.
|
|
16
|
+
* Writes the API does not replay (``_operations.NO_REPLAY``) are sent once;
|
|
17
|
+
only a 429 is retried, because the API refuses it before doing anything.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import asyncio
|
|
23
|
+
import json
|
|
24
|
+
import logging
|
|
25
|
+
import os
|
|
26
|
+
import random
|
|
27
|
+
import time
|
|
28
|
+
import uuid
|
|
29
|
+
from typing import (
|
|
30
|
+
Any,
|
|
31
|
+
AsyncIterator,
|
|
32
|
+
Callable,
|
|
33
|
+
Dict,
|
|
34
|
+
Iterator,
|
|
35
|
+
List,
|
|
36
|
+
Mapping,
|
|
37
|
+
Optional,
|
|
38
|
+
Union,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
import httpx
|
|
42
|
+
|
|
43
|
+
from ._exceptions import (
|
|
44
|
+
APIConnectionError,
|
|
45
|
+
APITimeoutError,
|
|
46
|
+
PantechError,
|
|
47
|
+
make_api_error,
|
|
48
|
+
parse_retry_after,
|
|
49
|
+
)
|
|
50
|
+
from ._streaming import ServerSentEvent, SSEDecoder
|
|
51
|
+
from ._types import NOT_GIVEN, NotGiven, Timeout
|
|
52
|
+
from ._version import __version__
|
|
53
|
+
|
|
54
|
+
__all__ = ["BaseClient", "SyncAPIClient", "AsyncAPIClient", "DEFAULT_BASE_URL"]
|
|
55
|
+
|
|
56
|
+
log = logging.getLogger("pantech")
|
|
57
|
+
|
|
58
|
+
DEFAULT_BASE_URL = "https://api.pantechdynamics.com/public/v1"
|
|
59
|
+
# Single requests of 20 to 48 seconds have been seen on busy days, so the read
|
|
60
|
+
# timeout is generous. It bounds each attempt, not the whole call.
|
|
61
|
+
DEFAULT_TIMEOUT = httpx.Timeout(60.0, connect=10.0)
|
|
62
|
+
DEFAULT_MAX_RETRIES = 3
|
|
63
|
+
INITIAL_RETRY_DELAY = 0.5
|
|
64
|
+
MAX_RETRY_DELAY = 8.0
|
|
65
|
+
MAX_RETRY_AFTER = 60.0
|
|
66
|
+
|
|
67
|
+
RETRYABLE_STATUSES = frozenset({429, 500, 502, 503, 504})
|
|
68
|
+
WRITE_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"})
|
|
69
|
+
|
|
70
|
+
ParseAs = str # "json" (default), "bytes"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _strip_not_given(values: Optional[Mapping[str, Any]]) -> Dict[str, Any]:
|
|
74
|
+
if not values:
|
|
75
|
+
return {}
|
|
76
|
+
return {k: v for k, v in values.items() if not isinstance(v, NotGiven)}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _query_value(value: Any) -> Any:
|
|
80
|
+
if isinstance(value, bool):
|
|
81
|
+
return "true" if value else "false"
|
|
82
|
+
return value
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class BaseClient:
|
|
86
|
+
"""Configuration and the pure parts of every request."""
|
|
87
|
+
|
|
88
|
+
api_key: str
|
|
89
|
+
base_url: str
|
|
90
|
+
max_retries: int
|
|
91
|
+
timeout: Timeout
|
|
92
|
+
|
|
93
|
+
def __init__(
|
|
94
|
+
self,
|
|
95
|
+
*,
|
|
96
|
+
api_key: Optional[str],
|
|
97
|
+
base_url: Optional[str],
|
|
98
|
+
timeout: Union[Timeout, NotGiven],
|
|
99
|
+
max_retries: int,
|
|
100
|
+
default_headers: Optional[Mapping[str, str]],
|
|
101
|
+
user_agent: Optional[str],
|
|
102
|
+
) -> None:
|
|
103
|
+
key = api_key if api_key is not None else os.environ.get("PANTECH_API_KEY")
|
|
104
|
+
if not key:
|
|
105
|
+
raise PantechError(
|
|
106
|
+
"No API key. Pass api_key=... or set PANTECH_API_KEY. "
|
|
107
|
+
"Create a key in the console under Organization › API keys."
|
|
108
|
+
)
|
|
109
|
+
self.api_key = key
|
|
110
|
+
url = base_url or os.environ.get("PANTECH_API_URL") or DEFAULT_BASE_URL
|
|
111
|
+
self.base_url = url.rstrip("/")
|
|
112
|
+
self.timeout = DEFAULT_TIMEOUT if isinstance(timeout, NotGiven) else timeout
|
|
113
|
+
if max_retries < 0:
|
|
114
|
+
raise ValueError("max_retries must be 0 or more")
|
|
115
|
+
self.max_retries = max_retries
|
|
116
|
+
self._default_headers = dict(default_headers or {})
|
|
117
|
+
self._user_agent = user_agent or f"pantech-python/{__version__}"
|
|
118
|
+
|
|
119
|
+
def __repr__(self) -> str:
|
|
120
|
+
return f"{type(self).__name__}(base_url={self.base_url!r})"
|
|
121
|
+
|
|
122
|
+
# -- building requests ---------------------------------------------------
|
|
123
|
+
|
|
124
|
+
def _build_request(
|
|
125
|
+
self,
|
|
126
|
+
http: Union[httpx.Client, httpx.AsyncClient],
|
|
127
|
+
method: str,
|
|
128
|
+
path: str,
|
|
129
|
+
*,
|
|
130
|
+
query: Optional[Mapping[str, Any]],
|
|
131
|
+
body: Any,
|
|
132
|
+
idempotency_key: Optional[str],
|
|
133
|
+
extra_headers: Optional[Mapping[str, str]],
|
|
134
|
+
timeout: Union[Timeout, NotGiven],
|
|
135
|
+
accept: str = "application/json",
|
|
136
|
+
) -> httpx.Request:
|
|
137
|
+
headers: Dict[str, str] = {
|
|
138
|
+
"Accept": accept,
|
|
139
|
+
"User-Agent": self._user_agent,
|
|
140
|
+
}
|
|
141
|
+
headers.update(self._default_headers)
|
|
142
|
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
143
|
+
if idempotency_key is not None:
|
|
144
|
+
headers["Idempotency-Key"] = idempotency_key
|
|
145
|
+
content: Optional[bytes] = None
|
|
146
|
+
if body is not None and not isinstance(body, NotGiven):
|
|
147
|
+
headers["Content-Type"] = "application/json"
|
|
148
|
+
content = json.dumps(body, separators=(",", ":")).encode("utf-8")
|
|
149
|
+
if extra_headers:
|
|
150
|
+
headers.update(extra_headers)
|
|
151
|
+
params = {k: _query_value(v) for k, v in _strip_not_given(query).items() if v is not None}
|
|
152
|
+
effective_timeout = self.timeout if isinstance(timeout, NotGiven) else timeout
|
|
153
|
+
return http.build_request(
|
|
154
|
+
method,
|
|
155
|
+
self.base_url + path,
|
|
156
|
+
params=params or None,
|
|
157
|
+
headers=headers,
|
|
158
|
+
content=content,
|
|
159
|
+
timeout=effective_timeout,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
@staticmethod
|
|
163
|
+
def _idempotency_key(method: str, given: Optional[str]) -> Optional[str]:
|
|
164
|
+
if given is not None:
|
|
165
|
+
return given
|
|
166
|
+
return str(uuid.uuid4()) if method.upper() in WRITE_METHODS else None
|
|
167
|
+
|
|
168
|
+
# -- retry decisions -----------------------------------------------------
|
|
169
|
+
|
|
170
|
+
def _should_retry_status(self, status: int, no_replay: bool, retries_taken: int, max_retries: int) -> bool:
|
|
171
|
+
if retries_taken >= max_retries or status not in RETRYABLE_STATUSES:
|
|
172
|
+
return False
|
|
173
|
+
return status == 429 or not no_replay
|
|
174
|
+
|
|
175
|
+
def _should_retry_error(self, no_replay: bool, retries_taken: int, max_retries: int) -> bool:
|
|
176
|
+
return retries_taken < max_retries and not no_replay
|
|
177
|
+
|
|
178
|
+
@staticmethod
|
|
179
|
+
def _retry_delay(retries_taken: int, response: Optional[httpx.Response]) -> float:
|
|
180
|
+
if response is not None:
|
|
181
|
+
retry_after = parse_retry_after(response.headers.get("retry-after"))
|
|
182
|
+
if retry_after is not None:
|
|
183
|
+
return min(retry_after, MAX_RETRY_AFTER)
|
|
184
|
+
delay = min(INITIAL_RETRY_DELAY * (1 << min(retries_taken, 16)), MAX_RETRY_DELAY)
|
|
185
|
+
# Half jitter: somewhere in [delay/2, delay], so throttled clients spread out.
|
|
186
|
+
return delay / 2 + random.random() * delay / 2 # noqa: S311 - jitter, not a secret
|
|
187
|
+
|
|
188
|
+
# -- responses -----------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
@staticmethod
|
|
191
|
+
def _parse(response: httpx.Response, parse_as: ParseAs) -> Any:
|
|
192
|
+
if parse_as == "bytes":
|
|
193
|
+
return response.content
|
|
194
|
+
if response.status_code == 204 or not response.content:
|
|
195
|
+
return None
|
|
196
|
+
return response.json()
|
|
197
|
+
|
|
198
|
+
@staticmethod
|
|
199
|
+
def _log_attempt(method: str, path: str, status: Union[int, str], attempt: int) -> None:
|
|
200
|
+
# Never the headers or the body: they carry the key and customer data.
|
|
201
|
+
log.debug("%s %s -> %s (attempt %d)", method, path, status, attempt)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _connection_error(exc: Exception) -> APIConnectionError:
|
|
205
|
+
if isinstance(exc, httpx.TimeoutException):
|
|
206
|
+
return APITimeoutError()
|
|
207
|
+
return APIConnectionError(f"Could not connect to the Pantech API: {type(exc).__name__}.")
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class SyncAPIClient(BaseClient):
|
|
211
|
+
"""The blocking HTTP loop."""
|
|
212
|
+
|
|
213
|
+
_http: httpx.Client
|
|
214
|
+
|
|
215
|
+
def __init__(
|
|
216
|
+
self,
|
|
217
|
+
*,
|
|
218
|
+
api_key: Optional[str],
|
|
219
|
+
base_url: Optional[str],
|
|
220
|
+
timeout: Union[Timeout, NotGiven],
|
|
221
|
+
max_retries: int,
|
|
222
|
+
default_headers: Optional[Mapping[str, str]],
|
|
223
|
+
user_agent: Optional[str],
|
|
224
|
+
http_client: Optional[httpx.Client],
|
|
225
|
+
) -> None:
|
|
226
|
+
super().__init__(
|
|
227
|
+
api_key=api_key,
|
|
228
|
+
base_url=base_url,
|
|
229
|
+
timeout=timeout,
|
|
230
|
+
max_retries=max_retries,
|
|
231
|
+
default_headers=default_headers,
|
|
232
|
+
user_agent=user_agent,
|
|
233
|
+
)
|
|
234
|
+
self._owns_http = http_client is None
|
|
235
|
+
self._http = http_client or httpx.Client(follow_redirects=False)
|
|
236
|
+
self._sleep: Callable[[float], None] = time.sleep
|
|
237
|
+
self._clock: Callable[[], float] = time.monotonic
|
|
238
|
+
|
|
239
|
+
def close(self) -> None:
|
|
240
|
+
"""Closes the connection pool (unless you passed your own ``http_client``)."""
|
|
241
|
+
if self._owns_http:
|
|
242
|
+
self._http.close()
|
|
243
|
+
|
|
244
|
+
def request(
|
|
245
|
+
self,
|
|
246
|
+
method: str,
|
|
247
|
+
path: str,
|
|
248
|
+
*,
|
|
249
|
+
query: Optional[Mapping[str, Any]] = None,
|
|
250
|
+
body: Any = None,
|
|
251
|
+
idempotency_key: Optional[str] = None,
|
|
252
|
+
no_replay: bool = False,
|
|
253
|
+
extra_headers: Optional[Mapping[str, str]] = None,
|
|
254
|
+
timeout: Union[Timeout, NotGiven] = NOT_GIVEN,
|
|
255
|
+
max_retries: Optional[int] = None,
|
|
256
|
+
parse_as: ParseAs = "json",
|
|
257
|
+
) -> Any:
|
|
258
|
+
"""Sends one API call with retries and returns the parsed body.
|
|
259
|
+
|
|
260
|
+
*path* is relative to the base URL (``"/instances"``). This is the
|
|
261
|
+
escape hatch for anything the typed methods do not cover yet.
|
|
262
|
+
"""
|
|
263
|
+
response = self._send(
|
|
264
|
+
method,
|
|
265
|
+
path,
|
|
266
|
+
query=query,
|
|
267
|
+
body=body,
|
|
268
|
+
idempotency_key=idempotency_key,
|
|
269
|
+
no_replay=no_replay,
|
|
270
|
+
extra_headers=extra_headers,
|
|
271
|
+
timeout=timeout,
|
|
272
|
+
max_retries=max_retries,
|
|
273
|
+
accept="application/pdf" if parse_as == "bytes" else "application/json",
|
|
274
|
+
)
|
|
275
|
+
return self._parse(response, parse_as)
|
|
276
|
+
|
|
277
|
+
def _send(
|
|
278
|
+
self,
|
|
279
|
+
method: str,
|
|
280
|
+
path: str,
|
|
281
|
+
*,
|
|
282
|
+
query: Optional[Mapping[str, Any]],
|
|
283
|
+
body: Any,
|
|
284
|
+
idempotency_key: Optional[str],
|
|
285
|
+
no_replay: bool,
|
|
286
|
+
extra_headers: Optional[Mapping[str, str]],
|
|
287
|
+
timeout: Union[Timeout, NotGiven],
|
|
288
|
+
max_retries: Optional[int],
|
|
289
|
+
accept: str,
|
|
290
|
+
stream: bool = False,
|
|
291
|
+
) -> httpx.Response:
|
|
292
|
+
method = method.upper()
|
|
293
|
+
key = self._idempotency_key(method, idempotency_key)
|
|
294
|
+
limit = self.max_retries if max_retries is None else max_retries
|
|
295
|
+
retries_taken = 0
|
|
296
|
+
while True:
|
|
297
|
+
request = self._build_request(
|
|
298
|
+
self._http,
|
|
299
|
+
method,
|
|
300
|
+
path,
|
|
301
|
+
query=query,
|
|
302
|
+
body=body,
|
|
303
|
+
idempotency_key=key,
|
|
304
|
+
extra_headers=extra_headers,
|
|
305
|
+
timeout=timeout,
|
|
306
|
+
accept=accept,
|
|
307
|
+
)
|
|
308
|
+
try:
|
|
309
|
+
response = self._http.send(request, stream=stream)
|
|
310
|
+
except httpx.TransportError as exc:
|
|
311
|
+
self._log_attempt(method, path, type(exc).__name__, retries_taken + 1)
|
|
312
|
+
if self._should_retry_error(no_replay, retries_taken, limit):
|
|
313
|
+
self._sleep(self._retry_delay(retries_taken, None))
|
|
314
|
+
retries_taken += 1
|
|
315
|
+
continue
|
|
316
|
+
raise _connection_error(exc) from None
|
|
317
|
+
self._log_attempt(method, path, response.status_code, retries_taken + 1)
|
|
318
|
+
if response.is_success:
|
|
319
|
+
return response
|
|
320
|
+
if self._should_retry_status(response.status_code, no_replay, retries_taken, limit):
|
|
321
|
+
response.close()
|
|
322
|
+
self._sleep(self._retry_delay(retries_taken, response))
|
|
323
|
+
retries_taken += 1
|
|
324
|
+
continue
|
|
325
|
+
if stream:
|
|
326
|
+
response.read()
|
|
327
|
+
response.close()
|
|
328
|
+
raise make_api_error(response)
|
|
329
|
+
|
|
330
|
+
def _paginate(
|
|
331
|
+
self,
|
|
332
|
+
path: str,
|
|
333
|
+
*,
|
|
334
|
+
query: Optional[Mapping[str, Any]] = None,
|
|
335
|
+
extra_headers: Optional[Mapping[str, str]] = None,
|
|
336
|
+
timeout: Union[Timeout, NotGiven] = NOT_GIVEN,
|
|
337
|
+
) -> Iterator[Any]:
|
|
338
|
+
params = _strip_not_given(query)
|
|
339
|
+
seen: List[str] = []
|
|
340
|
+
while True:
|
|
341
|
+
page = self.request("GET", path, query=params, extra_headers=extra_headers, timeout=timeout)
|
|
342
|
+
yield from page.get("data") or []
|
|
343
|
+
cursor = page.get("next_cursor")
|
|
344
|
+
if not cursor or cursor in seen:
|
|
345
|
+
return
|
|
346
|
+
seen.append(cursor)
|
|
347
|
+
params = {**params, "cursor": cursor}
|
|
348
|
+
|
|
349
|
+
def _stream_events(
|
|
350
|
+
self,
|
|
351
|
+
path: str,
|
|
352
|
+
*,
|
|
353
|
+
extra_headers: Optional[Mapping[str, str]] = None,
|
|
354
|
+
timeout: Union[Timeout, NotGiven] = NOT_GIVEN,
|
|
355
|
+
) -> Iterator[ServerSentEvent]:
|
|
356
|
+
response = self._send(
|
|
357
|
+
"GET",
|
|
358
|
+
path,
|
|
359
|
+
query=None,
|
|
360
|
+
body=None,
|
|
361
|
+
idempotency_key=None,
|
|
362
|
+
no_replay=False,
|
|
363
|
+
extra_headers=extra_headers,
|
|
364
|
+
timeout=timeout,
|
|
365
|
+
max_retries=None,
|
|
366
|
+
accept="text/event-stream",
|
|
367
|
+
stream=True,
|
|
368
|
+
)
|
|
369
|
+
decoder = SSEDecoder()
|
|
370
|
+
try:
|
|
371
|
+
for line in response.iter_lines():
|
|
372
|
+
event = decoder.feed(line)
|
|
373
|
+
if event is not None:
|
|
374
|
+
yield event
|
|
375
|
+
event = decoder.feed("")
|
|
376
|
+
if event is not None:
|
|
377
|
+
yield event
|
|
378
|
+
finally:
|
|
379
|
+
response.close()
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
class AsyncAPIClient(BaseClient):
|
|
383
|
+
"""The asyncio HTTP loop. Same rules as ``SyncAPIClient``."""
|
|
384
|
+
|
|
385
|
+
_http: httpx.AsyncClient
|
|
386
|
+
|
|
387
|
+
def __init__(
|
|
388
|
+
self,
|
|
389
|
+
*,
|
|
390
|
+
api_key: Optional[str],
|
|
391
|
+
base_url: Optional[str],
|
|
392
|
+
timeout: Union[Timeout, NotGiven],
|
|
393
|
+
max_retries: int,
|
|
394
|
+
default_headers: Optional[Mapping[str, str]],
|
|
395
|
+
user_agent: Optional[str],
|
|
396
|
+
http_client: Optional[httpx.AsyncClient],
|
|
397
|
+
) -> None:
|
|
398
|
+
super().__init__(
|
|
399
|
+
api_key=api_key,
|
|
400
|
+
base_url=base_url,
|
|
401
|
+
timeout=timeout,
|
|
402
|
+
max_retries=max_retries,
|
|
403
|
+
default_headers=default_headers,
|
|
404
|
+
user_agent=user_agent,
|
|
405
|
+
)
|
|
406
|
+
self._owns_http = http_client is None
|
|
407
|
+
self._http = http_client or httpx.AsyncClient(follow_redirects=False)
|
|
408
|
+
self._sleep: Callable[[float], Any] = asyncio.sleep
|
|
409
|
+
self._clock: Callable[[], float] = time.monotonic
|
|
410
|
+
|
|
411
|
+
async def close(self) -> None:
|
|
412
|
+
"""Closes the connection pool (unless you passed your own ``http_client``)."""
|
|
413
|
+
if self._owns_http:
|
|
414
|
+
await self._http.aclose()
|
|
415
|
+
|
|
416
|
+
async def request(
|
|
417
|
+
self,
|
|
418
|
+
method: str,
|
|
419
|
+
path: str,
|
|
420
|
+
*,
|
|
421
|
+
query: Optional[Mapping[str, Any]] = None,
|
|
422
|
+
body: Any = None,
|
|
423
|
+
idempotency_key: Optional[str] = None,
|
|
424
|
+
no_replay: bool = False,
|
|
425
|
+
extra_headers: Optional[Mapping[str, str]] = None,
|
|
426
|
+
timeout: Union[Timeout, NotGiven] = NOT_GIVEN,
|
|
427
|
+
max_retries: Optional[int] = None,
|
|
428
|
+
parse_as: ParseAs = "json",
|
|
429
|
+
) -> Any:
|
|
430
|
+
"""Sends one API call with retries and returns the parsed body."""
|
|
431
|
+
response = await self._send(
|
|
432
|
+
method,
|
|
433
|
+
path,
|
|
434
|
+
query=query,
|
|
435
|
+
body=body,
|
|
436
|
+
idempotency_key=idempotency_key,
|
|
437
|
+
no_replay=no_replay,
|
|
438
|
+
extra_headers=extra_headers,
|
|
439
|
+
timeout=timeout,
|
|
440
|
+
max_retries=max_retries,
|
|
441
|
+
accept="application/pdf" if parse_as == "bytes" else "application/json",
|
|
442
|
+
)
|
|
443
|
+
return self._parse(response, parse_as)
|
|
444
|
+
|
|
445
|
+
async def _send(
|
|
446
|
+
self,
|
|
447
|
+
method: str,
|
|
448
|
+
path: str,
|
|
449
|
+
*,
|
|
450
|
+
query: Optional[Mapping[str, Any]],
|
|
451
|
+
body: Any,
|
|
452
|
+
idempotency_key: Optional[str],
|
|
453
|
+
no_replay: bool,
|
|
454
|
+
extra_headers: Optional[Mapping[str, str]],
|
|
455
|
+
timeout: Union[Timeout, NotGiven],
|
|
456
|
+
max_retries: Optional[int],
|
|
457
|
+
accept: str,
|
|
458
|
+
stream: bool = False,
|
|
459
|
+
) -> httpx.Response:
|
|
460
|
+
method = method.upper()
|
|
461
|
+
key = self._idempotency_key(method, idempotency_key)
|
|
462
|
+
limit = self.max_retries if max_retries is None else max_retries
|
|
463
|
+
retries_taken = 0
|
|
464
|
+
while True:
|
|
465
|
+
request = self._build_request(
|
|
466
|
+
self._http,
|
|
467
|
+
method,
|
|
468
|
+
path,
|
|
469
|
+
query=query,
|
|
470
|
+
body=body,
|
|
471
|
+
idempotency_key=key,
|
|
472
|
+
extra_headers=extra_headers,
|
|
473
|
+
timeout=timeout,
|
|
474
|
+
accept=accept,
|
|
475
|
+
)
|
|
476
|
+
try:
|
|
477
|
+
response = await self._http.send(request, stream=stream)
|
|
478
|
+
except httpx.TransportError as exc:
|
|
479
|
+
self._log_attempt(method, path, type(exc).__name__, retries_taken + 1)
|
|
480
|
+
if self._should_retry_error(no_replay, retries_taken, limit):
|
|
481
|
+
await self._sleep(self._retry_delay(retries_taken, None))
|
|
482
|
+
retries_taken += 1
|
|
483
|
+
continue
|
|
484
|
+
raise _connection_error(exc) from None
|
|
485
|
+
self._log_attempt(method, path, response.status_code, retries_taken + 1)
|
|
486
|
+
if response.is_success:
|
|
487
|
+
return response
|
|
488
|
+
if self._should_retry_status(response.status_code, no_replay, retries_taken, limit):
|
|
489
|
+
await response.aclose()
|
|
490
|
+
await self._sleep(self._retry_delay(retries_taken, response))
|
|
491
|
+
retries_taken += 1
|
|
492
|
+
continue
|
|
493
|
+
if stream:
|
|
494
|
+
await response.aread()
|
|
495
|
+
await response.aclose()
|
|
496
|
+
raise make_api_error(response)
|
|
497
|
+
|
|
498
|
+
async def _paginate(
|
|
499
|
+
self,
|
|
500
|
+
path: str,
|
|
501
|
+
*,
|
|
502
|
+
query: Optional[Mapping[str, Any]] = None,
|
|
503
|
+
extra_headers: Optional[Mapping[str, str]] = None,
|
|
504
|
+
timeout: Union[Timeout, NotGiven] = NOT_GIVEN,
|
|
505
|
+
) -> AsyncIterator[Any]:
|
|
506
|
+
params = _strip_not_given(query)
|
|
507
|
+
seen: List[str] = []
|
|
508
|
+
while True:
|
|
509
|
+
page = await self.request("GET", path, query=params, extra_headers=extra_headers, timeout=timeout)
|
|
510
|
+
for item in page.get("data") or []:
|
|
511
|
+
yield item
|
|
512
|
+
cursor = page.get("next_cursor")
|
|
513
|
+
if not cursor or cursor in seen:
|
|
514
|
+
return
|
|
515
|
+
seen.append(cursor)
|
|
516
|
+
params = {**params, "cursor": cursor}
|
|
517
|
+
|
|
518
|
+
async def _stream_events(
|
|
519
|
+
self,
|
|
520
|
+
path: str,
|
|
521
|
+
*,
|
|
522
|
+
extra_headers: Optional[Mapping[str, str]] = None,
|
|
523
|
+
timeout: Union[Timeout, NotGiven] = NOT_GIVEN,
|
|
524
|
+
) -> AsyncIterator[ServerSentEvent]:
|
|
525
|
+
response = await self._send(
|
|
526
|
+
"GET",
|
|
527
|
+
path,
|
|
528
|
+
query=None,
|
|
529
|
+
body=None,
|
|
530
|
+
idempotency_key=None,
|
|
531
|
+
no_replay=False,
|
|
532
|
+
extra_headers=extra_headers,
|
|
533
|
+
timeout=timeout,
|
|
534
|
+
max_retries=None,
|
|
535
|
+
accept="text/event-stream",
|
|
536
|
+
stream=True,
|
|
537
|
+
)
|
|
538
|
+
decoder = SSEDecoder()
|
|
539
|
+
try:
|
|
540
|
+
async for line in response.aiter_lines():
|
|
541
|
+
event = decoder.feed(line)
|
|
542
|
+
if event is not None:
|
|
543
|
+
yield event
|
|
544
|
+
event = decoder.feed("")
|
|
545
|
+
if event is not None:
|
|
546
|
+
yield event
|
|
547
|
+
finally:
|
|
548
|
+
await response.aclose()
|