Repository: allenporter/gcal_sync (language: python)

Issue:
Support list iterator
Add support for async iterators when listing, rather than relying on page tokens. e.g. https://googleapis.dev/python/google-api-core/latest/page_iterator.html


Discussion / hints:

Fix patch:
diff --git a/gcal_sync/api.py b/gcal_sync/api.py
index f66fd80..470a6c6 100644
--- a/gcal_sync/api.py
+++ b/gcal_sync/api.py
@@ -5,9 +5,10 @@
 import datetime
 import json
 import logging
+from collections.abc import AsyncIterator, Awaitable, Callable
 from typing import Any, Optional
 
-from pydantic import BaseModel, Field, root_validator
+from pydantic import BaseModel, Field, PrivateAttr, root_validator
 
 from .auth import AbstractAuth
 from .model import EVENT_FIELDS, Calendar, Event
@@ -66,6 +67,25 @@ class ListEventsResponse(BaseModel):
     items: list[Event] = Field(default=[], alias="items")
     sync_token: Optional[str] = Field(default=None, alias="nextSyncToken")
     page_token: Optional[str] = Field(default=None, alias="nextPageToken")
+    _get_next_page: Optional[
+        Callable[[Optional[str]], Awaitable[dict[str, Any]]]
+    ] = PrivateAttr()
+
+    async def __aiter__(self) -> AsyncIterator[ListEventsResponse]:
+        """Async iterator to traverse through pages of responses."""
+        response = self
+        while response is not None:
+            yield response
+            if not response.page_token or not self._get_next_page:
+                break
+            json_result = await self._get_next_page(response.page_token)
+            response = ListEventsResponse.parse_obj(json_result)
+
+    def allow_iter(
+        self, get_next_page: Callable[[Optional[str]], Awaitable[dict[str, Any]]]
+    ) -> None:
+        """Initialize the iterator allowing async paging."""
+        self._get_next_page = get_next_page
 
 
 class GoogleCalendarService:
@@ -114,7 +134,17 @@ async def async_list_events(
                 request.json(exclude_none=True, by_alias=True, exclude={"calendar_id"})
             )
         )
-        result = await self._auth.get_json(
-            CALENDAR_EVENTS_URL.format(calendar_id=request.calendar_id), params=params
-        )
-        return ListEventsResponse.parse_obj(result)
+
+        async def get_next_page(page_token: str | None) -> dict[str, Any]:
+            if page_token is not None:
+                params["pageToken"] = page_token
+            return await self._auth.get_json(
+                CALENDAR_EVENTS_URL.format(calendar_id=request.calendar_id),
+                params=params,
+            )
+
+        json_result = await get_next_page(None)
+        json_result["_get_next_page"] = get_next_page
+        result = ListEventsResponse.parse_obj(json_result)
+        result.allow_iter(get_next_page)
+        return result


Test patch:
diff --git a/tests/test_api.py b/tests/test_api.py
index 9d99dc3..9d99f2d 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -121,6 +121,11 @@ async def test_list_events(
     assert result.page_token is None
     assert result.sync_token is None
 
+    items = []
+    async for result_page in result:
+        items.extend(result_page.items)
+    assert len(items) == 2
+
 
 async def test_list_events_with_date_limit(
     calendar_service_cb: Callable[[], Awaitable[GoogleCalendarService]],
@@ -313,3 +318,104 @@ async def test_list_events_page_token(
         ListEventsRequest(calendar_id="some-calendar-id")
     )
     assert result.page_token == "some-token"
+
+
+@freeze_time("2022-04-30 07:31:02", tz_offset=-6)
+async def test_list_events_multiple_pages_with_iterator(
+    calendar_service_cb: Callable[[], Awaitable[GoogleCalendarService]],
+    json_response: ApiResult,
+    url_request: Callable[[], str],
+) -> None:
+    """Test list calendars API."""
+
+    json_response(
+        {
+            "nextPageToken": "page-token-1",
+            "items": [
+                {
+                    "id": "some-event-id-1",
+                    "summary": "Event 1",
+                    "description": "Event description 1",
+                    "start": {
+                        "date": "2022-04-13",
+                    },
+                    "end": {
+                        "date": "2022-04-14",
+                    },
+                    "status": "confirmed",
+                    "transparency": "transparent",
+                },
+            ],
+        }
+    )
+    json_response(
+        {
+            "nextPageToken": "page-token-2",
+            "items": [
+                {
+                    "id": "some-event-id-2",
+                    "summary": "Event 2",
+                    "description": "Event description 2",
+                    "start": {
+                        "date": "2022-04-14",
+                    },
+                    "end": {
+                        "date": "2022-04-20",
+                    },
+                    "transparency": "opaque",
+                },
+            ],
+        }
+    )
+    json_response(
+        {
+            "items": [],
+        }
+    )
+    calendar_service = await calendar_service_cb()
+    result = await calendar_service.async_list_events(
+        ListEventsRequest(calendar_id="some-calendar-id")
+    )
+    # Before iterating, page token is present
+    assert result.page_token is not None
+
+    # Consume all items
+    items = []
+    page_tokens = []
+    async for result_page in result:
+        items.extend(result_page.items)
+        page_tokens.append(result_page.page_token)
+
+    assert url_request() == [
+        # Request #1
+        "/calendars/some-calendar-id/events?maxResult=100&singleEvents=true&orderBy=startTime"
+        "&fields=kind,nextPageToken,nextSyncToken,items(id,summary,description,location,start"
+        ",end,transparency)&timeMin=2022-04-30T01:31:02%2B00:00",
+        # Request #2
+        "/calendars/some-calendar-id/events?maxResult=100&singleEvents=true&orderBy=startTime"
+        "&fields=kind,nextPageToken,nextSyncToken,items(id,summary,description,location,start"
+        ",end,transparency)&timeMin=2022-04-30T01:31:02%2B00:00&pageToken=page-token-1",
+        # Request #3
+        "/calendars/some-calendar-id/events?maxResult=100&singleEvents=true&orderBy=startTime"
+        "&fields=kind,nextPageToken,nextSyncToken,items(id,summary,description,location,start"
+        ",end,transparency)&timeMin=2022-04-30T01:31:02%2B00:00&pageToken=page-token-2",
+    ]
+    assert items == [
+        Event(
+            id="some-event-id-1",
+            summary="Event 1",
+            description="Event description 1",
+            start=DateOrDatetime(date=datetime.date(2022, 4, 13)),
+            end=DateOrDatetime(date=datetime.date(2022, 4, 14)),
+            transparency="transparent",
+        ),
+        Event(
+            id="some-event-id-2",
+            summary="Event 2",
+            description="Event description 2",
+            start=DateOrDatetime(date=datetime.date(2022, 4, 14)),
+            end=DateOrDatetime(date=datetime.date(2022, 4, 20)),
+            transparency="opaque",
+        ),
+    ]
+    assert page_tokens == ["page-token-1", "page-token-2", None]