Skip to content

Commit b661d7e

Browse files
committed
Stream uploads from disk.
Currently, requests only supports streaming uploads by chunking them. This commit utilizes the requests_toolbelt's MultipartEncoder to use multipart-form uploads while still streaming the data from disk.
1 parent 51a8a47 commit b661d7e

File tree

13 files changed

+134
-14
lines changed

13 files changed

+134
-14
lines changed

HISTORY.rst

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,9 @@ Upcoming
99
- CPython 3.5 support.
1010
- Support for cryptography>=1.0 on PyPy 2.6.
1111
- Travis CI testing for CPython 3.5 and PyPy 2.6.0.
12+
- Stream uploads of files from disk.
1213

13-
1.2.1 (2015-07-22)
14+
1.2.2 (2015-07-22)
1415
++++++++++++++++++
1516

1617
- The SDK now supports setting a password when creating a shared link.

boxsdk/session/box_session.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import unicode_literals
44

55
from boxsdk.exception import BoxAPIException
6+
from boxsdk.util.multipart_stream import MultipartStream
67
from boxsdk.util.shared_link import get_shared_link_header
78

89

@@ -323,18 +324,25 @@ def _make_request(
323324

324325
# Reset stream positions to what they were when the request was made so the same data is sent even if this
325326
# is a retried attempt.
327+
request_kwargs = kwargs
326328
files, file_stream_positions = kwargs.get('files'), kwargs.pop('file_stream_positions')
327329
if files and file_stream_positions:
330+
request_kwargs = kwargs.copy()
328331
for name, position in file_stream_positions.items():
329332
files[name][1].seek(position)
333+
data = request_kwargs.pop('data', {})
334+
multipart_stream = MultipartStream(data, files)
335+
request_kwargs['data'] = multipart_stream
336+
del request_kwargs['files']
337+
headers['Content-Type'] = multipart_stream.content_type
330338

331339
# send the request
332340
network_response = self._network_layer.request(
333341
method,
334342
url,
335343
access_token=access_token_will_be_used,
336344
headers=headers,
337-
**kwargs
345+
**request_kwargs
338346
)
339347

340348
network_response = self._retry_request_if_necessary(

boxsdk/util/multipart_stream.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# coding: utf-8
2+
3+
from __future__ import unicode_literals
4+
5+
from requests_toolbelt.multipart.encoder import MultipartEncoder
6+
7+
from boxsdk.util.ordered_dict import OrderedDict
8+
9+
10+
class MultipartStream(MultipartEncoder):
11+
"""
12+
Subclass of the requests_toolbelt's :class:`MultipartEncoder` that ensures that data
13+
is encoded before files. This allows a server to process information in the data before
14+
receiving the file bytes.
15+
"""
16+
def __init__(self, data, files):
17+
fields = OrderedDict()
18+
for k in data:
19+
fields[k] = data[k]
20+
for k in files:
21+
fields[k] = files[k]
22+
super(MultipartStream, self).__init__(fields)

docs/source/boxsdk.util.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,14 @@ boxsdk.util.lru_cache module
2020
:undoc-members:
2121
:show-inheritance:
2222

23+
boxsdk.util.multipart_stream module
24+
-----------------------------------
25+
26+
.. automodule:: boxsdk.util.multipart_stream
27+
:members:
28+
:undoc-members:
29+
:show-inheritance:
30+
2331
boxsdk.util.ordered_dict module
2432
-------------------------------
2533

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
cryptography>=0.9.2
22
pyjwt>=1.3.0
33
requests>=2.4.3
4+
requests-toolbelt>=0.4.0
45
six >= 1.4.0
56
.

test/conftest.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,8 +128,8 @@ def auth_code():
128128

129129
@pytest.fixture(params=[
130130
b'Hello',
131-
'Goodbye',
132-
'42',
131+
b'Goodbye',
132+
b'42',
133133
])
134134
def test_file_content(request):
135135
return request.param

test/functional/conftest.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# coding: utf-8
22

33
from __future__ import unicode_literals
4-
from mock import mock_open, patch
4+
from mock import patch
55
import pytest
66
import re
77
import requests
@@ -11,6 +11,7 @@
1111
from boxsdk.config import API
1212
from boxsdk.client import Client
1313
from test.functional.mock_box.box import Box
14+
from test.util.streamable_mock_open import streamable_mock_open
1415

1516

1617
@pytest.fixture()
@@ -95,7 +96,7 @@ def user_login():
9596
@pytest.fixture()
9697
def uploaded_file(box_client, test_file_path, test_file_content, file_name):
9798
# pylint:disable=redefined-outer-name
98-
with patch('boxsdk.object.folder.open', mock_open(read_data=test_file_content), create=True):
99+
with patch('boxsdk.object.folder.open', streamable_mock_open(read_data=test_file_content), create=True):
99100
return box_client.folder('0').upload(test_file_path, file_name)
100101

101102

test/functional/test_delete.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
# coding: utf-8
22

33
from __future__ import unicode_literals
4-
from mock import patch, mock_open
4+
from mock import patch
55
import pytest
66
from boxsdk.client import Client
77
from boxsdk.exception import BoxAPIException
8+
from test.util.streamable_mock_open import streamable_mock_open
89

910

1011
def test_upload_then_delete(box_client, test_file_path, test_file_content, file_name):
11-
with patch('boxsdk.object.folder.open', mock_open(read_data=test_file_content), create=True):
12+
with patch('boxsdk.object.folder.open', streamable_mock_open(read_data=test_file_content), create=True):
1213
file_object = box_client.folder('0').upload(test_file_path, file_name)
1314
assert file_object.delete()
1415
assert len(box_client.folder('0').get_items(1)) == 0

test/functional/test_file_upload_update_download.py

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
# coding: utf-8
22

33
from __future__ import unicode_literals
4-
from mock import mock_open, patch
4+
from mock import patch
55
import six
6+
from test.util.streamable_mock_open import streamable_mock_open
67

78

89
def test_upload_then_update(box_client, test_file_path, test_file_content, update_file_content, file_name):
9-
with patch('boxsdk.object.folder.open', mock_open(read_data=test_file_content), create=True):
10+
with patch('boxsdk.object.folder.open', streamable_mock_open(read_data=test_file_content), create=True):
1011
file_object = box_client.folder('0').upload(test_file_path, file_name)
1112
assert file_object.name == file_name
1213
file_object_with_info = file_object.get()
@@ -20,13 +21,15 @@ def test_upload_then_update(box_client, test_file_path, test_file_content, updat
2021
assert len(folder_items) == 1
2122
assert folder_items[0].object_id == file_object.object_id
2223
assert folder_items[0].name == file_object.name
23-
with patch('boxsdk.object.file.open', mock_open(read_data=update_file_content), create=True):
24+
with patch('boxsdk.object.file.open', streamable_mock_open(read_data=update_file_content), create=True):
2425
updated_file_object = file_object.update_contents(test_file_path)
2526
assert updated_file_object.name == file_name
2627
file_object_with_info = updated_file_object.get()
2728
assert file_object_with_info.id == updated_file_object.object_id
2829
assert file_object_with_info.name == file_name
2930
file_content = updated_file_object.content()
31+
expected_file_content = update_file_content.encode('utf-8') if isinstance(update_file_content, six.text_type)\
32+
else update_file_content
3033
assert file_content == expected_file_content
3134
folder_items = box_client.folder('0').get_items(100)
3235
assert len(folder_items) == 1
@@ -35,10 +38,23 @@ def test_upload_then_update(box_client, test_file_path, test_file_content, updat
3538

3639

3740
def test_upload_then_download(box_client, test_file_path, test_file_content, file_name):
38-
with patch('boxsdk.object.folder.open', mock_open(read_data=test_file_content), create=True):
41+
with patch('boxsdk.object.folder.open', streamable_mock_open(read_data=test_file_content), create=True):
3942
file_object = box_client.folder('0').upload(test_file_path, file_name)
4043
writeable_stream = six.BytesIO()
4144
file_object.download_to(writeable_stream)
4245
expected_file_content = test_file_content.encode('utf-8') if isinstance(test_file_content, six.text_type)\
4346
else test_file_content
4447
assert writeable_stream.getvalue() == expected_file_content
48+
49+
50+
if __name__ == '__main__':
51+
from test.functional.conftest import box_client, box_oauth, mock_box, Box
52+
53+
class MonkeyPatch:
54+
def setattr(self, target, attr, value):
55+
setattr(target, attr, value)
56+
57+
client_id, client_secret, login = 'client_id', 'client_secret', 'login'
58+
box = mock_box(Box(), MonkeyPatch(), client_id, client_secret, 'user', login)
59+
client = box_client(box_oauth(client_id, client_secret, login))
60+
test_upload_then_update(client, '/path/to/file', 'Hello', 'Goodbye', 'foo.txt')

test/unit/session/test_box_session.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,8 +119,8 @@ def test_box_session_seeks_file_after_retry(box_session, server_error_response,
119119
assert box_response.status_code == 200
120120
assert box_response.json() == generic_successful_response.json()
121121
assert box_response.ok == generic_successful_response.ok
122-
mock_file_1.tell.assert_called_once_with()
123-
mock_file_2.tell.assert_called_once_with()
122+
mock_file_1.tell.assert_called_with()
123+
mock_file_2.tell.assert_called_with()
124124
mock_file_1.seek.assert_called_with(0)
125125
assert mock_file_1.seek.call_count == 2
126126
assert mock_file_1.seek.has_calls(call(0) * 2)

0 commit comments

Comments
 (0)