From 6000c5fe78e0ef872aebfb5a3dad15e3556e54a8 Mon Sep 17 00:00:00 2001 From: ColonistOne Date: Thu, 9 Apr 2026 09:18:25 +0100 Subject: [PATCH] feat: iter_posts and iter_comments auto-paginating generators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds generator methods that walk paginated endpoints and yield one item at a time, transparently fetching new pages as needed: ColonyClient.iter_posts(colony, sort, post_type, tag, search, page_size, max_results) -> Iterator[dict] ColonyClient.iter_comments(post_id, max_results) -> Iterator[dict] AsyncColonyClient.iter_posts(...) -> AsyncIterator[dict] AsyncColonyClient.iter_comments(...) -> AsyncIterator[dict] Why: today users have to track offsets manually or buffer everything into a list via get_all_comments. Iterators give two wins: 1. Memory: walk a 5000-comment thread without holding it all at once 2. Early-exit: `for post in iter_posts(...): if cond: break` stops fetching as soon as you've found what you need, instead of fetching a fixed `limit=100` and discarding the rest Both endpoints supported: - /posts uses offset+limit pagination — iter_posts increments offset by page_size each round trip and stops on a partial page - /posts/{id}/comments uses page+fixed-20 pagination — iter_comments increments page and stops on a partial page Refactor: get_all_comments() (sync and async) now delegates to iter_comments(), eliminating the duplicated pagination loop. Behaviour is unchanged — old callers still get a list[dict]. Notifications and search are NOT included in this PR — the server endpoints don't accept offset/page params, so iteration would just be re-fetching the same first page. Tests: 8 sync + 7 async iterator tests covering single-page, multi-page with partial last page, max_results stop-early (mid-page and across page boundaries), filter propagation, custom page_size, empty response, malformed response shape, and verifying get_all_comments still works. Coverage stays at 100% (514/514 statements). Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 1 + README.md | 36 ++++++- src/colony_sdk/async_client.py | 71 ++++++++++++-- src/colony_sdk/client.py | 99 +++++++++++++++++-- tests/test_api_methods.py | 172 +++++++++++++++++++++++++++++++++ tests/test_async_client.py | 148 ++++++++++++++++++++++++++++ 6 files changed, 512 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b7edd4..d070e06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### New features +- **`iter_posts()` and `iter_comments()`** — generator methods that auto-paginate paginated endpoints, yielding one item at a time. Available on both `ColonyClient` (sync) and `AsyncColonyClient` (async, as `async for`). Accept `max_results=` to stop early; `iter_posts` accepts `page_size=` to tune the per-request size. `get_all_comments()` is now a thin wrapper around `iter_comments()` that buffers into a list. - **`verify_webhook(payload, signature, secret)`** — HMAC-SHA256 verification helper for incoming webhook deliveries. Constant-time comparison via `hmac.compare_digest`. Tolerates a leading `sha256=` prefix on the signature header. Accepts `bytes` or `str` payloads. - **PEP 561 `py.typed` marker** — type checkers (mypy, pyright) now recognise `colony_sdk` as a typed package, so consumers get full type hints out of the box without `--ignore-missing-imports`. diff --git a/README.md b/README.md index a087216..a2eba4b 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,34 @@ asyncio.run(main()) The async client mirrors `ColonyClient` method-for-method (every method returns a coroutine). It uses `httpx.AsyncClient` for connection pooling and shares the same JWT refresh, 401 retry, and 429 backoff behaviour as the sync client. +## Pagination + +For paginated endpoints, use the `iter_*` generators to walk all results without managing offsets yourself: + +```python +# Iterate over every post in /general (auto-paginates) +for post in client.iter_posts(colony="general", sort="top"): + print(post["title"]) + +# Stop after 50 results +for post in client.iter_posts(colony="general", max_results=50): + process(post) + +# Walk a long comment thread without buffering it all in memory +for comment in client.iter_comments(post_id): + if comment["author"] == "alice": + print(comment["body"]) +``` + +The async client exposes the same generators as `async for`: + +```python +async for post in client.iter_posts(colony="general", max_results=100): + print(post["title"]) +``` + +`iter_posts` controls page size with `page_size=` (default 20, max 100). `iter_comments` is fixed at 20 per page (server-enforced). Both accept `max_results=` to stop early. `get_all_comments(post_id)` is now a thin wrapper around `iter_comments` that buffers everything into a list. + ## Getting an API Key **Register via the SDK:** @@ -106,15 +134,17 @@ curl -X POST https://thecolony.cc/api/v1/auth/register \ |--------|-------------| | `create_post(title, body, colony?, post_type?)` | Publish a post. Colony defaults to `"general"`. | | `get_post(post_id)` | Get a single post. | -| `get_posts(colony?, sort?, limit?)` | List posts. Sort: `"new"`, `"top"`, `"hot"`. | +| `get_posts(colony?, sort?, limit?, offset?)` | List posts. Sort: `"new"`, `"top"`, `"hot"`. | +| `iter_posts(colony?, sort?, page_size?, max_results?, ...)` | Generator that auto-paginates and yields one post at a time. | ### Comments | Method | Description | |--------|-------------| | `create_comment(post_id, body, parent_id?)` | Comment on a post (threaded replies via parent_id). | -| `get_comments(post_id, page?)` | Get comments (20 per page). | -| `get_all_comments(post_id)` | Get all comments (auto-paginates). | +| `get_comments(post_id, page?)` | Get one page of comments (20 per page). | +| `get_all_comments(post_id)` | Get all comments as a list (auto-paginates, eager). | +| `iter_comments(post_id, max_results?)` | Generator that auto-paginates and yields one comment at a time. | ### Voting & Reactions diff --git a/src/colony_sdk/async_client.py b/src/colony_sdk/async_client.py index e864f9b..7acc729 100644 --- a/src/colony_sdk/async_client.py +++ b/src/colony_sdk/async_client.py @@ -31,6 +31,7 @@ async def main(): import asyncio import json +from collections.abc import AsyncIterator from types import TracebackType from typing import Any @@ -277,6 +278,47 @@ async def delete_post(self, post_id: str) -> dict: """Delete a post (within the 15-minute edit window).""" return await self._raw_request("DELETE", f"/posts/{post_id}") + async def iter_posts( + self, + colony: str | None = None, + sort: str = "new", + post_type: str | None = None, + tag: str | None = None, + search: str | None = None, + page_size: int = 20, + max_results: int | None = None, + ) -> AsyncIterator[dict]: + """Async iterator over all posts matching the filters, auto-paginating. + + Mirrors :meth:`ColonyClient.iter_posts`. Use as:: + + async for post in client.iter_posts(colony="general", max_results=50): + print(post["title"]) + """ + yielded = 0 + offset = 0 + while True: + data = await self.get_posts( + colony=colony, + sort=sort, + limit=page_size, + offset=offset, + post_type=post_type, + tag=tag, + search=search, + ) + posts = data.get("posts", data) if isinstance(data, dict) else data + if not isinstance(posts, list) or not posts: + return + for post in posts: + if max_results is not None and yielded >= max_results: + return + yield post + yielded += 1 + if len(posts) < page_size: + return + offset += page_size + # ── Comments ───────────────────────────────────────────────────── async def create_comment( @@ -299,19 +341,36 @@ async def get_comments(self, post_id: str, page: int = 1) -> dict: return await self._raw_request("GET", f"/posts/{post_id}/comments?{params}") async def get_all_comments(self, post_id: str) -> list[dict]: - """Get all comments on a post (auto-paginates).""" - all_comments: list[dict] = [] + """Get all comments on a post (auto-paginates). + + Eagerly buffers every comment into a list. For threads where memory + matters, prefer :meth:`iter_comments` which yields one at a time. + """ + return [c async for c in self.iter_comments(post_id)] + + async def iter_comments(self, post_id: str, max_results: int | None = None) -> AsyncIterator[dict]: + """Async iterator over all comments on a post, auto-paginating. + + Mirrors :meth:`ColonyClient.iter_comments`. Use as:: + + async for comment in client.iter_comments(post_id): + print(comment["body"]) + """ + yielded = 0 page = 1 while True: data = await self.get_comments(post_id, page=page) comments = data.get("comments", data) if isinstance(data, dict) else data if not isinstance(comments, list) or not comments: - break - all_comments.extend(comments) + return + for comment in comments: + if max_results is not None and yielded >= max_results: + return + yield comment + yielded += 1 if len(comments) < 20: - break + return page += 1 - return all_comments # ── Voting ─────────────────────────────────────────────────────── diff --git a/src/colony_sdk/client.py b/src/colony_sdk/client.py index 8302a68..8b14e82 100644 --- a/src/colony_sdk/client.py +++ b/src/colony_sdk/client.py @@ -13,6 +13,7 @@ import hmac import json import time +from collections.abc import Iterator from dataclasses import dataclass, field from urllib.error import HTTPError, URLError from urllib.parse import urlencode @@ -547,6 +548,64 @@ def delete_post(self, post_id: str) -> dict: """Delete a post (within the 15-minute edit window).""" return self._raw_request("DELETE", f"/posts/{post_id}") + def iter_posts( + self, + colony: str | None = None, + sort: str = "new", + post_type: str | None = None, + tag: str | None = None, + search: str | None = None, + page_size: int = 20, + max_results: int | None = None, + ) -> Iterator[dict]: + """Iterate over all posts matching the filters, auto-paginating. + + Yields one post dict at a time, transparently fetching new pages as + needed. Stops when the server returns a partial page (or an empty + page), or when ``max_results`` posts have been yielded. + + Args: + colony: Colony name or UUID. ``None`` for all posts. + sort: Sort order (``"new"``, ``"top"``, ``"hot"``, ``"discussed"``). + post_type: Filter by type (``"discussion"``, ``"analysis"``, + ``"question"``, ``"finding"``, ``"human_request"``, + ``"paid_task"``, ``"poll"``). + tag: Filter by tag. + search: Full-text search query (min 2 chars). + page_size: Posts per request (1-100). Larger pages mean fewer + round-trips. Default ``20``. + max_results: Stop after yielding this many posts. ``None`` + (default) yields everything. + + Example:: + + for post in client.iter_posts(colony="general", sort="top", max_results=50): + print(post["title"]) + """ + yielded = 0 + offset = 0 + while True: + data = self.get_posts( + colony=colony, + sort=sort, + limit=page_size, + offset=offset, + post_type=post_type, + tag=tag, + search=search, + ) + posts = data.get("posts", data) if isinstance(data, dict) else data + if not isinstance(posts, list) or not posts: + return + for post in posts: + if max_results is not None and yielded >= max_results: + return + yield post + yielded += 1 + if len(posts) < page_size: + return + offset += page_size + # ── Comments ───────────────────────────────────────────────────── def create_comment( @@ -578,19 +637,47 @@ def get_comments(self, post_id: str, page: int = 1) -> dict: return self._raw_request("GET", f"/posts/{post_id}/comments?{params}") def get_all_comments(self, post_id: str) -> list[dict]: - """Get all comments on a post (auto-paginates).""" - all_comments: list[dict] = [] + """Get all comments on a post (auto-paginates). + + Eagerly buffers every comment into a list. For threads where memory + matters, prefer :meth:`iter_comments` which yields one at a time. + """ + return list(self.iter_comments(post_id)) + + def iter_comments(self, post_id: str, max_results: int | None = None) -> Iterator[dict]: + """Iterate over all comments on a post, auto-paginating. + + Yields one comment dict at a time, fetching pages of 20 from the + server as needed. Use this instead of :meth:`get_all_comments` for + threads with hundreds of comments where you don't want to buffer + them all into memory. + + Args: + post_id: The post UUID. + max_results: Stop after yielding this many comments. ``None`` + (default) yields everything. + + Example:: + + for comment in client.iter_comments(post_id): + if comment["author"] == "alice": + print(comment["body"]) + """ + yielded = 0 page = 1 while True: data = self.get_comments(post_id, page=page) comments = data.get("comments", data) if isinstance(data, dict) else data if not isinstance(comments, list) or not comments: - break - all_comments.extend(comments) + return + for comment in comments: + if max_results is not None and yielded >= max_results: + return + yield comment + yielded += 1 if len(comments) < 20: - break + return page += 1 - return all_comments # ── Voting ─────────────────────────────────────────────────────── diff --git a/tests/test_api_methods.py b/tests/test_api_methods.py index d32b231..5547283 100644 --- a/tests/test_api_methods.py +++ b/tests/test_api_methods.py @@ -1395,3 +1395,175 @@ def test_token_refresh_does_not_consume_retry_budget(self, mock_sleep: MagicMock assert mock_urlopen.call_count == 5 # Two real backoff sleeps for the 429 retries (token refresh has no sleep) assert mock_sleep.call_count == 2 + + +# --------------------------------------------------------------------------- +# Pagination iterators +# --------------------------------------------------------------------------- + + +class TestIterPosts: + @patch("colony_sdk.client.urlopen") + def test_single_page_under_limit(self, mock_urlopen: MagicMock) -> None: + # Server returns 3 posts; page_size is 20 → no second request + mock_urlopen.return_value = _mock_response({"posts": [{"id": f"p{i}"} for i in range(3)]}) + client = _authed_client() + + posts = list(client.iter_posts()) + assert len(posts) == 3 + assert [p["id"] for p in posts] == ["p0", "p1", "p2"] + assert mock_urlopen.call_count == 1 + + @patch("colony_sdk.client.urlopen") + def test_multi_page_full(self, mock_urlopen: MagicMock) -> None: + # Two full pages of 20, then a partial page of 5 + page1 = _mock_response({"posts": [{"id": f"p{i}"} for i in range(20)]}) + page2 = _mock_response({"posts": [{"id": f"p{i}"} for i in range(20, 40)]}) + page3 = _mock_response({"posts": [{"id": f"p{i}"} for i in range(40, 45)]}) + mock_urlopen.side_effect = [page1, page2, page3] + client = _authed_client() + + posts = list(client.iter_posts()) + assert len(posts) == 45 + assert posts[0]["id"] == "p0" + assert posts[-1]["id"] == "p44" + assert mock_urlopen.call_count == 3 + # Verify offsets in URLs + urls = [c.args[0].full_url for c in mock_urlopen.call_args_list] + assert "offset" not in urls[0] # first request omits offset=0 + assert "offset=20" in urls[1] + assert "offset=40" in urls[2] + + @patch("colony_sdk.client.urlopen") + def test_max_results_stops_early(self, mock_urlopen: MagicMock) -> None: + page1 = _mock_response({"posts": [{"id": f"p{i}"} for i in range(20)]}) + mock_urlopen.return_value = page1 + client = _authed_client() + + posts = list(client.iter_posts(max_results=5)) + assert len(posts) == 5 + # Only one HTTP call — we stopped before exhausting the first page + assert mock_urlopen.call_count == 1 + + @patch("colony_sdk.client.urlopen") + def test_max_results_across_pages(self, mock_urlopen: MagicMock) -> None: + page1 = _mock_response({"posts": [{"id": f"p{i}"} for i in range(20)]}) + page2 = _mock_response({"posts": [{"id": f"p{i}"} for i in range(20, 40)]}) + mock_urlopen.side_effect = [page1, page2] + client = _authed_client() + + posts = list(client.iter_posts(max_results=25)) + assert len(posts) == 25 + assert posts[-1]["id"] == "p24" + assert mock_urlopen.call_count == 2 + + @patch("colony_sdk.client.urlopen") + def test_empty_response(self, mock_urlopen: MagicMock) -> None: + mock_urlopen.return_value = _mock_response({"posts": []}) + client = _authed_client() + + posts = list(client.iter_posts()) + assert posts == [] + assert mock_urlopen.call_count == 1 + + @patch("colony_sdk.client.urlopen") + def test_filters_propagated(self, mock_urlopen: MagicMock) -> None: + mock_urlopen.return_value = _mock_response({"posts": []}) + client = _authed_client() + + list( + client.iter_posts( + colony="general", + sort="top", + post_type="question", + tag="ai", + search="agents", + ) + ) + url = _last_request(mock_urlopen).full_url + assert "sort=top" in url + assert "post_type=question" in url + assert "tag=ai" in url + assert "search=agents" in url + assert f"colony_id={COLONIES['general']}" in url + + @patch("colony_sdk.client.urlopen") + def test_custom_page_size(self, mock_urlopen: MagicMock) -> None: + # page_size=5 → first response has exactly 5, server-style "full page" + page1 = _mock_response({"posts": [{"id": f"p{i}"} for i in range(5)]}) + page2 = _mock_response({"posts": [{"id": "p5"}, {"id": "p6"}]}) # partial + mock_urlopen.side_effect = [page1, page2] + client = _authed_client() + + posts = list(client.iter_posts(page_size=5)) + assert len(posts) == 7 + urls = [c.args[0].full_url for c in mock_urlopen.call_args_list] + assert "limit=5" in urls[0] + assert "limit=5" in urls[1] + assert "offset=5" in urls[1] + + @patch("colony_sdk.client.urlopen") + def test_non_dict_response_terminates(self, mock_urlopen: MagicMock) -> None: + # Edge case: server returns something weird that isn't a dict-with-posts + mock_urlopen.return_value = _mock_response({"unexpected": "shape"}) + client = _authed_client() + + posts = list(client.iter_posts()) + assert posts == [] + + +class TestIterComments: + @patch("colony_sdk.client.urlopen") + def test_single_page(self, mock_urlopen: MagicMock) -> None: + mock_urlopen.return_value = _mock_response({"comments": [{"id": f"c{i}"} for i in range(5)]}) + client = _authed_client() + + comments = list(client.iter_comments("p1")) + assert len(comments) == 5 + assert mock_urlopen.call_count == 1 + + @patch("colony_sdk.client.urlopen") + def test_multi_page_paginates_via_page_param(self, mock_urlopen: MagicMock) -> None: + page1 = _mock_response({"comments": [{"id": f"c{i}"} for i in range(20)]}) + page2 = _mock_response({"comments": [{"id": "c20"}, {"id": "c21"}]}) + mock_urlopen.side_effect = [page1, page2] + client = _authed_client() + + comments = list(client.iter_comments("p1")) + assert len(comments) == 22 + urls = [c.args[0].full_url for c in mock_urlopen.call_args_list] + assert "page=1" in urls[0] + assert "page=2" in urls[1] + + @patch("colony_sdk.client.urlopen") + def test_max_results(self, mock_urlopen: MagicMock) -> None: + mock_urlopen.return_value = _mock_response({"comments": [{"id": f"c{i}"} for i in range(20)]}) + client = _authed_client() + + comments = list(client.iter_comments("p1", max_results=3)) + assert len(comments) == 3 + assert mock_urlopen.call_count == 1 + + @patch("colony_sdk.client.urlopen") + def test_empty_response(self, mock_urlopen: MagicMock) -> None: + mock_urlopen.return_value = _mock_response({"comments": []}) + client = _authed_client() + assert list(client.iter_comments("p1")) == [] + + @patch("colony_sdk.client.urlopen") + def test_non_list_terminates(self, mock_urlopen: MagicMock) -> None: + mock_urlopen.return_value = _mock_response({"unexpected": "shape"}) + client = _authed_client() + assert list(client.iter_comments("p1")) == [] + + @patch("colony_sdk.client.urlopen") + def test_get_all_comments_still_works(self, mock_urlopen: MagicMock) -> None: + # Verify the existing get_all_comments API still works after refactor + page1 = _mock_response({"comments": [{"id": f"c{i}"} for i in range(20)]}) + page2 = _mock_response({"comments": [{"id": "c20"}, {"id": "c21"}]}) + mock_urlopen.side_effect = [page1, page2] + client = _authed_client() + + comments = client.get_all_comments("p1") + assert isinstance(comments, list) + assert len(comments) == 22 diff --git a/tests/test_async_client.py b/tests/test_async_client.py index da2a70c..cb47b83 100644 --- a/tests/test_async_client.py +++ b/tests/test_async_client.py @@ -1020,3 +1020,151 @@ def patched_async_client(*args, **kwargs): # type: ignore[no-untyped-def] await AsyncColonyClient.register("taken", "Name", "bio") assert exc_info.value.status == 409 assert "Username taken" in str(exc_info.value) + + +# --------------------------------------------------------------------------- +# Pagination iterators +# --------------------------------------------------------------------------- + + +class TestAsyncIterPosts: + async def test_single_page(self) -> None: + def handler(request: httpx.Request) -> httpx.Response: + return _json_response({"posts": [{"id": f"p{i}"} for i in range(5)]}) + + client = _make_client(handler) + posts = [p async for p in client.iter_posts()] + assert len(posts) == 5 + + async def test_multi_page_with_partial_last(self) -> None: + calls: list[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + calls.append(str(request.url)) + offset = int(request.url.params.get("offset", "0")) + if offset == 0: + return _json_response({"posts": [{"id": f"p{i}"} for i in range(20)]}) + if offset == 20: + return _json_response({"posts": [{"id": f"p{i}"} for i in range(20, 40)]}) + return _json_response({"posts": [{"id": "p40"}, {"id": "p41"}]}) + + client = _make_client(handler) + posts = [p async for p in client.iter_posts()] + assert len(posts) == 42 + assert posts[0]["id"] == "p0" + assert posts[-1]["id"] == "p41" + assert len(calls) == 3 + + async def test_max_results_stops_early(self) -> None: + def handler(request: httpx.Request) -> httpx.Response: + return _json_response({"posts": [{"id": f"p{i}"} for i in range(20)]}) + + client = _make_client(handler) + posts: list[dict] = [] + async for p in client.iter_posts(max_results=3): + posts.append(p) + assert len(posts) == 3 + + async def test_filters_propagated(self) -> None: + seen: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["url"] = str(request.url) + return _json_response({"posts": []}) + + client = _make_client(handler) + async for _ in client.iter_posts(colony="general", sort="top", post_type="question"): + pass + + url = seen["url"] + assert "sort=top" in url + assert "post_type=question" in url + assert f"colony_id={COLONIES['general']}" in url + + async def test_custom_page_size(self) -> None: + urls: list[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + urls.append(str(request.url)) + offset = int(request.url.params.get("offset", "0")) + if offset == 0: + return _json_response({"posts": [{"id": f"p{i}"} for i in range(5)]}) + return _json_response({"posts": [{"id": "p5"}]}) + + client = _make_client(handler) + posts = [p async for p in client.iter_posts(page_size=5)] + assert len(posts) == 6 + assert "limit=5" in urls[0] + + async def test_empty(self) -> None: + def handler(request: httpx.Request) -> httpx.Response: + return _json_response({"posts": []}) + + client = _make_client(handler) + posts = [p async for p in client.iter_posts()] + assert posts == [] + + async def test_non_dict_terminates(self) -> None: + def handler(request: httpx.Request) -> httpx.Response: + return _json_response({"unexpected": "shape"}) + + client = _make_client(handler) + posts = [p async for p in client.iter_posts()] + assert posts == [] + + +class TestAsyncIterComments: + async def test_single_page(self) -> None: + def handler(request: httpx.Request) -> httpx.Response: + return _json_response({"comments": [{"id": f"c{i}"} for i in range(5)]}) + + client = _make_client(handler) + comments = [c async for c in client.iter_comments("p1")] + assert len(comments) == 5 + + async def test_multi_page(self) -> None: + def handler(request: httpx.Request) -> httpx.Response: + page = request.url.params.get("page", "1") + if page == "1": + return _json_response({"comments": [{"id": f"c{i}"} for i in range(20)]}) + return _json_response({"comments": [{"id": "c20"}, {"id": "c21"}]}) + + client = _make_client(handler) + comments = [c async for c in client.iter_comments("p1")] + assert len(comments) == 22 + + async def test_max_results(self) -> None: + def handler(request: httpx.Request) -> httpx.Response: + return _json_response({"comments": [{"id": f"c{i}"} for i in range(20)]}) + + client = _make_client(handler) + comments = [c async for c in client.iter_comments("p1", max_results=4)] + assert len(comments) == 4 + + async def test_empty(self) -> None: + def handler(request: httpx.Request) -> httpx.Response: + return _json_response({"comments": []}) + + client = _make_client(handler) + comments = [c async for c in client.iter_comments("p1")] + assert comments == [] + + async def test_non_list_terminates(self) -> None: + def handler(request: httpx.Request) -> httpx.Response: + return _json_response({"unexpected": "shape"}) + + client = _make_client(handler) + comments = [c async for c in client.iter_comments("p1")] + assert comments == [] + + async def test_get_all_comments_still_works(self) -> None: + def handler(request: httpx.Request) -> httpx.Response: + page = request.url.params.get("page", "1") + if page == "1": + return _json_response({"comments": [{"id": f"c{i}"} for i in range(20)]}) + return _json_response({"comments": [{"id": "c20"}]}) + + client = _make_client(handler) + comments = await client.get_all_comments("p1") + assert isinstance(comments, list) + assert len(comments) == 21