Skip to content

Commit 720ca4d

Browse files
authored
Add streaming read/write API for Sandbox (#135)
Sandbox file transfers need to handle large inputs without forcing callers or the SDK to materialize whole files in memory. Add async and sync streaming file handles for sandbox reads and writes, plus examples and coverage for local file interop, chunk boundaries, upload sizing, and streamed process/archive flows. This also lays the byte-stream foundation needed for the upcoming blob rework. Shared internal stream adapters and HTTP transport support now give sync and async runtimes the same streaming contract.
1 parent a62cf84 commit 720ca4d

33 files changed

Lines changed: 5069 additions & 428 deletions
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
#!/usr/bin/env python3
2+
"""Demonstrate streaming file upload and download on a Sandbox session."""
3+
4+
from datetime import timedelta
5+
from pathlib import Path
6+
from tempfile import TemporaryDirectory
7+
from uuid import uuid4
8+
9+
import anyio
10+
from dotenv import load_dotenv
11+
12+
from vercel.unstable import sandbox
13+
14+
load_dotenv()
15+
16+
DATA_SIZE = 1024 * 1024 # 1 MiB
17+
CHUNK_SIZE = 64 * 1024
18+
19+
20+
async def main() -> None:
21+
name = f"vercel-py-streaming-{uuid4().hex[:12]}"
22+
with TemporaryDirectory() as directory:
23+
source_path = anyio.Path(directory) / "source.bin"
24+
target_path = anyio.Path(directory) / "target.bin"
25+
sync_source_path = Path(directory) / "sync-source.bin"
26+
sync_target_path = Path(directory) / "sync-target.bin"
27+
await source_path.write_bytes(b"\x01" * DATA_SIZE)
28+
sync_source_path.write_bytes(b"\x02" * DATA_SIZE)
29+
30+
async with sandbox.create_sandbox(
31+
name=name,
32+
runtime="python3.13",
33+
execution_time_limit=timedelta(minutes=2),
34+
) as box:
35+
async with (
36+
await anyio.open_file(source_path, "rb") as source,
37+
box.fs.open("workspace/reference.bin", "wb", permissions=0o600) as target,
38+
):
39+
while chunk := await source.read(CHUNK_SIZE):
40+
await target.write(chunk)
41+
42+
copied = 0
43+
async with (
44+
box.fs.open("workspace/reference.bin", "rb") as source,
45+
await anyio.open_file(target_path, "wb") as target,
46+
):
47+
while chunk := await source.read(CHUNK_SIZE):
48+
await target.write(chunk)
49+
copied += len(chunk)
50+
print(f"Downloaded {copied} bytes")
51+
52+
assert await target_path.read_bytes() == b"\x01" * DATA_SIZE
53+
54+
with sync_source_path.open("rb") as source:
55+
async with box.fs.open(
56+
"workspace/sync-reference.bin",
57+
"wb",
58+
permissions=0o600,
59+
) as target:
60+
while chunk := source.read(CHUNK_SIZE):
61+
await target.write(chunk)
62+
63+
sync_copied = 0
64+
async with box.fs.open("workspace/sync-reference.bin", "rb") as source:
65+
with sync_target_path.open("wb") as target:
66+
while chunk := await source.read(CHUNK_SIZE):
67+
target.write(chunk)
68+
sync_copied += len(chunk)
69+
print(f"Downloaded {sync_copied} bytes with sync local files")
70+
71+
assert sync_target_path.read_bytes() == b"\x02" * DATA_SIZE
72+
73+
print("Streaming transfer complete")
74+
75+
76+
if __name__ == "__main__":
77+
anyio.run(main)
Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
"""Adapt byte sources for business logic shared by sync and async APIs.
2+
3+
Shared code consumes the async-shaped ``ReadableByteStream`` and
4+
``StagingByteFile`` protocols. Callers select the runtime matching their public
5+
API, then use its factories instead of constructing the private adapters directly.
6+
The sync runtime never suspends, while the async runtime awaits or offloads I/O as
7+
appropriate.
8+
"""
9+
10+
import inspect
11+
import io
12+
import tempfile
13+
from collections.abc import AsyncIterator
14+
from contextlib import AbstractAsyncContextManager, asynccontextmanager
15+
from typing import Protocol, TypeAlias, cast
16+
17+
import anyio
18+
from typing_extensions import Buffer
19+
20+
21+
class SyncByteReader(Protocol):
22+
"""Caller-provided byte source with a blocking ``read`` method."""
23+
24+
def read(self, size: int = -1, /) -> bytes: ...
25+
26+
27+
class AsyncByteReader(Protocol):
28+
"""Caller-provided byte source with an asynchronous ``read`` method.
29+
30+
This is structurally identical to ``ReadableByteStream`` but describes an
31+
input that has not yet been normalized by a byte-stream runtime.
32+
"""
33+
34+
async def read(self, size: int = -1, /) -> bytes: ...
35+
36+
37+
BytesLike: TypeAlias = bytes | bytearray | memoryview
38+
SyncByteSource: TypeAlias = BytesLike | SyncByteReader
39+
AsyncByteSource: TypeAlias = AsyncByteReader
40+
RawByteSource: TypeAlias = SyncByteSource | AsyncByteSource
41+
42+
43+
class ReadableByteStream(Protocol):
44+
"""Normalized readable stream consumed by shared internal workflows.
45+
46+
Its async shape hides whether a runtime performs the read inline, awaits an
47+
async source, or moves a blocking read to a worker thread.
48+
"""
49+
50+
async def read(self, size: int = -1, /) -> bytes: ...
51+
52+
53+
class StagingByteFile(ReadableByteStream, Protocol):
54+
"""SDK-owned temporary byte file used by shared staging workflows.
55+
56+
The runtime's temporary-file context manager owns the lifetime of streams
57+
implementing this protocol.
58+
"""
59+
60+
async def write(self, data: bytes, /) -> int: ...
61+
62+
async def readinto(self, buffer: Buffer, /) -> int:
63+
"""Read bytes into a writable buffer.
64+
65+
``Buffer`` cannot express mutability, so read-only buffers are rejected
66+
at runtime.
67+
"""
68+
...
69+
70+
async def flush(self) -> None: ...
71+
72+
async def tell(self) -> int: ...
73+
74+
async def seek(self, offset: int, whence: int = 0, /) -> int: ...
75+
76+
async def truncate(self, size: int | None = None, /) -> int: ...
77+
78+
79+
class StagingFileRuntime(Protocol):
80+
"""Runtime-specific temporary-file capability for shared business logic."""
81+
82+
def temporary_file(self) -> AbstractAsyncContextManager[StagingByteFile]: ...
83+
84+
85+
def _bytes_result(value: object) -> bytes:
86+
if isinstance(value, bytes):
87+
return value
88+
raise TypeError(f"byte stream returned {type(value).__name__}, expected bytes")
89+
90+
91+
class _SyncReader:
92+
"""Expose a blocking reader through an async-shaped, non-suspending method."""
93+
94+
def __init__(self, source: SyncByteReader) -> None:
95+
self._source = source
96+
97+
async def read(self, size: int = -1, /) -> bytes:
98+
return _bytes_result(self._source.read(size))
99+
100+
101+
class _MemoryReader:
102+
"""Give an immutable bytes snapshot a stateful, non-suspending read cursor."""
103+
104+
def __init__(self, data: BytesLike) -> None:
105+
self._data = memoryview(bytes(data))
106+
self._offset = 0
107+
108+
async def read(self, size: int = -1, /) -> bytes:
109+
remaining = self._data[self._offset :]
110+
if size < 0:
111+
self._offset = len(self._data)
112+
return bytes(remaining)
113+
chunk = bytes(remaining[:size])
114+
self._offset += len(chunk)
115+
return chunk
116+
117+
118+
class _AsyncReader:
119+
"""Normalize a genuinely asynchronous reader and validate its results."""
120+
121+
def __init__(self, source: AsyncByteReader) -> None:
122+
self._source = source
123+
124+
async def read(self, size: int = -1, /) -> bytes:
125+
return _bytes_result(await self._source.read(size))
126+
127+
128+
class _ThreadedSyncReader:
129+
"""Run a blocking reader on a worker thread for use by async workflows."""
130+
131+
def __init__(self, source: SyncByteReader) -> None:
132+
self._source = source
133+
134+
async def read(self, size: int = -1, /) -> bytes:
135+
return _bytes_result(await anyio.to_thread.run_sync(self._source.read, size))
136+
137+
138+
class _SyncTemporaryFile:
139+
"""Expose a blocking temporary file through non-suspending async methods."""
140+
141+
def __init__(self) -> None:
142+
self._file = cast(io.BufferedRandom, tempfile.TemporaryFile("w+b"))
143+
144+
def _ensure_open(self) -> None:
145+
if self._file.closed:
146+
raise anyio.ClosedResourceError
147+
148+
async def read(self, size: int = -1, /) -> bytes:
149+
self._ensure_open()
150+
return self._file.read(size)
151+
152+
async def write(self, data: bytes, /) -> int:
153+
self._ensure_open()
154+
return self._file.write(data)
155+
156+
async def readinto(self, buffer: Buffer, /) -> int:
157+
"""Read bytes into a writable buffer.
158+
159+
``Buffer`` cannot express mutability, so read-only buffers are rejected
160+
at runtime.
161+
"""
162+
self._ensure_open()
163+
return self._file.readinto(buffer)
164+
165+
async def flush(self) -> None:
166+
self._ensure_open()
167+
self._file.flush()
168+
169+
async def tell(self) -> int:
170+
self._ensure_open()
171+
return self._file.tell()
172+
173+
async def seek(self, offset: int, whence: int = 0, /) -> int:
174+
self._ensure_open()
175+
return self._file.seek(offset, whence)
176+
177+
async def truncate(self, size: int | None = None, /) -> int:
178+
self._ensure_open()
179+
return self._file.truncate(size)
180+
181+
def close(self) -> None:
182+
self._file.close()
183+
184+
185+
@asynccontextmanager
186+
async def _sync_temporary_file() -> AsyncIterator[StagingByteFile]:
187+
file = _SyncTemporaryFile()
188+
try:
189+
yield file
190+
finally:
191+
file.close()
192+
193+
194+
class SyncByteStreamRuntime:
195+
"""Adapt blocking byte primitives for shared async-shaped workflows.
196+
197+
Every operation completes without suspending so sync entry points can drive
198+
the shared coroutine with ``iter_coroutine`` and no event loop.
199+
"""
200+
201+
@staticmethod
202+
def reader(source: SyncByteSource) -> ReadableByteStream:
203+
if isinstance(source, (bytes, bytearray, memoryview)):
204+
return _MemoryReader(source)
205+
read = getattr(source, "read", None)
206+
if not callable(read):
207+
raise TypeError("byte source must provide a callable read method")
208+
if inspect.iscoroutinefunction(read):
209+
raise TypeError("sync byte stream runtime does not support async readers")
210+
return _SyncReader(cast(SyncByteReader, source))
211+
212+
def temporary_file(self) -> AbstractAsyncContextManager[StagingByteFile]:
213+
return _sync_temporary_file()
214+
215+
216+
class AsyncByteStreamRuntime:
217+
"""Adapt byte primitives for execution under AnyIO.
218+
219+
Async readers are awaited directly, while blocking readers run on a worker
220+
thread so they do not block the event loop.
221+
"""
222+
223+
@staticmethod
224+
def reader(source: RawByteSource) -> ReadableByteStream:
225+
if isinstance(source, (bytes, bytearray, memoryview)):
226+
return _MemoryReader(source)
227+
read = getattr(source, "read", None)
228+
if not callable(read):
229+
raise TypeError("byte source must provide a callable read method")
230+
if inspect.iscoroutinefunction(read):
231+
return _AsyncReader(cast(AsyncByteReader, source))
232+
return _ThreadedSyncReader(cast(SyncByteReader, source))
233+
234+
def temporary_file(self) -> AbstractAsyncContextManager[StagingByteFile]:
235+
return cast(
236+
AbstractAsyncContextManager[StagingByteFile],
237+
anyio.TemporaryFile("w+b"),
238+
)
239+
240+
241+
__all__ = [
242+
"AsyncByteReader",
243+
"AsyncByteSource",
244+
"AsyncByteStreamRuntime",
245+
"BytesLike",
246+
"RawByteSource",
247+
"ReadableByteStream",
248+
"StagingByteFile",
249+
"StagingFileRuntime",
250+
"SyncByteReader",
251+
"SyncByteSource",
252+
"SyncByteStreamRuntime",
253+
]

src/vercel/_internal/http/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
RawBody,
1818
ReadResponsePolicy,
1919
RequestBody,
20+
StreamingRequest,
21+
StreamingResponse,
2022
SyncTransport,
2123
TransportOptions,
2224
extract_structured_error,
@@ -34,6 +36,8 @@
3436
"RawBody",
3537
"ReadResponsePolicy",
3638
"RequestBody",
39+
"StreamingRequest",
40+
"StreamingResponse",
3741
"RetryPolicy",
3842
"SleepFn",
3943
"create_base_client",

0 commit comments

Comments
 (0)