Skip to content

Commit 9c5d09e

Browse files
committed
feat: Add stream_file_content parameter to upload methods
1 parent 320ffe9 commit 9c5d09e

File tree

5 files changed

+110
-13
lines changed

5 files changed

+110
-13
lines changed

boxsdk/object/folder.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,7 @@ def upload_stream(
263263
additional_attributes: Optional[dict] = None,
264264
sha1: Optional[str] = None,
265265
etag: Optional[str] = None,
266+
stream_file_content: bool = True,
266267
) -> 'File':
267268
"""
268269
Upload a file to the folder.
@@ -298,6 +299,9 @@ def upload_stream(
298299
A sha1 checksum for the file.
299300
:param etag:
300301
If specified, instruct the Box API to update the item only if the current version's etag matches.
302+
:param stream_file_content:
303+
If True, the upload will be performed as a stream request. If False, the file will be read into memory
304+
before being uploaded, but this may be required if using some proxy servers to handle redirects correctly.
301305
:returns:
302306
The newly uploaded file.
303307
"""
@@ -335,7 +339,7 @@ def upload_stream(
335339
if not headers:
336340
headers = None
337341
file_response = self._session.post(
338-
url, data=data, files=files, expect_json_response=False, headers=headers
342+
url, data=data, files=files, expect_json_response=False, headers=headers, stream_file_content=stream_file_content,
339343
).json()
340344
if 'entries' in file_response:
341345
file_response = file_response['entries'][0]
@@ -358,6 +362,7 @@ def upload(
358362
additional_attributes: Optional[dict] = None,
359363
sha1: Optional[str] = None,
360364
etag: Optional[str] = None,
365+
stream_file_content: bool = True,
361366
) -> 'File':
362367
"""
363368
Upload a file to the folder.
@@ -394,6 +399,9 @@ def upload(
394399
A sha1 checksum for the new content.
395400
:param etag:
396401
If specified, instruct the Box API to update the item only if the current version's etag matches.
402+
:param stream_file_content:
403+
If True, the upload will be performed as a stream request. If False, the file will be read into memory
404+
before being uploaded, but this may be required if using some proxy servers to handle redirects correctly.
397405
:returns:
398406
The newly uploaded file.
399407
"""
@@ -412,6 +420,7 @@ def upload(
412420
additional_attributes=additional_attributes,
413421
sha1=sha1,
414422
etag=etag,
423+
stream_file_content=stream_file_content,
415424
)
416425

417426
@api_call

boxsdk/session/session.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -468,7 +468,8 @@ def _send_request(self, request: '_BoxRequest', **kwargs: Any) -> 'NetworkRespon
468468
"""
469469
# Reset stream positions to what they were when the request was made so the same data is sent even if this
470470
# is a retried attempt.
471-
files, file_stream_positions = kwargs.get('files'), kwargs.pop('file_stream_positions')
471+
files, file_stream_positions, stream_file_content = (
472+
kwargs.get('files'), kwargs.pop('file_stream_positions'), kwargs.pop('stream_file_content', True))
472473
request_kwargs = self._default_network_request_kwargs.copy()
473474
request_kwargs.update(kwargs)
474475
proxy_dict = self._prepare_proxy()
@@ -477,11 +478,12 @@ def _send_request(self, request: '_BoxRequest', **kwargs: Any) -> 'NetworkRespon
477478
if files and file_stream_positions:
478479
for name, position in file_stream_positions.items():
479480
files[name][1].seek(position)
480-
data = request_kwargs.pop('data', {})
481-
multipart_stream = MultipartStream(data, files)
482-
request_kwargs['data'] = multipart_stream
483-
del request_kwargs['files']
484-
request.headers['Content-Type'] = multipart_stream.content_type
481+
if stream_file_content:
482+
data = request_kwargs.pop('data', {})
483+
multipart_stream = MultipartStream(data, files)
484+
request_kwargs['data'] = multipart_stream
485+
del request_kwargs['files']
486+
request.headers['Content-Type'] = multipart_stream.content_type
485487
request.access_token = request_kwargs.pop('access_token', None)
486488

487489
# send the request

test/integration_new/object/folder_itest.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -103,8 +103,8 @@ def test_auto_chunked_upload_NOT_using_upload_session_urls(parent_folder, large_
103103

104104

105105
def test_get_items(parent_folder, small_file_path):
106-
with BoxTestFolder(parent_folder=parent_folder) as subfolder,\
107-
BoxTestFile(parent_folder=parent_folder, file_path=small_file_path) as file,\
106+
with BoxTestFolder(parent_folder=parent_folder) as subfolder, \
107+
BoxTestFile(parent_folder=parent_folder, file_path=small_file_path) as file, \
108108
BoxTestWebLink(parent_folder=parent_folder, url='https://box.com') as web_link:
109109

110110
assert set(parent_folder.get_items()) == {subfolder, file, web_link}
@@ -130,6 +130,17 @@ def test_upload_small_file_to_folder(parent_folder, small_file_name, small_file_
130130
util.permanently_delete(uploaded_file)
131131

132132

133+
def test_upload_small_file_to_folder_with_disabled_streaming_file_content(
134+
parent_folder, small_file_name, small_file_path
135+
):
136+
uploaded_file = parent_folder.upload(file_path=small_file_path, file_name=small_file_name, stream_file_content=False)
137+
try:
138+
assert uploaded_file.id
139+
assert uploaded_file.parent == parent_folder
140+
finally:
141+
util.permanently_delete(uploaded_file)
142+
143+
133144
def test_create_subfolder(parent_folder):
134145
created_subfolder = parent_folder.create_subfolder(name=util.random_name())
135146
try:
@@ -199,7 +210,7 @@ def test_delete_folder(parent_folder):
199210

200211

201212
def test_cascade_and_get_metadata_cascade_policies(parent_folder):
202-
with BoxTestMetadataTemplate(display_name="test_template") as metadata_template,\
213+
with BoxTestMetadataTemplate(display_name="test_template") as metadata_template, \
203214
BoxTestFolder(parent_folder=parent_folder) as folder:
204215
folder.cascade_metadata(metadata_template)
205216

test/unit/object/test_folder.py

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from datetime import datetime
33
from io import BytesIO
44
from os.path import basename
5-
from unittest.mock import mock_open, patch, Mock, MagicMock
5+
from unittest.mock import mock_open, patch, Mock, MagicMock, ANY
66
import pytest
77
import pytz
88

@@ -334,7 +334,14 @@ def test_upload(
334334
# in Python 2 tests
335335
attributes.update(additional_attributes)
336336
data = {'attributes': json.dumps(attributes)}
337-
mock_box_session.post.assert_called_once_with(expected_url, expect_json_response=False, files=mock_files, data=data, headers=if_match_sha1_header)
337+
mock_box_session.post.assert_called_once_with(
338+
expected_url,
339+
expect_json_response=False,
340+
files=mock_files,
341+
data=data,
342+
headers=if_match_sha1_header,
343+
stream_file_content=True
344+
)
338345
assert isinstance(new_file, File)
339346
assert new_file.object_id == mock_object_id
340347
assert 'id' in new_file
@@ -438,6 +445,27 @@ def test_upload_does_preflight_check_if_specified(
438445
assert not test_folder.preflight_check.called
439446

440447

448+
@patch('boxsdk.object.folder.open', mock_open(read_data=b'some bytes'), create=True)
449+
@pytest.mark.parametrize('stream_file_content', (True, False))
450+
def test_upload_if_flag_stream_file_content_is_passed_to_session(
451+
mock_box_session,
452+
test_folder,
453+
stream_file_content,
454+
):
455+
expected_url = f'{API.UPLOAD_URL}/files/content'
456+
457+
test_folder.upload('foo.txt', file_name='foo.txt', stream_file_content=stream_file_content)
458+
459+
mock_files = {'file': ('unused', ANY)}
460+
mock_box_session.post.assert_called_once_with(
461+
expected_url,
462+
data=ANY,
463+
files=mock_files,
464+
expect_json_response=False,
465+
headers=None,
466+
stream_file_content=stream_file_content)
467+
468+
441469
def test_create_subfolder(test_folder, mock_box_session, mock_object_id, mock_folder_response):
442470
expected_url = test_folder.get_type_url()
443471
mock_box_session.post.return_value = mock_folder_response

test/unit/session/test_session.py

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
from functools import partial
2-
from io import IOBase
2+
from io import IOBase, BytesIO
33
from numbers import Number
4+
import os
45
from unittest.mock import MagicMock, Mock, PropertyMock, call, patch, ANY
56
from requests.exceptions import RequestException, SSLError, ConnectionError as RequestsConnectionError
7+
from requests_toolbelt import MultipartEncoder
68

79
import pytest
810

@@ -449,3 +451,48 @@ def test_proxy_malformed_dict_does_not_attach(box_session, monkeypatch, mock_net
449451

450452
def test_proxy_network_config_property(box_session):
451453
assert isinstance(box_session.proxy_config, Proxy)
454+
455+
456+
def test_multipart_request_with_disabled_streaming_file_content(
457+
box_session, mock_network_layer, generic_successful_response):
458+
test_url = 'https://example.com'
459+
file_bytes = os.urandom(1024)
460+
mock_network_layer.request.side_effect = [generic_successful_response]
461+
box_session.post(
462+
url=test_url,
463+
files={'file': ('unused', BytesIO(file_bytes))},
464+
data={'attributes': '{"name": "test_file"}'},
465+
stream_file_content=False
466+
)
467+
mock_network_layer.request.assert_called_once_with(
468+
'POST',
469+
test_url,
470+
access_token='fake_access_token',
471+
headers=ANY,
472+
log_response_content=True,
473+
files={'file': ('unused', ANY)},
474+
data={'attributes': '{"name": "test_file"}'},
475+
)
476+
477+
478+
def test_multipart_request_with_enabled_streaming_file_content(
479+
box_session, mock_network_layer, generic_successful_response):
480+
test_url = 'https://example.com'
481+
file_bytes = os.urandom(1024)
482+
mock_network_layer.request.side_effect = [generic_successful_response]
483+
box_session.post(
484+
url=test_url,
485+
files={'file': ('unused', BytesIO(file_bytes))},
486+
data={'attributes': '{"name": "test_file"}'},
487+
stream_file_content=True
488+
)
489+
call_args = mock_network_layer.request.call_args[0]
490+
call_kwargs = mock_network_layer.request.call_args[1]
491+
assert call_args[0] == 'POST'
492+
assert call_args[1] == test_url
493+
assert call_kwargs['access_token'] == 'fake_access_token'
494+
assert call_kwargs['log_response_content'] is True
495+
assert isinstance(call_kwargs['data'], MultipartEncoder)
496+
assert call_kwargs['data'].fields['attributes'] == '{"name": "test_file"}'
497+
assert call_kwargs['data'].fields['file'][0] == 'unused'
498+
assert isinstance(call_kwargs['data'].fields['file'][1], BytesIO)

0 commit comments

Comments
 (0)