Skip to content

Commit 7b5e491

Browse files
authored
sandbox: Add more file read methods (#68)
- `iter_file`: a chunked bytes iterator - `download_file`: stream to a file
1 parent 1e96f51 commit 7b5e491

13 files changed

Lines changed: 1281 additions & 35 deletions

File tree

examples/sandbox_02_fs_ops.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import asyncio
2+
from pathlib import Path
3+
from tempfile import TemporaryDirectory
24

35
from dotenv import load_dotenv
46

@@ -26,9 +28,23 @@ async def main() -> None:
2628
)
2729

2830
data1 = await sandbox.read_file("hello.txt")
31+
stream = await sandbox.iter_file("hello.txt", chunk_size=5)
2932
data2 = await sandbox.read_file("/vercel/sandbox/nested/dir/note.txt")
3033
result = await sandbox.run_command("./hello.sh")
34+
assert stream is not None
35+
streamed_data1 = b"".join([chunk async for chunk in stream])
36+
37+
with TemporaryDirectory() as tmp_dir:
38+
downloaded_path = Path(tmp_dir) / "downloaded-hello.txt"
39+
saved_path = await sandbox.download_file("hello.txt", downloaded_path)
40+
assert saved_path == str(downloaded_path.resolve())
41+
downloaded_data1 = downloaded_path.read_bytes()
42+
43+
assert data1 == streamed_data1
44+
assert data1 == downloaded_data1
3145
print("hello.txt:", data1.decode())
46+
print("hello.txt (streamed):", streamed_data1.decode())
47+
print("hello.txt (downloaded):", downloaded_data1.decode())
3248
print("note.txt:", data2.decode())
3349
print("hello.sh:", (await result.stdout()).strip())
3450

