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 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']}"