Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions src/runloop_api_client/sdk/async_devbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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],
Expand Down
26 changes: 26 additions & 0 deletions src/runloop_api_client/sdk/devbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import os
import logging
import threading
from typing import TYPE_CHECKING, Any, Callable, Optional, Sequence
Expand Down Expand Up @@ -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],
Expand Down
26 changes: 25 additions & 1 deletion tests/sdk/async_devbox/test_interfaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."""
Expand Down
24 changes: 23 additions & 1 deletion tests/sdk/devbox/test_interfaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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()
Expand Down
Loading