Skip to content

Commit c17451a

Browse files
authored
Merge pull request #60 from hyperbrowserai/list-sandbox-endpoints
list sandboxes, images, snapshots
2 parents 7dd80b3 + 4efbf25 commit c17451a

8 files changed

Lines changed: 550 additions & 13 deletions

File tree

hyperbrowser/client/managers/async_manager/sandbox.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,14 @@
77
SandboxExecParams,
88
SandboxExposeParams,
99
SandboxExposeResult,
10+
SandboxImageListResponse,
11+
SandboxListParams,
12+
SandboxListResponse,
1013
SandboxMemorySnapshotParams,
1114
SandboxMemorySnapshotResult,
1215
SandboxRuntimeSession,
16+
SandboxSnapshotListParams,
17+
SandboxSnapshotListResponse,
1318
StartSandboxFromSnapshotParams,
1419
)
1520
from ....models.session import BasicResponse
@@ -288,6 +293,34 @@ async def connect(self, sandbox_id: str) -> SandboxHandle:
288293
await sandbox.connect()
289294
return sandbox
290295

296+
async def list(
297+
self, params: SandboxListParams = SandboxListParams()
298+
) -> SandboxListResponse:
299+
if not isinstance(params, SandboxListParams):
300+
raise TypeError("params must be a SandboxListParams instance")
301+
payload = await self._request(
302+
"GET",
303+
"/sandboxes",
304+
params=params.model_dump(exclude_none=True, by_alias=True),
305+
)
306+
return SandboxListResponse(**payload)
307+
308+
async def list_images(self) -> SandboxImageListResponse:
309+
payload = await self._request("GET", "/images")
310+
return SandboxImageListResponse(**payload)
311+
312+
async def list_snapshots(
313+
self, params: SandboxSnapshotListParams = SandboxSnapshotListParams()
314+
) -> SandboxSnapshotListResponse:
315+
if not isinstance(params, SandboxSnapshotListParams):
316+
raise TypeError("params must be a SandboxSnapshotListParams instance")
317+
payload = await self._request(
318+
"GET",
319+
"/snapshots",
320+
params=params.model_dump(exclude_none=True, by_alias=True),
321+
)
322+
return SandboxSnapshotListResponse(**payload)
323+
291324
async def stop(self, sandbox_id: str) -> BasicResponse:
292325
payload = await self._request("PUT", f"/sandbox/{sandbox_id}/stop")
293326
return BasicResponse(**payload)

hyperbrowser/client/managers/sync_manager/sandbox.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,14 @@
77
SandboxExecParams,
88
SandboxExposeParams,
99
SandboxExposeResult,
10+
SandboxImageListResponse,
11+
SandboxListParams,
12+
SandboxListResponse,
1013
SandboxMemorySnapshotParams,
1114
SandboxMemorySnapshotResult,
1215
SandboxRuntimeSession,
16+
SandboxSnapshotListParams,
17+
SandboxSnapshotListResponse,
1318
StartSandboxFromSnapshotParams,
1419
)
1520
from ....models.session import BasicResponse
@@ -288,6 +293,34 @@ def connect(self, sandbox_id: str) -> SandboxHandle:
288293
sandbox.connect()
289294
return sandbox
290295

296+
def list(
297+
self, params: SandboxListParams = SandboxListParams()
298+
) -> SandboxListResponse:
299+
if not isinstance(params, SandboxListParams):
300+
raise TypeError("params must be a SandboxListParams instance")
301+
payload = self._request(
302+
"GET",
303+
"/sandboxes",
304+
params=params.model_dump(exclude_none=True, by_alias=True),
305+
)
306+
return SandboxListResponse(**payload)
307+
308+
def list_images(self) -> SandboxImageListResponse:
309+
payload = self._request("GET", "/images")
310+
return SandboxImageListResponse(**payload)
311+
312+
def list_snapshots(
313+
self, params: SandboxSnapshotListParams = SandboxSnapshotListParams()
314+
) -> SandboxSnapshotListResponse:
315+
if not isinstance(params, SandboxSnapshotListParams):
316+
raise TypeError("params must be a SandboxSnapshotListParams instance")
317+
payload = self._request(
318+
"GET",
319+
"/snapshots",
320+
params=params.model_dump(exclude_none=True, by_alias=True),
321+
)
322+
return SandboxSnapshotListResponse(**payload)
323+
291324
def stop(self, sandbox_id: str) -> BasicResponse:
292325
payload = self._request("PUT", f"/sandbox/{sandbox_id}/stop")
293326
return BasicResponse(**payload)

