|
| 1 | + |
| 2 | +# Copyright (c) [2025] [OpenAI] |
| 3 | +# Copyright (c) [2025] [ByteDance Ltd. and/or its affiliates.] |
| 4 | +# SPDX-License-Identifier: Apache-2.0 |
| 5 | +# |
| 6 | +# This file has been modified by [ByteDance Ltd. and/or its affiliates.] on 2025.7 |
| 7 | +# |
| 8 | +# Original file was released under Apache License Version 2.0, with the full license text |
| 9 | +# available at https://github.com/openai/openai-python/blob/main/LICENSE. |
| 10 | +# |
| 11 | +# This modified file is released under the same license. |
| 12 | + |
1 | 13 | from __future__ import annotations |
2 | 14 |
|
3 | 15 | import asyncio |
|
16 | 28 | TYPE_CHECKING, |
17 | 29 | Union, |
18 | 30 | Generic, |
| 31 | + Iterable, |
| 32 | + AsyncIterator, |
| 33 | + Iterator, |
| 34 | + Generator |
19 | 35 | ) |
| 36 | +from typing_extensions import override |
20 | 37 |
|
21 | 38 | import anyio |
22 | 39 | import httpx |
23 | 40 | import pydantic |
| 41 | + |
| 42 | +from pydantic import PrivateAttr |
24 | 43 | from httpx import URL, Timeout, Limits |
25 | 44 | from httpx._types import RequestFiles |
26 | 45 |
|
|
41 | 60 | ArkAPIStatusError, |
42 | 61 | ArkAPIResponseValidationError, |
43 | 62 | ) |
44 | | -from ._models import construct_type |
| 63 | +from ._models import construct_type, GenericModel |
45 | 64 | from ._request_options import RequestOptions, ExtraRequestOptions |
46 | 65 | from ._response import ArkAPIResponse, ArkAsyncAPIResponse |
47 | 66 | from ._streaming import SSEDecoder, SSEBytesDecoder, Stream, AsyncStream |
48 | | -from ._types import ResponseT, NotGiven, NOT_GIVEN, PostParser |
49 | | -from ._utils._utils import _gen_request_id, is_given |
| 67 | +from ._types import ( |
| 68 | + ResponseT, |
| 69 | + NotGiven, |
| 70 | + NOT_GIVEN, |
| 71 | + PostParser, |
| 72 | + Body, |
| 73 | + Query, |
| 74 | +) |
| 75 | +from ._utils._utils import _gen_request_id, is_given, is_mapping |
| 76 | +from ._compat import model_copy, PYDANTIC_V2 |
| 77 | + |
| 78 | +SyncPageT = TypeVar("SyncPageT", bound="BaseSyncPage[Any]") |
| 79 | +AsyncPageT = TypeVar("AsyncPageT", bound="BaseAsyncPage[Any]") |
50 | 80 |
|
51 | 81 | _T = TypeVar("_T") |
52 | 82 | _StreamT = TypeVar("_StreamT", bound=Stream[Any]) |
@@ -638,6 +668,36 @@ def post_without_retry( |
638 | 668 | ), |
639 | 669 | ) |
640 | 670 |
|
| 671 | + def get_api_list( |
| 672 | + self, |
| 673 | + path: str, |
| 674 | + *, |
| 675 | + model: Type[object], |
| 676 | + page: Type[SyncPageT], |
| 677 | + body: Body | None = None, |
| 678 | + options: ExtraRequestOptions = {}, |
| 679 | + method: str = "get", |
| 680 | + ) -> AsyncPageT: |
| 681 | + opts = RequestOptions.construct(method=method, url=path, json_data=body, **options) |
| 682 | + return self._request_api_list(model, page, opts) |
| 683 | + |
| 684 | + def _request_api_list( |
| 685 | + self, |
| 686 | + model: Type[object], |
| 687 | + page: Type[SyncPageT], |
| 688 | + options: RequestOptions, |
| 689 | + ) -> AsyncPageT: |
| 690 | + def _parser(resp: AsyncPageT) -> SyncPageT: |
| 691 | + resp._set_private_attributes( |
| 692 | + client=self, |
| 693 | + model=model, |
| 694 | + options=options, |
| 695 | + ) |
| 696 | + return resp |
| 697 | + |
| 698 | + options.post_parser = _parser |
| 699 | + return self.request(page, options, stream=False) |
| 700 | + |
641 | 701 | def request( |
642 | 702 | self, |
643 | 703 | cast_to: Type[ResponseT], |
@@ -820,6 +880,37 @@ async def post_without_retry( |
820 | 880 | cast_to, opts, remaining_retries=0, stream=stream, stream_cls=stream_cls |
821 | 881 | ) |
822 | 882 |
|
| 883 | + async def get_api_list( |
| 884 | + self, |
| 885 | + path: str, |
| 886 | + *, |
| 887 | + model: Type[object], |
| 888 | + page: Type[AsyncPageT], |
| 889 | + body: Body | None = None, |
| 890 | + options: ExtraRequestOptions = {}, |
| 891 | + method: str = "get", |
| 892 | + ) -> AsyncPageT: |
| 893 | + opts = RequestOptions.construct(method=method, url=path, json_data=body, **options) |
| 894 | + return await self._request_api_list(model, page, opts) |
| 895 | + |
| 896 | + async def _request_api_list( |
| 897 | + self, |
| 898 | + model: Type[object], |
| 899 | + page: Type[AsyncPageT], |
| 900 | + options: RequestOptions, |
| 901 | + ) -> AsyncPageT: |
| 902 | + def _parser(resp: AsyncPageT) -> SyncPageT: |
| 903 | + resp._set_private_attributes( |
| 904 | + client=self, |
| 905 | + model=model, |
| 906 | + options=options, |
| 907 | + ) |
| 908 | + return resp |
| 909 | + |
| 910 | + options.post_parser = _parser |
| 911 | + |
| 912 | + return await self.request(page, options, stream=False) |
| 913 | + |
823 | 914 | async def request( |
824 | 915 | self, |
825 | 916 | cast_to: Type[ResponseT], |
@@ -999,3 +1090,223 @@ async def __aexit__( |
999 | 1090 | exc_tb: TracebackType | None, |
1000 | 1091 | ) -> None: |
1001 | 1092 | await self.close() |
| 1093 | + |
| 1094 | + |
| 1095 | +class PageInfo: |
| 1096 | + """Stores the necessary information to build the request to retrieve the next page. |
| 1097 | +
|
| 1098 | + Either `url` or `params` must be set. |
| 1099 | + """ |
| 1100 | + |
| 1101 | + url: URL | NotGiven |
| 1102 | + params: Query | NotGiven |
| 1103 | + json: Body | NotGiven |
| 1104 | + |
| 1105 | + def __init__( |
| 1106 | + self, |
| 1107 | + *, |
| 1108 | + url: URL | NotGiven = NOT_GIVEN, |
| 1109 | + json: Body | NotGiven = NOT_GIVEN, |
| 1110 | + params: Query | NotGiven = NOT_GIVEN, |
| 1111 | + ) -> None: |
| 1112 | + self.url = url |
| 1113 | + self.json = json |
| 1114 | + self.params = params |
| 1115 | + |
| 1116 | + @override |
| 1117 | + def __repr__(self) -> str: |
| 1118 | + if self.url: |
| 1119 | + return f"{self.__class__.__name__}(url={self.url})" |
| 1120 | + if self.json: |
| 1121 | + return f"{self.__class__.__name__}(json={self.json})" |
| 1122 | + return f"{self.__class__.__name__}(params={self.params})" |
| 1123 | + |
| 1124 | + |
| 1125 | +class BasePage(GenericModel, Generic[_T]): |
| 1126 | + """ |
| 1127 | + Defines the core interface for pagination. |
| 1128 | +
|
| 1129 | + Type Args: |
| 1130 | + ModelT: The pydantic model that represents an item in the response. |
| 1131 | +
|
| 1132 | + Methods: |
| 1133 | + has_next_page(): Check if there is another page available |
| 1134 | + next_page_info(): Get the necessary information to make a request for the next page |
| 1135 | + """ |
| 1136 | + |
| 1137 | + _options: RequestOptions = PrivateAttr() |
| 1138 | + _model: Type[_T] = PrivateAttr() |
| 1139 | + |
| 1140 | + def has_next_page(self) -> bool: |
| 1141 | + items = self._get_page_items() |
| 1142 | + if not items: |
| 1143 | + return False |
| 1144 | + return self.next_page_info() is not None |
| 1145 | + |
| 1146 | + def next_page_info(self) -> Optional[PageInfo]: ... |
| 1147 | + |
| 1148 | + def _get_page_items(self) -> Iterable[_T]: # type: ignore[empty-body] |
| 1149 | + ... |
| 1150 | + |
| 1151 | + def _params_from_url(self, url: URL) -> httpx.QueryParams: |
| 1152 | + # TODO: do we have to preprocess params here? |
| 1153 | + return httpx.QueryParams(cast(Any, self._options.params)).merge(url.params) |
| 1154 | + |
| 1155 | + def _info_to_options(self, info: PageInfo) -> RequestOptions: |
| 1156 | + options = model_copy(self._options) |
| 1157 | + options._strip_raw_response_header() |
| 1158 | + |
| 1159 | + if not isinstance(info.params, NotGiven): |
| 1160 | + options.params = {**options.params, **info.params} |
| 1161 | + return options |
| 1162 | + |
| 1163 | + if not isinstance(info.url, NotGiven): |
| 1164 | + params = self._params_from_url(info.url) |
| 1165 | + url = info.url.copy_with(params=params) |
| 1166 | + options.params = dict(url.params) |
| 1167 | + options.url = str(url) |
| 1168 | + return options |
| 1169 | + |
| 1170 | + if not isinstance(info.json, NotGiven): |
| 1171 | + if not is_mapping(info.json): |
| 1172 | + raise TypeError("Pagination is only supported with mappings") |
| 1173 | + |
| 1174 | + if not options.json_data: |
| 1175 | + options.json_data = {**info.json} |
| 1176 | + else: |
| 1177 | + if not is_mapping(options.json_data): |
| 1178 | + raise TypeError("Pagination is only supported with mappings") |
| 1179 | + |
| 1180 | + options.json_data = {**options.json_data, **info.json} |
| 1181 | + return options |
| 1182 | + |
| 1183 | + raise ValueError("Unexpected PageInfo state") |
| 1184 | + |
| 1185 | + |
| 1186 | +class BaseSyncPage(BasePage[_T], Generic[_T]): |
| 1187 | + _client: SyncAPIClient = pydantic.PrivateAttr() |
| 1188 | + |
| 1189 | + def _set_private_attributes( |
| 1190 | + self, |
| 1191 | + client: SyncAPIClient, |
| 1192 | + model: Type[_T], |
| 1193 | + options: RequestOptions, |
| 1194 | + ) -> None: |
| 1195 | + if PYDANTIC_V2 and getattr(self, "__pydantic_private__", None) is None: |
| 1196 | + self.__pydantic_private__ = {} |
| 1197 | + |
| 1198 | + self._model = model |
| 1199 | + self._client = client |
| 1200 | + self._options = options |
| 1201 | + |
| 1202 | + # Pydantic uses a custom `__iter__` method to support casting BaseModels |
| 1203 | + # to dictionaries. e.g. dict(model). |
| 1204 | + # As we want to support `for item in page`, this is inherently incompatible |
| 1205 | + # with the default pydantic behaviour. It is not possible to support both |
| 1206 | + # use cases at once. Fortunately, this is not a big deal as all other pydantic |
| 1207 | + # methods should continue to work as expected as there is an alternative method |
| 1208 | + # to cast a model to a dictionary, model.dict(), which is used internally |
| 1209 | + # by pydantic. |
| 1210 | + def __iter__(self) -> Iterator[_T]: # type: ignore |
| 1211 | + for page in self.iter_pages(): |
| 1212 | + for item in page._get_page_items(): |
| 1213 | + yield item |
| 1214 | + |
| 1215 | + def iter_pages(self: SyncPageT) -> Iterator[SyncPageT]: |
| 1216 | + page = self |
| 1217 | + while True: |
| 1218 | + yield page |
| 1219 | + if page.has_next_page(): |
| 1220 | + page = page.get_next_page() |
| 1221 | + else: |
| 1222 | + return |
| 1223 | + |
| 1224 | + def get_next_page(self: SyncPageT) -> SyncPageT: |
| 1225 | + info = self.next_page_info() |
| 1226 | + if not info: |
| 1227 | + raise RuntimeError( |
| 1228 | + "No next page expected; please check `.has_next_page()` before calling `.get_next_page()`." |
| 1229 | + ) |
| 1230 | + |
| 1231 | + options = self._info_to_options(info) |
| 1232 | + return self._client._request_api_list(self._model, page=self.__class__, options=options) |
| 1233 | + |
| 1234 | + |
| 1235 | +class AsyncPaginator(Generic[_T, AsyncPageT]): |
| 1236 | + def __init__( |
| 1237 | + self, |
| 1238 | + client: AsyncAPIClient, |
| 1239 | + options: RequestOptions, |
| 1240 | + page_cls: Type[AsyncPageT], |
| 1241 | + model: Type[_T], |
| 1242 | + ) -> None: |
| 1243 | + self._model = model |
| 1244 | + self._client = client |
| 1245 | + self._options = options |
| 1246 | + self._page_cls = page_cls |
| 1247 | + |
| 1248 | + def __await__(self) -> Generator[Any, None, AsyncPageT]: |
| 1249 | + return self._get_page().__await__() |
| 1250 | + |
| 1251 | + async def _get_page(self) -> AsyncPageT: |
| 1252 | + def _parser(resp: AsyncPageT) -> AsyncPageT: |
| 1253 | + resp._set_private_attributes( |
| 1254 | + model=self._model, |
| 1255 | + options=self._options, |
| 1256 | + client=self._client, |
| 1257 | + ) |
| 1258 | + return resp |
| 1259 | + |
| 1260 | + self._options.post_parser = _parser |
| 1261 | + |
| 1262 | + return await self._client.request(self._page_cls, self._options) |
| 1263 | + |
| 1264 | + async def __aiter__(self) -> AsyncIterator[_T]: |
| 1265 | + # https://github.com/microsoft/pyright/issues/3464 |
| 1266 | + page = cast( |
| 1267 | + AsyncPageT, |
| 1268 | + await self, # type: ignore |
| 1269 | + ) |
| 1270 | + async for item in page: |
| 1271 | + yield item |
| 1272 | + |
| 1273 | + |
| 1274 | +class BaseAsyncPage(BasePage[_T], Generic[_T]): |
| 1275 | + _client: AsyncAPIClient = pydantic.PrivateAttr() |
| 1276 | + |
| 1277 | + def _set_private_attributes( |
| 1278 | + self, |
| 1279 | + model: Type[_T], |
| 1280 | + client: AsyncAPIClient, |
| 1281 | + options: RequestOptions, |
| 1282 | + ) -> None: |
| 1283 | + if PYDANTIC_V2 and getattr(self, "__pydantic_private__", None) is None: |
| 1284 | + self.__pydantic_private__ = {} |
| 1285 | + |
| 1286 | + self._model = model |
| 1287 | + self._client = client |
| 1288 | + self._options = options |
| 1289 | + |
| 1290 | + async def __aiter__(self) -> AsyncIterator[_T]: |
| 1291 | + async for page in self.iter_pages(): |
| 1292 | + for item in page._get_page_items(): |
| 1293 | + yield item |
| 1294 | + |
| 1295 | + async def iter_pages(self: AsyncPageT) -> AsyncIterator[AsyncPageT]: |
| 1296 | + page = self |
| 1297 | + while True: |
| 1298 | + yield page |
| 1299 | + if page.has_next_page(): |
| 1300 | + page = await page.get_next_page() |
| 1301 | + else: |
| 1302 | + return |
| 1303 | + |
| 1304 | + async def get_next_page(self: AsyncPageT) -> AsyncPageT: |
| 1305 | + info = self.next_page_info() |
| 1306 | + if not info: |
| 1307 | + raise RuntimeError( |
| 1308 | + "No next page expected; please check `.has_next_page()` before calling `.get_next_page()`." |
| 1309 | + ) |
| 1310 | + |
| 1311 | + options = self._info_to_options(info) |
| 1312 | + return await self._client._request_api_list(self._model, page=self.__class__, options=options) |
0 commit comments