Skip to content

Commit 7e54df0

Browse files
Reflexclaude
andcommitted
feat(sdk): add streaming download_to_file for devbox files
devbox.file.download() reads the entire file into memory via response.read(), which is wasteful for large downloads — the mirror image of the upload-side buffering. Add download_to_file(dest) on both the sync and async FileInterface that streams the response body to disk in chunks via with_streaming_response, so large files never have to fit in memory. The buffered download() is kept for small-file convenience. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 3c483bd commit 7e54df0

4 files changed

Lines changed: 100 additions & 2 deletions

File tree

src/runloop_api_client/sdk/async_devbox.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
import os
56
import asyncio
67
import logging
78
from typing import TYPE_CHECKING, Any, Callable, Optional, Sequence, Awaitable, cast
@@ -661,6 +662,31 @@ async def download(
661662
)
662663
return await response.read()
663664

665+
async def download_to_file(
666+
self,
667+
dest: str | os.PathLike[str],
668+
*,
669+
chunk_size: int | None = None,
670+
**params: Unpack[SDKDevboxDownloadFileParams],
671+
) -> None:
672+
"""Download a file from the devbox, streaming it directly to ``dest``.
673+
674+
Unlike :meth:`download`, this never holds the whole file in memory: the
675+
response body is streamed to disk in chunks, so it is safe for large files.
676+
677+
:param dest: Local path to write the downloaded contents to.
678+
:param chunk_size: Size in bytes of each streamed chunk (httpx default if None).
679+
:param params: See :typeddict:`~runloop_api_client.sdk._types.SDKDevboxDownloadFileParams` for available parameters
680+
681+
Example:
682+
>>> await devbox.file.download_to_file("local_output.bin", path="/home/user/output.bin")
683+
"""
684+
async with self._devbox._client.devboxes.with_streaming_response.download_file(
685+
self._devbox.id,
686+
**params,
687+
) as response:
688+
await response.stream_to_file(dest, chunk_size=chunk_size)
689+
664690
async def upload(
665691
self,
666692
**params: Unpack[SDKDevboxUploadFileParams],

src/runloop_api_client/sdk/devbox.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
import os
56
import logging
67
import threading
78
from typing import TYPE_CHECKING, Any, Callable, Optional, Sequence
@@ -664,6 +665,31 @@ def download(
664665
)
665666
return response.read()
666667

668+
def download_to_file(
669+
self,
670+
dest: str | os.PathLike[str],
671+
*,
672+
chunk_size: int | None = None,
673+
**params: Unpack[SDKDevboxDownloadFileParams],
674+
) -> None:
675+
"""Download a file from the devbox, streaming it directly to ``dest``.
676+
677+
Unlike :meth:`download`, this never holds the whole file in memory: the
678+
response body is streamed to disk in chunks, so it is safe for large files.
679+
680+
:param dest: Local path to write the downloaded contents to.
681+
:param chunk_size: Size in bytes of each streamed chunk (httpx default if None).
682+
:param params: See :typeddict:`~runloop_api_client.sdk._types.SDKDevboxDownloadFileParams` for available parameters
683+
684+
Example:
685+
>>> devbox.file.download_to_file("local_output.bin", path="/home/user/output.bin")
686+
"""
687+
with self._devbox._client.devboxes.with_streaming_response.download_file(
688+
self._devbox.id,
689+
**params,
690+
) as response:
691+
response.stream_to_file(dest, chunk_size=chunk_size)
692+
667693
def upload(
668694
self,
669695
**params: Unpack[SDKDevboxUploadFileParams],

tests/sdk/async_devbox/test_interfaces.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
from types import SimpleNamespace
99
from pathlib import Path
10-
from unittest.mock import AsyncMock
10+
from unittest.mock import Mock, AsyncMock, MagicMock
1111

1212
import httpx
1313
import pytest
@@ -140,6 +140,30 @@ async def test_download(self, mock_async_client: AsyncMock) -> None:
140140
assert result == b"file content"
141141
mock_async_client.devboxes.download_file.assert_called_once()
142142

143+
@pytest.mark.asyncio
144+
async def test_download_to_file(self, mock_async_client: AsyncMock, tmp_path: Path) -> None:
145+
"""Test streaming file download writes to disk without buffering."""
146+
dest = tmp_path / "out.bin"
147+
148+
def _stream(path: object, *args: object, **kwargs: object) -> None: # noqa: ARG001
149+
with open(path, "wb") as f: # type: ignore[arg-type]
150+
f.write(b"streamed content")
151+
152+
mock_response = Mock()
153+
mock_response.stream_to_file = AsyncMock(side_effect=_stream)
154+
cm = MagicMock()
155+
cm.__aenter__ = AsyncMock(return_value=mock_response)
156+
cm.__aexit__ = AsyncMock(return_value=None)
157+
mock_async_client.devboxes.with_streaming_response.download_file = Mock(return_value=cm)
158+
159+
devbox = AsyncDevbox(mock_async_client, "dbx_123")
160+
await devbox.file.download_to_file(dest, path="/path/to/file")
161+
162+
assert dest.read_bytes() == b"streamed content"
163+
mock_response.stream_to_file.assert_awaited_once()
164+
call_kwargs = mock_async_client.devboxes.with_streaming_response.download_file.call_args[1]
165+
assert call_kwargs["path"] == "/path/to/file"
166+
143167
@pytest.mark.asyncio
144168
async def test_upload(self, mock_async_client: AsyncMock, tmp_path: Path) -> None:
145169
"""Test file upload."""

tests/sdk/devbox/test_interfaces.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
from types import SimpleNamespace
1010
from pathlib import Path
11-
from unittest.mock import Mock
11+
from unittest.mock import Mock, MagicMock
1212

1313
import httpx
1414

@@ -233,6 +233,28 @@ def test_download(self, mock_client: Mock) -> None:
233233
assert call_kwargs["path"] == "/path/to/file"
234234
assert "timeout" not in call_kwargs
235235

236+
def test_download_to_file(self, mock_client: Mock, tmp_path: Path) -> None:
237+
"""Test streaming file download writes to disk without buffering."""
238+
dest = tmp_path / "out.bin"
239+
240+
def _stream(path: object, *args: object, **kwargs: object) -> None: # noqa: ARG001
241+
with open(path, "wb") as f: # type: ignore[arg-type]
242+
f.write(b"streamed content")
243+
244+
mock_response = Mock()
245+
mock_response.stream_to_file.side_effect = _stream
246+
cm = MagicMock()
247+
cm.__enter__.return_value = mock_response
248+
mock_client.devboxes.with_streaming_response.download_file.return_value = cm
249+
250+
devbox = Devbox(mock_client, "dbx_123")
251+
devbox.file.download_to_file(dest, path="/path/to/file")
252+
253+
assert dest.read_bytes() == b"streamed content"
254+
mock_response.stream_to_file.assert_called_once()
255+
call_kwargs = mock_client.devboxes.with_streaming_response.download_file.call_args[1]
256+
assert call_kwargs["path"] == "/path/to/file"
257+
236258
def test_upload(self, mock_client: Mock, tmp_path: Path) -> None:
237259
"""Test file upload."""
238260
execution_detail = SimpleNamespace()

0 commit comments

Comments
 (0)