Skip to content

Commit d7015fa

Browse files
andrii-balitskyirazor-xseambot
authored
feat: Add seam.create_paginator (#311)
Co-authored-by: Evan Sosenko <evan@getseam.com> Co-authored-by: Seam Bot <seambot@getseam.com>
1 parent 96858fd commit d7015fa

File tree

5 files changed

+300
-1
lines changed

5 files changed

+300
-1
lines changed

README.rst

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,16 @@ Contents
4747

4848
* `Action Attempts <Action Attempts_>`_
4949

50+
* `Pagination <Pagination_>`_
51+
52+
* `Manually fetch pages with the nextPageCursor <Manually fetch pages with the nextPageCursor_>`_
53+
54+
* `Resume pagination <Resume pagination_>`_
55+
56+
* `Iterate over all resources <Iterate over all resources_>`_
57+
58+
* `Return all resources across all pages as a list <Return all resources across all pages as a list_>`_
59+
5060
* `Interacting with Multiple Workspaces <Interacting with Multiple Workspaces_>`_
5161

5262
* `Webhooks <Webhooks_>`_
@@ -257,6 +267,102 @@ For example:
257267
except SeamActionAttemptTimeoutError as e:
258268
print("Door took too long to unlock")
259269
270+
Pagination
271+
~~~~~~~~~~
272+
273+
Some Seam API endpoints that return lists of resources support pagination.
274+
Use the ``SeamPaginator`` class to fetch and process resources across multiple pages.
275+
276+
Manually fetch pages with the nextPageCursor
277+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
278+
279+
.. code-block:: python
280+
281+
from seam import Seam
282+
283+
seam = Seam()
284+
285+
paginator = seam.create_paginator(seam.connected_accounts.list, {"limit": 20})
286+
287+
connected_accounts, pagination = paginator.first_page()
288+
289+
if pagination.has_next_page:
290+
more_connected_accounts, _ = paginator.next_page(pagination.next_page_cursor)
291+
292+
Resume pagination
293+
^^^^^^^^^^^^^^^^^
294+
295+
Get the first page on initial load and store the state (e.g., in memory or a file):
296+
297+
.. code-block:: python
298+
299+
import json
300+
from seam import Seam
301+
302+
seam = Seam()
303+
304+
params = {"limit": 20}
305+
paginator = seam.create_paginator(seam.connected_accounts.list, params)
306+
307+
connected_accounts, pagination = paginator.first_page()
308+
309+
# Example: Store state for later use (e.g., in a file or database)
310+
pagination_state = {
311+
"params": params,
312+
"next_page_cursor": pagination.next_page_cursor,
313+
"has_next_page": pagination.has_next_page,
314+
}
315+
with open("/tmp/seam_connected_accounts_list.json", "w") as f:
316+
json.dump(pagination_state, f)
317+
318+
Get the next page at a later time using the stored state:
319+
320+
.. code-block:: python
321+
322+
import json
323+
from seam import Seam
324+
325+
seam = Seam()
326+
327+
# Example: Load state from where it was stored
328+
with open("/tmp/seam_connected_accounts_list.json", "r") as f:
329+
pagination_state = json.load(f)
330+
331+
if pagination_state.get("has_next_page"):
332+
paginator = seam.create_paginator(
333+
seam.connected_accounts.list, pagination_state["params"]
334+
)
335+
more_connected_accounts, _ = paginator.next_page(
336+
pagination_state["next_page_cursor"]
337+
)
338+
339+
Iterate over all resources
340+
^^^^^^^^^^^^^^^^^^^^^^^^^^
341+
342+
.. code-block:: python
343+
344+
from seam import Seam
345+
346+
seam = Seam()
347+
348+
paginator = seam.create_paginator(seam.connected_accounts.list, {"limit": 20})
349+
350+
for account in paginator.flatten():
351+
print(account.account_type_display_name)
352+
353+
Return all resources across all pages as a list
354+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
355+
356+
.. code-block:: python
357+
358+
from seam import Seam
359+
360+
seam = Seam()
361+
362+
paginator = seam.create_paginator(seam.connected_accounts.list, {"limit": 20})
363+
364+
all_connected_accounts = paginator.flatten_to_list()
365+
260366
Interacting with Multiple Workspaces
261367
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
262368

seam/pagination.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
class Pagination:
2+
def __init__(
3+
self,
4+
has_next_page: bool,
5+
next_page_cursor: str | None,
6+
next_page_url: str | None,
7+
):
8+
self.has_next_page = has_next_page
9+
self.next_page_cursor = next_page_cursor
10+
self.next_page_url = next_page_url

seam/paginator.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
from typing import Callable, Dict, Any, Tuple, Generator, List
2+
from .client import SeamHttpClient
3+
from niquests import Response, JSONDecodeError
4+
from .pagination import Pagination
5+
6+
7+
class SeamPaginator:
8+
"""
9+
Handles pagination for API list endpoints.
10+
11+
Iterates through pages of results returned by a callable function.
12+
"""
13+
14+
_FIRST_PAGE = "FIRST_PAGE"
15+
16+
def __init__(
17+
self,
18+
client: SeamHttpClient,
19+
request: Callable,
20+
params: Dict[str, Any] = None,
21+
):
22+
"""
23+
Initializes the Paginator.
24+
25+
Args:
26+
request: The function to call to fetch a page of data.
27+
http_client: The Seam HTTP client used in the request.
28+
params: Initial parameters to pass to the callable function.
29+
"""
30+
self._request = request
31+
self.client = client
32+
self._params = params or {}
33+
self._pagination_cache: Dict[str, Pagination] = {}
34+
35+
def first_page(self) -> Tuple[List[Any], Pagination | None]:
36+
"""Fetches the first page of results."""
37+
self.client.hooks["response"].append(
38+
lambda response: self._cache_pagination(response, self._FIRST_PAGE)
39+
)
40+
data = self._request(**self._params)
41+
self.client.hooks["response"].pop()
42+
43+
pagination = self._pagination_cache.get(self._FIRST_PAGE)
44+
45+
return data, pagination
46+
47+
def next_page(
48+
self, next_page_cursor: str, /
49+
) -> Tuple[List[Any], Pagination | None]:
50+
"""Fetches the next page of results using a cursor."""
51+
if not next_page_cursor:
52+
raise ValueError("Cannot get the next page with a null next_page_cursor.")
53+
54+
params = {
55+
**self._params,
56+
"page_cursor": next_page_cursor,
57+
}
58+
59+
self.client.hooks["response"].append(
60+
lambda response: self._cache_pagination(response, next_page_cursor)
61+
)
62+
data = self._request(**params)
63+
self.client.hooks["response"].pop()
64+
65+
pagination = self._pagination_cache.get(next_page_cursor)
66+
67+
return data, pagination
68+
69+
def flatten_to_list(self) -> List[Any]:
70+
"""Fetches all pages and returns all items as a single list."""
71+
all_items = []
72+
current_items, pagination = self.first_page()
73+
74+
if current_items:
75+
all_items.extend(current_items)
76+
77+
while pagination.has_next_page:
78+
current_items, pagination = self.next_page(pagination.next_page_cursor)
79+
if current_items:
80+
all_items.extend(current_items)
81+
82+
return all_items
83+
84+
def flatten(self) -> Generator[Any, None, None]:
85+
"""Fetches all pages and yields items one by one using a generator."""
86+
current_items, pagination = self.first_page()
87+
if current_items:
88+
yield from current_items
89+
90+
while pagination and pagination.has_next_page and pagination.next_page_cursor:
91+
current_items, pagination = self.next_page(pagination.next_page_cursor)
92+
if current_items:
93+
yield from current_items
94+
95+
def _cache_pagination(self, response: Response, page_key: str) -> None:
96+
"""Extracts pagination dict from response, creates Pagination object, and caches it."""
97+
try:
98+
response_json = response.json()
99+
pagination = response_json.get("pagination", {})
100+
except JSONDecodeError:
101+
pagination = {}
102+
103+
if isinstance(pagination, dict):
104+
self._pagination_cache[page_key] = Pagination(
105+
has_next_page=pagination.get("has_next_page", False),
106+
next_page_cursor=pagination.get("next_page_cursor"),
107+
next_page_url=pagination.get("next_page_url"),
108+
)

seam/seam.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import Any, Optional, Union, Dict
1+
from typing import Any, Optional, Union, Dict, Callable
22
from typing_extensions import Self
33
from urllib3.util.retry import Retry
44

@@ -7,6 +7,7 @@
77
from .routes import Routes
88
from .models import AbstractSeam
99
from .client import SeamHttpClient
10+
from .paginator import SeamPaginator
1011

1112

1213
class Seam(AbstractSeam):
@@ -89,6 +90,31 @@ def __init__(
8990

9091
Routes.__init__(self, client=self.client, defaults=self.defaults)
9192

93+
def create_paginator(
94+
self, request: Callable, params: Optional[Dict[str, Any]] = None, /
95+
) -> SeamPaginator:
96+
"""
97+
Creates a Paginator instance for iterating through list endpoints.
98+
99+
This is a helper method to simplify the process of paginating through
100+
API results.
101+
102+
Args:
103+
request: The API route method function to call for fetching pages
104+
(e.g., connected_accounts.list).
105+
params: Optional dictionary of initial parameters to pass to the request
106+
function.
107+
108+
Returns:
109+
An initialized Paginator object ready to fetch pages.
110+
111+
Example:
112+
>>> connected_accounts_paginator = seam.create_paginator(seam.connected_accounts.list)
113+
>>> for connected_account in connected_accounts_paginator.flatten():
114+
>>> print(connected_account.account_type_display_name)
115+
"""
116+
return SeamPaginator(self.client, request, params)
117+
92118
@classmethod
93119
def from_api_key(
94120
cls,

test/paginator_test.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
from seam import Seam
2+
3+
4+
def test_paginator_first_page(seam: Seam):
5+
paginator = seam.create_paginator(seam.connected_accounts.list, {"limit": 2})
6+
connected_accounts, pagination = paginator.first_page()
7+
8+
assert isinstance(connected_accounts, list)
9+
assert len(connected_accounts) == 2
10+
assert pagination is not None
11+
assert pagination.has_next_page is True
12+
assert pagination.next_page_cursor is not None
13+
assert pagination.next_page_url is not None
14+
15+
16+
def test_paginator_next_page(seam: Seam):
17+
paginator = seam.create_paginator(seam.connected_accounts.list, {"limit": 2})
18+
first_page_accounts, first_pagination = paginator.first_page()
19+
20+
assert len(first_page_accounts) == 2
21+
assert first_pagination.has_next_page is True
22+
23+
next_page_accounts, _ = paginator.next_page(first_pagination.next_page_cursor)
24+
25+
assert isinstance(next_page_accounts, list)
26+
assert len(next_page_accounts) == 1
27+
28+
29+
def test_paginator_flatten_to_list(seam: Seam):
30+
all_connected_accounts = seam.connected_accounts.list()
31+
32+
paginator = seam.create_paginator(seam.connected_accounts.list, {"limit": 1})
33+
paginated_accounts = paginator.flatten_to_list()
34+
35+
assert len(paginated_accounts) > 1
36+
assert len(paginated_accounts) == len(all_connected_accounts)
37+
38+
39+
def test_paginator_flatten(seam: Seam):
40+
all_connected_accounts = seam.connected_accounts.list()
41+
42+
paginator = seam.create_paginator(seam.connected_accounts.list, {"limit": 1})
43+
44+
collected_accounts = []
45+
for account in paginator.flatten():
46+
collected_accounts.append(account)
47+
48+
assert len(collected_accounts) > 1
49+
assert len(collected_accounts) == len(all_connected_accounts)

0 commit comments

Comments
 (0)