diff --git a/docs/V6_MIGRATION_GUIDE.md b/docs/V6_MIGRATION_GUIDE.md index 36954296..56e48fa7 100644 --- a/docs/V6_MIGRATION_GUIDE.md +++ b/docs/V6_MIGRATION_GUIDE.md @@ -261,7 +261,7 @@ List endpoints now return typed page wrappers with cursor metadata and built-in ```python page = client.organizations.list_organizations() -for organization in page: +for organization in page.auto_paging_iter(): print(organization.id) assert page.before is None or isinstance(page.before, str) @@ -273,12 +273,14 @@ assert page.after is None or isinstance(page.after, str) ```python page = await async_client.organizations.list_organizations() -items = [organization async for organization in page] +items = [organization async for organization in page.auto_paging_iter()] ``` **Affected users:** Any code that expected a handwritten list wrapper or manually handled pagination state differently. -**Migration:** Update pagination code to work with `SyncPage` or `AsyncPage`, and use `page.data`, `page.before`, `page.after`, or iteration over the page as needed. +**Migration:** Update pagination code to work with `SyncPage` or `AsyncPage`, and use `page.data`, `page.before`, `page.after`, or `auto_paging_iter()` as needed. + +**Breaking change (v11):** Iterating a page directly (`for organization in page`, `async for`) yields only the current page's `data` and makes no further requests. In v6–v10 it auto-paginates; in v11, call `page.auto_paging_iter()` to iterate across all pages. ### Requests now retry by default diff --git a/src/workos/_pagination.py b/src/workos/_pagination.py index 71c15ac9..c061e742 100644 --- a/src/workos/_pagination.py +++ b/src/workos/_pagination.py @@ -69,8 +69,8 @@ def auto_paging_iter(self) -> Iterator[T]: page = page._fetch_page(after=page.after) def __iter__(self) -> Iterator[T]: - """Iterate through all items across all pages.""" - return self.auto_paging_iter() + """Iterate this page's items only; use auto_paging_iter() to cross pages.""" + return iter(self.data) @dataclass @@ -109,6 +109,7 @@ async def auto_paging_iter(self) -> AsyncIterator[T]: break page = await page._fetch_page(after=page.after) - def __aiter__(self) -> AsyncIterator[T]: - """Iterate through all items across all pages.""" - return self.auto_paging_iter() + async def __aiter__(self) -> AsyncIterator[T]: + """Iterate this page's items only; use auto_paging_iter() to cross pages.""" + for item in self.data: + yield item diff --git a/tests/test_http_backends.py b/tests/test_http_backends.py index 5c27a9e6..3b169fd0 100644 --- a/tests/test_http_backends.py +++ b/tests/test_http_backends.py @@ -374,13 +374,13 @@ def handler(request: Any) -> Any: page = await client.user_management.list_users( email="alice@example.com", limit=7 ) - users = [user async for user in page] + users = [user async for user in page.auto_paging_iter()] else: sync_client = WorkOSClient(api_key=API_KEY, http_client=http_client) sync_page = sync_client.user_management.list_users( email="alice@example.com", limit=7 ) - users = list(sync_page) + users = list(sync_page.auto_paging_iter()) finally: if asynchronous: await http_client.aclose() diff --git a/tests/test_pagination.py b/tests/test_pagination.py index 6d7fcdb1..1db74412 100644 --- a/tests/test_pagination.py +++ b/tests/test_pagination.py @@ -108,24 +108,26 @@ async def _fetch(after=None): assert [i.id for i in items] == ["1", "2", "3"] +ORG_BASE = { + "object": "organization", + "domains": [], + "metadata": {}, + "external_id": None, + "created_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-01-01T00:00:00Z", +} + + class TestPaginationHTTPIntegration: """Integration test verifying auto_paging_iter fetches multiple pages via httpx.""" def test_auto_paging_iter_fetches_two_pages(self, workos, httpx_mock): - org_base = { - "object": "organization", - "domains": [], - "metadata": {}, - "external_id": None, - "created_at": "2024-01-01T00:00:00Z", - "updated_at": "2024-01-01T00:00:00Z", - } page1_json = { - "data": [{"id": "org_1", "name": "Org 1", **org_base}], + "data": [{"id": "org_1", "name": "Org 1", **ORG_BASE}], "list_metadata": {"after": "cursor_page2"}, } page2_json = { - "data": [{"id": "org_2", "name": "Org 2", **org_base}], + "data": [{"id": "org_2", "name": "Org 2", **ORG_BASE}], "list_metadata": {}, } httpx_mock.add_response(json=page1_json) @@ -140,3 +142,46 @@ def test_auto_paging_iter_fetches_two_pages(self, workos, httpx_mock): requests = httpx_mock.get_requests() assert len(requests) == 2 assert "after=cursor_page2" in str(requests[1].url) + + +class TestPageLocalIteration: + """Iterating a page yields only that page's data and makes no extra request.""" + + def test_iter_yields_current_page_only(self, workos, httpx_mock): + httpx_mock.add_response( + json={ + "data": [ + {"id": "org_1", "name": "Org 1", **ORG_BASE}, + {"id": "org_2", "name": "Org 2", **ORG_BASE}, + ], + "list_metadata": {"after": "cursor_page2"}, + } + ) + + page = workos.organizations.list_organizations( + request_options={"max_retries": 0} + ) + items = list(page) + + assert [item.id for item in items] == ["org_1", "org_2"] + assert len(httpx_mock.get_requests()) == 1 + + @pytest.mark.asyncio + async def test_aiter_yields_current_page_only(self, async_workos, httpx_mock): + httpx_mock.add_response( + json={ + "data": [ + {"id": "org_1", "name": "Org 1", **ORG_BASE}, + {"id": "org_2", "name": "Org 2", **ORG_BASE}, + ], + "list_metadata": {"after": "cursor_page2"}, + } + ) + + page = await async_workos.organizations.list_organizations( + request_options={"max_retries": 0} + ) + items = [item async for item in page] + + assert [item.id for item in items] == ["org_1", "org_2"] + assert len(httpx_mock.get_requests()) == 1