Skip to content

Commit a6a32b4

Browse files
congminh1254claude
andcommitted
feat(boxsdk): Replace requests-toolbelt with streaming MultipartStream encoder
Back the legacy boxsdk MultipartStream with the streaming encoder from box_sdk_gen.networking.multipart_stream, keeping its (data, files) constructor and data-before-files ordering. Seekable file streams are now sent with an exact Content-Length. This removes the requests-toolbelt dependency from the combined SDK. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent b2a66e5 commit a6a32b4

5 files changed

Lines changed: 88 additions & 31 deletions

File tree

‎boxsdk/util/multipart_stream.py‎

Lines changed: 28 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,34 @@
1-
from collections import OrderedDict
1+
from io import BytesIO
2+
from typing import Any
23

3-
from requests_toolbelt.multipart.encoder import MultipartEncoder
4+
from box_sdk_gen.networking.multipart_stream import (
5+
MultipartField,
6+
MultipartStream as _MultipartStream,
7+
)
48

59

6-
class MultipartStream(MultipartEncoder):
10+
class MultipartStream(_MultipartStream):
711
"""
8-
Subclass of the requests_toolbelt's :class:`MultipartEncoder` that ensures that data
9-
is encoded before files. This allows a server to process information in the data before
10-
receiving the file bytes.
12+
Streaming multipart/form-data body that ensures that data is encoded before files.
13+
This allows a server to process information in the data before receiving the file bytes.
14+
File streams are read lazily, so uploads are sent without loading whole files in memory.
15+
16+
Field values are either a string, bytes or a binary stream, or a
17+
(file_name, value) or (file_name, value, content_type) tuple.
1118
"""
1219

13-
def __init__(self, data, files):
14-
fields = OrderedDict()
15-
for k in data:
16-
fields[k] = data[k]
17-
for k in files:
18-
fields[k] = files[k]
19-
super().__init__(fields)
20+
def __init__(self, data: dict, files: dict):
21+
super().__init__(
22+
[self._to_field(name, value) for name, value in data.items()]
23+
+ [self._to_field(name, value) for name, value in files.items()]
24+
)
25+
26+
@staticmethod
27+
def _to_field(name: str, value: Any) -> MultipartField:
28+
file_name, content_type = None, None
29+
if isinstance(value, tuple):
30+
file_name, value, *rest = value
31+
content_type = rest[0] if rest else None
32+
if isinstance(value, bytes):
33+
value = BytesIO(value)
34+
return name, file_name, value, content_type

‎docs/boxsdk/usage/files.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,7 @@ which controls how the file content is uploaded.
197197
If you are uploading a large file, you may want to stream the request to avoid excessive memory usage.
198198
According to `requests'` library [docs][request_docs], by default, the `requests` library does not support streaming uploads,
199199
and all the data must be read into memory before being sent to the server.
200-
However, the `requests-toolbelt` package includes a `MultipartEncoder` class, which enables file uploads without
200+
However, the Box Python SDK includes a streaming multipart encoder, which enables file uploads without
201201
loading the entire file into memory. This approach is the default in the Box Python SDK.
202202

203203
That said, handling 307 Temporary Redirects presents a challenge with streamed file uploads.

‎setup.py‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,6 @@ def main():
5656
'urllib3',
5757
'dataclasses',
5858
'requests<3',
59-
'requests-toolbelt<2',
6059
'python-dateutil',
6160
]
6261
redis_requires = ['redis>=2.10.3']

‎test/boxsdk/unit/session/test_session.py‎

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from functools import partial
2-
from io import IOBase, BytesIO
2+
from io import IOBase, BytesIO, SEEK_END
33
from numbers import Number
44
import os
55
from unittest.mock import MagicMock, Mock, PropertyMock, call, patch, ANY
@@ -8,8 +8,6 @@
88
SSLError,
99
ConnectionError as RequestsConnectionError,
1010
)
11-
from requests_toolbelt import MultipartEncoder
12-
1311
import pytest
1412

1513
from boxsdk import CCGAuth
@@ -19,6 +17,7 @@
1917
from boxsdk.network.default_network import DefaultNetwork, DefaultNetworkResponse
2018
from boxsdk.session.box_response import BoxResponse
2119
from boxsdk.session.session import Session, Translator, AuthorizedSession
20+
from boxsdk.util.multipart_stream import MultipartStream
2221

2322