hyperbrowser/models/__init__.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,12 @@
246246
StartSandboxFromSnapshotParams,
247247
SandboxListParams,
248248
SandboxListResponse,
249+
SandboxImageListResponse,
250+
SandboxImageSummary,
251+
SandboxSnapshotStatus,
252+
SandboxSnapshotListResponse,
253+
SandboxSnapshotSummary,
254+
SandboxSnapshotListParams,
249255
SandboxMemorySnapshotParams,
250256
SandboxMemorySnapshotResult,
251257
SandboxExposeParams,
@@ -490,6 +496,12 @@
490496
"StartSandboxFromSnapshotParams",
491497
"SandboxListParams",
492498
"SandboxListResponse",
499+
"SandboxImageListResponse",
500+
"SandboxImageSummary",
501+
"SandboxSnapshotStatus",
502+
"SandboxSnapshotListResponse",
503+
"SandboxSnapshotSummary",
504+
"SandboxSnapshotListParams",
493505
"SandboxMemorySnapshotParams",
494506
"SandboxMemorySnapshotResult",
495507
"SandboxExposeParams",

hyperbrowser/models/sandbox.py

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
"killed",
2424
"timed_out",
2525
]
26+
SandboxSnapshotStatus = Literal["creating", "created", "failed"]
2627
SandboxFileType = Literal["file", "dir"]
2728
SandboxFileEncoding = Literal["utf8", "base64"]
2829
SandboxFileReadFormat = Literal["text", "bytes", "blob", "stream"]
@@ -164,10 +165,9 @@ class StartSandboxFromSnapshotParams(CreateSandboxParams):
164165

165166

166167
class SandboxListParams(SandboxBaseModel):
167-
status: Optional[SandboxStatus] = None
168-
page: Optional[int] = None
169-
limit: Optional[int] = None
170-
search: Optional[str] = None
168+
status: Optional[SandboxStatus] = Field(default=None, exclude=None)
169+
page: int = Field(default=1, ge=1)
170+
limit: int = Field(default=10, ge=1)
171171

172172

173173
class SandboxListResponse(SandboxBaseModel):
@@ -177,6 +177,51 @@ class SandboxListResponse(SandboxBaseModel):
177177
per_page: int = Field(alias="perPage")
178178

179179

