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 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
+ ]
@@ -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)