Skip to content

Commit 9e2fbee

Browse files
committed
Moving backend specific behavior from Page to Iterator.
This is to lower the burden on implementers. The previous approach (requiring a Page and Iterator subclass) ended up causing lots of copy-pasta docstrings that were just a distraction. Follow up to #2531.
1 parent 3c879aa commit 9e2fbee

File tree

2 files changed

+90
-62
lines changed

2 files changed

+90
-62
lines changed

packages/google-cloud-core/google/cloud/iterator.py

Lines changed: 65 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -17,22 +17,19 @@
1717
These iterators simplify the process of paging through API responses
1818
where the response is a list of results with a ``nextPageToken``.
1919
20-
To make an iterator work, just override the ``PAGE_CLASS`` class
21-
attribute so that given a response (containing a page of results) can
22-
be parsed into an iterable page of the actual objects you want::
20+
To make an iterator work, you may need to override the
21+
``ITEMS_KEY`` class attribute so that given a response (containing a page of
22+
results) can be parsed into an iterable page of the actual objects you want::
2323
24-
class MyPage(Page):
24+
class MyIterator(Iterator):
25+
26+
ITEMS_KEY = 'blocks'
2527
2628
def _item_to_value(self, item):
2729
my_item = MyItemClass(other_arg=True)
2830
my_item._set_properties(item)
2931
return my_item
3032
31-
32-
class MyIterator(Iterator):
33-
34-
PAGE_CLASS = MyPage
35-
3633
You then can use this to get **all** the results from a resource::
3734
3835
>>> iterator = MyIterator(...)
@@ -69,6 +66,30 @@ class MyIterator(Iterator):
6966
2
7067
>>> iterator.page.remaining
7168
19
69+
70+
It's also possible to consume an entire page and handle the paging process
71+
manually::
72+
73+
>>> iterator = MyIterator(...)
74+
>>> items = list(iterator.page)
75+
>>> items
76+
[
77+
<MyItemClass at 0x7fd64a098ad0>,
78+
<MyItemClass at 0x7fd64a098ed0>,
79+
<MyItemClass at 0x7fd64a098e90>,
80+
]
81+
>>> iterator.page.remaining
82+
0
83+
>>> iterator.page.num_items
84+
3
85+
>>> iterator.next_page_token
86+
'eav1OzQB0OM8rLdGXOEsyQWSG'
87+
>>> # And just do the same thing to consume the next page.
88+
>>> list(iterator.page)
89+
[
90+
<MyItemClass at 0x7fea740abdd0>,
91+
<MyItemClass at 0x7fea740abe50>,
92+
]
7293
"""
7394

7495

@@ -83,16 +104,19 @@ class Page(object):
83104
84105
:type response: dict
85106
:param response: The JSON API response for a page.
86-
"""
87107
88-
ITEMS_KEY = 'items'
108+
:type items_key: str
109+
:param items_key: The dictionary key used to retrieve items
110+
from the response.
111+
"""
89112

90-
def __init__(self, parent, response):
113+
def __init__(self, parent, response, items_key):
91114
self._parent = parent
92-
items = response.get(self.ITEMS_KEY, ())
115+
items = response.get(items_key, ())
93116
self._num_items = len(items)
94117
self._remaining = self._num_items
95118
self._item_iter = iter(items)
119+
self.response = response
96120

97121
@property
98122
def num_items(self):
@@ -116,23 +140,10 @@ def __iter__(self):
116140
"""The :class:`Page` is an iterator."""
117141
return self
118142

119-
def _item_to_value(self, item):
120-
"""Get the next item in the page.
121-
122-
This method (along with the constructor) is the workhorse
123-
of this class. Subclasses will need to implement this method.
124-
125-
:type item: dict
126-
:param item: An item to be converted to a native object.
127-
128-
:raises NotImplementedError: Always
129-
"""
130-
raise NotImplementedError
131-
132143
def next(self):
133144
"""Get the next value in the iterator."""
134145
item = six.next(self._item_iter)
135-
result = self._item_to_value(item)
146+
result = self._parent._item_to_value(item)
136147
# Since we've successfully got the next value from the
137148
# iterator, we update the number of remaining.
138149
self._remaining -= 1
@@ -145,7 +156,8 @@ def next(self):
145156
class Iterator(object):
146157
"""A generic class for iterating through Cloud JSON APIs list responses.
147158
148-
Sub-classes need to over-write ``PAGE_CLASS``.
159+
Sub-classes need to over-write :attr:`ITEMS_KEY` and to define
160+
:meth:`_item_to_value`.
149161
150162
:type client: :class:`google.cloud.client.Client`
151163
:param client: The client, which owns a connection to make requests.
@@ -166,8 +178,9 @@ class Iterator(object):
166178
PAGE_TOKEN = 'pageToken'
167179
MAX_RESULTS = 'maxResults'
168180
RESERVED_PARAMS = frozenset([PAGE_TOKEN, MAX_RESULTS])
169-
PAGE_CLASS = Page
170181
PATH = None
182+
ITEMS_KEY = 'items'
183+
"""The dictionary key used to retrieve items from each response."""
171184

