zerogen 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.
zerogen/__init__.py ADDED
@@ -0,0 +1,27 @@
1
+ """The official Python library for the ZeroGen API."""
2
+
3
+ from . import types
4
+ from ._client import AsyncZeroGen, ZeroGen
5
+ from ._errors import ZeroGenConnectionError, ZeroGenError
6
+ from ._options import PostRequestOptions, RequestOptions
7
+ from ._pagination import AsyncPage, AsyncPaginator, Page
8
+ from ._version import __version__
9
+ from .resources.generations import has_ended
10
+ from .resources.uploads import ReadyUpload, UploadSource
11
+
12
+ __all__ = [
13
+ "AsyncPage",
14
+ "AsyncPaginator",
15
+ "AsyncZeroGen",
16
+ "Page",
17
+ "PostRequestOptions",
18
+ "ReadyUpload",
19
+ "RequestOptions",
20
+ "UploadSource",
21
+ "ZeroGen",
22
+ "ZeroGenConnectionError",
23
+ "ZeroGenError",
24
+ "__version__",
25
+ "has_ended",
26
+ "types",
27
+ ]
zerogen/_client.py ADDED
@@ -0,0 +1,444 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import math
5
+ import os
6
+ import random
7
+ import time
8
+ import uuid
9
+ from collections.abc import AsyncIterable, Callable, Iterable, Mapping
10
+ from dataclasses import dataclass
11
+ from typing import Any, Literal, Protocol, TypeVar, cast
12
+
13
+ import anyio
14
+ import httpx
15
+ from typing_extensions import Self, Unpack
16
+
17
+ from ._errors import ZeroGenConnectionError, ZeroGenError
18
+ from ._options import PostRequestOptions, RequestOptions
19
+ from ._pagination import AsyncPage, AsyncPaginator, Page
20
+ from ._version import __version__
21
+ from .resources.generations import AsyncGenerations, Generations
22
+ from .resources.models import AsyncModels, Models
23
+ from .resources.uploads import AsyncUploads, Uploads
24
+ from .types import Balance, Me
25
+
26
+ DEFAULT_BASE_URL = "https://api.zerogen.com/v1"
27
+ RETRY_AFTER_MAX_SECONDS = 60.0
28
+
29
+ Result = TypeVar("Result")
30
+ Item = TypeVar("Item")
31
+ Query = Mapping[str, "str | int | bool | None"]
32
+ Method = Literal["GET", "POST"]
33
+
34
+
35
+ class ListBody(Protocol[Item]):
36
+ @property
37
+ def data(self) -> list[Item]: ...
38
+
39
+ @property
40
+ def next_cursor(self) -> str | None: ...
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class _Prepared:
45
+ url: str
46
+ params: dict[str, str]
47
+ headers: httpx.Headers
48
+ content: bytes | None
49
+ timeout: float
50
+ max_retries: int
51
+
52
+
53
+ class _BaseClient:
54
+ base_url: str
55
+ timeout: float
56
+ """Seconds httpx waits on each step of one attempt. A request that waits on a generation
57
+ gets its wait added."""
58
+ max_retries: int
59
+ """How many times to retry a connection error or a retryable response."""
60
+
61
+ def __init__(
62
+ self,
63
+ *,
64
+ api_key: str | None,
65
+ base_url: str | None,
66
+ timeout: float,
67
+ max_retries: int,
68
+ default_headers: Mapping[str, str] | None,
69
+ ) -> None:
70
+ api_key = api_key if api_key is not None else _env("ZEROGEN_API_KEY")
71
+ if not api_key:
72
+ raise ValueError("Pass api_key or set the ZEROGEN_API_KEY environment variable.")
73
+ self._api_key = api_key
74
+ base_url = base_url if base_url is not None else _env("ZEROGEN_BASE_URL")
75
+ self.base_url = (base_url if base_url is not None else DEFAULT_BASE_URL).rstrip("/")
76
+ self.timeout = timeout
77
+ self.max_retries = max_retries
78
+ self._default_headers = dict(default_headers or {})
79
+
80
+ def _prepare(
81
+ self,
82
+ method: Method,
83
+ path: str,
84
+ query: Query | None,
85
+ body: object | None,
86
+ wait: int | None,
87
+ options: RequestOptions,
88
+ ) -> _Prepared:
89
+ headers = httpx.Headers(
90
+ {"Accept": "application/json", "User-Agent": f"zerogen-python/{__version__}"}
91
+ )
92
+ headers.update(self._default_headers)
93
+ headers.update(options.get("headers") or {})
94
+ headers["Authorization"] = f"Bearer {self._api_key}"
95
+ content = None
96
+ if body is not None:
97
+ headers["Content-Type"] = "application/json"
98
+ content = json.dumps(body, separators=(",", ":"), ensure_ascii=False).encode()
99
+ if method == "POST":
100
+ key = cast("PostRequestOptions", options).get("idempotency_key")
101
+ headers["Idempotency-Key"] = key if key is not None else str(uuid.uuid4())
102
+ if wait is not None:
103
+ headers["Prefer"] = f"wait={wait}"
104
+ return _Prepared(
105
+ url=self.base_url + path,
106
+ params={
107
+ name: _query_value(value)
108
+ for name, value in (query or {}).items()
109
+ if value is not None
110
+ },
111
+ headers=headers,
112
+ content=content,
113
+ timeout=options.get("timeout", self.timeout) + (wait or 0),
114
+ max_retries=options.get("max_retries", self.max_retries),
115
+ )
116
+
117
+
118
+ class ZeroGen(_BaseClient):
119
+ """A client for the ZeroGen API."""
120
+
121
+ generations: Generations
122
+ models: Models
123
+ uploads: Uploads
124
+
125
+ def __init__(
126
+ self,
127
+ *,
128
+ api_key: str | None = None,
129
+ base_url: str | None = None,
130
+ timeout: float = 120.0,
131
+ max_retries: int = 2,
132
+ default_headers: Mapping[str, str] | None = None,
133
+ http_client: httpx.Client | None = None,
134
+ ) -> None:
135
+ """Creates a client.
136
+
137
+ Args:
138
+ api_key: Defaults to the `ZEROGEN_API_KEY` environment variable.
139
+ base_url: Defaults to the `ZEROGEN_BASE_URL` environment variable, then
140
+ `https://api.zerogen.com/v1`.
141
+ timeout: Seconds httpx waits on each step of one attempt of a request. A request
142
+ that waits on a generation gets its wait added.
143
+ max_retries: How many times to retry a connection error or a retryable response.
144
+ default_headers: Headers to send with every request.
145
+ http_client: An `httpx.Client` to send requests with. The client doesn't close it.
146
+ """
147
+ super().__init__(
148
+ api_key=api_key,
149
+ base_url=base_url,
150
+ timeout=timeout,
151
+ max_retries=max_retries,
152
+ default_headers=default_headers,
153
+ )
154
+ self._http = http_client if http_client is not None else httpx.Client()
155
+ self._owns_http = http_client is None
156
+ self.generations = Generations(self)
157
+ self.models = Models(self)
158
+ self.uploads = Uploads(self)
159
+
160
+ def me(self, **options: Unpack[RequestOptions]) -> Me:
161
+ """The API key, the organization it belongs to, and what it can do."""
162
+ return self._request(Me.model_validate, "GET", "/me", options=options)
163
+
164
+ def balance(
165
+ self, *, project_id: str | None = None, **options: Unpack[RequestOptions]
166
+ ) -> Balance:
167
+ """The organization's balance and spending limits, and a project's with `project_id`."""
168
+ return self._request(
169
+ Balance.model_validate,
170
+ "GET",
171
+ "/balance",
172
+ query={"projectId": project_id},
173
+ options=options,
174
+ )
175
+
176
+ def close(self) -> None:
177
+ """Closes the HTTP client, unless it was passed in as `http_client`."""
178
+ if self._owns_http:
179
+ self._http.close()
180
+
181
+ def __enter__(self) -> Self:
182
+ return self
183
+
184
+ def __exit__(self, *_: object) -> None:
185
+ self.close()
186
+
187
+ def _request(
188
+ self,
189
+ cast_to: Callable[[Any], Result],
190
+ method: Method,
191
+ path: str,
192
+ *,
193
+ query: Query | None = None,
194
+ body: object | None = None,
195
+ wait: int | None = None,
196
+ options: RequestOptions,
197
+ ) -> Result:
198
+ prepared = self._prepare(method, path, query, body, wait, options)
199
+ response = self._send(
200
+ method,
201
+ prepared.url,
202
+ params=prepared.params,
203
+ headers=prepared.headers,
204
+ content=lambda: prepared.content,
205
+ timeout=prepared.timeout,
206
+ max_retries=prepared.max_retries,
207
+ )
208
+ return cast_to(response.json())
209
+
210
+ def _list(
211
+ self,
212
+ cast_to: Callable[[Any], ListBody[Item]],
213
+ path: str,
214
+ query: Query,
215
+ options: RequestOptions,
216
+ ) -> Page[Item]:
217
+ def load(cursor: str | None) -> Page[Item]:
218
+ body = self._request(
219
+ cast_to,
220
+ "GET",
221
+ path,
222
+ query=query if cursor is None else {**query, "cursor": cursor},
223
+ options=options,
224
+ )
225
+ return Page(body.data, body.next_cursor, load)
226
+
227
+ return load(None)
228
+
229
+ def _send(
230
+ self,
231
+ method: str,
232
+ url: str,
233
+ *,
234
+ params: dict[str, str] | None = None,
235
+ headers: httpx.Headers | Mapping[str, str],
236
+ content: Callable[[], bytes | Iterable[bytes] | None],
237
+ timeout: float | None,
238
+ max_retries: int,
239
+ ) -> httpx.Response:
240
+ """Sends a request, retrying connection errors and retryable responses, and returns the
241
+ first successful response. A `timeout` of None sends without one."""
242
+ attempt = 0
243
+ while True:
244
+ try:
245
+ response = self._http.request(
246
+ method, url, params=params, headers=headers, content=content(), timeout=timeout
247
+ )
248
+ except httpx.TransportError as error:
249
+ time.sleep(_connection_retry_delay(error, attempt, max_retries, timeout))
250
+ else:
251
+ if response.is_success:
252
+ return response
253
+ time.sleep(_error_retry_delay(response, attempt, max_retries))
254
+ attempt += 1
255
+
256
+
257
+ class AsyncZeroGen(_BaseClient):
258
+ """An asynchronous client for the ZeroGen API."""
259
+
260
+ generations: AsyncGenerations
261
+ models: AsyncModels
262
+ uploads: AsyncUploads
263
+
264
+ def __init__(
265
+ self,
266
+ *,
267
+ api_key: str | None = None,
268
+ base_url: str | None = None,
269
+ timeout: float = 120.0,
270
+ max_retries: int = 2,
271
+ default_headers: Mapping[str, str] | None = None,
272
+ http_client: httpx.AsyncClient | None = None,
273
+ ) -> None:
274
+ """Creates a client.
275
+
276
+ Args:
277
+ api_key: Defaults to the `ZEROGEN_API_KEY` environment variable.
278
+ base_url: Defaults to the `ZEROGEN_BASE_URL` environment variable, then
279
+ `https://api.zerogen.com/v1`.
280
+ timeout: Seconds httpx waits on each step of one attempt of a request. A request
281
+ that waits on a generation gets its wait added.
282
+ max_retries: How many times to retry a connection error or a retryable response.
283
+ default_headers: Headers to send with every request.
284
+ http_client: An `httpx.AsyncClient` to send requests with. The client doesn't
285
+ close it.
286
+ """
287
+ super().__init__(
288
+ api_key=api_key,
289
+ base_url=base_url,
290
+ timeout=timeout,
291
+ max_retries=max_retries,
292
+ default_headers=default_headers,
293
+ )
294
+ self._http = http_client if http_client is not None else httpx.AsyncClient()
295
+ self._owns_http = http_client is None
296
+ self.generations = AsyncGenerations(self)
297
+ self.models = AsyncModels(self)
298
+ self.uploads = AsyncUploads(self)
299
+
300
+ async def me(self, **options: Unpack[RequestOptions]) -> Me:
301
+ """The API key, the organization it belongs to, and what it can do."""
302
+ return await self._request(Me.model_validate, "GET", "/me", options=options)
303
+
304
+ async def balance(
305
+ self, *, project_id: str | None = None, **options: Unpack[RequestOptions]
306
+ ) -> Balance:
307
+ """The organization's balance and spending limits, and a project's with `project_id`."""
308
+ return await self._request(
309
+ Balance.model_validate,
310
+ "GET",
311
+ "/balance",
312
+ query={"projectId": project_id},
313
+ options=options,
314
+ )
315
+
316
+ async def aclose(self) -> None:
317
+ """Closes the HTTP client, unless it was passed in as `http_client`."""
318
+ if self._owns_http:
319
+ await self._http.aclose()
320
+
321
+ async def __aenter__(self) -> Self:
322
+ return self
323
+
324
+ async def __aexit__(self, *_: object) -> None:
325
+ await self.aclose()
326
+
327
+ async def _request(
328
+ self,
329
+ cast_to: Callable[[Any], Result],
330
+ method: Method,
331
+ path: str,
332
+ *,
333
+ query: Query | None = None,
334
+ body: object | None = None,
335
+ wait: int | None = None,
336
+ options: RequestOptions,
337
+ ) -> Result:
338
+ prepared = self._prepare(method, path, query, body, wait, options)
339
+ response = await self._send(
340
+ method,
341
+ prepared.url,
342
+ params=prepared.params,
343
+ headers=prepared.headers,
344
+ content=lambda: prepared.content,
345
+ timeout=prepared.timeout,
346
+ max_retries=prepared.max_retries,
347
+ )
348
+ return cast_to(response.json())
349
+
350
+ def _list(
351
+ self,
352
+ cast_to: Callable[[Any], ListBody[Item]],
353
+ path: str,
354
+ query: Query,
355
+ options: RequestOptions,
356
+ ) -> AsyncPaginator[Item]:
357
+ async def load(cursor: str | None = None) -> AsyncPage[Item]:
358
+ body = await self._request(
359
+ cast_to,
360
+ "GET",
361
+ path,
362
+ query=query if cursor is None else {**query, "cursor": cursor},
363
+ options=options,
364
+ )
365
+ return AsyncPage(body.data, body.next_cursor, load)
366
+
367
+ return AsyncPaginator(load)
368
+
369
+ async def _send(
370
+ self,
371
+ method: str,
372
+ url: str,
373
+ *,
374
+ params: dict[str, str] | None = None,
375
+ headers: httpx.Headers | Mapping[str, str],
376
+ content: Callable[[], bytes | AsyncIterable[bytes] | None],
377
+ timeout: float | None,
378
+ max_retries: int,
379
+ ) -> httpx.Response:
380
+ """Sends a request, retrying connection errors and retryable responses, and returns the
381
+ first successful response. A `timeout` of None sends without one."""
382
+ attempt = 0
383
+ while True:
384
+ try:
385
+ response = await self._http.request(
386
+ method, url, params=params, headers=headers, content=content(), timeout=timeout
387
+ )
388
+ except httpx.TransportError as error:
389
+ await anyio.sleep(_connection_retry_delay(error, attempt, max_retries, timeout))
390
+ else:
391
+ if response.is_success:
392
+ return response
393
+ await anyio.sleep(_error_retry_delay(response, attempt, max_retries))
394
+ attempt += 1
395
+
396
+
397
+ def _env(name: str) -> str | None:
398
+ return os.environ.get(name) or None
399
+
400
+
401
+ def _query_value(value: str | int | bool) -> str:
402
+ if isinstance(value, bool):
403
+ return "true" if value else "false"
404
+ return str(value)
405
+
406
+
407
+ def _backoff(attempt: int) -> float:
408
+ return min(8.0, 0.5 * 2**attempt) * (0.75 + random.random() * 0.5)
409
+
410
+
411
+ def _retry_delay(headers: httpx.Headers, attempt: int) -> float | None:
412
+ """How long to wait before retrying: `Retry-After` when the response sets it, or no retry
413
+ past a minute."""
414
+ try:
415
+ seconds = float(headers.get("retry-after", "nan"))
416
+ except ValueError:
417
+ seconds = math.nan
418
+ if not math.isfinite(seconds) or seconds < 0:
419
+ return _backoff(attempt)
420
+ return seconds if seconds <= RETRY_AFTER_MAX_SECONDS else None
421
+
422
+
423
+ def _connection_retry_delay(
424
+ error: httpx.TransportError, attempt: int, max_retries: int, timeout: float | None
425
+ ) -> float:
426
+ if attempt >= max_retries:
427
+ if isinstance(error, httpx.TimeoutException) and timeout is not None:
428
+ raise ZeroGenConnectionError(
429
+ f"The request timed out after {timeout:g} seconds."
430
+ ) from error
431
+ raise ZeroGenConnectionError("The request failed to connect.") from error
432
+ return _backoff(attempt)
433
+
434
+
435
+ def _error_retry_delay(response: httpx.Response, attempt: int, max_retries: int) -> float:
436
+ error = ZeroGenError.from_response(response)
437
+ delay = (
438
+ _retry_delay(response.headers, attempt)
439
+ if error.retryable and attempt < max_retries
440
+ else None
441
+ )
442
+ if delay is None:
443
+ raise error
444
+ return delay
zerogen/_errors.py ADDED
@@ -0,0 +1,96 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Any, cast
5
+
6
+ import httpx
7
+
8
+
9
+ class ZeroGenError(Exception):
10
+ """An error response from the ZeroGen API.
11
+
12
+ Branch on `code`, which is stable; `message` is for people and can change.
13
+ """
14
+
15
+ status: int
16
+ """The HTTP status."""
17
+ code: str
18
+ message: str
19
+ param: str | None
20
+ """The request field, header or query parameter at fault."""
21
+ retryable: bool
22
+ """Whether sending the same request again can succeed."""
23
+ details: dict[str, Any] | None
24
+ request_id: str | None
25
+ """The response's `Request-Id`; quote it when contacting support."""
26
+ headers: httpx.Headers
27
+
28
+ def __init__(
29
+ self,
30
+ message: str,
31
+ *,
32
+ status: int,
33
+ code: str,
34
+ param: str | None = None,
35
+ retryable: bool = False,
36
+ details: dict[str, Any] | None = None,
37
+ headers: httpx.Headers | None = None,
38
+ ) -> None:
39
+ super().__init__(message)
40
+ self.status = status
41
+ self.code = code
42
+ self.message = message
43
+ self.param = param
44
+ self.retryable = retryable
45
+ self.details = details
46
+ self.headers = headers if headers is not None else httpx.Headers()
47
+ self.request_id = self.headers.get("request-id")
48
+
49
+ def __str__(self) -> str:
50
+ return f"{self.code}: {self.message}"
51
+
52
+ @classmethod
53
+ def from_response(cls, response: httpx.Response) -> ZeroGenError:
54
+ """Reads an error response, including one that isn't ZeroGen's JSON envelope."""
55
+ text = response.text
56
+ error = _envelope(text)
57
+ if error is not None:
58
+ param = error.get("param")
59
+ details = error.get("details")
60
+ return cls(
61
+ error["message"],
62
+ status=response.status_code,
63
+ code=error["code"],
64
+ param=param if isinstance(param, str) else None,
65
+ retryable=error.get("retryable") is True,
66
+ details=cast("dict[str, Any]", details) if isinstance(details, dict) else None,
67
+ headers=response.headers,
68
+ )
69
+ return cls(
70
+ f"HTTP {response.status_code}: {text[:200]}"
71
+ if text
72
+ else f"HTTP {response.status_code}",
73
+ status=response.status_code,
74
+ code="unexpected_response",
75
+ retryable=response.status_code == 429 or response.status_code >= 500,
76
+ headers=response.headers,
77
+ )
78
+
79
+
80
+ class ZeroGenConnectionError(Exception):
81
+ """The request got no response: the connection failed or the request timed out."""
82
+
83
+
84
+ def _envelope(text: str) -> dict[str, Any] | None:
85
+ """The error in ZeroGen's envelope, when the body is one."""
86
+ try:
87
+ body: object = json.loads(text)
88
+ except ValueError:
89
+ return None
90
+ error = cast("dict[str, object]", body).get("error") if isinstance(body, dict) else None
91
+ if not isinstance(error, dict):
92
+ return None
93
+ error = cast("dict[str, Any]", error)
94
+ if not isinstance(error.get("code"), str) or not isinstance(error.get("message"), str):
95
+ return None
96
+ return error
zerogen/_models.py ADDED
@@ -0,0 +1,9 @@
1
+ import pydantic
2
+
3
+
4
+ class BaseModel(pydantic.BaseModel):
5
+ """A response from the API. Fields it adds later are kept as extra fields."""
6
+
7
+ model_config = pydantic.ConfigDict(
8
+ extra="allow", validate_by_name=True, protected_namespaces=()
9
+ )
zerogen/_options.py ADDED
@@ -0,0 +1,31 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+ from typing import cast
5
+
6
+ from typing_extensions import TypedDict
7
+
8
+
9
+ class RequestOptions(TypedDict, total=False):
10
+ """Options that every method takes as keyword arguments."""
11
+
12
+ timeout: float
13
+ """Overrides the client's `timeout` for this request."""
14
+ max_retries: int
15
+ """Overrides the client's `max_retries` for this request."""
16
+ headers: Mapping[str, str]
17
+ """Headers to send with this request."""
18
+
19
+
20
+ class PostRequestOptions(RequestOptions, total=False):
21
+ """Options that every method sending a `POST` takes as keyword arguments."""
22
+
23
+ idempotency_key: str
24
+ """Each call sends a new random key by default and reuses it on that call's retries; pass
25
+ your own to make a retry across calls safe."""
26
+
27
+
28
+ def without_idempotency_key(options: PostRequestOptions) -> RequestOptions:
29
+ rest = dict(options)
30
+ rest.pop("idempotency_key", None)
31
+ return cast(RequestOptions, rest)
zerogen/_pagination.py ADDED
@@ -0,0 +1,94 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import AsyncIterable, AsyncIterator, Awaitable, Callable, Generator, Iterator
4
+ from typing import Any, Generic, TypeVar
5
+
6
+ Item = TypeVar("Item")
7
+
8
+
9
+ class Page(Generic[Item]):
10
+ """One page of a list. Iterate it to read every item on this page and the pages after it."""
11
+
12
+ data: list[Item]
13
+ next_cursor: str | None
14
+ """The cursor for the next page, or None on the last page."""
15
+
16
+ def __init__(
17
+ self, data: list[Item], next_cursor: str | None, load: Callable[[str], Page[Item]]
18
+ ) -> None:
19
+ self.data = data
20
+ self.next_cursor = next_cursor
21
+ self._load = load
22
+
23
+ def has_next_page(self) -> bool:
24
+ return self.next_cursor is not None
25
+
26
+ def get_next_page(self) -> Page[Item]:
27
+ if self.next_cursor is None:
28
+ raise RuntimeError("This is the last page.")
29
+ return self._load(self.next_cursor)
30
+
31
+ def __iter__(self) -> Iterator[Item]:
32
+ page = self
33
+ while True:
34
+ yield from page.data
35
+ if not page.has_next_page():
36
+ return
37
+ page = page.get_next_page()
38
+
39
+
40
+ class AsyncPage(Generic[Item]):
41
+ """One page of a list. Iterate it with `async for` to read every item on this page and the
42
+ pages after it."""
43
+
44
+ data: list[Item]
45
+ next_cursor: str | None
46
+ """The cursor for the next page, or None on the last page."""
47
+
48
+ def __init__(
49
+ self,
50
+ data: list[Item],
51
+ next_cursor: str | None,
52
+ load: Callable[[str], Awaitable[AsyncPage[Item]]],
53
+ ) -> None:
54
+ self.data = data
55
+ self.next_cursor = next_cursor
56
+ self._load = load
57
+
58
+ def has_next_page(self) -> bool:
59
+ return self.next_cursor is not None
60
+
61
+ async def get_next_page(self) -> AsyncPage[Item]:
62
+ if self.next_cursor is None:
63
+ raise RuntimeError("This is the last page.")
64
+ return await self._load(self.next_cursor)
65
+
66
+ async def __aiter__(self) -> AsyncIterator[Item]:
67
+ page = self
68
+ while True:
69
+ for item in page.data:
70
+ yield item
71
+ if not page.has_next_page():
72
+ return
73
+ page = await page.get_next_page()
74
+
75
+
76
+ class AsyncPaginator(Awaitable[AsyncPage[Item]], AsyncIterable[Item]):
77
+ """A list request. Await it for the first page, or iterate it with `async for` for every
78
+ item on every page."""
79
+
80
+ def __init__(self, load: Callable[[], Awaitable[AsyncPage[Item]]]) -> None:
81
+ self._load = load
82
+ self._first: AsyncPage[Item] | None = None
83
+
84
+ async def _first_page(self) -> AsyncPage[Item]:
85
+ if self._first is None:
86
+ self._first = await self._load()
87
+ return self._first
88
+
89
+ def __await__(self) -> Generator[Any, Any, AsyncPage[Item]]:
90
+ return self._first_page().__await__()
91
+
92
+ async def __aiter__(self) -> AsyncIterator[Item]:
93
+ async for item in await self:
94
+ yield item