From 7e54df0d6a0dbbf72d3496ef3601c03aaa3ca90d Mon Sep 17 00:00:00 2001 From: Reflex Date: Sat, 27 Jun 2026 01:28:19 +0000 Subject: [PATCH] feat(sdk): add streaming download_to_file for devbox files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/runloop_api_client/sdk/async_devbox.py | 26 ++++++++++++++++++++++ src/runloop_api_client/sdk/devbox.py | 26 ++++++++++++++++++++++ tests/sdk/async_devbox/test_interfaces.py | 26 +++++++++++++++++++++- tests/sdk/devbox/test_interfaces.py | 24 +++++++++++++++++++- 4 files changed, 100 insertions(+), 2 deletions(-) diff --git a/src/runloop_api_client/sdk/async_devbox.py b/src/runloop_api_client/sdk/async_devbox.py index bed785a7d..332c8706f 100644 --- a/src/runloop_api_client/sdk/async_devbox.py +++ b/src/runloop_api_client/sdk/async_devbox.py @@ -2,6 +2,7 @@ from __future__ import annotations +import os import asyncio import logging from typing import TYPE_CHECKING, Any, Callable, Optional, Sequence, Awaitable, cast @@ -661,6 +662,31 @@ async def download( ) return await response.read() + async def download_to_file( + self, + dest: str | os.PathLike[str], + *, + chunk_size: int | None = None, + **params: Unpack[SDKDevboxDownloadFileParams], + ) -> None: + """Download a file from the devbox, streaming it directly to ``dest``. + + Unlike :meth:`download`, this never holds the whole file in memory: the + response body is streamed to disk in chunks, so it is safe for large files. + + :param dest: Local path to write the downloaded contents to. + :param chunk_size: Size in bytes of each streamed chunk (httpx default if None). + :param params: See :typeddict:`~runloop_api_client.sdk._types.SDKDevboxDownloadFileParams` for available parameters + + Example: + >>> await devbox.file.download_to_file("local_output.bin", path="/home/user/output.bin") + """ + async with self._devbox._client.devboxes.with_streaming_response.download_file( + self._devbox.id, + **params, + ) as response: + await response.stream_to_file(dest, chunk_size=chunk_size) + async def upload( self, **params: Unpack[SDKDevboxUploadFileParams], diff --git a/src/runloop_api_client/sdk/devbox.py b/src/runloop_api_client/sdk/devbox.py index c97ea2682..bd7fc089b 100644 --- a/src/runloop_api_client/sdk/devbox.py +++ b/src/runloop_api_client/sdk/devbox.py @@ -2,6 +2,7 @@ from __future__ import annotations +import os import logging import threading from typing import TYPE_CHECKING, Any, Callable, Optional, Sequence @@ -664,6 +665,31 @@ def download( ) return response.read() + def download_to_file( + self, + dest: str | os.PathLike[str], + *, + chunk_size: int | None = None, + **params: Unpack[SDKDevboxDownloadFileParams], + ) -> None: + """Download a file from the devbox, streaming it directly to ``dest``. + + Unlike :meth:`download`, this never holds the whole file in memory: the + response body is streamed to disk in chunks, so it is safe for large files. + + :param dest: Local path to write the downloaded contents to. + :param chunk_size: Size in bytes of each streamed chunk (httpx default if None). + :param params: See :typeddict:`~runloop_api_client.sdk._types.SDKDevboxDownloadFileParams` for available parameters + + Example: + >>> devbox.file.download_to_file("local_output.bin", path="/home/user/output.bin") + """ + with self._devbox._client.devboxes.with_streaming_response.download_file( + self._devbox.id, + **params, + ) as response: + response.stream_to_file(dest, chunk_size=chunk_size) + def upload( self, **params: Unpack[SDKDevboxUploadFileParams], diff --git a/tests/sdk/async_devbox/test_interfaces.py b/tests/sdk/async_devbox/test_interfaces.py index 06ac528ab..abf3f08cd 100644 --- a/tests/sdk/async_devbox/test_interfaces.py +++ b/tests/sdk/async_devbox/test_interfaces.py @@ -7,7 +7,7 @@ from types import SimpleNamespace from pathlib import Path -from unittest.mock import AsyncMock +from unittest.mock import Mock, AsyncMock, MagicMock import httpx import pytest @@ -140,6 +140,30 @@ async def test_download(self, mock_async_client: AsyncMock) -> None: assert result == b"file content" mock_async_client.devboxes.download_file.assert_called_once() + @pytest.mark.asyncio + async def test_download_to_file(self, mock_async_client: AsyncMock, tmp_path: Path) -> None: + """Test streaming file download writes to disk without buffering.""" + dest = tmp_path / "out.bin" + + def _stream(path: object, *args: object, **kwargs: object) -> None: # noqa: ARG001 + with open(path, "wb") as f: # type: ignore[arg-type] + f.write(b"streamed content") + + mock_response = Mock() + mock_response.stream_to_file = AsyncMock(side_effect=_stream) + cm = MagicMock() + cm.__aenter__ = AsyncMock(return_value=mock_response) + cm.__aexit__ = AsyncMock(return_value=None) + mock_async_client.devboxes.with_streaming_response.download_file = Mock(return_value=cm) + + devbox = AsyncDevbox(mock_async_client, "dbx_123") + await devbox.file.download_to_file(dest, path="/path/to/file") + + assert dest.read_bytes() == b"streamed content" + mock_response.stream_to_file.assert_awaited_once() + call_kwargs = mock_async_client.devboxes.with_streaming_response.download_file.call_args[1] + assert call_kwargs["path"] == "/path/to/file" + @pytest.mark.asyncio async def test_upload(self, mock_async_client: AsyncMock, tmp_path: Path) -> None: """Test file upload.""" diff --git a/tests/sdk/devbox/test_interfaces.py b/tests/sdk/devbox/test_interfaces.py index 41ec46c3a..53d717e25 100644 --- a/tests/sdk/devbox/test_interfaces.py +++ b/tests/sdk/devbox/test_interfaces.py @@ -8,7 +8,7 @@ from types import SimpleNamespace from pathlib import Path -from unittest.mock import Mock +from unittest.mock import Mock, MagicMock import httpx @@ -233,6 +233,28 @@ def test_download(self, mock_client: Mock) -> None: assert call_kwargs["path"] == "/path/to/file" assert "timeout" not in call_kwargs + def test_download_to_file(self, mock_client: Mock, tmp_path: Path) -> None: + """Test streaming file download writes to disk without buffering.""" + dest = tmp_path / "out.bin" + + def _stream(path: object, *args: object, **kwargs: object) -> None: # noqa: ARG001 + with open(path, "wb") as f: # type: ignore[arg-type] + f.write(b"streamed content") + + mock_response = Mock() + mock_response.stream_to_file.side_effect = _stream + cm = MagicMock() + cm.__enter__.return_value = mock_response + mock_client.devboxes.with_streaming_response.download_file.return_value = cm + + devbox = Devbox(mock_client, "dbx_123") + devbox.file.download_to_file(dest, path="/path/to/file") + + assert dest.read_bytes() == b"streamed content" + mock_response.stream_to_file.assert_called_once() + call_kwargs = mock_client.devboxes.with_streaming_response.download_file.call_args[1] + assert call_kwargs["path"] == "/path/to/file" + def test_upload(self, mock_client: Mock, tmp_path: Path) -> None: """Test file upload.""" execution_detail = SimpleNamespace()