src/vercel/_internal/fs.py

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
"""Internal filesystem client primitives with runtime-specific platforms.
2+
3+
The shared client delegates to a platform with an async-shaped interface. The
4+
sync platform wraps explicitly synchronous filesystem operations so shared
5+
business logic can still run through ``iter_coroutine()`` without suspension.
6+
The async platform delegates to ``anyio``'s async filesystem primitives.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import os
12+
from typing import BinaryIO, Generic, Protocol, TypeVar
13+
14+
import anyio
15+
16+
PathInput = str | os.PathLike[str]
17+
SyncFileHandle = BinaryIO
18+
AsyncFileHandle = anyio.AsyncFile[bytes]
19+
FileHandle = SyncFileHandle | AsyncFileHandle
20+
HandleT = TypeVar("HandleT", SyncFileHandle, AsyncFileHandle)
21+
22+
23+
def _coerce_path(path: PathInput) -> str:
24+
return os.fspath(path)
25+
26+
27+
def _parent_dir(path: PathInput) -> str:
28+
return os.path.dirname(_coerce_path(path)) or "."
29+
30+
31+
class FilesystemPlatform(Protocol[HandleT]):
32+
async def coerce_path(self, path: PathInput) -> str: ...
33+
34+
async def create_parent_directories(self, path: PathInput) -> None: ...
35+
36+
async def open_binary_writer(self, path: PathInput) -> HandleT: ...
37+
38+
async def write(self, handle: HandleT, data: bytes) -> None: ...
39+
40+
async def close(self, handle: HandleT) -> None: ...
41+
42+
async def replace(self, src: PathInput, dst: PathInput) -> None: ...
43+
44+
async def remove_if_exists(self, path: PathInput) -> None: ...
45+
46+
async def exists(self, path: PathInput) -> bool: ...
47+
48+
49+
class SyncFilesystemPlatform:
50+
"""Sync platform with async interface for use with ``iter_coroutine()``."""
51+
52+
async def coerce_path(self, path: PathInput) -> str:
53+
return _coerce_path(path)
54+
55+
async def create_parent_directories(self, path: PathInput) -> None:
56+
os.makedirs(_parent_dir(path), exist_ok=True)
57+
58+
async def open_binary_writer(self, path: PathInput) -> SyncFileHandle:
59+
return open(_coerce_path(path), "wb")
60+
61+
async def write(self, handle: SyncFileHandle, data: bytes) -> None:
62+
handle.write(data)
63+
64+
async def close(self, handle: SyncFileHandle) -> None:
65+
handle.close()
66+
67+
async def replace(self, src: PathInput, dst: PathInput) -> None:
68+
os.replace(_coerce_path(src), _coerce_path(dst))
69+
70+
async def remove_if_exists(self, path: PathInput) -> None:
71+
try:
72+
os.remove(_coerce_path(path))
73+
except FileNotFoundError:
74+
pass
75+
76+
async def exists(self, path: PathInput) -> bool:
77+
return os.path.exists(_coerce_path(path))
78+
79+
80+
class AsyncFilesystemPlatform:
81+
async def coerce_path(self, path: PathInput) -> str:
82+
return _coerce_path(path)
83+
84+
async def create_parent_directories(self, path: PathInput) -> None:
85+
await anyio.Path(_parent_dir(path)).mkdir(parents=True, exist_ok=True)
86+
87+
async def open_binary_writer(self, path: PathInput) -> AsyncFileHandle:
88+
return await anyio.open_file(_coerce_path(path), "wb")
89+
90+
async def write(self, handle: AsyncFileHandle, data: bytes) -> None:
91+
await handle.write(data)
92+
93+
async def close(self, handle: AsyncFileHandle) -> None:
94+
await handle.aclose()
95+
96+
async def replace(self, src: PathInput, dst: PathInput) -> None:
97+
await anyio.Path(_coerce_path(src)).replace(_coerce_path(dst))
98+
99+
async def remove_if_exists(self, path: PathInput) -> None:
100+
await anyio.Path(_coerce_path(path)).unlink(missing_ok=True)
101+
102+
async def exists(self, path: PathInput) -> bool:
103+
return await anyio.Path(_coerce_path(path)).exists()
104+
105+
106+
class FilesystemClient(Generic[HandleT]):
107+
"""Shared filesystem client with a transport-backed async API."""
108+
109+
def __init__(self, *, platform: FilesystemPlatform[HandleT]) -> None:
110+
self._platform: FilesystemPlatform[HandleT] = platform
111+
112+
async def coerce_path(self, path: PathInput) -> str:
113+
return await self._platform.coerce_path(path)
114+
115+
async def create_parent_directories(self, path: PathInput) -> None:
116+
await self._platform.create_parent_directories(path)
117+
118+
async def open_binary_writer(self, path: PathInput) -> HandleT:
119+
return await self._platform.open_binary_writer(path)
120+
121+
async def write(self, handle: HandleT, data: bytes) -> None:
122+
await self._platform.write(handle, data)
123+
124+
async def close(self, handle: HandleT) -> None:
125+
await self._platform.close(handle)
126+
127+
async def replace(self, src: PathInput, dst: PathInput) -> None:
128+
await self._platform.replace(src, dst)
129+
130+
async def remove_if_exists(self, path: PathInput) -> None:
131+
await self._platform.remove_if_exists(path)
132+
133+
async def exists(self, path: PathInput) -> bool:
134+
return await self._platform.exists(path)
135+
136+
137+
def create_filesystem_client() -> FilesystemClient[SyncFileHandle]:
138+
"""Create a sync filesystem client backed by blocking file operations."""
139+
140+
return FilesystemClient(platform=SyncFilesystemPlatform())
141+
142+
143+
def create_async_filesystem_client() -> FilesystemClient[AsyncFileHandle]:
144+
"""Create an async filesystem client backed by anyio filesystem primitives."""
145+
146+
return FilesystemClient(platform=AsyncFilesystemPlatform())
147+
148+
149+
class SyncFilesystemClient(FilesystemClient[SyncFileHandle]):
150+
"""Convenience wrapper matching the repo's sync/async client naming pattern."""
151+
152+
def __init__(self) -> None:
153+
super().__init__(platform=SyncFilesystemPlatform())
154+
155+
156+
class AsyncFilesystemClient(FilesystemClient[AsyncFileHandle]):
157+
"""Convenience wrapper matching the repo's sync/async client naming pattern."""
158+
159+
def __init__(self) -> None:
160+
super().__init__(platform=AsyncFilesystemPlatform())
161+
162+
163+
__all__ = [
164+
"AsyncFileHandle",
165+
"AsyncFilesystemClient",
166+
"AsyncFilesystemPlatform",
167+
"FileHandle",
168+
"FilesystemClient",
169+
"FilesystemPlatform",
170+
"PathInput",
171+
"SyncFileHandle",
172+
"SyncFilesystemClient",
173+
"SyncFilesystemPlatform",
174+
"create_async_filesystem_client",
175+
"create_filesystem_client",
176+
]

src/vercel/_internal/sandbox/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
APIError,
66
SandboxAuthError,
77
SandboxError,
8+
SandboxNotFoundError,
89
SandboxPermissionError,
910
SandboxRateLimitError,
1011
SandboxServerError,
@@ -16,6 +17,7 @@
1617
"SandboxError",
1718
"APIError",
1819
"SandboxAuthError",
20+
"SandboxNotFoundError",
1921
"SandboxPermissionError",
2022
"SandboxRateLimitError",
2123
"SandboxServerError",

0 commit comments

Comments
 (0)