From b3b41d3caeb9dc7356691ecdcc455b68dd2cfcd1 Mon Sep 17 00:00:00 2001 From: Sujay Garlanka Date: Thu, 1 Oct 2020 18:13:29 -0400 Subject: [PATCH] Add zip functionality (#539) --- HISTORY.rst | 1 + boxsdk/client/client.py | 62 ++++++++++++++++++++++++++++ docs/usage/zip.md | 29 +++++++++++++ test/unit/client/test_client.py | 73 ++++++++++++++++++++++++++++++++- 4 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 docs/usage/zip.md diff --git a/HISTORY.rst b/HISTORY.rst index fd6733220..4232623fe 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -9,6 +9,7 @@ Next Release - Fix bug with updating a collaboration role to owner - Allow creating tasks with the `action` and `completion_rule` parameters. - Add support for `copyInstanceOnItemCopy` field for metadata templates +- Add zip functionality 2.9.0 (2020-06-23) ++++++++ diff --git a/boxsdk/client/client.py b/boxsdk/client/client.py index 577dcbf40..5318463e9 100644 --- a/boxsdk/client/client.py +++ b/boxsdk/client/client.py @@ -1671,3 +1671,65 @@ def create_metadata_template(self, display_name, fields, template_key=None, hidd session=self._session, response_object=response, ) + + @api_call + def __create_zip(self, name, items): + """ + Creates a zip file containing multiple files and/or folders for later download. + + :param name: + The name of the zip file to be created. + :type name: + `unicode` + :param items: + List of files and/or folders to be contained in the zip file. + :type items: + `Iterable` + :returns: + A dictionary representing a created zip + :rtype: + :class:`dict` + """ + # pylint: disable=protected-access + url = self._session.get_url('zip_downloads') + zip_file_items = [] + for item in items: + zip_file_items.append({'type': item._item_type, 'id': item.object_id}) + data = { + 'download_file_name': name, + 'items': zip_file_items + } + return self._session.post(url, data=json.dumps(data)).json() + + @api_call + def download_zip(self, name, items, writeable_stream): + """ + Downloads a zip file containing multiple files and/or folders. + + :param name: + The name of the zip file to be created. + :type name: + `unicode` + :param items: + List of files or folders to be part of the created zip. + :type items: + `Iterable` + :param writeable_stream: + Stream to pipe the readable stream of the zip file. + :type writeable_stream: + `zip` + :returns: + A status response object + :rtype: + :class:`dict` + """ + created_zip = self.__create_zip(name, items) + response = self._session.get(created_zip['download_url'], expect_json_response=False, stream=True) + for chunk in response.network_response.response_as_stream.stream(decode_content=True): + writeable_stream.write(chunk) + status = self._session.get(created_zip['status_url']).json() + status.update(created_zip) + return self.translator.translate( + session=self._session, + response_object=status, + ) diff --git a/docs/usage/zip.md b/docs/usage/zip.md new file mode 100644 index 000000000..23a94b45d --- /dev/null +++ b/docs/usage/zip.md @@ -0,0 +1,29 @@ +Zip +======== + +Allows you to create a temporary zip file on Box, containing Box files and folders, and then download it. + + + + +- [Download a Zip File](#download-a-zip-file) + + + +Download a Zip File +----------------------------- + +Calling [`client.download_zip(name, items, writable_stream)`][create_zip] will let you create a new zip file +with the specified name and with the specified items and download it to the stream that is passed in. The response is a status `dict` that contains information about the download, including whether it was successful. The created zip file does not show up in your Box account. + +```python +name = 'test' +file = mock_client.file('466239504569') +folder = mock_client.folder('466239504580') +items = [file, folder] +output_file = open('test.zip', 'wb') +status = client.download_zip(name, items, output_file) +print('The status of the zip download is {0}'.format(status['state'])) +``` + +[download_zip]: https://box-python-sdk.readthedocs.io/en/latest/boxsdk.client.html#boxsdk.client.client.Client.download_zip diff --git a/test/unit/client/test_client.py b/test/unit/client/test_client.py index c3a4e8bef..c6fb0b677 100644 --- a/test/unit/client/test_client.py +++ b/test/unit/client/test_client.py @@ -6,7 +6,7 @@ from mock import Mock import pytest -from six import text_type +from six import text_type, BytesIO, int2byte, PY2 # pylint:disable=redefined-builtin # pylint:disable=import-error @@ -119,6 +119,14 @@ def mock_folder_response(mock_object_id, make_mock_box_request): return mock_box_response +@pytest.fixture(scope='function') +def mock_content_response(make_mock_box_request): + 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) + return mock_box_response + + @pytest.fixture(scope='module') def marker_id(): return 'marker_1' @@ -1465,3 +1473,66 @@ def test_device_pinner(mock_client): assert isinstance(pin, DevicePinner) assert pin.object_id == pin_id + + +def test_download_zip(mock_client, mock_box_session, mock_content_response): + expected_create_url = '{0}/zip_downloads'.format(API.BASE_API_URL) + name = 'test' + file_item = mock_client.file('466239504569') + folder_item = mock_client.folder('466239504580') + items = [file_item, folder_item] + mock_writeable_stream = BytesIO() + expected_create_body = { + 'download_file_name': name, + 'items': [ + { + 'type': 'file', + 'id': '466239504569' + }, + { + 'type': 'folder', + 'id': '466239504580' + } + ] + } + status_response_mock = Mock() + status_response_mock.json.return_value = { + 'total_file_count': 20, + 'downloaded_file_count': 10, + 'skipped_file_count': 10, + 'skipped_folder_count': 10, + 'state': 'succeeded' + } + mock_box_session.post.return_value.json.return_value = { + 'download_url': 'https://dl.boxcloud.com/2.0/zip_downloads/124hfiowk3fa8kmrwh/content', + 'status_url': 'https://api.box.com/2.0/zip_downloads/124hfiowk3fa8kmrwh/status', + 'expires_at': '2018-04-25T11:00:18-07:00', + 'name_conflicts': [ + [ + { + 'id': '100', + 'type': 'file', + 'original_name': 'salary.pdf', + 'download_name': 'aqc823.pdf' + }, + { + 'id': '200', + 'type': 'file', + 'original_name': 'salary.pdf', + 'download_name': 'aci23s.pdf' + } + ] + ] + } + + mock_box_session.get.side_effect = [mock_content_response, status_response_mock] + + status_returned = mock_client.download_zip(name, items, mock_writeable_stream) + mock_box_session.post.assert_called_once_with(expected_create_url, data=json.dumps(expected_create_body)) + mock_box_session.get.assert_any_call('https://dl.boxcloud.com/2.0/zip_downloads/124hfiowk3fa8kmrwh/content', + expect_json_response=False, stream=True) + mock_box_session.get.assert_called_with('https://api.box.com/2.0/zip_downloads/124hfiowk3fa8kmrwh/status') + mock_writeable_stream.seek(0) + assert mock_writeable_stream.read() == mock_content_response.content + assert status_returned['total_file_count'] == 20 + assert status_returned['name_conflicts'][0][0]['id'] == '100'