frontmost 0.2.1__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.
- frontmost/__init__.py +21 -0
- frontmost/_core.py +501 -0
- frontmost/_generated.py +1475 -0
- frontmost/py.typed +0 -0
- frontmost-0.2.1.dist-info/METADATA +253 -0
- frontmost-0.2.1.dist-info/RECORD +8 -0
- frontmost-0.2.1.dist-info/WHEEL +4 -0
- frontmost-0.2.1.dist-info/licenses/LICENSE +21 -0
frontmost/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""The Frontmost AI API client. `from frontmost import Frontmost` and `Frontmost()` reads FRONTMOST_API_KEY."""
|
|
2
|
+
|
|
3
|
+
from ._core import VERSION as VERSION
|
|
4
|
+
from ._core import APIConnectionError as APIConnectionError
|
|
5
|
+
from ._core import APIConnectionTimeoutError as APIConnectionTimeoutError
|
|
6
|
+
from ._core import APIError as APIError
|
|
7
|
+
from ._core import AsyncPage as AsyncPage
|
|
8
|
+
from ._core import AuthenticationError as AuthenticationError
|
|
9
|
+
from ._core import BadRequestError as BadRequestError
|
|
10
|
+
from ._core import ConflictError as ConflictError
|
|
11
|
+
from ._core import FrontmostError as FrontmostError
|
|
12
|
+
from ._core import InternalServerError as InternalServerError
|
|
13
|
+
from ._core import ListPage as ListPage
|
|
14
|
+
from ._core import NotFoundError as NotFoundError
|
|
15
|
+
from ._core import PermissionDeniedError as PermissionDeniedError
|
|
16
|
+
from ._core import RateLimitError as RateLimitError
|
|
17
|
+
from ._core import RequestOptions as RequestOptions
|
|
18
|
+
from ._core import SyncPage as SyncPage
|
|
19
|
+
from ._core import UnprocessableEntityError as UnprocessableEntityError
|
|
20
|
+
from ._core import format_rate as format_rate
|
|
21
|
+
from ._generated import * # noqa: F403 (the generated module lists its names in __all__)
|
frontmost/_core.py
ADDED
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
# Hand-written runtime. The generator never writes this file. It holds what every route shares:
|
|
2
|
+
# the key, retries, timeouts, errors, idempotency keys, and cursor pages.
|
|
3
|
+
# The sync and async clients share every decision. Only the send and the sleep differ.
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import math
|
|
7
|
+
import os
|
|
8
|
+
import random
|
|
9
|
+
import time
|
|
10
|
+
import uuid
|
|
11
|
+
from collections.abc import AsyncIterator, Awaitable, Callable, Iterator, Mapping
|
|
12
|
+
from typing import Any, Generic, Literal, Optional, TypeVar, Union, cast
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
from typing_extensions import TypedDict
|
|
16
|
+
|
|
17
|
+
VERSION = "0.2.1"
|
|
18
|
+
DEFAULT_BASE_URL = "https://app.frontmost.ai/api/v1"
|
|
19
|
+
# The longest time an `..._and_wait` method waits, in seconds, before it stops with an error.
|
|
20
|
+
WAIT_LIMIT = 600.0
|
|
21
|
+
|
|
22
|
+
T = TypeVar("T")
|
|
23
|
+
L = TypeVar("L")
|
|
24
|
+
D = TypeVar("D")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class RequestOptions(TypedDict, total=False):
|
|
28
|
+
"""Per-call settings. Pass them as keyword arguments to any method."""
|
|
29
|
+
|
|
30
|
+
max_retries: int
|
|
31
|
+
# Seconds for one attempt.
|
|
32
|
+
timeout: float
|
|
33
|
+
# Default for a POST: a new random key, sent again on each retry of that call.
|
|
34
|
+
idempotency_key: str
|
|
35
|
+
extra_headers: Mapping[str, str]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ListPage(TypedDict, Generic[T]):
|
|
39
|
+
object: Literal["list"]
|
|
40
|
+
data: list[T]
|
|
41
|
+
has_more: bool
|
|
42
|
+
next_cursor: Optional[str]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class FrontmostError(Exception):
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class APIError(FrontmostError):
|
|
50
|
+
"""An error reply from the API. `message` is the API's own text, which says what to do next."""
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
status: int,
|
|
55
|
+
code: str,
|
|
56
|
+
message: str,
|
|
57
|
+
param: Optional[str],
|
|
58
|
+
request_id: Optional[str],
|
|
59
|
+
headers: httpx.Headers,
|
|
60
|
+
) -> None:
|
|
61
|
+
super().__init__(message)
|
|
62
|
+
self.status = status
|
|
63
|
+
self.code = code
|
|
64
|
+
self.message = message
|
|
65
|
+
self.param = param
|
|
66
|
+
self.request_id = request_id
|
|
67
|
+
self.headers = headers
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class BadRequestError(APIError):
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class AuthenticationError(APIError):
|
|
75
|
+
pass
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class PermissionDeniedError(APIError):
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class NotFoundError(APIError):
|
|
83
|
+
pass
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class ConflictError(APIError):
|
|
87
|
+
pass
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class UnprocessableEntityError(APIError):
|
|
91
|
+
pass
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class RateLimitError(APIError):
|
|
95
|
+
pass
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class InternalServerError(APIError):
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class APIConnectionError(FrontmostError):
|
|
103
|
+
pass
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class APIConnectionTimeoutError(APIConnectionError):
|
|
107
|
+
pass
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
_ERRORS: dict[int, type[APIError]] = {
|
|
111
|
+
400: BadRequestError,
|
|
112
|
+
401: AuthenticationError,
|
|
113
|
+
403: PermissionDeniedError,
|
|
114
|
+
404: NotFoundError,
|
|
115
|
+
409: ConflictError,
|
|
116
|
+
422: UnprocessableEntityError,
|
|
117
|
+
429: RateLimitError,
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _retryable(status: int, code: str) -> bool:
|
|
122
|
+
"""Retry 408, 429, 5xx, and a 409 that says the same request is still running. Never retry other 4xx replies."""
|
|
123
|
+
return status == 408 or status == 429 or status >= 500 or (status == 409 and code == "request_in_progress")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _delay(attempt: int, headers: Optional[httpx.Headers] = None) -> float:
|
|
127
|
+
"""Retry-After seconds when sent, else 0.5 s doubling to 8 s, plus a random part so clients spread out."""
|
|
128
|
+
try:
|
|
129
|
+
after = float(headers["retry-after"]) if headers is not None else math.nan
|
|
130
|
+
except (KeyError, ValueError):
|
|
131
|
+
after = math.nan
|
|
132
|
+
base = min(after, 60.0) if math.isfinite(after) and after >= 0 else min(0.5 * 2**attempt, 8.0)
|
|
133
|
+
return base + random.random() * 0.25
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _text(value: Any, default: D) -> Union[str, D]:
|
|
137
|
+
"""The API's own text unchanged, or the default when the field is missing or null."""
|
|
138
|
+
return default if value is None else str(value)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _api_error(response: httpx.Response, method: str, path: str) -> APIError:
|
|
142
|
+
try:
|
|
143
|
+
body = response.json()
|
|
144
|
+
except ValueError:
|
|
145
|
+
body = None
|
|
146
|
+
e = body.get("error") if isinstance(body, dict) else None
|
|
147
|
+
e = e if isinstance(e, dict) else {}
|
|
148
|
+
status = response.status_code
|
|
149
|
+
kind = _ERRORS.get(status) or (InternalServerError if status >= 500 else APIError)
|
|
150
|
+
return kind(
|
|
151
|
+
status,
|
|
152
|
+
_text(e.get("code"), "unknown_error"),
|
|
153
|
+
_text(e.get("message"), f"{method} {path} failed with {status}."),
|
|
154
|
+
_text(e.get("param"), None),
|
|
155
|
+
_text(e.get("request_id"), response.headers.get("x-request-id")),
|
|
156
|
+
response.headers,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _parse(response: httpx.Response) -> Any:
|
|
161
|
+
if response.status_code == 204 or not response.content:
|
|
162
|
+
return None
|
|
163
|
+
return response.json()
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class _Wait:
|
|
167
|
+
"""The rules for following a 202 reply: wait `retry_after` seconds, then send its `next` call."""
|
|
168
|
+
|
|
169
|
+
def __init__(self, base_url: str, options: RequestOptions) -> None:
|
|
170
|
+
self.deadline = time.monotonic() + WAIT_LIMIT
|
|
171
|
+
self.base_path = httpx.URL(base_url).path.rstrip("/")
|
|
172
|
+
# Each follow-up call gets its own idempotency key, because its body is different.
|
|
173
|
+
self.options: RequestOptions = {**options}
|
|
174
|
+
self.options.pop("idempotency_key", None)
|
|
175
|
+
|
|
176
|
+
def next(self, reply: Any) -> tuple[float, str, str, Any]:
|
|
177
|
+
"""Seconds to wait, then the method, path, and body of the next call. Raises when the wait is too long."""
|
|
178
|
+
after, nxt = float(reply["retry_after"]), reply["next"]
|
|
179
|
+
if time.monotonic() + after > self.deadline:
|
|
180
|
+
raise FrontmostError(
|
|
181
|
+
f"{reply['id']} is still running after {WAIT_LIMIT / 60:.0f} minutes. "
|
|
182
|
+
f"Send {nxt['method']} {nxt['path']} with {nxt['body']} later to continue."
|
|
183
|
+
)
|
|
184
|
+
path = str(nxt["path"])
|
|
185
|
+
# `next.path` starts at the site root, such as /api/v1/projects. Requests start after the base URL.
|
|
186
|
+
if path.startswith(f"{self.base_path}/"):
|
|
187
|
+
path = path[len(self.base_path) :]
|
|
188
|
+
return after, str(nxt["method"]), path, nxt["body"]
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class _Base:
|
|
192
|
+
def __init__(
|
|
193
|
+
self,
|
|
194
|
+
api_key: Optional[str] = None,
|
|
195
|
+
*,
|
|
196
|
+
base_url: Optional[str] = None,
|
|
197
|
+
max_retries: int = 2,
|
|
198
|
+
timeout: float = 60.0,
|
|
199
|
+
default_headers: Optional[Mapping[str, str]] = None,
|
|
200
|
+
) -> None:
|
|
201
|
+
key = api_key or os.environ.get("FRONTMOST_API_KEY")
|
|
202
|
+
if not key:
|
|
203
|
+
raise FrontmostError(
|
|
204
|
+
"No API key. Set FRONTMOST_API_KEY, or pass api_key. Create a key on the Team page: https://app.frontmost.ai/team"
|
|
205
|
+
)
|
|
206
|
+
self._api_key = key
|
|
207
|
+
self.base_url = (base_url or os.environ.get("FRONTMOST_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
|
|
208
|
+
self.max_retries = max_retries
|
|
209
|
+
self.timeout = timeout
|
|
210
|
+
self._headers = dict(default_headers or {})
|
|
211
|
+
|
|
212
|
+
def _prepare(self, method: str, options: RequestOptions) -> "_Call":
|
|
213
|
+
key = (options.get("idempotency_key") or f"sdk_{uuid.uuid4()}") if method == "POST" else None
|
|
214
|
+
return _Call(options.get("max_retries", self.max_retries), options.get("timeout", self.timeout), key)
|
|
215
|
+
|
|
216
|
+
def _send_args(
|
|
217
|
+
self,
|
|
218
|
+
method: str,
|
|
219
|
+
path: str,
|
|
220
|
+
query: Optional[Mapping[str, object]],
|
|
221
|
+
body: Optional[Mapping[str, object]],
|
|
222
|
+
call: "_Call",
|
|
223
|
+
attempt: int,
|
|
224
|
+
options: RequestOptions,
|
|
225
|
+
) -> dict[str, Any]:
|
|
226
|
+
headers = {
|
|
227
|
+
"accept": "application/json",
|
|
228
|
+
"authorization": f"Bearer {self._api_key}",
|
|
229
|
+
"x-frontmost-lang": "python",
|
|
230
|
+
"x-frontmost-package-version": VERSION,
|
|
231
|
+
"x-frontmost-retry-count": str(attempt),
|
|
232
|
+
**({"content-type": "application/json"} if body is not None else {}),
|
|
233
|
+
**({"idempotency-key": call.idempotency_key} if call.idempotency_key else {}),
|
|
234
|
+
**self._headers,
|
|
235
|
+
**options.get("extra_headers", {}),
|
|
236
|
+
}
|
|
237
|
+
params = {k: v for k, v in (query or {}).items() if v is not None}
|
|
238
|
+
return {
|
|
239
|
+
"method": method,
|
|
240
|
+
"url": f"{self.base_url}{path}",
|
|
241
|
+
"params": params,
|
|
242
|
+
"json": body,
|
|
243
|
+
"headers": headers,
|
|
244
|
+
"timeout": call.timeout,
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
def _retry_delay(
|
|
248
|
+
self, failure: Union[httpx.Response, httpx.TransportError], call: "_Call", attempt: int, method: str, path: str
|
|
249
|
+
) -> float:
|
|
250
|
+
"""Seconds to wait before the next attempt. Raises the error when the call must stop."""
|
|
251
|
+
if isinstance(failure, httpx.Response):
|
|
252
|
+
error = _api_error(failure, method, path)
|
|
253
|
+
if attempt < call.max_retries and _retryable(error.status, error.code):
|
|
254
|
+
return _delay(attempt, failure.headers)
|
|
255
|
+
raise error
|
|
256
|
+
if attempt < call.max_retries:
|
|
257
|
+
return _delay(attempt)
|
|
258
|
+
if isinstance(failure, httpx.TimeoutException):
|
|
259
|
+
raise APIConnectionTimeoutError(f"No reply in {call.timeout} s from {method} {path}.") from failure
|
|
260
|
+
raise APIConnectionError(f"Could not reach {self.base_url}{path}: {failure}") from failure
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
class _Call:
|
|
264
|
+
def __init__(self, max_retries: int, timeout: float, idempotency_key: Optional[str]) -> None:
|
|
265
|
+
self.max_retries = max_retries
|
|
266
|
+
self.timeout = timeout
|
|
267
|
+
self.idempotency_key = idempotency_key
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
class SyncPage(Generic[T, L]):
|
|
271
|
+
"""Every item of a list, page by page. `for x in page` reads all pages.
|
|
272
|
+
|
|
273
|
+
`L` is the full reply of one page, which can carry more than the items, such as the `scope` of a rivals list.
|
|
274
|
+
"""
|
|
275
|
+
|
|
276
|
+
def __init__(self, fetch: Callable[[Optional[str]], L]) -> None:
|
|
277
|
+
self._fetch = fetch
|
|
278
|
+
|
|
279
|
+
def page(self, cursor: Optional[str] = None) -> L:
|
|
280
|
+
"""One page. Pass the `next_cursor` of the page before."""
|
|
281
|
+
return self._fetch(cursor)
|
|
282
|
+
|
|
283
|
+
def __iter__(self) -> Iterator[T]:
|
|
284
|
+
cursor: Optional[str] = None
|
|
285
|
+
while True:
|
|
286
|
+
p = cast(ListPage[T], self._fetch(cursor))
|
|
287
|
+
yield from p["data"]
|
|
288
|
+
cursor = p["next_cursor"] if p["has_more"] else None
|
|
289
|
+
if not cursor:
|
|
290
|
+
return
|
|
291
|
+
|
|
292
|
+
def to_list(self) -> list[T]:
|
|
293
|
+
return list(self)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
class AsyncPage(Generic[T, L]):
|
|
297
|
+
"""Every item of a list, page by page. `async for x in page` reads all pages.
|
|
298
|
+
|
|
299
|
+
`L` is the full reply of one page, which can carry more than the items, such as the `scope` of a rivals list.
|
|
300
|
+
"""
|
|
301
|
+
|
|
302
|
+
def __init__(self, fetch: Callable[[Optional[str]], Awaitable[L]]) -> None:
|
|
303
|
+
self._fetch = fetch
|
|
304
|
+
|
|
305
|
+
async def page(self, cursor: Optional[str] = None) -> L:
|
|
306
|
+
"""One page. Pass the `next_cursor` of the page before."""
|
|
307
|
+
return await self._fetch(cursor)
|
|
308
|
+
|
|
309
|
+
async def __aiter__(self) -> AsyncIterator[T]:
|
|
310
|
+
cursor: Optional[str] = None
|
|
311
|
+
while True:
|
|
312
|
+
p = cast(ListPage[T], await self._fetch(cursor))
|
|
313
|
+
for x in p["data"]:
|
|
314
|
+
yield x
|
|
315
|
+
cursor = p["next_cursor"] if p["has_more"] else None
|
|
316
|
+
if not cursor:
|
|
317
|
+
return
|
|
318
|
+
|
|
319
|
+
async def to_list(self) -> list[T]:
|
|
320
|
+
return [x async for x in self]
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
class SyncCore(_Base):
|
|
324
|
+
def __init__(
|
|
325
|
+
self,
|
|
326
|
+
api_key: Optional[str] = None,
|
|
327
|
+
*,
|
|
328
|
+
base_url: Optional[str] = None,
|
|
329
|
+
max_retries: int = 2,
|
|
330
|
+
timeout: float = 60.0,
|
|
331
|
+
default_headers: Optional[Mapping[str, str]] = None,
|
|
332
|
+
http_client: Optional[httpx.Client] = None,
|
|
333
|
+
) -> None:
|
|
334
|
+
super().__init__(
|
|
335
|
+
api_key, base_url=base_url, max_retries=max_retries, timeout=timeout, default_headers=default_headers
|
|
336
|
+
)
|
|
337
|
+
self._owns_http = http_client is None
|
|
338
|
+
self._http = http_client or httpx.Client()
|
|
339
|
+
|
|
340
|
+
def request(
|
|
341
|
+
self,
|
|
342
|
+
method: str,
|
|
343
|
+
path: str,
|
|
344
|
+
*,
|
|
345
|
+
query: Optional[Mapping[str, object]] = None,
|
|
346
|
+
body: Optional[Mapping[str, object]] = None,
|
|
347
|
+
options: Optional[RequestOptions] = None,
|
|
348
|
+
) -> Any:
|
|
349
|
+
return self._send(method, path, query, body, options or {})[1]
|
|
350
|
+
|
|
351
|
+
def wait(
|
|
352
|
+
self,
|
|
353
|
+
method: str,
|
|
354
|
+
path: str,
|
|
355
|
+
*,
|
|
356
|
+
body: Optional[Mapping[str, object]] = None,
|
|
357
|
+
options: Optional[RequestOptions] = None,
|
|
358
|
+
) -> Any:
|
|
359
|
+
"""Sends the call, and while the reply is a 202, waits and sends its `next` call. Returns the final reply."""
|
|
360
|
+
rules = _Wait(self.base_url, options or {})
|
|
361
|
+
status, reply = self._send(method, path, None, body, options or {})
|
|
362
|
+
while status == 202:
|
|
363
|
+
after, method, path, body = rules.next(reply)
|
|
364
|
+
time.sleep(after)
|
|
365
|
+
status, reply = self._send(method, path, None, body, rules.options)
|
|
366
|
+
return reply
|
|
367
|
+
|
|
368
|
+
def _send(
|
|
369
|
+
self,
|
|
370
|
+
method: str,
|
|
371
|
+
path: str,
|
|
372
|
+
query: Optional[Mapping[str, object]],
|
|
373
|
+
body: Optional[Mapping[str, object]],
|
|
374
|
+
options: RequestOptions,
|
|
375
|
+
) -> tuple[int, Any]:
|
|
376
|
+
call = self._prepare(method, options)
|
|
377
|
+
attempt = 0
|
|
378
|
+
while True:
|
|
379
|
+
failure: Union[httpx.Response, httpx.TransportError]
|
|
380
|
+
try:
|
|
381
|
+
failure = self._http.request(**self._send_args(method, path, query, body, call, attempt, options))
|
|
382
|
+
if failure.is_success:
|
|
383
|
+
return failure.status_code, _parse(failure)
|
|
384
|
+
except httpx.TransportError as e:
|
|
385
|
+
failure = e
|
|
386
|
+
time.sleep(self._retry_delay(failure, call, attempt, method, path))
|
|
387
|
+
attempt += 1
|
|
388
|
+
|
|
389
|
+
def list(self, path: str, query: Mapping[str, object], options: RequestOptions) -> SyncPage[Any, Any]:
|
|
390
|
+
return SyncPage(lambda cursor: self.request("GET", path, query={**query, "cursor": cursor}, options=options))
|
|
391
|
+
|
|
392
|
+
def close(self) -> None:
|
|
393
|
+
if self._owns_http:
|
|
394
|
+
self._http.close()
|
|
395
|
+
|
|
396
|
+
def __enter__(self: "S") -> "S":
|
|
397
|
+
return self
|
|
398
|
+
|
|
399
|
+
def __exit__(self, *exc: object) -> None:
|
|
400
|
+
self.close()
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
class AsyncCore(_Base):
|
|
404
|
+
def __init__(
|
|
405
|
+
self,
|
|
406
|
+
api_key: Optional[str] = None,
|
|
407
|
+
*,
|
|
408
|
+
base_url: Optional[str] = None,
|
|
409
|
+
max_retries: int = 2,
|
|
410
|
+
timeout: float = 60.0,
|
|
411
|
+
default_headers: Optional[Mapping[str, str]] = None,
|
|
412
|
+
http_client: Optional[httpx.AsyncClient] = None,
|
|
413
|
+
) -> None:
|
|
414
|
+
super().__init__(
|
|
415
|
+
api_key, base_url=base_url, max_retries=max_retries, timeout=timeout, default_headers=default_headers
|
|
416
|
+
)
|
|
417
|
+
self._owns_http = http_client is None
|
|
418
|
+
self._http = http_client or httpx.AsyncClient()
|
|
419
|
+
|
|
420
|
+
async def request(
|
|
421
|
+
self,
|
|
422
|
+
method: str,
|
|
423
|
+
path: str,
|
|
424
|
+
*,
|
|
425
|
+
query: Optional[Mapping[str, object]] = None,
|
|
426
|
+
body: Optional[Mapping[str, object]] = None,
|
|
427
|
+
options: Optional[RequestOptions] = None,
|
|
428
|
+
) -> Any:
|
|
429
|
+
return (await self._send(method, path, query, body, options or {}))[1]
|
|
430
|
+
|
|
431
|
+
async def wait(
|
|
432
|
+
self,
|
|
433
|
+
method: str,
|
|
434
|
+
path: str,
|
|
435
|
+
*,
|
|
436
|
+
body: Optional[Mapping[str, object]] = None,
|
|
437
|
+
options: Optional[RequestOptions] = None,
|
|
438
|
+
) -> Any:
|
|
439
|
+
"""Sends the call, and while the reply is a 202, waits and sends its `next` call. Returns the final reply."""
|
|
440
|
+
rules = _Wait(self.base_url, options or {})
|
|
441
|
+
status, reply = await self._send(method, path, None, body, options or {})
|
|
442
|
+
while status == 202:
|
|
443
|
+
after, method, path, body = rules.next(reply)
|
|
444
|
+
await asyncio.sleep(after)
|
|
445
|
+
status, reply = await self._send(method, path, None, body, rules.options)
|
|
446
|
+
return reply
|
|
447
|
+
|
|
448
|
+
async def _send(
|
|
449
|
+
self,
|
|
450
|
+
method: str,
|
|
451
|
+
path: str,
|
|
452
|
+
query: Optional[Mapping[str, object]],
|
|
453
|
+
body: Optional[Mapping[str, object]],
|
|
454
|
+
options: RequestOptions,
|
|
455
|
+
) -> tuple[int, Any]:
|
|
456
|
+
call = self._prepare(method, options)
|
|
457
|
+
attempt = 0
|
|
458
|
+
while True:
|
|
459
|
+
failure: Union[httpx.Response, httpx.TransportError]
|
|
460
|
+
try:
|
|
461
|
+
failure = await self._http.request(**self._send_args(method, path, query, body, call, attempt, options))
|
|
462
|
+
if failure.is_success:
|
|
463
|
+
return failure.status_code, _parse(failure)
|
|
464
|
+
except httpx.TransportError as e:
|
|
465
|
+
failure = e
|
|
466
|
+
await asyncio.sleep(self._retry_delay(failure, call, attempt, method, path))
|
|
467
|
+
attempt += 1
|
|
468
|
+
|
|
469
|
+
def list(self, path: str, query: Mapping[str, object], options: RequestOptions) -> AsyncPage[Any, Any]:
|
|
470
|
+
return AsyncPage(lambda cursor: self.request("GET", path, query={**query, "cursor": cursor}, options=options))
|
|
471
|
+
|
|
472
|
+
async def close(self) -> None:
|
|
473
|
+
if self._owns_http:
|
|
474
|
+
await self._http.aclose()
|
|
475
|
+
|
|
476
|
+
async def __aenter__(self: "A") -> "A":
|
|
477
|
+
return self
|
|
478
|
+
|
|
479
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
480
|
+
await self.close()
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
S = TypeVar("S", bound=SyncCore)
|
|
484
|
+
A = TypeVar("A", bound=AsyncCore)
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
class SyncResource:
|
|
488
|
+
def __init__(self, core: SyncCore) -> None:
|
|
489
|
+
self._core = core
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
class AsyncResource:
|
|
493
|
+
def __init__(self, core: AsyncCore) -> None:
|
|
494
|
+
self._core = core
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def format_rate(rate: Optional[Mapping[str, Any]]) -> str:
|
|
498
|
+
""" "22% · 8 of 36" from a rate with its counts. A rate with no answers is "No answers"."""
|
|
499
|
+
if not rate or rate.get("value") is None or rate.get("denominator") == 0:
|
|
500
|
+
return "No answers"
|
|
501
|
+
return f"{math.floor(rate['value'] * 100 + 0.5)}% · {rate['numerator']} of {rate['denominator']}"
|