Repository: box/box-python-sdk
Pull request #41 (2015-04-07 01:17:15)

Add Box Accelerator Support
Supports both new uploads and updating existing files on Box. I also
added a new example in demo/example.py.


Patch:
diff --git a/.pylintrc b/.pylintrc
index f75a2ae..558f6ea 100644
--- a/.pylintrc
+++ b/.pylintrc
@@ -43,7 +43,7 @@ profile=no
 # W0108 => unnecessary lambda
 # W0142 => Used * or ** magic
 # R0401 => cyclic-import
-disable=I, C0111, W0108, W0142, R0921, R0922, W0232, R0401
+disable=I, C0111, W0108, W0142, R0921, R0922, W0232, R0401, R0801
 
 
 [REPORTS]
diff --git a/boxsdk/object/file.py b/boxsdk/object/file.py
index 9a04867..96f6079 100644
--- a/boxsdk/object/file.py
+++ b/boxsdk/object/file.py
@@ -33,6 +33,17 @@ def preflight_check(self, size, name=None):
             file_id=self._object_id,
         )
 
+    def _get_accelerator_upload_url_for_update(self):
+        """
+        Get Accelerator upload url for updating the file.
+
+        :return:
+            The Accelerator upload url for updating the file or None if cannot get one
+        :rtype:
+            `unicode` or None
+        """
+        return self._get_accelerator_upload_url(file_id=self._object_id)
+
     def content(self):
         """
         Get the content of a file on Box.
@@ -60,7 +71,14 @@ def download_to(self, writeable_stream):
         for chunk in box_response.network_response.response_as_stream.stream(decode_content=True):
             writeable_stream.write(chunk)
 
-    def update_contents_with_stream(self, file_stream, etag=None, preflight_check=False, preflight_expected_size=0):
+    def update_contents_with_stream(
+            self,
+            file_stream,
+            etag=None,
+            preflight_check=False,
+            preflight_expected_size=0,
+            upload_using_accelerator=False,
+    ):
         """
         Upload a new version of a file, taking the contents from the given file stream.
 
