diff --git a/README.md b/README.md index 4516805..8708489 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ and offers both synchronous and asynchronous clients powered by [httpx](https:// ## Documentation -The REST API documentation can be found on [autorender.mintlify.app](https://autorender.mintlify.app/). The full API of this library can be found in [api.md](api.md). +The REST API documentation can be found on [autorender.io](https://autorender.io/docs). The full API of this library can be found in [api.md](api.md). ## Installation @@ -30,10 +30,10 @@ client = Autorender( api_key=os.environ.get("AUTORENDER_API_KEY"), # This is the default and can be omitted ) -files = client.files.list( +page = client.files.list( limit=10, ) -print(files.files) +print(page.files) ``` While you can provide an `api_key` keyword argument, @@ -56,10 +56,10 @@ client = AsyncAutorender( async def main() -> None: - files = await client.files.list( + page = await client.files.list( limit=10, ) - print(files.files) + print(page.files) asyncio.run(main()) @@ -92,10 +92,10 @@ async def main() -> None: api_key=os.environ.get("AUTORENDER_API_KEY"), # This is the default and can be omitted http_client=DefaultAioHttpClient(), ) as client: - files = await client.files.list( + page = await client.files.list( limit=10, ) - print(files.files) + print(page.files) asyncio.run(main()) @@ -110,6 +110,69 @@ Nested request parameters are [TypedDicts](https://docs.python.org/3/library/typ Typed requests and responses provide autocomplete and documentation within your editor. If you would like to see type errors in VS Code to help catch bugs earlier, set `python.analysis.typeCheckingMode` to `basic`. +## Pagination + +List methods in the Autorender API are paginated. + +This library provides auto-paginating iterators with each list response, so you do not have to request successive pages manually: + +```python +from autorender import Autorender + +client = Autorender() + +all_files = [] +# Automatically fetches more pages as needed. +for file in client.files.list(): + # Do something with file here + all_files.append(file) +print(all_files) +``` + +Or, asynchronously: + +```python +import asyncio +from autorender import AsyncAutorender + +client = AsyncAutorender() + + +async def main() -> None: + all_files = [] + # Iterate through items across all pages, issuing requests as needed. + async for file in client.files.list(): + all_files.append(file) + print(all_files) + + +asyncio.run(main()) +``` + +Alternatively, you can use the `.has_next_page()`, `.next_page_info()`, or `.get_next_page()` methods for more granular control working with pages: + +```python +first_page = await client.files.list() +if first_page.has_next_page(): + print(f"will fetch next page using these details: {first_page.next_page_info()}") + next_page = await first_page.get_next_page() + print(f"number of items we just fetched: {len(next_page.files)}") + +# Remove `await` for non-async usage. +``` + +Or just work directly with the returned data: + +```python +first_page = await client.files.list() + +print(f"page number: {first_page.meta.page}") # => "page number: 1" +for file in first_page.files: + print(file.id) + +# Remove `await` for non-async usage. +``` + ## File uploads Request parameters that correspond to file uploads can be passed as `bytes`, or a [`PathLike`](https://docs.python.org/3/library/os.html#os.PathLike) instance or a tuple of `(filename, contents, media type)`. diff --git a/api.md b/api.md index 2d1092f..520b981 100644 --- a/api.md +++ b/api.md @@ -22,7 +22,7 @@ from autorender.types import FileRetrieveResponse, FileListResponse, FileRenameR Methods: - client.files.retrieve(file_no) -> FileRetrieveResponse -- client.files.list(\*\*params) -> FileListResponse +- client.files.list(\*\*params) -> SyncPagePagination[FileListResponse] - client.files.delete(file_no) -> None - client.files.rename(file_no, \*\*params) -> FileRenameResponse diff --git a/src/autorender/pagination.py b/src/autorender/pagination.py index c8e897c..0f8ca09 100644 --- a/src/autorender/pagination.py +++ b/src/autorender/pagination.py @@ -3,38 +3,50 @@ from typing import List, Generic, TypeVar, Optional from typing_extensions import override +from pydantic import Field as FieldInfo + +from ._models import BaseModel from ._base_client import BasePage, PageInfo, BaseSyncPage, BaseAsyncPage -__all__ = ["SyncPagePagination", "AsyncPagePagination"] +__all__ = ["PagePaginationMeta", "SyncPagePagination", "AsyncPagePagination"] _T = TypeVar("_T") +class PagePaginationMeta(BaseModel): + has_next: Optional[bool] = FieldInfo(alias="hasNext", default=None) + + page: Optional[int] = None + + class SyncPagePagination(BaseSyncPage[_T], BasePage[_T], Generic[_T]): - data: List[_T] - current_page: Optional[int] = None - has_next: Optional[bool] = None - total_results: Optional[int] = None + files: List[_T] + meta: Optional[PagePaginationMeta] = None @override def _get_page_items(self) -> List[_T]: - data = self.data - if not data: + files = self.files + if not files: return [] - return data + return files @override def has_next_page(self) -> bool: - items = self._get_page_items() - if not items: + has_next = None + if self.meta is not None: + if self.meta.has_next is not None: + has_next = self.meta.has_next + if has_next is not None and has_next is False: return False - if self.has_next is not None: - return self.has_next - return self.next_page_info() is not None + + return super().has_next_page() @override def next_page_info(self) -> Optional[PageInfo]: - current_page = self.current_page + current_page = None + if self.meta is not None: + if self.meta.page is not None: + current_page = self.meta.page if current_page is None: current_page = 1 @@ -42,30 +54,33 @@ def next_page_info(self) -> Optional[PageInfo]: class AsyncPagePagination(BaseAsyncPage[_T], BasePage[_T], Generic[_T]): - data: List[_T] - current_page: Optional[int] = None - has_next: Optional[bool] = None - total_results: Optional[int] = None + files: List[_T] + meta: Optional[PagePaginationMeta] = None @override def _get_page_items(self) -> List[_T]: - data = self.data - if not data: + files = self.files + if not files: return [] - return data + return files @override def has_next_page(self) -> bool: - items = self._get_page_items() - if not items: + has_next = None + if self.meta is not None: + if self.meta.has_next is not None: + has_next = self.meta.has_next + if has_next is not None and has_next is False: return False - if self.has_next is not None: - return self.has_next - return self.next_page_info() is not None + + return super().has_next_page() @override def next_page_info(self) -> Optional[PageInfo]: - current_page = self.current_page + current_page = None + if self.meta is not None: + if self.meta.page is not None: + current_page = self.meta.page if current_page is None: current_page = 1 diff --git a/src/autorender/resources/files.py b/src/autorender/resources/files.py index b0d1ac8..8627b02 100644 --- a/src/autorender/resources/files.py +++ b/src/autorender/resources/files.py @@ -17,7 +17,8 @@ async_to_raw_response_wrapper, async_to_streamed_response_wrapper, ) -from .._base_client import make_request_options +from ..pagination import SyncPagePagination, AsyncPagePagination +from .._base_client import AsyncPaginator, make_request_options from ..types.file_list_response import FileListResponse from ..types.file_rename_response import FileRenameResponse from ..types.file_retrieve_response import FileRetrieveResponse @@ -95,7 +96,7 @@ def list( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> FileListResponse: + ) -> SyncPagePagination[FileListResponse]: """ List/search files with pagination, filtering, and sorting. @@ -112,8 +113,9 @@ def list( timeout: Override the client-level default timeout for this request, in seconds """ - return self._get( + return self._get_api_list( "/api/v1/files", + page=SyncPagePagination[FileListResponse], options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, @@ -130,7 +132,7 @@ def list( file_list_params.FileListParams, ), ), - cast_to=FileListResponse, + model=FileListResponse, ) def delete( @@ -260,7 +262,7 @@ async def retrieve( cast_to=FileRetrieveResponse, ) - async def list( + def list( self, *, folder_no: str | Omit = omit, @@ -275,7 +277,7 @@ async def list( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> FileListResponse: + ) -> AsyncPaginator[FileListResponse, AsyncPagePagination[FileListResponse]]: """ List/search files with pagination, filtering, and sorting. @@ -292,14 +294,15 @@ async def list( timeout: Override the client-level default timeout for this request, in seconds """ - return await self._get( + return self._get_api_list( "/api/v1/files", + page=AsyncPagePagination[FileListResponse], options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout, - query=await async_maybe_transform( + query=maybe_transform( { "folder_no": folder_no, "limit": limit, @@ -310,7 +313,7 @@ async def list( file_list_params.FileListParams, ), ), - cast_to=FileListResponse, + model=FileListResponse, ) async def delete( diff --git a/src/autorender/types/file_list_response.py b/src/autorender/types/file_list_response.py index d8e0b7c..1c32786 100644 --- a/src/autorender/types/file_list_response.py +++ b/src/autorender/types/file_list_response.py @@ -3,14 +3,12 @@ from typing import Dict, List, Optional from datetime import datetime -from pydantic import Field as FieldInfo - from .._models import BaseModel -__all__ = ["FileListResponse", "File", "Meta"] +__all__ = ["FileListResponse"] -class File(BaseModel): +class FileListResponse(BaseModel): id: str created_at: datetime @@ -44,23 +42,3 @@ class File(BaseModel): url: str width: Optional[int] = None - - -class Meta(BaseModel): - has_next: bool = FieldInfo(alias="hasNext") - - has_prev: bool = FieldInfo(alias="hasPrev") - - limit: int - - page: int - - total: int - - -class FileListResponse(BaseModel): - """Files list""" - - files: List[File] - - meta: Meta diff --git a/tests/api_resources/test_files.py b/tests/api_resources/test_files.py index 8dff053..cc949a7 100644 --- a/tests/api_resources/test_files.py +++ b/tests/api_resources/test_files.py @@ -14,6 +14,7 @@ FileRenameResponse, FileRetrieveResponse, ) +from autorender.pagination import SyncPagePagination, AsyncPagePagination base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") @@ -62,7 +63,7 @@ def test_path_params_retrieve(self, client: Autorender) -> None: @parametrize def test_method_list(self, client: Autorender) -> None: file = client.files.list() - assert_matches_type(FileListResponse, file, path=["response"]) + assert_matches_type(SyncPagePagination[FileListResponse], file, path=["response"]) @parametrize def test_method_list_with_all_params(self, client: Autorender) -> None: @@ -73,7 +74,7 @@ def test_method_list_with_all_params(self, client: Autorender) -> None: search="search", sort="name_asc", ) - assert_matches_type(FileListResponse, file, path=["response"]) + assert_matches_type(SyncPagePagination[FileListResponse], file, path=["response"]) @parametrize def test_raw_response_list(self, client: Autorender) -> None: @@ -82,7 +83,7 @@ def test_raw_response_list(self, client: Autorender) -> None: assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" file = response.parse() - assert_matches_type(FileListResponse, file, path=["response"]) + assert_matches_type(SyncPagePagination[FileListResponse], file, path=["response"]) @parametrize def test_streaming_response_list(self, client: Autorender) -> None: @@ -91,7 +92,7 @@ def test_streaming_response_list(self, client: Autorender) -> None: assert response.http_request.headers.get("X-Stainless-Lang") == "python" file = response.parse() - assert_matches_type(FileListResponse, file, path=["response"]) + assert_matches_type(SyncPagePagination[FileListResponse], file, path=["response"]) assert cast(Any, response.is_closed) is True @@ -222,7 +223,7 @@ async def test_path_params_retrieve(self, async_client: AsyncAutorender) -> None @parametrize async def test_method_list(self, async_client: AsyncAutorender) -> None: file = await async_client.files.list() - assert_matches_type(FileListResponse, file, path=["response"]) + assert_matches_type(AsyncPagePagination[FileListResponse], file, path=["response"]) @parametrize async def test_method_list_with_all_params(self, async_client: AsyncAutorender) -> None: @@ -233,7 +234,7 @@ async def test_method_list_with_all_params(self, async_client: AsyncAutorender) search="search", sort="name_asc", ) - assert_matches_type(FileListResponse, file, path=["response"]) + assert_matches_type(AsyncPagePagination[FileListResponse], file, path=["response"]) @parametrize async def test_raw_response_list(self, async_client: AsyncAutorender) -> None: @@ -242,7 +243,7 @@ async def test_raw_response_list(self, async_client: AsyncAutorender) -> None: assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" file = await response.parse() - assert_matches_type(FileListResponse, file, path=["response"]) + assert_matches_type(AsyncPagePagination[FileListResponse], file, path=["response"]) @parametrize async def test_streaming_response_list(self, async_client: AsyncAutorender) -> None: @@ -251,7 +252,7 @@ async def test_streaming_response_list(self, async_client: AsyncAutorender) -> N assert response.http_request.headers.get("X-Stainless-Lang") == "python" file = await response.parse() - assert_matches_type(FileListResponse, file, path=["response"]) + assert_matches_type(AsyncPagePagination[FileListResponse], file, path=["response"]) assert cast(Any, response.is_closed) is True