Skip to content

Commit 1479b6f

Browse files
feat: Pagination helper (#185)
1 parent 7b3307b commit 1479b6f

File tree

4 files changed

+365
-0
lines changed

4 files changed

+365
-0
lines changed
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
"""Example demonstrating the paginate helper for iterating through paginated API results.
2+
3+
This example shows how to use the paginate() helper function to automatically
4+
handle continuation tokens when fetching all results from a paginated API.
5+
"""
6+
7+
from nisystemlink.clients.core.helpers import paginate
8+
from nisystemlink.clients.testmonitor import TestMonitorClient
9+
from nisystemlink.clients.testmonitor.models import Result
10+
11+
# Server configuration is not required when used with SystemLink Client or run through Jupyter on SystemLink
12+
server_configuration = None
13+
14+
# # Example of setting up the server configuration to point to your instance of SystemLink Enterprise
15+
# from nisystemlink.clients.core import HttpConfiguration
16+
# server_configuration = HttpConfiguration(
17+
# server_uri="https://yourserver.yourcompany.com",
18+
# api_key="YourAPIKeyGeneratedFromSystemLink",
19+
# )
20+
21+
client = TestMonitorClient(configuration=server_configuration)
22+
23+
# Example 1: Basic usage - iterate through all results automatically
24+
print("Example 1: Iterating through all results")
25+
print("-" * 50)
26+
result: Result
27+
for result in paginate(client.get_results, items_field="results", take=100):
28+
print(f"Result ID: {result.id}, Status: {result.status.status_type}") # type: ignore[union-attr]
29+
30+
# Example 2: Collect all results into a list
31+
print("\nExample 2: Collecting all results into a list")
32+
print("-" * 50)
33+
all_results: list[Result] = list(
34+
paginate(client.get_results, items_field="results", take=100)
35+
)
36+
print(f"Total results retrieved: {len(all_results)}")
37+
38+
# Example 3: Process in chunks while still using automatic pagination
39+
print("\nExample 3: Processing results in batches")
40+
print("-" * 50)
41+
batch: list[Result] = []
42+
batch_size = 50
43+
for i, result in enumerate(
44+
paginate(client.get_results, items_field="results", take=100), start=1
45+
):
46+
batch.append(result)
47+
if len(batch) >= batch_size:
48+
# Process batch
49+
print(f"Processing batch of {len(batch)} results...")
50+
# Do something with batch
51+
batch = []
52+
53+
# Process remaining items
54+
if batch:
55+
print(f"Processing final batch of {len(batch)} results...")
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from ._iterator_file_like import IteratorFileLike
22
from ._minion_id import read_minion_id
3+
from ._pagination import paginate
34

45
# flake8: noqa
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# -*- coding: utf-8 -*-
2+
from typing import Any, Callable, Generator
3+
4+
from nisystemlink.clients.core._uplink._with_paging import WithPaging
5+
6+
7+
def paginate(
8+
fetch_function: Callable[..., WithPaging],
9+
items_field: str,
10+
**fetch_kwargs: Any,
11+
) -> Generator[Any, None, None]:
12+
"""Generate items from paginated API responses using continuation tokens.
13+
14+
This helper function provides a convenient way to iterate over all items
15+
from a paginated API endpoint that uses continuation tokens. It automatically
16+
handles fetching subsequent pages until all items are retrieved.
17+
18+
Args:
19+
fetch_function: The API function to call to fetch each page of items.
20+
Must accept a ``continuation_token`` parameter and return a response
21+
that derives from ``WithPaging``.
22+
items_field: The name of the field in the response object that contains
23+
the list of items to yield.
24+
**fetch_kwargs: Additional keyword arguments to pass to the fetch function
25+
on every call (e.g., filters, take limits, etc.).
26+
27+
Yields:
28+
Individual items from each page of results.
29+
30+
Note:
31+
The fetch function will be called with the `continuation_token` parameter
32+
set to `None` on the first call, then with each subsequent token until
33+
the response contains a `None` continuation token.
34+
"""
35+
continuation_token = None
36+
37+
while True:
38+
# Set the continuation token parameter for this page
39+
fetch_kwargs["continuation_token"] = continuation_token
40+
41+
# Fetch the current page
42+
response = fetch_function(**fetch_kwargs)
43+
44+
# Get the items from the response using the specified field name
45+
items = getattr(response, items_field, [])
46+
47+
# Yield each item individually
48+
for item in items:
49+
yield item
50+
51+
# Get the continuation token for the next page
52+
next_continuation_token = response.continuation_token
53+
54+
# Stop if there are no more pages
55+
if next_continuation_token is None:
56+
break
57+
58+
# Guard against infinite loop if continuation token doesn't change
59+
if next_continuation_token == continuation_token:
60+
raise RuntimeError("Continuation token did not change between iterations.")
61+
62+
continuation_token = next_continuation_token

tests/core/test_pagination.py

Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
# -*- coding: utf-8 -*-
2+
"""Tests for the pagination helper function."""
3+
4+
from typing import Any, List
5+
from unittest.mock import MagicMock
6+
7+
import pytest
8+
from nisystemlink.clients.core.helpers import paginate
9+
10+
11+
class MockResponseWithItems:
12+
"""Mock API response object with 'items' field for testing pagination."""
13+
14+
def __init__(self, items: List[Any], continuation_token: str | None = None):
15+
self.items = items
16+
self.continuation_token = continuation_token
17+
18+
19+
class MockResponseWithResults:
20+
"""Mock API response object with 'results' field for testing pagination."""
21+
22+
def __init__(self, results: List[Any], continuation_token: str | None = None):
23+
self.results = results
24+
self.continuation_token = continuation_token
25+
26+
27+
class TestPaginate:
28+
"""Tests for the paginate helper function."""
29+
30+
def test__paginate_single_page__yields_all_items(self):
31+
"""Test pagination with a single page of results."""
32+
# Arrange
33+
items = [1, 2, 3, 4, 5]
34+
mock_fetch = MagicMock(return_value=MockResponseWithItems(items, None))
35+
36+
# Act
37+
result = list(paginate(mock_fetch, "items"))
38+
39+
# Assert
40+
assert result == items
41+
assert mock_fetch.call_count == 1
42+
mock_fetch.assert_called_with(continuation_token=None)
43+
44+
def test__paginate_multiple_pages__yields_all_items_in_order(self):
45+
"""Test pagination with multiple pages."""
46+
# Arrange
47+
page1_items = [1, 2, 3]
48+
page2_items = [4, 5, 6]
49+
page3_items = [7, 8, 9]
50+
51+
mock_fetch = MagicMock(
52+
side_effect=[
53+
MockResponseWithItems(page1_items, "token1"),
54+
MockResponseWithItems(page2_items, "token2"),
55+
MockResponseWithItems(page3_items, None),
56+
]
57+
)
58+
59+
# Act
60+
result = list(paginate(mock_fetch, "items"))
61+
62+
# Assert
63+
assert result == [1, 2, 3, 4, 5, 6, 7, 8, 9]
64+
assert mock_fetch.call_count == 3
65+
assert mock_fetch.call_args_list[0][1]["continuation_token"] is None
66+
assert mock_fetch.call_args_list[1][1]["continuation_token"] == "token1"
67+
assert mock_fetch.call_args_list[2][1]["continuation_token"] == "token2"
68+
69+
def test__paginate_with_results_field__yields_all_items(self):
70+
"""Test pagination with 'results' as the items field name."""
71+
# Arrange
72+
page1_results = ["a", "b"]
73+
page2_results = ["c", "d"]
74+
75+
mock_fetch = MagicMock(
76+
side_effect=[
77+
MockResponseWithResults(page1_results, "next1"),
78+
MockResponseWithResults(page2_results, None),
79+
]
80+
)
81+
82+
# Act
83+
result = list(paginate(mock_fetch, items_field="results"))
84+
85+
# Assert
86+
assert result == ["a", "b", "c", "d"]
87+
assert mock_fetch.call_count == 2
88+
89+
def test__paginate_with_additional_kwargs__passes_kwargs_to_fetch(self):
90+
"""Test that additional keyword arguments are passed to the fetch function."""
91+
# Arrange
92+
items = [1, 2, 3]
93+
mock_fetch = MagicMock(return_value=MockResponseWithItems(items, None))
94+
95+
# Act
96+
result = list(
97+
paginate(
98+
mock_fetch, "items", take=10, filter="status==PASSED", return_count=True
99+
)
100+
)
101+
102+
# Assert
103+
assert result == items
104+
mock_fetch.assert_called_once_with(
105+
continuation_token=None, take=10, filter="status==PASSED", return_count=True
106+
)
107+
108+
def test__paginate_empty_results__returns_empty(self):
109+
"""Test pagination with no results."""
110+
# Arrange
111+
mock_fetch = MagicMock(return_value=MockResponseWithItems([], None))
112+
113+
# Act
114+
result = list(paginate(mock_fetch, "items"))
115+
116+
# Assert
117+
assert result == []
118+
assert mock_fetch.call_count == 1
119+
120+
def test__paginate_empty_page_in_middle__yields_only_non_empty_pages(self):
121+
"""Test pagination when a middle page is empty."""
122+
# Arrange
123+
page1_items = [1, 2, 3]
124+
page2_items = []
125+
page3_items = [4, 5]
126+
127+
mock_fetch = MagicMock(
128+
side_effect=[
129+
MockResponseWithItems(page1_items, "token1"),
130+
MockResponseWithItems(page2_items, "token2"),
131+
MockResponseWithItems(page3_items, None),
132+
]
133+
)
134+
135+
# Act
136+
result = list(paginate(mock_fetch, "items"))
137+
138+
# Assert
139+
assert result == [1, 2, 3, 4, 5]
140+
assert mock_fetch.call_count == 3
141+
142+
def test__paginate_generator__can_be_used_in_for_loop(self):
143+
"""Test that paginate works as expected in a for loop."""
144+
# Arrange
145+
page1_items = [1, 2]
146+
page2_items = [3, 4]
147+
mock_fetch = MagicMock(
148+
side_effect=[
149+
MockResponseWithItems(page1_items, "token1"),
150+
MockResponseWithItems(page2_items, None),
151+
]
152+
)
153+
154+
# Act
155+
collected = []
156+
for item in paginate(mock_fetch, "items"):
157+
collected.append(item * 2)
158+
159+
# Assert
160+
assert collected == [2, 4, 6, 8]
161+
162+
def test__paginate_lazy_evaluation__only_fetches_as_needed(self):
163+
"""Test that pagination is lazy and doesn't fetch all pages immediately."""
164+
# Arrange
165+
page1_items = [1, 2, 3]
166+
page2_items = [4, 5, 6]
167+
mock_fetch = MagicMock(
168+
side_effect=[
169+
MockResponseWithItems(page1_items, "token1"),
170+
MockResponseWithItems(page2_items, None),
171+
]
172+
)
173+
174+
# Act
175+
gen = paginate(mock_fetch, "items")
176+
assert mock_fetch.call_count == 0 # Nothing called yet
177+
178+
first_item = next(gen)
179+
assert first_item == 1
180+
assert mock_fetch.call_count == 1 # First page fetched
181+
182+
# Consume rest of first page
183+
next(gen) # 2
184+
next(gen) # 3
185+
186+
# First page exhausted, but second page not yet fetched
187+
assert mock_fetch.call_count == 1
188+
189+
# Fetch first item of second page
190+
fourth_item = next(gen)
191+
assert fourth_item == 4
192+
assert mock_fetch.call_count == 2 # Second page fetched
193+
194+
def test__paginate_with_kwargs_persisted_across_pages__kwargs_used_on_all_calls(
195+
self,
196+
):
197+
"""Test that kwargs are passed to all fetch function calls."""
198+
# Arrange
199+
mock_fetch = MagicMock(
200+
side_effect=[
201+
MockResponseWithItems([1, 2], "token1"),
202+
MockResponseWithItems([3, 4], None),
203+
]
204+
)
205+
206+
# Act
207+
list(paginate(mock_fetch, "items", take=100, filter="active==true"))
208+
209+
# Assert
210+
for call in mock_fetch.call_args_list:
211+
assert call[1]["take"] == 100
212+
assert call[1]["filter"] == "active==true"
213+
214+
def test__paginate_missing_items_field__yields_nothing(self):
215+
"""Test pagination when the items field doesn't exist on the response."""
216+
# Arrange
217+
mock_response = MockResponseWithItems([], None)
218+
# Remove the items field
219+
delattr(mock_response, "items")
220+
mock_fetch = MagicMock(return_value=mock_response)
221+
222+
# Act
223+
result = list(paginate(mock_fetch, "items"))
224+
225+
# Assert
226+
assert result == []
227+
assert mock_fetch.call_count == 1
228+
229+
def test__paginate_continuation_token_unchanged__raises_runtime_error(self):
230+
"""Test that an error is raised if the continuation token doesn't change."""
231+
# Arrange
232+
# First call returns token1, second call returns token1 again (infinite loop)
233+
mock_fetch = MagicMock(
234+
side_effect=[
235+
MockResponseWithItems([1, 2], "token1"),
236+
MockResponseWithItems(
237+
[3, 4], "token1"
238+
), # Same token - should raise error
239+
]
240+
)
241+
242+
# Act & Assert
243+
with pytest.raises(RuntimeError, match="Continuation token did not change"):
244+
list(paginate(mock_fetch, "items"))
245+
246+
# Should have made 2 calls before detecting the issue
247+
assert mock_fetch.call_count == 2

0 commit comments

Comments
 (0)