Skip to content

Commit 87fbf9a

Browse files
congminh1254claude
andcommitted
feat: Replace requests-toolbelt with streaming MultipartStream encoder
Add a small file-like multipart/form-data body (MultipartStream) built on urllib3's RequestField formatter. It reads part streams lazily, sends an exact Content-Length for seekable streams and falls back to chunked transfer encoding for non-seekable ones. This removes the requests-toolbelt dependency. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 1247aba commit 87fbf9a

4 files changed

Lines changed: 358 additions & 27 deletions

File tree

‎box_sdk_gen/networking/box_network_client.py‎

Lines changed: 35 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,16 @@
11
import io
22

33
import time
4-
from collections import OrderedDict
54
from dataclasses import dataclass
6-
from typing import Optional, Dict, Union, Tuple
5+
from typing import Optional, Dict, Union, Tuple, List
76
from sys import version_info as py_version
87

98
import requests
109
from requests import RequestException, Session, Response
11-
from requests_toolbelt import MultipartEncoder
1210

1311
from ..internal.logging import DataSanitizer
1412
from .retries import BoxRetryStrategy
13+
from .multipart_stream import MultipartField, MultipartStream
1514
from ..networking.fetch_options import FetchOptions
1615
from ..networking.fetch_response import FetchResponse
1716
from ..box.errors import BoxAPIError, BoxSDKError, RequestInfo, ResponseInfo
@@ -39,7 +38,7 @@ class APIRequest:
3938
url: str
4039
headers: Dict[str, str]
4140
params: Dict[str, str]
42-
data: Optional[Union[str, ByteStream, MultipartEncoder]]
41+
data: Optional[Union[str, ByteStream, MultipartStream]]
4342
content_type: Optional[str] = None
4443
allow_redirects: bool = True
4544
timeout: Optional[Tuple[Optional[float], Optional[float]]] = None
@@ -158,19 +157,16 @@ def _prepare_request(
158157

159158
if options.content_type:
160159
if options.content_type == 'multipart/form-data':
161-
fields = OrderedDict()
162-
for part in options.multipart_data:
163-
if part.data:
164-
fields[part.part_name] = sd_to_json(part.data)
165-
else:
166-
fields[part.part_name] = (
167-
part.file_name or '',
168-
part.file_stream,
169-
part.content_type,
170-
)
171-
172-
multipart_stream = MultipartEncoder(fields)
160+
multipart_stream = MultipartStream(
161+
self._prepare_multipart_fields(options)
162+
)
173163
data = multipart_stream
164+
# replace any caller-provided Content-Type, it must carry the boundary
165+
headers = {
166+
name: value
167+
for name, value in headers.items()
168+
if name.lower() != 'content-type'
169+
}
174170
headers['Content-Type'] = multipart_stream.content_type
175171
else:
176172
headers['Content-Type'] = options.content_type
@@ -186,6 +182,29 @@ def _prepare_request(
186182
timeout=timeout,
187183
)
188184

185+
@staticmethod
186+
def _prepare_multipart_fields(
187+
options: 'FetchOptions',
188+
) -> List[MultipartField]:
189+
fields = []
190+
for part in options.multipart_data:
191+
if part.data is not None:
192+
fields.append((part.part_name, None, sd_to_json(part.data), None))
193+
elif part.file_stream is not None:
194+
fields.append(
195+
(
196+
part.part_name,
197+
part.file_name or '',
198+
part.file_stream,
199+
part.content_type,
200+
)
201+
)
202+
else:
203+
raise BoxSDKError(
204+
message=f'Multipart part "{part.part_name}" has neither data nor file_stream'
205+
)
206+
return fields
207+
189208
@staticmethod
190209
def _get_request_timeout(
191210
options: 'FetchOptions',
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
from io import SEEK_END
2+
from typing import Iterator, List, Optional, Tuple, Union
3+
4+
from urllib3.fields import RequestField
5+
from urllib3.filepost import choose_boundary
6+
7+
from ..internal.utils import ByteStream
8+
9+
CHUNK_SIZE = 64 * 1024
10+
11+
MultipartField = Tuple[str, Optional[str], Union[str, ByteStream], Optional[str]]
12+
13+
14+
class MultipartStream:
15+
"""
16+
File-like multipart/form-data body which reads part streams lazily,
17+
so uploads are sent without buffering whole files in memory.
18+
19+
Fields are (name, file_name, value, content_type) tuples, where value is
20+
either a string or a binary stream read from its current position.
21+
"""
22+
23+
def __init__(self, fields: List[MultipartField]):
24+
self.boundary = choose_boundary()
25+
self.content_type = f'multipart/form-data; boundary={self.boundary}'
26+
self._segments: List[Union[bytes, ByteStream]] = []
27+
for name, file_name, value, content_type in fields:
28+
field = RequestField(name=name, data=b'', filename=file_name)
29+
field.make_multipart(content_type=content_type)
30+
self._segments.append(
31+
f'--{self.boundary}\r\n{field.render_headers()}'.encode('utf-8')
32+
)
33+
self._segments.append(
34+
value.encode('utf-8') if isinstance(value, str) else value
35+
)
36+
self._segments.append(b'\r\n')
37+
self._segments.append(f'--{self.boundary}--\r\n'.encode('utf-8'))
38+
self._index = 0
39+
self._offset = 0
40+
# bytes still to send per stream segment, None when the size is unknown
41+
self._remaining: List[Optional[int]] = [
42+
None if isinstance(segment, bytes) else self._stream_size(segment)
43+
for segment in self._segments
44+
]
45+
# requests reads `len` to set Content-Length; None makes it fall back
46+
# to chunked transfer encoding
47+
self.len = self._compute_length()
48+
49+
@staticmethod
50+
def _stream_size(stream: ByteStream) -> Optional[int]:
51+
try:
52+
if not stream.seekable():
53+
return None
54+
position = stream.tell()
55+
size = stream.seek(0, SEEK_END) - position
56+
stream.seek(position)
57+
return size
58+
except (OSError, AttributeError):
59+
return None
60+
61+
def _compute_length(self) -> Optional[int]:
62+
total = 0
63+
for segment, remaining in zip(self._segments, self._remaining):
64+
if isinstance(segment, bytes):
65+
total += len(segment)
66+
elif remaining is None:
67+
return None
68+
else:
69+
total += remaining
70+
return total
71+
72+
def read(self, size: Optional[int] = -1) -> bytes:
73+
if size is None or size < 0:
74+
return b''.join(iter(lambda: self.read(CHUNK_SIZE), b''))
75+
76+
chunks = []
77+
while size > 0 and self._index < len(self._segments):
78+
segment = self._segments[self._index]
79+
if isinstance(segment, bytes):
80+
chunk = segment[self._offset : self._offset + size]
81+
self._offset += len(chunk)
82+
if self._offset >= len(segment):
83+
self._index += 1
84+
self._offset = 0
85+
else:
86+
remaining = self._remaining[self._index]
87+
if remaining == 0:
88+
# send exactly the size declared in Content-Length, even if the stream grew
89+
self._index += 1
90+
continue
91+
chunk = segment.read(
92+
size if remaining is None else min(size, remaining)
93+
)
94+
if not chunk:
95+
if remaining is not None:
96+
raise IOError(
97+
f'Multipart stream ended {remaining} bytes before its declared size'
98+
)
99+
self._index += 1
100+
continue
101+
if remaining is not None:
102+
self._remaining[self._index] = remaining - len(chunk)
103+
chunks.append(chunk)
104+
size -= len(chunk)
105+
return b''.join(chunks)
106+
107+
def __iter__(self) -> Iterator[bytes]:
108+
return iter(lambda: self.read(CHUNK_SIZE), b'')
109+
110+
def __repr__(self) -> str:
111+
return f'<MultipartStream boundary={self.boundary}>'

‎setup.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77

88
def main():
9-
install_requires = ['requests', 'requests-toolbelt']
9+
install_requires = ['requests']
1010
tests_require = [
1111
'pytest',
1212
'pytest-timeout',

0 commit comments

Comments
 (0)