Repository: box/box-python-sdk
Pull request #347 (2018-10-09 01:16:36)

Add full API parity for search

boxcla: Verified that @mattwiller has signed the CLA. Thanks for the pull request!
coveralls: ## Pull Request Test Coverage Report for [Build 1106](https://coveralls.io/builds/19416083)

* **26** of **26**   **(100.0%)**  changed or added relevant lines in **2** files are covered.
* No unchanged relevant lines lost coverage.
* Overall coverage increased (+**0.04%**) to **94.573%**

---



|  Totals | [![Coverage Status](https://coveralls.io/builds/19416083/badge)](https://coveralls.io/builds/19416083) |
| :-- | --: |
| Change from base [Build 1095](https://coveralls.io/builds/19377977): |  0.04% |
| Covered Lines: | 2091 |
| Relevant Lines: | 2211 |

---
##### 💛  - [Coveralls](https://coveralls.io)


Patch:
diff --git a/HISTORY.rst b/HISTORY.rst
index ddb5b8f..d279437 100644
--- a/HISTORY.rst
+++ b/HISTORY.rst
@@ -10,6 +10,8 @@ Release History
 
 - Python 2.6 is no longer supported.
 - Python 3.3 is no longer supported.
+- ``client.search()`` now returns a ``Search`` object that exposes a ``query()`` method to call the Search API.
+  Use ``client.search().query(**search_params)`` instead of ``client.search(**search_params)``.
 - ``client.get_memberships(...)`` has a change in signature. The limit and offset parameters have swapped positions to keep
   consistency with the rest of the SDK.
 - ``client.groups(...)`` has been changed to ``client.get_groups``. The limit and offset parameters have swapped positions.
@@ -51,7 +53,7 @@ Release History
 
   + ``client.users()``
   + ``client.groups()``
-  + ``client.search()``
+  + ``client.search().query()``
   + ``folder.get_items()``
 
   Since ``folder.get_items()`` now returns an iterator, ``folder.get_items_limit_offset()`` and
diff --git a/README.rst b/README.rst
index de48524..abc1789 100644
--- a/README.rst
+++ b/README.rst
@@ -183,7 +183,7 @@ Search
 
 .. code-block:: python
 
-    client.search('some_query', limit=100, offset=0)
+    client.search().query('some_query', limit=100, offset=0)
 
 Metadata Search
 ~~~~~~~~~~~~~~~
@@ -199,7 +199,7 @@ Metadata Search
     metadata_search_filters = MetadataSearchFilters()
     metadata_search_filters.add_filter(metadata_search_filter)
 
-    client.search('some_query', limit=100, offset=0, metadata_filters=metadata_search_filters)
+    client.search().query('some_query', limit=100, offset=0, metadata_filters=metadata_search_filters)
 
 Events
 ~~~~~~
diff --git a/boxsdk/client/client.py b/boxsdk/client/client.py
index 956f175..2253598 100644
--- a/boxsdk/client/client.py
+++ b/boxsdk/client/client.py
@@ -462,67 +462,16 @@ def users(self, limit=None, offset=0, filter_term=None, user_type=None, fields=N
         )
 
     @api_call
-    def search(
-            self,
-            query,
-            limit,
-            offset,
-            ancestor_folders=None,
-            file_extensions=None,
-            metadata_filters=None,
-            result_type=None,
-            content_types=None
-    ):
+    def search(self):
         """
-        Search Box for items matching the given query.
+        Get a Search object that can be used for searching Box content.
 
-        :param query:
-            The string to search for.
-        :type query:
-            `unicode`
-        :param limit:
-            The maximum number of items to return.
-        :type limit:
-            `int`
-        :param offset:
-            The search result at which to start the response.
-        :type offset:
-            `int`
-        :param ancestor_folders:
-            Folder ids to limit the search to.
-        :type ancestor_folders:
-            `iterable` of :class:`Folder`
-        :param file_extensions:
-            File extensions to limit the search to.
-        :type file_extensions:
-            `iterable` of `unicode`
-        :param metadata_filters:
-            Filters used for metadata search
-        :type metadata_filters:
-            :class:`MetadataSearchFilters`
-        :param result_type:
-            Which type of result you want. Can be file or folder.
-        :type result_type:
-            `unicode`
-        :param content_types:
-            Which content types to search. Valid types include name, description, file_content, comments, and tags.
-        :type content_types:
-            `Iterable` of `unicode`
         :return:
-            A list of items that match the search query.
+            The Search object
         :rtype:
-            `list` of :class:`Item`
+            :class:`Search`
         """
-        return Search(self._session).search(
-            query=query,
-            limit=limit,
-            offset=offset,
-            ancestor_folders=ancestor_folders,
-            file_extensions=file_extensions,
-            metadata_filters=metadata_filters,
-            result_type=result_type,
-            content_types=content_types,
-        )
+        return Search(self._session)
 
     def events(self):
         """
diff --git a/boxsdk/object/search.py b/boxsdk/object/search.py
index 5da561a..0db0f83 100644
--- a/boxsdk/object/search.py
+++ b/boxsdk/object/search.py
@@ -7,6 +7,19 @@
 from .base_endpoint import BaseEndpoint
 from ..pagination.limit_offset_based_object_collection import LimitOffsetBasedObjectCollection
 from ..util.api_call_decorator import api_call
+from ..util.text_enum import TextEnum
+
+
+class SearchScope(TextEnum):
+    """Enum of possible serach scopes."""
+    USER = 'user_content'
+    ENTERPRISE = 'enterprise_content'
+
+
+class TrashContent(TextEnum):
+    """Enum of trash content values."""
+    NONE = 'non_trashed_only'
+    ONLY = 'trashed_only'
 
 
 class MetadataSearchFilter(object):
@@ -159,7 +172,8 @@ def make_single_metadata_filter(template_key, scope):
         return MetadataSearchFilter(template_key, scope)
 
     @api_call
-    def search(
+    # pylint: disable=too-many-arguments,too-many-locals
+    def query(
             self,
             query,
             limit=None,
@@ -169,6 +183,12 @@ def search(
             metadata_filters=None,
             result_type=None,
             content_types=None,
+            scope=None,
+            created_at_range=None,
+            updated_at_range=None,
+            size_range=None,
+            owner_users=None,
+            trash_content=None,
             fields=None,
             **kwargs
     ):
@@ -207,6 +227,30 @@ def search(
             Which content types to search. Valid types include name, description, file_content, comments, and tags.
         :type content_types:
             `Iterable` of `unicode`
+        :param scope:
+            The scope of content to search over
+        :type scope:
+            `unicode` or None
+        :param created_at_range:
+            A tuple of the form (lower_bound, upper_bound) for the creation datetime of items to search.
+        :type created_at_range:
+            (`unicode` or None, `unicode` or None)
+        :param updated_at_range:
+            A tuple of the form (lower_bound, upper_bound) for the update datetime of items to search.
+        :type updated_at_range:
+            (`unicode` or None, `unicode` or None)
+        :param size_range:
+            A tuple of the form (lower_bound, upper_bound) for the size in bytes of items to search.
+        :type size_range:
+            (`int` or None, `int` or None)
+        :param owner_users:
+            Owner users to filter content by; only content belonging to these users will be returned.
+        :type owner_users:
+            `iterable` of :class:`User`
+        :param trash_content:
+            Whether to search trashed or non-trashed content.
+        :type trash_content:
+            `unicode` or None
         :param fields:
             Fields to include on the returned items.
         :type fields:
@@ -218,16 +262,29 @@ def search(
         """
         url = self.get_url()
         additional_params = {'query': query}
-        if ancestor_folders:
+        if ancestor_folders is not None:
             additional_params['ancestor_folder_ids'] = ','.join([folder.object_id for folder in ancestor_folders])
-        if file_extensions:
+        if file_extensions is not None:
             additional_params['file_extensions'] = ','.join(file_extensions)
-        if metadata_filters:
+        if metadata_filters is not None:
             additional_params['mdfilters'] = json.dumps(metadata_filters.as_list())
-        if content_types:
+        if content_types is not None:
             additional_params['content_types'] = ','.join(content_types)
-        if result_type:
+        if result_type is not None:
             additional_params['type'] = result_type
+        if scope is not None:
+            additional_params['scope'] = scope
+        if created_at_range is not None:
+            additional_params['created_at_range'] = '{},{}'.format(created_at_range[0] or '', created_at_range[1] or '')
+        if updated_at_range is not None:
+            additional_params['updated_at_range'] = '{},{}'.format(updated_at_range[0] or '', updated_at_range[1] or '')
+        if size_range is not None:
+            additional_params['size_range'] = '{},{}'.format(size_range[0] or '', size_range[1] or '')
+        if owner_users is not None:
+            additional_params['owner_user_ids'] = ','.join([user.object_id for user in owner_users])
+        if trash_content is not None:
+            additional_params['trash_content'] = trash_content
+
         additional_params.update(kwargs)
 
         return LimitOffsetBasedObjectCollection(
diff --git a/demo/example.py b/demo/example.py
index 9471ee6..95318ef 100644
--- a/demo/example.py
+++ b/demo/example.py
@@ -115,7 +115,7 @@ def update_file(client):
 
 
 def search_files(client):
-    search_results = client.search(
+    search_results = client.search().query(
         'i-am-a-file.txt',
         limit=2,
         offset=0,
diff --git a/demo/music_player.py b/demo/music_player.py
index ee35619..1ba9379 100644
--- a/demo/music_player.py
+++ b/demo/music_player.py
@@ -24,7 +24,7 @@ def _authenticate(self):
 
     def _get_all_mp3_files(self, client):
         # music_folder = client.folder(folder_id='0').get_subfolder('music')
-        return client.search(query='*.mp3', limit=100, offset=0, file_extensions=['mp3'])
+        return client.search().query(query='*.mp3', limit=100, offset=0, file_extensions=['mp3'])
 
     def play(self):
         for item in self._mp3_files:


Test patch:
diff --git a/test/unit/client/test_client.py b/test/unit/client/test_client.py
index fa63f57..217512e 100644
--- a/test/unit/client/test_client.py
+++ b/test/unit/client/test_client.py
@@ -436,7 +436,7 @@ def test_search_instantiates_search_and_calls_search(
     # pylint:disable=redefined-outer-name
     mock_box_session.get.return_value = search_response
     search_term = 'lolcatz'
-    search_result = mock_client.search(
+    search_result = mock_client.search().query(
         search_term,
         10,
         0,
diff --git a/test/unit/object/test_search.py b/test/unit/object/test_search.py
index d823db4..e461bfd 100644
--- a/test/unit/object/test_search.py
+++ b/test/unit/object/test_search.py
@@ -7,7 +7,8 @@
 import pytest
 
 from boxsdk.object.file import File
-from boxsdk.object.search import MetadataSearchFilters, MetadataSearchFilter
+from boxsdk.object.user import User
+from boxsdk.object.search import MetadataSearchFilters, MetadataSearchFilter, SearchScope, TrashContent
 
 
 @pytest.fixture
@@ -103,7 +104,7 @@ def compare_params(self, other):
     return True
 
 
-def test_search_with_value_based_filters(
+def test_query_with_value_based_filters(
         mock_box_session,
         make_mock_box_request,
         test_search,
@@ -118,7 +119,7 @@ def test_search_with_value_based_filters(
 ):
     # pylint:disable=redefined-outer-name
     mock_box_session.get.return_value, _ = make_mock_box_request(response=search_response)
-    response = test_search.search(
+    response = test_search.query(
         search_query,
         limit=search_limit,
         offset=search_offset,
@@ -142,7 +143,7 @@ def test_search_with_value_based_filters(
     )
 
 
-def test_search_with_range_filters(
+def test_query_with_range_filters(
         mock_box_session,
         make_mock_box_request,
         test_search,
@@ -157,7 +158,7 @@ def test_search_with_range_filters(
 ):
     # pylint:disable=redefined-outer-name
     mock_box_session.get.return_value, _ = make_mock_box_request(response=search_response)
-    response = test_search.search(
+    response = test_search.query(
         search_query,
         limit=search_limit,
         offset=search_offset,
@@ -181,6 +182,79 @@ def test_search_with_range_filters(
     )
 
 
+@pytest.mark.parametrize('kwargs, params', [
+    ({'scope': SearchScope.ENTERPRISE}, {'scope': 'enterprise_content'}),
+    ({'scope': SearchScope.USER}, {'scope': 'user_content'}),
+    ({'created_at_range': (None, '2018-01-01T00:00:00Z')}, {'created_at_range': ',2018-01-01T00:00:00Z'}),
+    ({'created_at_range': ('2015-02-03T12:00:00-08:00', None)}, {'created_at_range': '2015-02-03T12:00:00-08:00,'}),
+    ({'created_at_range': ('2012-01-01T00:00:00Z', '2012-12-31T11:59:59Z')}, {'created_at_range': '2012-01-01T00:00:00Z,2012-12-31T11:59:59Z'}),
+    ({'updated_at_range': (None, '2018-01-01T00:00:00Z')}, {'updated_at_range': ',2018-01-01T00:00:00Z'}),
+    ({'updated_at_range': ('2015-02-03T12:00:00-08:00', None)}, {'updated_at_range': '2015-02-03T12:00:00-08:00,'}),
+    ({'updated_at_range': ('2012-01-01T00:00:00Z', '2012-12-31T11:59:59Z')}, {'updated_at_range': '2012-01-01T00:00:00Z,2012-12-31T11:59:59Z'}),
+    ({'size_range': (None, 123)}, {'size_range': ',123'}),
+    ({'size_range': (123, None)}, {'size_range': '123,'}),
+    ({'size_range': (123, 456)}, {'size_range': '123,456'}),
+    ({'trash_content': TrashContent.NONE}, {'trash_content': 'non_trashed_only'}),
+    ({'trash_content': TrashContent.ONLY}, {'trash_content': 'trashed_only'}),
+])
+def test_query_with_optional_parameters(
+        mock_box_session,
+        test_search,
+        make_mock_box_request,
+        search_query,
+        search_response,
+        search_entries,
+        kwargs,
+        params
+):
+    # pylint:disable=redefined-outer-name
+    mock_box_session.get.return_value, _ = make_mock_box_request(response=search_response)
+    response = test_search.query(
+        search_query,
+        **kwargs
+    )
+    for actual, expected in zip(response, [File(mock_box_session, item['id'], item) for item in search_entries]):
+        assert actual == expected
+
+    expected_params = {
+        'query': search_query,
+    }
+    expected_params.update(params)
+
+    mock_box_session.get.assert_called_once_with(
+        test_search.get_url(),
+        params=Matcher(compare_params, expected_params)
+    )
+
+
+def test_query_with_owner_users(
+        mock_box_session,
+        test_search,
+        make_mock_box_request,
+        search_query,
+        search_response,
+        search_entries,
+):
+    # pylint:disable=redefined-outer-name
+    user1 = User(mock_box_session, '987')
+    user2 = User(mock_box_session, '654')
+    mock_box_session.get.return_value, _ = make_mock_box_request(response=search_response)
+    response = test_search.query(
+        search_query,
+        owner_users=[user1, user2],
+    )
+    for actual, expected in zip(response, [File(mock_box_session, item['id'], item) for item in search_entries]):
+        assert actual == expected
+
+    mock_box_session.get.assert_called_once_with(
+        test_search.get_url(),
+        params=Matcher(compare_params, {
+            'query': search_query,
+            'owner_user_ids': '987,654'
+        })
+    )
+
+
 def test_range_filter_without_gt_and_lt_will_fail_validation():
     metadata_filter = MetadataSearchFilter(template_key='mytemplate', scope='enterprise')
     with pytest.raises(ValueError):