180+
class SandboxImageSummary(SandboxBaseModel):
181+
id: str
182+
image_name: str = Field(alias="imageName")
183+
namespace: str
184+
uploaded: bool
185+
created_at: datetime = Field(alias="createdAt")
186+
updated_at: datetime = Field(alias="updatedAt")
187+
188+
189+
class SandboxImageListResponse(SandboxBaseModel):
190+
images: List[SandboxImageSummary]
191+
# TODO: add pagination metadata when /api/images supports it.
192+
# total_count: Optional[int] = Field(default=None, alias="totalCount")
193+
# page: Optional[int] = None
194+
# per_page: Optional[int] = Field(default=None, alias="perPage")
195+
196+
197+
class SandboxSnapshotSummary(SandboxBaseModel):
198+
id: str
199+
snapshot_name: str = Field(alias="snapshotName")
200+
namespace: str
201+
image_namespace: str = Field(alias="imageNamespace")
202+
image_name: str = Field(alias="imageName")
203+
image_id: str = Field(alias="imageId")
204+
status: SandboxSnapshotStatus
205+
compatibility_tag: Optional[str] = Field(default=None, alias="compatibilityTag")
206+
metadata: Dict[str, object]
207+
uploaded: bool
208+
created_at: datetime = Field(alias="createdAt")
209+
updated_at: datetime = Field(alias="updatedAt")
210+
211+
212+
class SandboxSnapshotListParams(SandboxBaseModel):
213+
status: Optional[SandboxSnapshotStatus] = Field(default=None, exclude=None)
214+
limit: Optional[int] = Field(default=None, ge=1)
215+
216+
217+
class SandboxSnapshotListResponse(SandboxBaseModel):
218+
snapshots: List[SandboxSnapshotSummary]
219+
# TODO: add pagination metadata when /api/snapshots supports it.
220+
# total_count: Optional[int] = Field(default=None, alias="totalCount")
221+
# page: Optional[int] = None
222+
# per_page: Optional[int] = Field(default=None, alias="perPage")
223+
224+
180225
class SandboxMemorySnapshotParams(SandboxBaseModel):
181226
snapshot_name: Optional[str] = Field(
182227
default=None, serialization_alias="snapshotName"

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "hyperbrowser"
3-
version = "0.86.0"
3+
version = "0.87.0"
44
description = "Python SDK for hyperbrowser"
55
authors = ["Nikhil Shahi <nshahi1998@gmail.com>"]
66
license = "MIT"
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import asyncio
2+
3+
import pytest
4+
5+
from hyperbrowser.models import (
6+
SandboxListParams,
7+
SandboxMemorySnapshotParams,
8+
SandboxSnapshotListParams,
9+
)
10+
11+
from tests.helpers.config import create_async_client, make_test_name
12+
from tests.helpers.sandbox import (
13+
default_sandbox_params,
14+
stop_sandbox_if_running_async,
15+
wait_for_runtime_ready_async,
16+
)
17+
18+
SANDBOX_PAGE_LIMIT = 50
19+
SNAPSHOT_LIST_LIMIT = 200
20+
LIST_POLL_DELAY_SECONDS = 0.5
21+
LIST_POLL_TIMEOUT_SECONDS = 90
22+
23+
24+
async def _wait_for_sandbox_in_list(client, sandbox_id: str):
25+
deadline = asyncio.get_running_loop().time() + LIST_POLL_TIMEOUT_SECONDS
26+
27+
while asyncio.get_running_loop().time() < deadline:
28+
page = 1
29+
30+
while True:
31+
response = await client.sandboxes.list(
32+
SandboxListParams(
33+
status="active",
34+
page=page,
35+
limit=SANDBOX_PAGE_LIMIT,
36+
)
37+
)
38+
match = next(
39+
(entry for entry in response.sandboxes if entry.id == sandbox_id),
40+
None,
41+
)
42+
if match is not None:
43+
return match
44+
45+
has_more = page * response.per_page < response.total_count
46+
if not has_more or not response.sandboxes:
47+
break
48+
49+
page += 1
50+
51+
await asyncio.sleep(LIST_POLL_DELAY_SECONDS)
52+
53+
raise RuntimeError(f"sandbox {sandbox_id} did not appear in list()")
54+
55+
56+
async def _wait_for_created_snapshot(client, snapshot_id: str):
57+
deadline = asyncio.get_running_loop().time() + LIST_POLL_TIMEOUT_SECONDS
58+
59+
while asyncio.get_running_loop().time() < deadline:
60+
response = await client.sandboxes.list_snapshots(
61+
SandboxSnapshotListParams(limit=SNAPSHOT_LIST_LIMIT)
62+
)
63+
match = next(
64+
(entry for entry in response.snapshots if entry.id == snapshot_id),
65+
None,
66+
)
67+
if match is not None and match.status == "created":
68+
return match
69+
70+
await asyncio.sleep(LIST_POLL_DELAY_SECONDS)
71+
72+
raise RuntimeError(
73+
f"snapshot {snapshot_id} did not appear as created in list_snapshots()"
74+
)
75+
76+
77+
@pytest.mark.anyio
78+
async def test_async_sandbox_list_e2e():
79+
client = create_async_client()
80+
sandbox = None
81+
memory_snapshot = None
82+
snapshot_name = make_test_name("py-async-list-snapshot")
83+
84+
try:
85+
sandbox = await client.sandboxes.create(default_sandbox_params("py-async-list"))
86+
await wait_for_runtime_ready_async(sandbox)
87+
88+
memory_snapshot = await sandbox.create_memory_snapshot(
89+
SandboxMemorySnapshotParams(snapshot_name=snapshot_name)
90+
)
91+
92+
listed_sandbox = await _wait_for_sandbox_in_list(client, sandbox.id)
93+
assert listed_sandbox.id == sandbox.id
94+
assert listed_sandbox.status == "active"
95+
assert listed_sandbox.region == sandbox.region
96+
assert listed_sandbox.runtime.transport == "regional_proxy"
97+
assert listed_sandbox.runtime.base_url == sandbox.runtime.base_url
98+
99+
images = await client.sandboxes.list_images()
100+
listed_image = next(
101+
(entry for entry in images.images if entry.id == memory_snapshot.image_id),
102+
None,
103+
)
104+
assert listed_image is not None
105+
assert listed_image.image_name == memory_snapshot.image_name
106+
assert listed_image.namespace == memory_snapshot.image_namespace
107+
assert isinstance(listed_image.uploaded, bool)
108+
109+
listed_snapshot = await _wait_for_created_snapshot(
110+
client, memory_snapshot.snapshot_id
111+
)
112+
assert listed_snapshot.id == memory_snapshot.snapshot_id
113+
assert listed_snapshot.snapshot_name == snapshot_name
114+
assert listed_snapshot.namespace == memory_snapshot.namespace
115+
assert listed_snapshot.image_id == memory_snapshot.image_id
116+
assert listed_snapshot.image_name == memory_snapshot.image_name
117+
assert listed_snapshot.image_namespace == memory_snapshot.image_namespace
118+
assert listed_snapshot.status == "created"
119+
120+
created_snapshots = await client.sandboxes.list_snapshots(
121+
SandboxSnapshotListParams(
122+
status="created",
123+
limit=SNAPSHOT_LIST_LIMIT,
124+
)
125+
)
126+
assert any(
127+
entry.id == listed_snapshot.id for entry in created_snapshots.snapshots
128+
)
129+
finally:
130+
await stop_sandbox_if_running_async(sandbox)
131+
await client.close()

0 commit comments

Comments
 (0)