@@ -81,6 +99,15 @@ def update_contents_with_stream(self, file_stream, etag=None, preflight_check=Fa
             which means the file size is unknown.
         :type preflight_expected_size:
             `int`
+        :param upload_using_accelerator:
+            If specified, the upload will try to use Box Accelerator to speed up the uploads for big files.
+            It will make an extra API call before the actual upload to get the Accelerator upload url, and then make
+            a POST request to that url instead of the default Box upload url. It falls back to normal upload endpoint,
+            if cannot get the Accelerator upload url.
+
+            Please notice that this is a premium feature, which might not be available to your app.
+        :type upload_using_accelerator:
+            `bool`
         :returns:
             A new file object
         :rtype:
@@ -93,6 +120,11 @@ def update_contents_with_stream(self, file_stream, etag=None, preflight_check=Fa
             self.preflight_check(size=preflight_expected_size)
 
         url = self.get_url('content').replace(API.BASE_API_URL, API.UPLOAD_URL)
+        if upload_using_accelerator:
+            accelerator_upload_url = self._get_accelerator_upload_url_for_update()
+            if accelerator_upload_url:
+                url = accelerator_upload_url
+
         files = {'file': ('unused', file_stream)}
         headers = {'If-Match': etag} if etag is not None else None
         return File(
@@ -101,7 +133,14 @@ def update_contents_with_stream(self, file_stream, etag=None, preflight_check=Fa
             response_object=self._session.post(url, expect_json_response=False, files=files, headers=headers).json(),
         )
 
-    def update_contents(self, file_path, etag=None, preflight_check=False, preflight_expected_size=0):
+    def update_contents(
+            self,
+            file_path,
+            etag=None,
+            preflight_check=False,
+            preflight_expected_size=0,
+            upload_using_accelerator=False,
+    ):
         """Upload a new version of a file. The contents are taken from the given file path.
 
         :param file_path:
@@ -121,6 +160,15 @@ def update_contents(self, file_path, etag=None, preflight_check=False, preflight
             which means the file size is unknown.
         :type preflight_expected_size:
             `int`
+        :param upload_using_accelerator:
+            If specified, the upload will try to use Box Accelerator to speed up the uploads for big files.
+            It will make an extra API call before the actual upload to get the Accelerator upload url, and then make
+            a POST request to that url instead of the default Box upload url. It falls back to normal upload endpoint,
+            if cannot get the Accelerator upload url.
+
+            Please notice that this is a premium feature, which might not be available to your app.
+        :type upload_using_accelerator:
+            `bool`
         :returns:
             A new file object
         :rtype:
@@ -135,6 +183,7 @@ def update_contents(self, file_path, etag=None, preflight_check=False, preflight
                 etag,
                 preflight_check,
                 preflight_expected_size=preflight_expected_size,
+                upload_using_accelerator=upload_using_accelerator,
             )
 
     def lock(self, prevent_download=False):
diff --git a/boxsdk/object/folder.py b/boxsdk/object/folder.py
index 01e8d37..3aaf1c2 100644
--- a/boxsdk/object/folder.py
+++ b/boxsdk/object/folder.py
@@ -112,6 +112,17 @@ def preflight_check(self, size, name):
             parent_id=self._object_id,
         )
 
+    def _get_accelerator_upload_url_fow_new_uploads(self):
+        """
+        Get Accelerator upload url for uploading new files.
+
+        :return:
+            The Accelerator upload url or None if cannot get one
+        :rtype:
+            `unicode` or None
+        """
+        return self._get_accelerator_upload_url()
+
     def get_items(self, limit, offset=0, fields=None):
         """Get the items in a folder.
 
@@ -143,7 +154,14 @@ def get_items(self, limit, offset=0, fields=None):
         response = box_response.json()
         return [Translator().translate(item['type'])(self._session, item['id'], item) for item in response['entries']]
 
-    def upload_stream(self, file_stream, file_name, preflight_check=False, preflight_expected_size=0):
+    def upload_stream(
+            self,
+            file_stream,
+            file_name,
+            preflight_check=False,
+            preflight_expected_size=0,
+            upload_using_accelerator=False,
+    ):
         """
         Upload a file to the folder.
         The contents are taken from the given file stream, and it will have the given name.
@@ -165,6 +183,15 @@ def upload_stream(self, file_stream, file_name, preflight_check=False, preflight
             which means the file size is unknown.
         :type preflight_expected_size:
             `int`
+        :param upload_using_accelerator:
+            If specified, the upload will try to use Box Accelerator to speed up the uploads for big files.
+            It will make an extra API call before the actual upload to get the Accelerator upload url, and then make
+            a POST request to that url instead of the default Box upload url. It falls back to normal upload endpoint,
+            if cannot get the Accelerator upload url.
+
+            Please notice that this is a premium feature, which might not be available to your app.
+        :type upload_using_accelerator:
+            `bool`
         :returns:
             The newly uploaded file.
         :rtype:
@@ -174,6 +201,11 @@ def upload_stream(self, file_stream, file_name, preflight_check=False, preflight
             self.preflight_check(size=preflight_expected_size, name=file_name)
 
         url = '{0}/files/content'.format(API.UPLOAD_URL)
+        if upload_using_accelerator:
+            accelerator_upload_url = self._get_accelerator_upload_url_fow_new_uploads()
+            if accelerator_upload_url:
+                url = accelerator_upload_url
+
         data = {'attributes': json.dumps({
             'name': file_name,
             'parent': {'id': self._object_id},
@@ -190,7 +222,14 @@ def upload_stream(self, file_stream, file_name, preflight_check=False, preflight
             response_object=file_response,
         )
 
-    def upload(self, file_path=None, file_name=None, preflight_check=False, preflight_expected_size=0):
+    def upload(
+            self,
+            file_path=None,
+            file_name=None,
+            preflight_check=False,
+            preflight_expected_size=0,
+            upload_using_accelerator=False,
+    ):
         """
         Upload a file to the folder.
         The contents are taken from the given file path, and it will have the given name.
@@ -213,6 +252,15 @@ def upload(self, file_path=None, file_name=None, preflight_check=False, prefligh
             which means the file size is unknown.
         :type preflight_expected_size:
             `int`
+        :param upload_using_accelerator:
+            If specified, the upload will try to use Box Accelerator to speed up the uploads for big files.
+            It will make an extra API call before the actual upload to get the Accelerator upload url, and then make
+            a POST request to that url instead of the default Box upload url. It falls back to normal upload endpoint,
+            if cannot get the Accelerator upload url.
+
+            Please notice that this is a premium feature, which might not be available to your app.
+        :type upload_using_accelerator:
+            `bool`
         :returns:
             The newly uploaded file.
         :rtype:
@@ -226,6 +274,7 @@ def upload(self, file_path=None, file_name=None, preflight_check=False, prefligh
                 file_name,
                 preflight_check,
                 preflight_expected_size=preflight_expected_size,
+                upload_using_accelerator=upload_using_accelerator,
             )
 
     def create_subfolder(self, name):
diff --git a/boxsdk/object/item.py b/boxsdk/object/item.py
index 8e5e483..e507e86 100644
--- a/boxsdk/object/item.py
+++ b/boxsdk/object/item.py
@@ -6,11 +6,36 @@
 
 from .base_object import BaseObject
 from boxsdk.config import API
+from boxsdk.exception import BoxAPIException
 
 
 class Item(BaseObject):
     """Box API endpoint for interacting with files and folders."""
 
+    def _get_accelerator_upload_url(self, file_id=None):
+        """
+        Make an API call to get the Accelerator upload url for either upload a new file or updating an existing file.
+
+        :param file_id:
+            Box id of the file to be uploaded. Not required for new file uploads.
+        :type file_id:
+            `unicode` or None
+        :return:
+            The Accelerator upload url or None if cannot get the Accelerator upload url.
+        :rtype:
+            `unicode` or None
+        """
+        endpoint = '{0}/content'.format(file_id) if file_id else 'content'
+        url = '{0}/files/{1}'.format(API.BASE_API_URL, endpoint)
+        try:
+            response_json = self._session.options(
+                url=url,
+                expect_json_response=True,
+            ).json()
+            return response_json.get('upload_url', None)
+        except BoxAPIException:
+            return None
+
     def _preflight_check(self, size, name=None, file_id=None, parent_id=None):
         """
         Make an API call to check if certain file can be uploaded to Box or not.
diff --git a/demo/example.py b/demo/example.py
index 0f4eca7..6300131 100644
--- a/demo/example.py
+++ b/demo/example.py
@@ -76,6 +76,19 @@ def upload_file(client):
         print('Delete i-am-a-file.txt succeeded: {0}'.format(a_file.delete()))
 
 
+def upload_accelerator(client):
+    root_folder = client.folder(folder_id='0')
+    file_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'file.txt')
+    a_file = root_folder.upload(file_path, file_name='i-am-a-file.txt', upload_using_accelerator=True)
+    try:
+        print('{0} uploaded via Accelerator: '.format(a_file.get()['name']))
+        file_v2_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'file_v2.txt')
+        a_file = a_file.update_contents(file_v2_path, upload_using_accelerator=True)
+        print('{0} updated via Accelerator: '.format(a_file.get()['name']))
+    finally:
+        print('Delete i-am-a-file.txt succeeded: {0}'.format(a_file.delete()))
+
+
 def rename_file(client):
     root_folder = client.folder(folder_id='0')
     file_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'file.txt')
@@ -265,13 +278,18 @@ def run_examples(oauth):
     get_events(client)
     get_latest_stream_position(client)
     # long_poll(client)
+
     # Enterprise accounts only
     run_groups_example(client)
     run_metadata_example(client)
 
+    # Premium Apps only
+    upload_accelerator(client)
+
 
 def main():
 
+    # Please notice that you need to put in your client id and client secret in demo/auth.py in order to make this work.
     oauth = authenticate()
     run_examples(oauth)
     os._exit(0)


Test patch:
diff --git a/test/unit/object/conftest.py b/test/unit/object/conftest.py
index 8aa4b45..0d625af 100644
--- a/test/unit/object/conftest.py
+++ b/test/unit/object/conftest.py
@@ -13,6 +13,9 @@
 from boxsdk.object.search import Search
 
 
+# pylint:disable=redefined-outer-name
+
+
 @pytest.fixture(scope='module')
 def mock_group_membership_id():
     return 'fake-group-membership-5'
@@ -30,7 +33,6 @@ def mock_file_path():
 
 @pytest.fixture(scope='function')
 def mock_content_response(make_mock_box_request):
-    # pylint:disable=redefined-outer-name
     mock_box_response, mock_network_response = make_mock_box_request(content=b'Contents of a text file.')
     mock_network_response.response_as_stream = raw = Mock()
     raw.stream.return_value = (b if PY2 else int2byte(b) for b in mock_box_response.content)
@@ -39,7 +41,6 @@ def mock_content_response(make_mock_box_request):
 
 @pytest.fixture(scope='function')
 def mock_upload_response(mock_object_id, make_mock_box_request):
-    # pylint:disable=redefined-outer-name
     mock_box_response, _ = make_mock_box_request(
         response={'entries': [{'type': 'file', 'id': mock_object_id}]},
     )
@@ -48,37 +49,31 @@ def mock_upload_response(mock_object_id, make_mock_box_request):
 
 @pytest.fixture()
 def test_collaboration(mock_box_session, mock_collaboration_id):
-    # pylint:disable=redefined-outer-name
     return Collaboration(mock_box_session, mock_collaboration_id)
 
 
 @pytest.fixture()
 def test_file(mock_box_session, mock_object_id):
-    # pylint:disable=redefined-outer-name
     return File(mock_box_session, mock_object_id)
 
 
 @pytest.fixture()
 def test_folder(mock_box_session, mock_object_id):
-    # pylint:disable=redefined-outer-name
     return Folder(mock_box_session, mock_object_id)
 
 
 @pytest.fixture()
 def test_group(mock_box_session, mock_group_id):
-    # pylint:disable=redefined-outer-name
     return Group(mock_box_session, mock_group_id)
 
 
 @pytest.fixture()
 def test_search(mock_box_session):
-    # pylint:disable=redefined-outer-name
     return Search(mock_box_session)
 
 
 @pytest.fixture(scope='function')
 def mock_collab_response(make_mock_box_request, mock_collaboration_id):
-    # pylint:disable=redefined-outer-name
     mock_box_response, _ = make_mock_box_request(
         response={'type': 'collaboration', 'id': mock_collaboration_id},
     )
@@ -87,14 +82,12 @@ def mock_collab_response(make_mock_box_request, mock_collaboration_id):
 
 @pytest.fixture()
 def mock_user(mock_box_session, mock_user_id):
-    # pylint:disable=redefined-outer-name
     user = User(mock_box_session, mock_user_id)
     return user
 
 
 @pytest.fixture(scope='function')
 def mock_precondition_failed_response(mock_object_id, make_mock_box_request):
-    # pylint:disable=redefined-outer-name
     mock_box_response, _ = make_mock_box_request(
         status_code=412,
         response_ok=False,
@@ -105,7 +98,6 @@ def mock_precondition_failed_response(mock_object_id, make_mock_box_request):
 
 @pytest.fixture()
 def mock_group_membership_dict(mock_group_membership_id, mock_user_id, mock_group_id):
-    # pylint:disable=redefined-outer-name
     data = {
         'type': 'group_membership',
         'id': mock_group_membership_id,
@@ -118,7 +110,6 @@ def mock_group_membership_dict(mock_group_membership_id, mock_user_id, mock_grou
 
 @pytest.fixture()
 def mock_add_member_response(mock_group_membership_dict, make_mock_box_request):
-    # pylint:disable=redefined-outer-name
     mock_box_response, _ = make_mock_box_request(
         status_code=200,
         response_ok=True,
@@ -129,13 +120,11 @@ def mock_add_member_response(mock_group_membership_dict, make_mock_box_request):
 
 @pytest.fixture()
 def if_match_header(etag):
-    # pylint:disable=redefined-outer-name
     return {'If-Match': etag} if etag is not None else None
 
 
 @pytest.fixture()
 def if_none_match_header(etag):
-    # pylint:disable=redefined-outer-name
     return {'If-None-Match': etag} if etag is not None else None
 
 
@@ -149,12 +138,21 @@ def preflight_check(request):
     return request.param
 
 
+@pytest.fixture(params=[True, False])
+def upload_using_accelerator(request):
+    return request.param
+
+
 @pytest.fixture(params=[True, False])
 def preflight_fails(preflight_check, request):
-    # pylint:disable=redefined-outer-name
     return preflight_check and request.param
 
 
+@pytest.fixture(params=[True, False])
+def upload_using_accelerator_fails(upload_using_accelerator, request):
+    return upload_using_accelerator and request.param
+
+
 @pytest.fixture(params=[0, 100])
 def file_size(request):
     return request.param
@@ -162,6 +160,5 @@ def file_size(request):
 
 @pytest.fixture()
 def mock_group(mock_box_session, mock_group_id):
-    # pylint:disable=redefined-outer-name
     group = Group(mock_box_session, mock_group_id)
     return group
diff --git a/test/unit/object/test_file.py b/test/unit/object/test_file.py
index c991ef3..ecbd4b2 100644
--- a/test/unit/object/test_file.py
+++ b/test/unit/object/test_file.py
@@ -11,6 +11,22 @@
 
 
 # pylint:disable=protected-access
+# pylint:disable=redefined-outer-name
+
+@pytest.fixture()
+def mock_accelerator_upload_url_for_update():
+    return 'https://upload.box.com/api/2.0/files/fake_file_id/content?upload_session_id=123'
+
+
+@pytest.fixture(scope='function')
+def mock_accelerator_response_for_update(make_mock_box_request, mock_accelerator_upload_url_for_update):
+    mock_response, _ = make_mock_box_request(
+        response={
+            'upload_url': mock_accelerator_upload_url_for_update,
+            'download_url': None,
+        }
+    )
+    return mock_response
 
 
 def test_delete_file(test_file, mock_box_session, etag, if_match_header):
@@ -43,27 +59,46 @@ def test_get_content(test_file, mock_box_session, mock_content_response):
 
 
 @pytest.mark.parametrize('is_stream', (True, False))
-def test_update_content(
+def test_update_contents(
         test_file,
         mock_box_session,
         mock_content_response,
         mock_upload_response,
         mock_file_path,
         etag,
+        upload_using_accelerator,
+        mock_accelerator_response_for_update,
+        mock_accelerator_upload_url_for_update,
+        upload_using_accelerator_fails,
         if_match_header,
         is_stream,
 ):
     expected_url = test_file.get_url('content').replace(API.BASE_API_URL, API.UPLOAD_URL)
+    if upload_using_accelerator:
+        if upload_using_accelerator_fails:
+            mock_box_session.options.side_effect = BoxAPIException(400)
+        else:
+            mock_box_session.options.return_value = mock_accelerator_response_for_update
+            expected_url = mock_accelerator_upload_url_for_update
+
     mock_box_session.post.return_value = mock_upload_response
 
     if is_stream:
         mock_file_stream = BytesIO(mock_content_response.content)
-        new_file = test_file.update_contents_with_stream(mock_file_stream, etag=etag)
+        new_file = test_file.update_contents_with_stream(
+            mock_file_stream,
+            etag=etag,
+            upload_using_accelerator=upload_using_accelerator,
+        )
     else:
         mock_file = mock_open(read_data=mock_content_response.content)
         mock_file_stream = mock_file.return_value
         with patch('boxsdk.object.file.open', mock_file, create=True):
-            new_file = test_file.update_contents(mock_file_path, etag=etag)
+            new_file = test_file.update_contents(
+                mock_file_path,
+                etag=etag,
+                upload_using_accelerator=upload_using_accelerator,
+            )
 
     mock_files = {'file': ('unused', mock_file_stream)}
     mock_box_session.post.assert_called_once_with(
diff --git a/test/unit/object/test_folder.py b/test/unit/object/test_folder.py
index a7f72ca..3b49d09 100644
--- a/test/unit/object/test_folder.py
+++ b/test/unit/object/test_folder.py
@@ -17,6 +17,23 @@
 
 
 # pylint:disable=protected-access
+# pylint:disable=redefined-outer-name
+
+
+@pytest.fixture()
+def mock_new_upload_accelerator_url():
+    return 'https://upload.box.com/api/2.0/files/content?upload_session_id=123'
+
+
+@pytest.fixture(scope='function')
+def mock_accelerator_response_for_new_uploads(make_mock_box_request, mock_new_upload_accelerator_url):
+    mock_response, _ = make_mock_box_request(
+        response={
+            'upload_url': mock_new_upload_accelerator_url,
+            'upload_token': None,
+        }
+    )
+    return mock_response
 
 
 @pytest.fixture()
@@ -130,18 +147,38 @@ def test_upload(
         mock_upload_response,
         mock_file_path,
         mock_object_id,
+        upload_using_accelerator,
+        mock_accelerator_response_for_new_uploads,
+        mock_new_upload_accelerator_url,
+        upload_using_accelerator_fails,
         is_stream,
 ):
     expected_url = '{0}/files/content'.format(API.UPLOAD_URL)
+    if upload_using_accelerator:
+        if upload_using_accelerator_fails:
+            mock_box_session.options.side_effect = BoxAPIException(400)
+        else:
+            mock_box_session.options.return_value = mock_accelerator_response_for_new_uploads
+            expected_url = mock_new_upload_accelerator_url
+
     mock_box_session.post.return_value = mock_upload_response
+
     if is_stream:
         mock_file_stream = BytesIO(mock_content_response.content)
-        new_file = test_folder.upload_stream(mock_file_stream, basename(mock_file_path))
+        new_file = test_folder.upload_stream(
+            mock_file_stream,
+            basename(mock_file_path),
+            upload_using_accelerator=upload_using_accelerator,
+        )
     else:
         mock_file = mock_open(read_data=mock_content_response.content)
         mock_file_stream = mock_file.return_value
         with patch('boxsdk.object.folder.open', mock_file, create=True):
-            new_file = test_folder.upload(mock_file_path)
+            new_file = test_folder.upload(
+                mock_file_path,
+                upload_using_accelerator=upload_using_accelerator,
+            )
+
     mock_files = {'file': ('unused', mock_file_stream)}
     data = {'attributes': json.dumps({'name': basename(mock_file_path), 'parent': {'id': mock_object_id}})}
     mock_box_session.post.assert_called_once_with(expected_url, expect_json_response=False, files=mock_files, data=data)