vaquill 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.
- vaquill/__init__.py +81 -0
- vaquill/_base_client.py +578 -0
- vaquill/_client.py +94 -0
- vaquill/_config.py +22 -0
- vaquill/_errors.py +207 -0
- vaquill/_idempotency.py +23 -0
- vaquill/_pagination.py +182 -0
- vaquill/_response.py +154 -0
- vaquill/_retry.py +74 -0
- vaquill/_version.py +1 -0
- vaquill/_webhooks.py +109 -0
- vaquill/cli/__init__.py +5 -0
- vaquill/cli/_main.py +628 -0
- vaquill/cli/_render.py +61 -0
- vaquill/py.typed +0 -0
- vaquill/resources/__init__.py +29 -0
- vaquill/resources/_async/__init__.py +1 -0
- vaquill/resources/_async/boards.py +61 -0
- vaquill/resources/_async/credits.py +19 -0
- vaquill/resources/_async/pricing.py +29 -0
- vaquill/resources/_async/session_laws.py +378 -0
- vaquill/resources/_async/statutes.py +702 -0
- vaquill/resources/_async/watches.py +252 -0
- vaquill/resources/_base.py +97 -0
- vaquill/resources/boards.py +60 -0
- vaquill/resources/credits.py +18 -0
- vaquill/resources/pricing.py +28 -0
- vaquill/resources/session_laws.py +377 -0
- vaquill/resources/statutes.py +691 -0
- vaquill/resources/watches.py +251 -0
- vaquill/types/__init__.py +434 -0
- vaquill/types/_models.py +10219 -0
- vaquill-0.1.0.dist-info/METADATA +195 -0
- vaquill-0.1.0.dist-info/RECORD +37 -0
- vaquill-0.1.0.dist-info/WHEEL +4 -0
- vaquill-0.1.0.dist-info/entry_points.txt +2 -0
- vaquill-0.1.0.dist-info/licenses/LICENSE +21 -0
vaquill/__init__.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Vaquill Python SDK."""
|
|
2
|
+
|
|
3
|
+
from ._client import AsyncVaquill, Vaquill
|
|
4
|
+
from ._errors import (
|
|
5
|
+
APIConnectionError,
|
|
6
|
+
APIStatusError,
|
|
7
|
+
APITimeoutError,
|
|
8
|
+
AuthenticationError,
|
|
9
|
+
BadRequestError,
|
|
10
|
+
ConflictError,
|
|
11
|
+
DailyCapExceededError,
|
|
12
|
+
IdempotencyConflictError,
|
|
13
|
+
IdempotencyInProgressError,
|
|
14
|
+
IdempotencyKeyReusedError,
|
|
15
|
+
IdempotencyReplayUnavailableError,
|
|
16
|
+
InsufficientCreditsError,
|
|
17
|
+
InternalServerError,
|
|
18
|
+
NotFoundError,
|
|
19
|
+
PermissionDeniedError,
|
|
20
|
+
RateLimitError,
|
|
21
|
+
SpendGuardExceeded,
|
|
22
|
+
UnprocessableEntityError,
|
|
23
|
+
VaquillError,
|
|
24
|
+
WebhookVerificationError,
|
|
25
|
+
)
|
|
26
|
+
from ._pagination import (
|
|
27
|
+
AsyncPaginator,
|
|
28
|
+
CursorStyle,
|
|
29
|
+
IdWindowStyle,
|
|
30
|
+
OffsetStyle,
|
|
31
|
+
SyncPaginator,
|
|
32
|
+
)
|
|
33
|
+
from ._response import (
|
|
34
|
+
APIResponse,
|
|
35
|
+
RateLimit,
|
|
36
|
+
ResponseModel,
|
|
37
|
+
VaquillModel,
|
|
38
|
+
lenient_enum,
|
|
39
|
+
lenient_validator,
|
|
40
|
+
)
|
|
41
|
+
from ._version import __version__
|
|
42
|
+
from ._webhooks import WebhookEvent, verify_webhook
|
|
43
|
+
|
|
44
|
+
__all__ = [
|
|
45
|
+
"APIConnectionError",
|
|
46
|
+
"APIResponse",
|
|
47
|
+
"APIStatusError",
|
|
48
|
+
"APITimeoutError",
|
|
49
|
+
"AsyncPaginator",
|
|
50
|
+
"AsyncVaquill",
|
|
51
|
+
"AuthenticationError",
|
|
52
|
+
"BadRequestError",
|
|
53
|
+
"ConflictError",
|
|
54
|
+
"CursorStyle",
|
|
55
|
+
"DailyCapExceededError",
|
|
56
|
+
"IdWindowStyle",
|
|
57
|
+
"IdempotencyConflictError",
|
|
58
|
+
"IdempotencyInProgressError",
|
|
59
|
+
"IdempotencyKeyReusedError",
|
|
60
|
+
"IdempotencyReplayUnavailableError",
|
|
61
|
+
"InsufficientCreditsError",
|
|
62
|
+
"InternalServerError",
|
|
63
|
+
"NotFoundError",
|
|
64
|
+
"OffsetStyle",
|
|
65
|
+
"PermissionDeniedError",
|
|
66
|
+
"RateLimit",
|
|
67
|
+
"RateLimitError",
|
|
68
|
+
"ResponseModel",
|
|
69
|
+
"SpendGuardExceeded",
|
|
70
|
+
"SyncPaginator",
|
|
71
|
+
"UnprocessableEntityError",
|
|
72
|
+
"Vaquill",
|
|
73
|
+
"VaquillError",
|
|
74
|
+
"VaquillModel",
|
|
75
|
+
"WebhookEvent",
|
|
76
|
+
"WebhookVerificationError",
|
|
77
|
+
"__version__",
|
|
78
|
+
"lenient_enum",
|
|
79
|
+
"lenient_validator",
|
|
80
|
+
"verify_webhook",
|
|
81
|
+
]
|
vaquill/_base_client.py
ADDED
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
"""Transport shared by the sync and async clients: auth, retries, idempotency, metering."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import platform
|
|
8
|
+
import threading
|
|
9
|
+
from collections.abc import Mapping
|
|
10
|
+
from types import TracebackType
|
|
11
|
+
from typing import Any, Self, TypeVar
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
from pydantic import TypeAdapter
|
|
15
|
+
|
|
16
|
+
from . import _retry
|
|
17
|
+
from ._config import (
|
|
18
|
+
API_KEY_ENV,
|
|
19
|
+
API_KEY_PREFIX,
|
|
20
|
+
BASE_URL_ENV,
|
|
21
|
+
DEFAULT_BASE_URL,
|
|
22
|
+
DEFAULT_MAX_RETRIES,
|
|
23
|
+
DEFAULT_TIMEOUT,
|
|
24
|
+
IDEMPOTENCY_HEADER,
|
|
25
|
+
IDEMPOTENCY_STATUS_HEADER,
|
|
26
|
+
)
|
|
27
|
+
from ._errors import (
|
|
28
|
+
APIConnectionError,
|
|
29
|
+
APIStatusError,
|
|
30
|
+
APITimeoutError,
|
|
31
|
+
SpendGuardExceeded,
|
|
32
|
+
VaquillError,
|
|
33
|
+
make_status_error,
|
|
34
|
+
)
|
|
35
|
+
from ._idempotency import new_key, redact_key, validate_key
|
|
36
|
+
from ._response import APIResponse
|
|
37
|
+
from ._version import __version__
|
|
38
|
+
|
|
39
|
+
log = logging.getLogger("vaquill")
|
|
40
|
+
|
|
41
|
+
T = TypeVar("T")
|
|
42
|
+
Query = Mapping[str, Any]
|
|
43
|
+
Timeout = float | httpx.Timeout
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class SpendTracker:
|
|
47
|
+
"""Running total of credits this client (and clients derived from it) has consumed."""
|
|
48
|
+
|
|
49
|
+
def __init__(self) -> None:
|
|
50
|
+
self._lock = threading.Lock()
|
|
51
|
+
self._total = 0.0
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def total(self) -> float:
|
|
55
|
+
return self._total
|
|
56
|
+
|
|
57
|
+
def add(self, credits: float) -> None:
|
|
58
|
+
with self._lock:
|
|
59
|
+
self._total += credits
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _resolve_api_key(api_key: str | None) -> str:
|
|
63
|
+
key = api_key if api_key is not None else os.environ.get(API_KEY_ENV)
|
|
64
|
+
if not key:
|
|
65
|
+
raise VaquillError(f"No API key. Pass api_key=... or set the {API_KEY_ENV} variable.")
|
|
66
|
+
if not key.startswith(API_KEY_PREFIX):
|
|
67
|
+
# The key is deliberately not echoed.
|
|
68
|
+
raise VaquillError(f"Invalid API key: Vaquill keys start with '{API_KEY_PREFIX}'.")
|
|
69
|
+
return key
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _user_agent() -> str:
|
|
73
|
+
py, os_name = platform.python_version(), platform.system().lower()
|
|
74
|
+
return f"vaquill-python/{__version__} python/{py} {os_name}"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class BaseClient:
|
|
78
|
+
def __init__(
|
|
79
|
+
self,
|
|
80
|
+
*,
|
|
81
|
+
api_key: str | None,
|
|
82
|
+
base_url: str | None,
|
|
83
|
+
timeout: Timeout | None,
|
|
84
|
+
max_retries: int,
|
|
85
|
+
default_headers: Mapping[str, str] | None,
|
|
86
|
+
idempotency_keys: bool,
|
|
87
|
+
max_credits: float | None,
|
|
88
|
+
) -> None:
|
|
89
|
+
if max_retries < 0:
|
|
90
|
+
raise VaquillError("max_retries must be >= 0.")
|
|
91
|
+
self._api_key = _resolve_api_key(api_key)
|
|
92
|
+
self.base_url = (base_url or os.environ.get(BASE_URL_ENV) or DEFAULT_BASE_URL).rstrip("/")
|
|
93
|
+
self.timeout: Timeout = DEFAULT_TIMEOUT if timeout is None else timeout
|
|
94
|
+
self.max_retries = max_retries
|
|
95
|
+
self.default_headers: dict[str, str] = dict(default_headers or {})
|
|
96
|
+
self.idempotency_keys = idempotency_keys
|
|
97
|
+
self.max_credits = max_credits
|
|
98
|
+
self._spend = SpendTracker()
|
|
99
|
+
|
|
100
|
+
def __repr__(self) -> str:
|
|
101
|
+
return f"{type(self).__name__}(base_url={self.base_url!r})"
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def credits_spent(self) -> float:
|
|
105
|
+
"""Credits consumed by charged calls since construction (shared with derived clients)."""
|
|
106
|
+
return self._spend.total
|
|
107
|
+
|
|
108
|
+
def _derived_kwargs(
|
|
109
|
+
self,
|
|
110
|
+
*,
|
|
111
|
+
api_key: str | None,
|
|
112
|
+
base_url: str | None,
|
|
113
|
+
timeout: Timeout | None,
|
|
114
|
+
max_retries: int | None,
|
|
115
|
+
default_headers: Mapping[str, str] | None,
|
|
116
|
+
idempotency_keys: bool | None,
|
|
117
|
+
max_credits: float | None,
|
|
118
|
+
) -> dict[str, Any]:
|
|
119
|
+
return {
|
|
120
|
+
"api_key": api_key or self._api_key,
|
|
121
|
+
"base_url": base_url or self.base_url,
|
|
122
|
+
"timeout": self.timeout if timeout is None else timeout,
|
|
123
|
+
"max_retries": self.max_retries if max_retries is None else max_retries,
|
|
124
|
+
"default_headers": {**self.default_headers, **(default_headers or {})},
|
|
125
|
+
"idempotency_keys": (
|
|
126
|
+
self.idempotency_keys if idempotency_keys is None else idempotency_keys
|
|
127
|
+
),
|
|
128
|
+
"max_credits": self.max_credits if max_credits is None else max_credits,
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
def _check_spend(self, charged: bool) -> None:
|
|
132
|
+
if charged and self.max_credits is not None and self._spend.total >= self.max_credits:
|
|
133
|
+
raise SpendGuardExceeded(
|
|
134
|
+
f"Spend guard reached: {self._spend.total:g} of {self.max_credits:g} credits used."
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
def _key_for(self, charged: bool, idempotency_key: str | None) -> str | None:
|
|
138
|
+
if idempotency_key is not None:
|
|
139
|
+
return validate_key(idempotency_key)
|
|
140
|
+
return new_key() if charged and self.idempotency_keys else None
|
|
141
|
+
|
|
142
|
+
def _headers(self, key: str | None, extra: Mapping[str, str] | None) -> dict[str, str]:
|
|
143
|
+
headers = {"User-Agent": _user_agent(), "Accept": "application/json"}
|
|
144
|
+
headers.update(self.default_headers)
|
|
145
|
+
headers["Authorization"] = f"Bearer {self._api_key}"
|
|
146
|
+
if key is not None:
|
|
147
|
+
headers[IDEMPOTENCY_HEADER] = key
|
|
148
|
+
headers.update(extra or {})
|
|
149
|
+
return headers
|
|
150
|
+
|
|
151
|
+
def _url(self, path: str) -> str:
|
|
152
|
+
return f"{self.base_url}/{path.lstrip('/')}"
|
|
153
|
+
|
|
154
|
+
@staticmethod
|
|
155
|
+
def _params(params: Query | None) -> dict[str, Any] | None:
|
|
156
|
+
return None if params is None else {k: v for k, v in params.items() if v is not None}
|
|
157
|
+
|
|
158
|
+
@staticmethod
|
|
159
|
+
def _error_for(response: httpx.Response) -> APIStatusError:
|
|
160
|
+
try:
|
|
161
|
+
body: object = response.json()
|
|
162
|
+
except ValueError:
|
|
163
|
+
body = response.text
|
|
164
|
+
return make_status_error(response, body)
|
|
165
|
+
|
|
166
|
+
def _finish(
|
|
167
|
+
self, response: httpx.Response, charged: bool, cast_to: Any, key: str | None
|
|
168
|
+
) -> APIResponse[Any]:
|
|
169
|
+
if response.headers.get(IDEMPOTENCY_STATUS_HEADER, "").lower() == "unavailable":
|
|
170
|
+
log.warning(
|
|
171
|
+
"Server could not deduplicate this request (idempotency store unavailable)."
|
|
172
|
+
)
|
|
173
|
+
try:
|
|
174
|
+
data = response.json() if response.content else None
|
|
175
|
+
except ValueError as exc:
|
|
176
|
+
raise VaquillError(
|
|
177
|
+
f"Response was not valid JSON (status {response.status_code})."
|
|
178
|
+
) from exc
|
|
179
|
+
adapter: TypeAdapter[Any] | None = None if cast_to is Any else TypeAdapter(cast_to)
|
|
180
|
+
result: APIResponse[Any] = APIResponse(
|
|
181
|
+
response, data, (lambda d: d) if adapter is None else adapter.validate_python
|
|
182
|
+
)
|
|
183
|
+
if charged:
|
|
184
|
+
self._spend.add(result.credits_charged_this_call)
|
|
185
|
+
log.debug("Response %s key=%s", response.status_code, redact_key(key) if key else None)
|
|
186
|
+
return result
|
|
187
|
+
|
|
188
|
+
def _log_retry(self, why: str, delay: float, attempt: int, retries: int) -> None:
|
|
189
|
+
log.debug("Retrying after %s in %.2fs (retry %d of %d)", why, delay, attempt + 1, retries)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class SyncAPIClient(BaseClient):
|
|
193
|
+
def __init__(
|
|
194
|
+
self,
|
|
195
|
+
*,
|
|
196
|
+
api_key: str | None = None,
|
|
197
|
+
base_url: str | None = None,
|
|
198
|
+
timeout: Timeout | None = None,
|
|
199
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
200
|
+
default_headers: Mapping[str, str] | None = None,
|
|
201
|
+
idempotency_keys: bool = True,
|
|
202
|
+
max_credits: float | None = None,
|
|
203
|
+
http_client: httpx.Client | None = None,
|
|
204
|
+
) -> None:
|
|
205
|
+
super().__init__(
|
|
206
|
+
api_key=api_key,
|
|
207
|
+
base_url=base_url,
|
|
208
|
+
timeout=timeout,
|
|
209
|
+
max_retries=max_retries,
|
|
210
|
+
default_headers=default_headers,
|
|
211
|
+
idempotency_keys=idempotency_keys,
|
|
212
|
+
max_credits=max_credits,
|
|
213
|
+
)
|
|
214
|
+
self._owns_http = http_client is None
|
|
215
|
+
self._http = http_client or httpx.Client()
|
|
216
|
+
|
|
217
|
+
def with_options(
|
|
218
|
+
self,
|
|
219
|
+
*,
|
|
220
|
+
api_key: str | None = None,
|
|
221
|
+
base_url: str | None = None,
|
|
222
|
+
timeout: Timeout | None = None,
|
|
223
|
+
max_retries: int | None = None,
|
|
224
|
+
default_headers: Mapping[str, str] | None = None,
|
|
225
|
+
idempotency_keys: bool | None = None,
|
|
226
|
+
max_credits: float | None = None,
|
|
227
|
+
) -> Self:
|
|
228
|
+
"""A copy with overrides. Shares the HTTP client and the spend total with this one."""
|
|
229
|
+
new = type(self)(
|
|
230
|
+
**self._derived_kwargs(
|
|
231
|
+
api_key=api_key,
|
|
232
|
+
base_url=base_url,
|
|
233
|
+
timeout=timeout,
|
|
234
|
+
max_retries=max_retries,
|
|
235
|
+
default_headers=default_headers,
|
|
236
|
+
idempotency_keys=idempotency_keys,
|
|
237
|
+
max_credits=max_credits,
|
|
238
|
+
),
|
|
239
|
+
http_client=self._http,
|
|
240
|
+
)
|
|
241
|
+
new._spend = self._spend # pyright: ignore[reportPrivateUsage]
|
|
242
|
+
return new
|
|
243
|
+
|
|
244
|
+
def close(self) -> None:
|
|
245
|
+
if self._owns_http:
|
|
246
|
+
self._http.close()
|
|
247
|
+
|
|
248
|
+
def __enter__(self) -> Self:
|
|
249
|
+
return self
|
|
250
|
+
|
|
251
|
+
def __exit__(
|
|
252
|
+
self,
|
|
253
|
+
exc_type: type[BaseException] | None,
|
|
254
|
+
exc: BaseException | None,
|
|
255
|
+
tb: TracebackType | None,
|
|
256
|
+
) -> None:
|
|
257
|
+
self.close()
|
|
258
|
+
|
|
259
|
+
@property
|
|
260
|
+
def with_raw_response(self) -> SyncRawRequester:
|
|
261
|
+
return SyncRawRequester(self)
|
|
262
|
+
|
|
263
|
+
def request(
|
|
264
|
+
self,
|
|
265
|
+
method: str,
|
|
266
|
+
path: str,
|
|
267
|
+
*,
|
|
268
|
+
cast_to: type[T],
|
|
269
|
+
params: Query | None = None,
|
|
270
|
+
json: Any = None,
|
|
271
|
+
headers: Mapping[str, str] | None = None,
|
|
272
|
+
charged: bool = False,
|
|
273
|
+
idempotency_key: str | None = None,
|
|
274
|
+
timeout: Timeout | None = None,
|
|
275
|
+
max_retries: int | None = None,
|
|
276
|
+
) -> T:
|
|
277
|
+
"""Send a request and return the parsed ``cast_to``.
|
|
278
|
+
|
|
279
|
+
``charged=True`` marks an operation that consumes credits: it gets an idempotency key
|
|
280
|
+
(unless disabled), is checked against ``max_credits`` and is counted afterwards.
|
|
281
|
+
"""
|
|
282
|
+
return self.request_with_response(
|
|
283
|
+
method,
|
|
284
|
+
path,
|
|
285
|
+
cast_to=cast_to,
|
|
286
|
+
params=params,
|
|
287
|
+
json=json,
|
|
288
|
+
headers=headers,
|
|
289
|
+
charged=charged,
|
|
290
|
+
idempotency_key=idempotency_key,
|
|
291
|
+
timeout=timeout,
|
|
292
|
+
max_retries=max_retries,
|
|
293
|
+
).parse()
|
|
294
|
+
|
|
295
|
+
def raw_request(
|
|
296
|
+
self,
|
|
297
|
+
method: str,
|
|
298
|
+
path: str,
|
|
299
|
+
*,
|
|
300
|
+
params: Query | None = None,
|
|
301
|
+
json: Any = None,
|
|
302
|
+
headers: Mapping[str, str] | None = None,
|
|
303
|
+
charged: bool = False,
|
|
304
|
+
idempotency_key: str | None = None,
|
|
305
|
+
timeout: Timeout | None = None,
|
|
306
|
+
max_retries: int | None = None,
|
|
307
|
+
) -> APIResponse[Any]:
|
|
308
|
+
"""Escape hatch for endpoints newer than this SDK. The body is returned as plain JSON."""
|
|
309
|
+
return self.request_with_response(
|
|
310
|
+
method,
|
|
311
|
+
path,
|
|
312
|
+
cast_to=Any,
|
|
313
|
+
params=params,
|
|
314
|
+
json=json,
|
|
315
|
+
headers=headers,
|
|
316
|
+
charged=charged,
|
|
317
|
+
idempotency_key=idempotency_key,
|
|
318
|
+
timeout=timeout,
|
|
319
|
+
max_retries=max_retries,
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
def request_with_response(
|
|
323
|
+
self,
|
|
324
|
+
method: str,
|
|
325
|
+
path: str,
|
|
326
|
+
*,
|
|
327
|
+
cast_to: Any,
|
|
328
|
+
params: Query | None = None,
|
|
329
|
+
json: Any = None,
|
|
330
|
+
headers: Mapping[str, str] | None = None,
|
|
331
|
+
charged: bool = False,
|
|
332
|
+
idempotency_key: str | None = None,
|
|
333
|
+
timeout: Timeout | None = None,
|
|
334
|
+
max_retries: int | None = None,
|
|
335
|
+
) -> APIResponse[Any]:
|
|
336
|
+
self._check_spend(charged)
|
|
337
|
+
key = self._key_for(charged, idempotency_key)
|
|
338
|
+
retries = self.max_retries if max_retries is None else max_retries
|
|
339
|
+
attempt = 0
|
|
340
|
+
while True:
|
|
341
|
+
request = self._http.build_request(
|
|
342
|
+
method,
|
|
343
|
+
self._url(path),
|
|
344
|
+
params=self._params(params),
|
|
345
|
+
json=json,
|
|
346
|
+
headers=self._headers(key, headers),
|
|
347
|
+
timeout=self.timeout if timeout is None else timeout,
|
|
348
|
+
)
|
|
349
|
+
log.debug("Request %s %s key=%s", method, request.url, redact_key(key) if key else None)
|
|
350
|
+
try:
|
|
351
|
+
response = self._http.send(request)
|
|
352
|
+
except httpx.TransportError as exc:
|
|
353
|
+
if attempt < retries and _retry.connection_error_is_retryable(
|
|
354
|
+
exc, charged=charged, has_idempotency_key=key is not None
|
|
355
|
+
):
|
|
356
|
+
delay = _retry.backoff_delay(attempt)
|
|
357
|
+
self._log_retry(type(exc).__name__, delay, attempt, retries)
|
|
358
|
+
_retry.sleep(delay)
|
|
359
|
+
attempt += 1
|
|
360
|
+
continue
|
|
361
|
+
if isinstance(exc, httpx.TimeoutException):
|
|
362
|
+
raise APITimeoutError(request=request) from exc
|
|
363
|
+
raise APIConnectionError(request=request) from exc
|
|
364
|
+
if response.is_success:
|
|
365
|
+
return self._finish(response, charged, cast_to, key)
|
|
366
|
+
error = self._error_for(response)
|
|
367
|
+
delay = _retry.delay_for_status(error, attempt) if attempt < retries else None
|
|
368
|
+
if delay is None:
|
|
369
|
+
raise error
|
|
370
|
+
self._log_retry(f"status {response.status_code}", delay, attempt, retries)
|
|
371
|
+
_retry.sleep(delay)
|
|
372
|
+
attempt += 1
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
class AsyncAPIClient(BaseClient):
|
|
376
|
+
def __init__(
|
|
377
|
+
self,
|
|
378
|
+
*,
|
|
379
|
+
api_key: str | None = None,
|
|
380
|
+
base_url: str | None = None,
|
|
381
|
+
timeout: Timeout | None = None,
|
|
382
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
383
|
+
default_headers: Mapping[str, str] | None = None,
|
|
384
|
+
idempotency_keys: bool = True,
|
|
385
|
+
max_credits: float | None = None,
|
|
386
|
+
http_client: httpx.AsyncClient | None = None,
|
|
387
|
+
) -> None:
|
|
388
|
+
super().__init__(
|
|
389
|
+
api_key=api_key,
|
|
390
|
+
base_url=base_url,
|
|
391
|
+
timeout=timeout,
|
|
392
|
+
max_retries=max_retries,
|
|
393
|
+
default_headers=default_headers,
|
|
394
|
+
idempotency_keys=idempotency_keys,
|
|
395
|
+
max_credits=max_credits,
|
|
396
|
+
)
|
|
397
|
+
self._owns_http = http_client is None
|
|
398
|
+
self._http = http_client or httpx.AsyncClient()
|
|
399
|
+
|
|
400
|
+
def with_options(
|
|
401
|
+
self,
|
|
402
|
+
*,
|
|
403
|
+
api_key: str | None = None,
|
|
404
|
+
base_url: str | None = None,
|
|
405
|
+
timeout: Timeout | None = None,
|
|
406
|
+
max_retries: int | None = None,
|
|
407
|
+
default_headers: Mapping[str, str] | None = None,
|
|
408
|
+
idempotency_keys: bool | None = None,
|
|
409
|
+
max_credits: float | None = None,
|
|
410
|
+
) -> Self:
|
|
411
|
+
"""A copy with overrides. Shares the HTTP client and the spend total with this one."""
|
|
412
|
+
new = type(self)(
|
|
413
|
+
**self._derived_kwargs(
|
|
414
|
+
api_key=api_key,
|
|
415
|
+
base_url=base_url,
|
|
416
|
+
timeout=timeout,
|
|
417
|
+
max_retries=max_retries,
|
|
418
|
+
default_headers=default_headers,
|
|
419
|
+
idempotency_keys=idempotency_keys,
|
|
420
|
+
max_credits=max_credits,
|
|
421
|
+
),
|
|
422
|
+
http_client=self._http,
|
|
423
|
+
)
|
|
424
|
+
new._spend = self._spend # pyright: ignore[reportPrivateUsage]
|
|
425
|
+
return new
|
|
426
|
+
|
|
427
|
+
async def aclose(self) -> None:
|
|
428
|
+
if self._owns_http:
|
|
429
|
+
await self._http.aclose()
|
|
430
|
+
|
|
431
|
+
async def __aenter__(self) -> Self:
|
|
432
|
+
return self
|
|
433
|
+
|
|
434
|
+
async def __aexit__(
|
|
435
|
+
self,
|
|
436
|
+
exc_type: type[BaseException] | None,
|
|
437
|
+
exc: BaseException | None,
|
|
438
|
+
tb: TracebackType | None,
|
|
439
|
+
) -> None:
|
|
440
|
+
await self.aclose()
|
|
441
|
+
|
|
442
|
+
@property
|
|
443
|
+
def with_raw_response(self) -> AsyncRawRequester:
|
|
444
|
+
return AsyncRawRequester(self)
|
|
445
|
+
|
|
446
|
+
async def request(
|
|
447
|
+
self,
|
|
448
|
+
method: str,
|
|
449
|
+
path: str,
|
|
450
|
+
*,
|
|
451
|
+
cast_to: type[T],
|
|
452
|
+
params: Query | None = None,
|
|
453
|
+
json: Any = None,
|
|
454
|
+
headers: Mapping[str, str] | None = None,
|
|
455
|
+
charged: bool = False,
|
|
456
|
+
idempotency_key: str | None = None,
|
|
457
|
+
timeout: Timeout | None = None,
|
|
458
|
+
max_retries: int | None = None,
|
|
459
|
+
) -> T:
|
|
460
|
+
"""Async twin of :meth:`SyncAPIClient.request`."""
|
|
461
|
+
response = await self.request_with_response(
|
|
462
|
+
method,
|
|
463
|
+
path,
|
|
464
|
+
cast_to=cast_to,
|
|
465
|
+
params=params,
|
|
466
|
+
json=json,
|
|
467
|
+
headers=headers,
|
|
468
|
+
charged=charged,
|
|
469
|
+
idempotency_key=idempotency_key,
|
|
470
|
+
timeout=timeout,
|
|
471
|
+
max_retries=max_retries,
|
|
472
|
+
)
|
|
473
|
+
return response.parse()
|
|
474
|
+
|
|
475
|
+
async def raw_request(
|
|
476
|
+
self,
|
|
477
|
+
method: str,
|
|
478
|
+
path: str,
|
|
479
|
+
*,
|
|
480
|
+
params: Query | None = None,
|
|
481
|
+
json: Any = None,
|
|
482
|
+
headers: Mapping[str, str] | None = None,
|
|
483
|
+
charged: bool = False,
|
|
484
|
+
idempotency_key: str | None = None,
|
|
485
|
+
timeout: Timeout | None = None,
|
|
486
|
+
max_retries: int | None = None,
|
|
487
|
+
) -> APIResponse[Any]:
|
|
488
|
+
"""Escape hatch for endpoints newer than this SDK. The body is returned as plain JSON."""
|
|
489
|
+
return await self.request_with_response(
|
|
490
|
+
method,
|
|
491
|
+
path,
|
|
492
|
+
cast_to=Any,
|
|
493
|
+
params=params,
|
|
494
|
+
json=json,
|
|
495
|
+
headers=headers,
|
|
496
|
+
charged=charged,
|
|
497
|
+
idempotency_key=idempotency_key,
|
|
498
|
+
timeout=timeout,
|
|
499
|
+
max_retries=max_retries,
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
async def request_with_response(
|
|
503
|
+
self,
|
|
504
|
+
method: str,
|
|
505
|
+
path: str,
|
|
506
|
+
*,
|
|
507
|
+
cast_to: Any,
|
|
508
|
+
params: Query | None = None,
|
|
509
|
+
json: Any = None,
|
|
510
|
+
headers: Mapping[str, str] | None = None,
|
|
511
|
+
charged: bool = False,
|
|
512
|
+
idempotency_key: str | None = None,
|
|
513
|
+
timeout: Timeout | None = None,
|
|
514
|
+
max_retries: int | None = None,
|
|
515
|
+
) -> APIResponse[Any]:
|
|
516
|
+
self._check_spend(charged)
|
|
517
|
+
key = self._key_for(charged, idempotency_key)
|
|
518
|
+
retries = self.max_retries if max_retries is None else max_retries
|
|
519
|
+
attempt = 0
|
|
520
|
+
while True:
|
|
521
|
+
request = self._http.build_request(
|
|
522
|
+
method,
|
|
523
|
+
self._url(path),
|
|
524
|
+
params=self._params(params),
|
|
525
|
+
json=json,
|
|
526
|
+
headers=self._headers(key, headers),
|
|
527
|
+
timeout=self.timeout if timeout is None else timeout,
|
|
528
|
+
)
|
|
529
|
+
log.debug("Request %s %s key=%s", method, request.url, redact_key(key) if key else None)
|
|
530
|
+
try:
|
|
531
|
+
response = await self._http.send(request)
|
|
532
|
+
except httpx.TransportError as exc:
|
|
533
|
+
if attempt < retries and _retry.connection_error_is_retryable(
|
|
534
|
+
exc, charged=charged, has_idempotency_key=key is not None
|
|
535
|
+
):
|
|
536
|
+
delay = _retry.backoff_delay(attempt)
|
|
537
|
+
self._log_retry(type(exc).__name__, delay, attempt, retries)
|
|
538
|
+
await _retry.asleep(delay)
|
|
539
|
+
attempt += 1
|
|
540
|
+
continue
|
|
541
|
+
if isinstance(exc, httpx.TimeoutException):
|
|
542
|
+
raise APITimeoutError(request=request) from exc
|
|
543
|
+
raise APIConnectionError(request=request) from exc
|
|
544
|
+
if response.is_success:
|
|
545
|
+
return self._finish(response, charged, cast_to, key)
|
|
546
|
+
error = self._error_for(response)
|
|
547
|
+
delay = _retry.delay_for_status(error, attempt) if attempt < retries else None
|
|
548
|
+
if delay is None:
|
|
549
|
+
raise error
|
|
550
|
+
self._log_retry(f"status {response.status_code}", delay, attempt, retries)
|
|
551
|
+
await _retry.asleep(delay)
|
|
552
|
+
attempt += 1
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
class SyncRawRequester:
|
|
556
|
+
"""``client.with_raw_response``: the same requests, returning :class:`APIResponse`."""
|
|
557
|
+
|
|
558
|
+
def __init__(self, client: SyncAPIClient) -> None:
|
|
559
|
+
self._client = client
|
|
560
|
+
|
|
561
|
+
def request(self, method: str, path: str, *, cast_to: type[T], **kwargs: Any) -> APIResponse[T]:
|
|
562
|
+
return self._client.request_with_response(method, path, cast_to=cast_to, **kwargs)
|
|
563
|
+
|
|
564
|
+
def raw_request(self, method: str, path: str, **kwargs: Any) -> APIResponse[Any]:
|
|
565
|
+
return self._client.raw_request(method, path, **kwargs)
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
class AsyncRawRequester:
|
|
569
|
+
def __init__(self, client: AsyncAPIClient) -> None:
|
|
570
|
+
self._client = client
|
|
571
|
+
|
|
572
|
+
async def request(
|
|
573
|
+
self, method: str, path: str, *, cast_to: type[T], **kwargs: Any
|
|
574
|
+
) -> APIResponse[T]:
|
|
575
|
+
return await self._client.request_with_response(method, path, cast_to=cast_to, **kwargs)
|
|
576
|
+
|
|
577
|
+
async def raw_request(self, method: str, path: str, **kwargs: Any) -> APIResponse[Any]:
|
|
578
|
+
return await self._client.raw_request(method, path, **kwargs)
|