Skip to content

Commit a232fd3

Browse files
box-sdk-buildbox-sdk-build
andauthored
feat(boxsdkgen): replace MultipartEncoder with streaming MultipartStream for multipart uploads (box/box-codegen#994)
Co-authored-by: box-sdk-build <box-sdk-build@box.com>
1 parent 69b20ad commit a232fd3

6 files changed

Lines changed: 489 additions & 26 deletions

File tree

‎.codegen.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
{ "engineHash": "daeb1ea", "specHash": "c5a35a6", "version": "4.16.0" }
1+
{ "engineHash": "12fa0b7", "specHash": "c5a35a6", "version": "4.16.0" }

‎box_sdk_gen/networking/__init__.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616

1717
from box_sdk_gen.networking.retries import *
1818

19+
from box_sdk_gen.networking.multipart_stream import *
20+
1921
from box_sdk_gen.networking.base_urls import *
2022

2123
from box_sdk_gen.networking.version import *

‎box_sdk_gen/networking/box_network_client.py‎

Lines changed: 42 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,17 @@
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
1110
from requests.structures import CaseInsensitiveDict
12-
from requests_toolbelt import MultipartEncoder
1311

1412
from ..internal.logging import DataSanitizer
1513
from .retries import BoxRetryStrategy
14+
from .multipart_stream import MultipartField, MultipartStream
1615
from ..networking.fetch_options import FetchOptions
1716
from ..networking.fetch_response import FetchResponse
1817
from ..box.errors import BoxAPIError, BoxSDKError, RequestInfo, ResponseInfo
@@ -40,7 +39,7 @@ class APIRequest:
4039
url: str
4140
headers: Dict[str, str]
4241
params: Dict[str, str]
43-
data: Optional[Union[str, ByteStream, MultipartEncoder]]
42+
data: Optional[Union[str, ByteStream, MultipartStream]]
4443
content_type: Optional[str] = None
4544
allow_redirects: bool = True
4645
timeout: Optional[Union[float, Tuple[Optional[float], Optional[float]]]] = None
@@ -160,19 +159,16 @@ def _prepare_request(
160159

161160
if options.content_type:
162161
if options.content_type == 'multipart/form-data':
163-
fields = OrderedDict()
164-
for part in options.multipart_data:
165-
if part.data:
166-
fields[part.part_name] = sd_to_json(part.data)
167-
else:
168-
fields[part.part_name] = (
169-
part.file_name or '',
170-
part.file_stream,
171-
part.content_type,
172-
)
173-
174-
multipart_stream = MultipartEncoder(fields)
162+
multipart_stream = MultipartStream(
163+
self._prepare_multipart_fields(options)
164+
)
175165
data = multipart_stream
166+
# replace any caller-provided Content-Type, it must carry the boundary
167+
headers = {
168+
name: value
169+
for name, value in headers.items()
170+
if name.lower() != 'content-type'
171+
}
176172
headers['Content-Type'] = multipart_stream.content_type
177173
else:
178174
headers['Content-Type'] = options.content_type
@@ -188,6 +184,29 @@ def _prepare_request(
188184
timeout=timeout,
189185
)
190186

187+
@staticmethod
188+
def _prepare_multipart_fields(
189+
options: 'FetchOptions',
190+
) -> List[MultipartField]:
191+
fields = []
192+
for part in options.multipart_data:
193+
if part.data is not None:
194+
fields.append((part.part_name, None, sd_to_json(part.data), None))
195+
elif part.file_stream is not None:
196+
fields.append(
197+
(
198+
part.part_name,
199+
part.file_name or '',
200+
part.file_stream,
201+
part.content_type,
202+
)
203+
)
204+
else:
205+
raise BoxSDKError(
206+
message=f'Multipart part "{part.part_name}" has neither data nor file_stream'
207+
)
208+
return fields
209+
191210
@staticmethod
192211
def _get_request_timeout(
193212
options: 'FetchOptions',
@@ -274,6 +293,13 @@ def _make_request(self, request: APIRequest) -> APIResponse:
274293
timeout=timeout,
275294
)
276295
except RequestException as request_exc:
296+
if (
297+
isinstance(request.data, MultipartStream)
298+
and request.data.size_error is not None
299+
):
300+
raise BoxSDKError(
301+
message=str(request.data.size_error), error=request_exc
302+
) from request_exc
277303
raised_exception = request_exc
278304
network_response = None
279305

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
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+
# set when a part stream ends before its declared size; retrying won't help
41+
self.size_error: Optional[IOError] = None
42+
# bytes still to send per stream segment, None when the size is unknown
43+
self._remaining: List[Optional[int]] = [
44+
None if isinstance(segment, bytes) else self._stream_size(segment)
45+
for segment in self._segments
46+
]
47+
# requests reads `len` to set Content-Length; None makes it fall back
48+
# to chunked transfer encoding
49+
self.len = self._compute_length()
50+
51+
@staticmethod
52+
def _stream_size(stream: ByteStream) -> Optional[int]:
53+
try:
54+
if not stream.seekable():
55+
return None
56+
position = stream.tell()
57+
stream.seek(0, SEEK_END)
58+
end = stream.tell()
59+
stream.seek(position)
60+
# a stream positioned past its end has nothing left to send
61+
return max(0, end - position)
62+
except (OSError, AttributeError, TypeError):
63+
return None
64+
65+
def _compute_length(self) -> Optional[int]:
66+
total = 0
67+
for segment, remaining in zip(self._segments, self._remaining):
68+
if isinstance(segment, bytes):
69+
total += len(segment)
70+
elif remaining is None:
71+
return None
72+
else:
73+
total += remaining
74+
return total
75+
76+
def read(self, size: Optional[int] = -1) -> bytes:
77+
if size is None or size < 0:
78+
return b''.join(iter(lambda: self.read(CHUNK_SIZE), b''))
79+
80+
chunks = []
81+
while size > 0 and self._index < len(self._segments):
82+
segment = self._segments[self._index]
83+
if isinstance(segment, bytes):
84+
chunk = segment[self._offset : self._offset + size]
85+
self._offset += len(chunk)
86+
if self._offset >= len(segment):
87+
self._index += 1
88+
self._offset = 0
89+
else:
90+
remaining = self._remaining[self._index]
91+
if remaining == 0:
92+
# send exactly the size declared in Content-Length, even if the stream grew
93+
self._index += 1
94+
continue
95+
chunk = segment.read(
96+
size if remaining is None else min(size, remaining)
97+
)
98+
if not chunk:
99+
if remaining is not None:
100+
self.size_error = IOError(
101+
f'Multipart stream ended {remaining} bytes before its declared size'
102+
)
103+
raise self.size_error
104+
self._index += 1
105+
continue
106+
if remaining is not None:
107+
self._remaining[self._index] = remaining - len(chunk)
108+
chunks.append(chunk)
109+
size -= len(chunk)
110+
return b''.join(chunks)
111+
112+
def __iter__(self) -> Iterator[bytes]:
113+
return iter(lambda: self.read(CHUNK_SIZE), b'')
114+
115+
def __repr__(self) -> str:
116+
return f'<MultipartStream boundary={self.boundary}>'

0 commit comments

Comments
 (0)