Skip to content

Commit 3da71da

Browse files
Reflexclaude
andcommitted
fix(files): stream file uploads from disk instead of buffering in memory
upload_file (and any PathLike file input) read the entire file into memory via read_bytes() before the request was built, contradicting the streaming behavior these large-file endpoints imply. Hand httpx an open file handle so its multipart encoder reads lazily in chunks. Tests assert an io.IOBase handle is returned and close it to avoid resource warnings. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 3c483bd commit 3da71da

2 files changed

Lines changed: 48 additions & 14 deletions

File tree

src/runloop_api_client/_files.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,11 @@ def _transform_file(file: FileTypes) -> HttpxFileTypes:
6666
if is_file_content(file):
6767
if isinstance(file, os.PathLike):
6868
path = pathlib.Path(file)
69-
return (path.name, path.read_bytes())
69+
# Hand httpx an open file handle so its multipart encoder reads the
70+
# file lazily in chunks. read_bytes() would buffer the entire file
71+
# in memory up front, which is wasteful for the large files this
72+
# endpoint targets.
73+
return (path.name, path.open("rb"))
7074

7175
return file
7276

@@ -107,8 +111,11 @@ async def async_to_httpx_files(files: RequestFiles | None) -> HttpxRequestFiles
107111
async def _async_transform_file(file: FileTypes) -> HttpxFileTypes:
108112
if is_file_content(file):
109113
if isinstance(file, os.PathLike):
110-
path = anyio.Path(file)
111-
return (path.name, await path.read_bytes())
114+
path = pathlib.Path(file)
115+
# Same rationale as the sync path: stream from an open handle rather
116+
# than buffering the whole file. httpx's multipart encoder reads from
117+
# this handle in chunks as it serializes the request body.
118+
return (path.name, path.open("rb"))
112119

113120
return file
114121

tests/test_files.py

Lines changed: 38 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,73 @@
1+
import io
12
from pathlib import Path
23

34
import anyio
45
import pytest
5-
from dirty_equals import IsDict, IsList, IsBytes, IsTuple
6+
from dirty_equals import IsDict, IsList, IsTuple, IsInstance
67

78
from runloop_api_client._files import to_httpx_files, deepcopy_with_paths, async_to_httpx_files
89
from runloop_api_client._utils import extract_files
910

1011
readme_path = Path(__file__).parent.parent.joinpath("README.md")
1112

1213

14+
def _close_file_handles(value: object) -> None:
15+
"""Recursively close any open file handles in to_httpx_files output.
16+
17+
The transform returns streaming file handles, so tests that don't actually
18+
issue a request must close them to avoid resource warnings.
19+
"""
20+
if isinstance(value, io.IOBase):
21+
value.close()
22+
elif isinstance(value, dict):
23+
for v in value.values():
24+
_close_file_handles(v)
25+
elif isinstance(value, (list, tuple)):
26+
for v in value:
27+
_close_file_handles(v)
28+
29+
1330
def test_pathlib_includes_file_name() -> None:
1431
result = to_httpx_files({"file": readme_path})
15-
print(result)
16-
assert result == IsDict({"file": IsTuple("README.md", IsBytes())})
32+
try:
33+
assert result == IsDict({"file": IsTuple("README.md", IsInstance(io.IOBase))})
34+
finally:
35+
_close_file_handles(result)
1736

1837

1938
def test_tuple_input() -> None:
2039
result = to_httpx_files([("file", readme_path)])
21-
print(result)
22-
assert result == IsList(IsTuple("file", IsTuple("README.md", IsBytes())))
40+
try:
41+
assert result == IsList(IsTuple("file", IsTuple("README.md", IsInstance(io.IOBase))))
42+
finally:
43+
_close_file_handles(result)
2344

2445

2546
@pytest.mark.asyncio
2647
async def test_async_pathlib_includes_file_name() -> None:
2748
result = await async_to_httpx_files({"file": readme_path})
28-
print(result)
29-
assert result == IsDict({"file": IsTuple("README.md", IsBytes())})
49+
try:
50+
assert result == IsDict({"file": IsTuple("README.md", IsInstance(io.IOBase))})
51+
finally:
52+
_close_file_handles(result)
3053

3154

3255
@pytest.mark.asyncio
3356
async def test_async_supports_anyio_path() -> None:
3457
result = await async_to_httpx_files({"file": anyio.Path(readme_path)})
35-
print(result)
36-
assert result == IsDict({"file": IsTuple("README.md", IsBytes())})
58+
try:
59+
assert result == IsDict({"file": IsTuple("README.md", IsInstance(io.IOBase))})
60+
finally:
61+
_close_file_handles(result)
3762

3863

3964
@pytest.mark.asyncio
4065
async def test_async_tuple_input() -> None:
4166
result = await async_to_httpx_files([("file", readme_path)])
42-
print(result)
43-
assert result == IsList(IsTuple("file", IsTuple("README.md", IsBytes())))
67+
try:
68+
assert result == IsList(IsTuple("file", IsTuple("README.md", IsInstance(io.IOBase))))
69+
finally:
70+
_close_file_handles(result)
4471

4572

4673
def test_string_not_allowed() -> None:

0 commit comments

Comments
 (0)