172185
def __init__(self, client, page_token=None, max_results=None,
173186
extra_params=None, path=None):
@@ -200,31 +213,46 @@ def page(self):
200213
:rtype: :class:`Page`
201214
:returns: The page of items that has been retrieved.
202215
"""
216+
self._update_page()
203217
return self._page
204218

205219
def __iter__(self):
206220
"""The :class:`Iterator` is an iterator."""
207221
return self
208222

209223
def _update_page(self):
210-
"""Replace the current page.
224+
"""Update the current page if needed.
211225
212-
Does nothing if the current page is non-null and has items
213-
remaining.
226+
Subclasses will need to implement this method if they
227+
use data from the ``response`` other than the items.
214228
229+
:rtype: bool
230+
:returns: Flag indicated if the page was updated.
215231
:raises: :class:`~exceptions.StopIteration` if there is no next page.
216232
"""
217-
if self.page is not None and self.page.remaining > 0:
218-
return
219-
if self.has_next_page():
233+
if self._page is not None and self._page.remaining > 0:
234+
return False
235+
elif self.has_next_page():
220236
response = self._get_next_page_response()
221-
self._page = self.PAGE_CLASS(self, response)
237+
self._page = Page(self, response, self.ITEMS_KEY)
238+
return True
222239
else:
223240
raise StopIteration
224241

242+
def _item_to_value(self, item):
243+
"""Get the next item in the page.
244+
245+
Subclasses will need to implement this method.
246+
247+
:type item: dict
248+
:param item: An item to be converted to a native object.
249+
250+
:raises NotImplementedError: Always
251+
"""
252+
raise NotImplementedError
253+
225254
def next(self):
226255
"""Get the next value in the iterator."""
227-
self._update_page()
228256
item = six.next(self.page)
229257
self.num_results += 1
230258
return item

packages/google-cloud-core/unit_tests/test_iterator.py

Lines changed: 25 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -25,41 +25,34 @@ def _makeOne(self, *args, **kw):
2525
return self._getTargetClass()(*args, **kw)
2626

2727
def test_constructor(self):
28-
klass = self._getTargetClass()
2928
parent = object()
30-
response = {klass.ITEMS_KEY: (1, 2, 3)}
31-
page = self._makeOne(parent, response)
29+
items_key = 'potatoes'
30+
response = {items_key: (1, 2, 3)}
31+
page = self._makeOne(parent, response, items_key)
3232
self.assertIs(page._parent, parent)
3333
self.assertEqual(page._num_items, 3)
3434
self.assertEqual(page._remaining, 3)
3535

3636
def test_num_items_property(self):
37-
page = self._makeOne(None, {})
37+
page = self._makeOne(None, {}, '')
3838
num_items = 42
3939
page._num_items = num_items
4040
self.assertEqual(page.num_items, num_items)
4141

