Skip to content

Commit bf1a326

Browse files
congminh1254claude
andcommitted
fix: Harden MultipartStream size handling and test non-seekable uploads
- Clamp the part size to zero for streams positioned past their end. - Read the end position with tell() so streams whose seek() returns None work. - Fail immediately with BoxSDKError when a part stream ends before its declared size, instead of retrying a request that can't succeed. - List urllib3 in install_requires, since it is now imported directly. - Add an integration test uploading a file and a file version from a non-seekable stream, which is sent with chunked transfer encoding. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent b1a934e commit bf1a326

5 files changed

Lines changed: 121 additions & 5 deletions

File tree

‎box_sdk_gen/networking/box_network_client.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,13 @@ def _make_request(self, request: APIRequest) -> APIResponse:
293293
timeout=timeout,
294294
)
295295
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
296303
raised_exception = request_exc
297304
network_response = None
298305

‎box_sdk_gen/networking/multipart_stream.py‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ def __init__(self, fields: List[MultipartField]):
3737
self._segments.append(f'--{self.boundary}--\r\n'.encode('utf-8'))
3838
self._index = 0
3939
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
4042
# bytes still to send per stream segment, None when the size is unknown
4143
self._remaining: List[Optional[int]] = [
4244
None if isinstance(segment, bytes) else self._stream_size(segment)
@@ -52,10 +54,12 @@ def _stream_size(stream: ByteStream) -> Optional[int]:
5254
if not stream.seekable():
5355
return None
5456
position = stream.tell()
55-
size = stream.seek(0, SEEK_END) - position
57+
stream.seek(0, SEEK_END)
58+
end = stream.tell()
5659
stream.seek(position)
57-
return size
58-
except (OSError, AttributeError):
60+
# a stream positioned past its end has nothing left to send
61+
return max(0, end - position)
62+
except (OSError, AttributeError, TypeError):
5963
return None
6064

6165
def _compute_length(self) -> Optional[int]:
@@ -93,9 +97,10 @@ def read(self, size: Optional[int] = -1) -> bytes:
9397
)
9498
if not chunk:
9599
if remaining is not None:
96-
raise IOError(
100+
self.size_error = IOError(
97101
f'Multipart stream ended {remaining} bytes before its declared size'
98102
)
103+
raise self.size_error
99104
self._index += 1
100105
continue
101106
if remaining is not None:

‎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']
9+
install_requires = ['requests', 'urllib3']
1010
tests_require = [
1111
'pytest',
1212
'pytest-timeout',

‎test/box_network_client.py‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1459,3 +1459,43 @@ def test_multipart_stream_sends_declared_size_when_stream_grows():
14591459

14601460
assert len(body) == multipart_stream.len
14611461
assert b"EXTRA" not in body
1462+
1463+
1464+
def test_multipart_upload_short_stream_fails_without_retry(multipart_server):
1465+
url, _, _ = multipart_server
1466+
1467+
class Truncated(BytesIO):
1468+
def read(self, size=-1):
1469+
return super().read(max(0, min(size, 5 - self.tell())))
1470+
1471+
with patch("time.sleep") as sleep:
1472+
with pytest.raises(BoxSDKError, match="ended 5 bytes before its declared size"):
1473+
BoxNetworkClient().fetch(_upload_options(url, Truncated(b"0123456789")))
1474+
1475+
sleep.assert_not_called()
1476+
1477+
1478+
def test_multipart_stream_positioned_past_end_sends_empty_part():
1479+
stream = BytesIO(b"0123456789")
1480+
stream.seek(20)
1481+
multipart_stream = MultipartStream([("file", "f", stream, None)])
1482+
1483+
body = multipart_stream.read()
1484+
1485+
assert len(body) == multipart_stream.len
1486+
assert body.count(b"\r\n\r\n\r\n") == 1
1487+
1488+
1489+
def test_multipart_stream_handles_seek_returning_none():
1490+
class LegacySeek(BytesIO):
1491+
def seek(self, *args):
1492+
super().seek(*args)
1493+
1494+
stream = LegacySeek(b"0123456789")
1495+
stream.read(2)
1496+
multipart_stream = MultipartStream([("file", "f", stream, None)])
1497+
1498+
body = multipart_stream.read()
1499+
1500+
assert len(body) == multipart_stream.len
1501+
assert b"\r\n\r\n23456789\r\n" in body

‎test/multipart_uploads.py‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
from io import BytesIO, RawIOBase
2+
3+
from box_sdk_gen.client import BoxClient
4+
from box_sdk_gen.internal.utils import (
5+
generate_byte_buffer,
6+
get_uuid,
7+
read_byte_stream,
8+
)
9+
from box_sdk_gen.managers.uploads import (
10+
UploadFileAttributes,
11+
UploadFileAttributesParentField,
12+
UploadFileVersionAttributes,
13+
)
14+
from box_sdk_gen.schemas.file_full import FileFull
15+
16+
from test.commons import get_default_client
17+
18+
client: BoxClient = get_default_client()
19+
20+
21+
class NonSeekableStream(RawIOBase):
22+
def __init__(self, content: bytes):
23+
self._content = BytesIO(content)
24+
25+
def readable(self) -> bool:
26+
return True
27+
28+
def seekable(self) -> bool:
29+
return False
30+
31+
def readinto(self, buffer) -> int:
32+
chunk = self._content.read(len(buffer))
33+
buffer[: len(chunk)] = chunk
34+
return len(chunk)
35+
36+
37+
def testUploadFileAndFileVersionFromNonSeekableStream():
38+
content: bytes = generate_byte_buffer(5 * 1024 * 1024)
39+
new_file_name: str = get_uuid()
40+
uploaded_file: FileFull = client.uploads.upload_file(
41+
UploadFileAttributes(
42+
name=new_file_name, parent=UploadFileAttributesParentField(id='0')
43+
),
44+
NonSeekableStream(content),
45+
).entries[0]
46+
try:
47+
assert uploaded_file.name == new_file_name
48+
assert uploaded_file.size == len(content)
49+
assert read_byte_stream(client.downloads.download_file(uploaded_file.id)) == (
50+
content
51+
)
52+
53+
new_content: bytes = generate_byte_buffer(1024 * 1024)
54+
new_file_version: FileFull = client.uploads.upload_file_version(
55+
uploaded_file.id,
56+
UploadFileVersionAttributes(name=get_uuid()),
57+
NonSeekableStream(new_content),
58+
).entries[0]
59+
assert new_file_version.size == len(new_content)
60+
assert read_byte_stream(client.downloads.download_file(uploaded_file.id)) == (
61+
new_content
62+
)
63+
finally:
64+
client.files.delete_file_by_id(uploaded_file.id)

0 commit comments

Comments
 (0)