Repository: box/box-python-sdk
Pull request #9 (2015-02-15 19:22:44)

Improve the paging API
All clients to know exactly when GET calls might be triggered when
one of the pageable-APIs is used. This is achieved by returning
information about the *current* page on each call to 'next()' of
the underlying generator. The first call might return:
   <object>, 10, 0
indicating this object is the 0th indexed item in the current page
that has 10 items. This means that previous API returned info
about 10 such items. The subsequent call to next() would return:
   <object>, 10, 1
And so forth. Eventually returning:
   <object>, 10, 9

At this point, the next time next() is called a new GET request
will be trigger in order to fetch the next *page* of results. This
might then return:
   <object>, 12, 0
indicating that this next *page* has 12 results.


Patch:
diff --git a/boxsdk/object/base_object.py b/boxsdk/object/base_object.py
index ba5f598..b9cbee8 100644
--- a/boxsdk/object/base_object.py
+++ b/boxsdk/object/base_object.py
@@ -207,9 +207,12 @@ def _paging_wrapper(self, url, starting_index, limit, factory=None):
         :type factory:
             `callable` or None
         :returns:
-            A generator of instances returned by the given factory callable.
+            A generator of 3-tuples. Each tuple contains:
+            1) An instance returned by the given factory callable.
+            2) The number of objects in the current page.
+            3) Index the current instance in the current page.
         :rtype:
-            `generator` of varies
+            `generator` of `tuple` of (varies, `int`, `int`)
         """
         current_index = starting_index
 
@@ -218,11 +221,15 @@ def _paging_wrapper(self, url, starting_index, limit, factory=None):
             box_response = self._session.get(url, params=params)
             response = box_response.json()
 
+            current_page_size = len(response['entries'])
+            index_in_current_page = 0
             for item in response['entries']:
                 instance_factory = factory
                 if not instance_factory:
                     instance_factory = Translator().translate(item['type'])
-                yield instance_factory(self._session, item['id'], item)
+                instance = instance_factory(self._session, item['id'], item)
+                yield instance, current_page_size, index_in_current_page
+                index_in_current_page += 1
 
             current_index += limit
             if current_index >= response['total_count']:
diff --git a/boxsdk/object/group.py b/boxsdk/object/group.py
index 1e2cfb2..9d8a8b7 100644
--- a/boxsdk/object/group.py
+++ b/boxsdk/object/group.py
@@ -14,21 +14,17 @@ class Group(BaseObject):
 
     _item_type = 'group'
 
-    def membership(self, starting_index=0, limit=100):
+    def membership(self, starting_index=0, limit=100, include_page_info=False):
         """
         A generator over all the members of this Group. The paging in the API is transparently implemented
         inside the generator. By adjusting the page_size, the caller can control the chattiness of the API. Caller
-        can also implement their owning paging and/or control exactly when an API is called:
+        can also implement their owning paging and/or control exactly when an API is called by
+        using the 'include_page_info' param as follows:
 
-            def get_slice(group, start, limit):
-                return list(itertools.islice(group.membership(..., start, limit, ...), limit))
-
-            first_ten = get_slice(some_group, 0, 10)
-            second_ten = get_slice(some_group, 10, 10)
-            third_ten = get_slice(some_group, 20, 10)
-
-        caveat - any hidden items (see the Box Developer API for more details) will render the above
-        inaccurate. Hidden results will lead the above get_slice() code to trigger API calls at non-expected places.
+            for group, page_size, index in group.membership(..., include_page_info=True):
+                # when index + 1 == page_size, the next iteration of this loop will
+                # trigger an API call, unless we've reached the end of *all* the data.
+                pass
 
         :param starting_index:
             The index at which to begin.
@@ -39,15 +35,24 @@ def get_slice(group, start, limit):
         :type limit:
             `int`
         :returns:
-            A generator of GroupMembership instances.
+            A generator of GroupMembership instances. Or, if include_page_info
+            is True, it is a generator of 3-tuples, where each tuple is
+                1) GroupMembership instance
+                2) Number of GroupMemberships returned by the last paged API call
+                3) Index of *this* GroupMembership instance in the current page.
         :rtype:
-            `generator` of :class:`GroupMembership`
+            `generator` of :class:`GroupMembership` or, if include_page_info
+            is True then `tuple` of (:class:`GroupMembership`, `int`, `int`)
         """
         url = self.get_url('memberships')
 
         membership_factory = partial(GroupMembership, group=self)
-        for group_membership in self._paging_wrapper(url, starting_index, limit, membership_factory):
-            yield group_membership
+        for group_membership_tuple in self._paging_wrapper(url, starting_index, limit, membership_factory):
+            if include_page_info:
+                yield group_membership_tuple
+            else:
+                group_membership, _, _ = group_membership_tuple
+                yield group_membership
 
     def add_member(self, user, role):
         """


Test patch:
diff --git a/test/unit/object/test_group.py b/test/unit/object/test_group.py
index 09ed1e2..ae8d841 100644
--- a/test/unit/object/test_group.py
+++ b/test/unit/object/test_group.py
@@ -154,3 +154,42 @@ def test_membership_with_hidden_results(test_group, mock_box_session, mock_membe
     # Assert we got the expected number of membership instances
     assert len(all_members) == total - total_hidden
     assert all(isinstance(m, GroupMembership) for m in all_members)
+
+
+def test_membership_with_page_info(test_group, mock_box_session, mock_membership_responses):
+    """
+    Verify that the paging info returned by the membership call when include_page_info=True
+    is correct, thus allowing a client complete knowledge of when another API call
+    is going to be triggered.
+    """
+    # pylint:disable=redefined-outer-name
+    total = 9
+    page_size = 3
+    hidden_in_batch = 0, 2, 1
+
+    # Each call to 'get' (the GET next page call) will return the next response
+    mock_box_session.get.side_effect = mock_membership_responses(total, page_size, hidden_in_batch=hidden_in_batch)
+
+    # Initialize the generator of all the membership
+    group_generator = test_group.membership(0, page_size, include_page_info=True)
+
+    # manually get all the items, verifying that the page-info data is correct.
+    _, page_size, index = next(group_generator)
+    assert page_size == 3 and index == 0
+    _, page_size, index = next(group_generator)
+    assert page_size == 3 and index == 1
+    _, page_size, index = next(group_generator)
+    assert page_size == 3 and index == 2
+
+    # This next call will trigger a new GET request, returning the 2nd page
+    _, page_size, index = next(group_generator)
+    assert page_size == 1 and index == 0
+
+    # This next call will trigger a new GET request, returning the 3rd page
+    _, page_size, index = next(group_generator)
+    assert page_size == 2 and index == 0
+    _, page_size, index = next(group_generator)
+    assert page_size == 2 and index == 1
+
+    with pytest.raises(StopIteration):
+        next(group_generator)