4242
def test_remaining_property(self):
43-
page = self._makeOne(None, {})
43+
page = self._makeOne(None, {}, '')
4444
remaining = 1337
4545
page._remaining = remaining
4646
self.assertEqual(page.remaining, remaining)
4747

4848
def test___iter__(self):
49-
page = self._makeOne(None, {})
49+
page = self._makeOne(None, {}, '')
5050
self.assertIs(iter(page), page)
5151

52-
def test__item_to_value(self):
53-
page = self._makeOne(None, {})
54-
with self.assertRaises(NotImplementedError):
55-
page._item_to_value(None)
56-
5752
def test_iterator_calls__item_to_value(self):
5853
import six
5954

60-
klass = self._getTargetClass()
61-
62-
class CountItPage(klass):
55+
class Parent(object):
6356

6457
calls = 0
6558
values = None
@@ -68,20 +61,22 @@ def _item_to_value(self, item):
6861
self.calls += 1
6962
return item
7063

71-
response = {klass.ITEMS_KEY: [10, 11, 12]}
72-
page = CountItPage(None, response)
64+
items_key = 'turkeys'
65+
response = {items_key: [10, 11, 12]}
66+
parent = Parent()
67+
page = self._makeOne(parent, response, items_key)
7368
page._remaining = 100
7469

75-
self.assertEqual(page.calls, 0)
70+
self.assertEqual(parent.calls, 0)
7671
self.assertEqual(page.remaining, 100)
7772
self.assertEqual(six.next(page), 10)
78-
self.assertEqual(page.calls, 1)
73+
self.assertEqual(parent.calls, 1)
7974
self.assertEqual(page.remaining, 99)
8075
self.assertEqual(six.next(page), 11)
81-
self.assertEqual(page.calls, 2)
76+
self.assertEqual(parent.calls, 2)
8277
self.assertEqual(page.remaining, 98)
8378
self.assertEqual(six.next(page), 12)
84-
self.assertEqual(page.calls, 3)
79+
self.assertEqual(parent.calls, 3)
8580
self.assertEqual(page.remaining, 97)
8681

8782

@@ -132,24 +127,24 @@ def test___iter__(self):
132127

133128
def test_iterate(self):
134129
import six
135-
from google.cloud.iterator import Page
136130

137131
path = '/foo'
138132
key1 = 'key1'
139133
key2 = 'key2'
140134
item1, item2 = object(), object()
141135
ITEMS = {key1: item1, key2: item2}
142136

143-
class _Page(Page):
137+
klass = self._getTargetClass()
138+
139+
class WithItemToValue(klass):
144140

145141
def _item_to_value(self, item):
146142
return ITEMS[item['name']]
147143

148144
connection = _Connection(
149145
{'items': [{'name': key1}, {'name': key2}]})
150146
client = _Client(connection)
151-
iterator = self._makeOne(client, path=path)
152-
iterator.PAGE_CLASS = _Page
147+
iterator = WithItemToValue(client, path=path)
153148
self.assertEqual(iterator.num_results, 0)
154149

155150
val1 = six.next(iterator)
@@ -274,6 +269,11 @@ def test__get_next_page_response_new_no_token_in_response(self):
274269
self.assertEqual(kw['path'], path)
275270
self.assertEqual(kw['query_params'], {})
276271

272+
def test__item_to_value_virtual(self):
273+
iterator = self._makeOne(None)
274+
with self.assertRaises(NotImplementedError):
275+
iterator._item_to_value({})
276+
277277
def test_reset(self):
278278
connection = _Connection()
279279
client = _Client(connection)
@@ -287,7 +287,7 @@ def test_reset(self):
287287
self.assertEqual(iterator.page_number, 0)
288288
self.assertEqual(iterator.num_results, 0)
289289
self.assertIsNone(iterator.next_page_token)
290-
self.assertIsNone(iterator.page)
290+
self.assertIsNone(iterator._page)
291291

292292

293293
class _Connection(object):

0 commit comments

Comments
 (0)