2423
@pytest.fixture(scope='function', params=[False, True])
@@ -271,12 +270,10 @@ def test_box_session_seeks_file_after_retry(
271270
assert box_response.ok == generic_successful_response.ok
272271
mock_file_1.tell.assert_called_with()
273272
mock_file_2.tell.assert_called_with()
274-
mock_file_1.seek.assert_called_with(0)
275-
assert mock_file_1.seek.call_count == 2
276-
mock_file_1.seek.assert_has_calls([call(0), call(0)])
277-
mock_file_2.seek.assert_called_with(3)
278-
assert mock_file_2.seek.call_count == 2
279-
mock_file_2.seek.assert_has_calls([call(3), call(3)])
273+
# before each attempt the session rewinds the stream, then the multipart
274+
# encoder measures its size and restores the position
275+
assert mock_file_1.seek.call_args_list == [call(0), call(0, SEEK_END), call(0)] * 2
276+
assert mock_file_2.seek.call_args_list == [call(3), call(0, SEEK_END), call(3)] * 2
280277

281278

282279
def test_box_session_raises_for_non_json_response(
@@ -645,7 +642,14 @@ def test_multipart_request_with_enabled_streaming_file_content(
645642
assert call_args[1] == test_url
646643
assert call_kwargs['access_token'] == 'fake_access_token'
647644
assert call_kwargs['log_response_content'] is True
648-
assert isinstance(call_kwargs['data'], MultipartEncoder)
649-
assert call_kwargs['data'].fields['attributes'] == '{"name": "test_file"}'
650-
assert call_kwargs['data'].fields['file'][0] == 'unused'
651-
assert isinstance(call_kwargs['data'].fields['file'][1], BytesIO)
645+
multipart_stream = call_kwargs['data']
646+
assert isinstance(multipart_stream, MultipartStream)
647+
assert call_kwargs['headers']['Content-Type'] == multipart_stream.content_type
648+
body = multipart_stream.read()
649+
assert (
650+
b'name="attributes"\r\n\r\n{"name": "test_file"}\r\n'
651+
+ f'--{multipart_stream.boundary}\r\n'.encode()
652+
+ b'Content-Disposition: form-data; name="file"; filename="unused"\r\n\r\n'
653+
+ file_bytes
654+
+ b'\r\n'
655+
) in body

‎test/boxsdk/unit/util/test_multipart_stream.py‎

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from io import BytesIO
2+
13
import pytest
24

35
from boxsdk.util.multipart_stream import MultipartStream
@@ -17,10 +19,8 @@ def test_multipart_stream_orders_data_before_files(
1719
multipart_stream_data, multipart_stream_files
1820
):
1921
# pylint:disable=redefined-outer-name
20-
if not multipart_stream_data and not multipart_stream_files:
21-
pytest.xfail('Encoder does not support empty fields.')
2222
stream = MultipartStream(multipart_stream_data, multipart_stream_files)
23-
encoded_stream = stream.to_string()
23+
encoded_stream = stream.read()
2424
data_indices = [
2525
encoded_stream.find(value) for value in multipart_stream_data.values()
2626
]
@@ -30,3 +30,42 @@ def test_multipart_stream_orders_data_before_files(
3030
assert -1 not in data_indices
3131
assert -1 not in file_indices
3232
assert all(all(data_index < f for f in file_indices) for data_index in data_indices)
33+
assert len(encoded_stream) == stream.len
34+
35+
36+
def test_multipart_stream_encodes_data_and_file_tuples():
37+
stream = MultipartStream(
38+
{'attributes': '{"name": "test_file"}'},
39+
{
40+
'file': ('unused', BytesIO(b'file content')),
41+
'pic': ('avatar.png', BytesIO(b'png bytes'), 'image/png'),
42+
},
43+
)
44+
45+
assert stream.content_type == f'multipart/form-data; boundary={stream.boundary}'
46+
assert (
47+
stream.read()
48+
== (
49+
f'--{stream.boundary}\r\n'
50+
'Content-Disposition: form-data; name="attributes"\r\n\r\n'
51+
'{"name": "test_file"}\r\n'
52+
f'--{stream.boundary}\r\n'
53+
'Content-Disposition: form-data; name="file"; filename="unused"\r\n\r\n'
54+
'file content\r\n'
55+
f'--{stream.boundary}\r\n'
56+
'Content-Disposition: form-data; name="pic"; filename="avatar.png"\r\n'
57+
'Content-Type: image/png\r\n\r\n'
58+
'png bytes\r\n'
59+
f'--{stream.boundary}--\r\n'
60+
).encode()
61+
)
62+
63+
64+
def test_multipart_stream_reads_file_lazily():
65+
file_stream = BytesIO(b'file content')
66+
file_stream.read(5)
67+
68+
stream = MultipartStream({}, {'file': ('unused', file_stream)})
69+
70+
assert file_stream.tell() == 5
71+
assert b'\r\n\r\ncontent\r\n' in stream.read()

0 commit comments

Comments
 (0)