Skip to content

Commit 579f018

Browse files
authored
Merge pull request #64 from hyperbrowserai/update-sandbox
update python sdk sandbox
2 parents 7a0f6ba + 0ca4d0c commit 579f018

File tree

18 files changed

+695
-173
lines changed

18 files changed

+695
-173
lines changed

README.md

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,118 @@ def main():
100100
# Run the asyncio event loop
101101
main()
102102
```
103+
104+
## Sandboxes
105+
106+
The sync and async clients expose the same sandbox APIs through `client.sandboxes`.
107+
108+
### Create a sandbox with pre-exposed ports
109+
110+
```python
111+
from hyperbrowser import Hyperbrowser
112+
from hyperbrowser.models import CreateSandboxParams, SandboxExposeParams
113+
114+
client = Hyperbrowser(api_key="test-key")
115+
sandbox = client.sandboxes.create(
116+
CreateSandboxParams(
117+
image_name="node",
118+
exposed_ports=[SandboxExposeParams(port=3000, auth=True)],
119+
)
120+
)
121+
122+
print(sandbox.exposed_ports[0].browser_url)
123+
sandbox.stop()
124+
client.close()
125+
```
126+
127+
### List sandboxes with filters
128+
129+
```python
130+
from hyperbrowser import Hyperbrowser
131+
from hyperbrowser.models import SandboxListParams
132+
133+
client = Hyperbrowser(api_key="test-key")
134+
result = client.sandboxes.list(
135+
SandboxListParams(
136+
status="active",
137+
search="sandbox",
138+
start=1711929600000,
139+
end=1712016000000,
140+
limit=20,
141+
)
142+
)
143+
144+
for sandbox in result.sandboxes:
145+
print(sandbox.id, sandbox.status)
146+
```
147+
148+
### List snapshots for a specific image
149+
150+
```python
151+
from hyperbrowser import Hyperbrowser
152+
from hyperbrowser.models import SandboxSnapshotListParams
153+
154+
client = Hyperbrowser(api_key="test-key")
155+
snapshots = client.sandboxes.list_snapshots(
156+
SandboxSnapshotListParams(image_name="node", status="created", limit=10)
157+
)
158+
```
159+
160+
### Expose and unexpose ports
161+
162+
```python
163+
from hyperbrowser import Hyperbrowser
164+
from hyperbrowser.models import CreateSandboxParams, SandboxExposeParams
165+
166+
client = Hyperbrowser(api_key="test-key")
167+
sandbox = client.sandboxes.create(CreateSandboxParams(image_name="node"))
168+
169+
result = sandbox.expose(SandboxExposeParams(port=8080, auth=True))
170+
print(result.url, result.browser_url)
171+
172+
sandbox.unexpose(8080)
173+
```
174+
175+
### Batch file writes with per-file options
176+
177+
```python
178+
from hyperbrowser import Hyperbrowser
179+
from hyperbrowser.models import CreateSandboxParams, SandboxFileWriteEntry
180+
181+
client = Hyperbrowser(api_key="test-key")
182+
sandbox = client.sandboxes.create(CreateSandboxParams(image_name="node"))
183+
184+
sandbox.files.write(
185+
[
186+
SandboxFileWriteEntry(
187+
path="/tmp/config.json",
188+
data='{"debug":true}\n',
189+
append=True,
190+
mode="600",
191+
),
192+
SandboxFileWriteEntry(
193+
path="/tmp/blob.bin",
194+
data=b"\x00\x01\x02",
195+
),
196+
]
197+
)
198+
```
199+
200+
### Resume terminal output after reconnect
201+
202+
```python
203+
from hyperbrowser import Hyperbrowser
204+
from hyperbrowser.models import CreateSandboxParams, SandboxTerminalCreateParams
205+
206+
client = Hyperbrowser(api_key="test-key")
207+
sandbox = client.sandboxes.create(CreateSandboxParams(image_name="node"))
208+
terminal = sandbox.terminal.create(SandboxTerminalCreateParams(command="bash"))
209+
210+
connection = terminal.attach(cursor=10)
211+
for event in connection.events():
212+
print(event)
213+
```
214+
103215
## License
104216

105217
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.

hyperbrowser/client/managers/async_manager/sandbox.py

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
SandboxRuntimeSession,
1616
SandboxSnapshotListParams,
1717
SandboxSnapshotListResponse,
18+
SandboxUnexposeResult,
1819
StartSandboxFromSnapshotParams,
1920
)
2021
from ....models.session import BasicResponse
@@ -113,6 +114,10 @@ def token_expires_at(self):
113114
def session_url(self) -> str:
114115
return self._detail.session_url
115116

117+
@property
118+
def exposed_ports(self):
119+
return self._detail.exposed_ports
120+
116121
def to_dict(self):
117122
return self._detail.model_dump()
118123

@@ -152,7 +157,27 @@ async def create_memory_snapshot(
152157
async def expose(self, params: SandboxExposeParams) -> SandboxExposeResult:
153158
if not isinstance(params, SandboxExposeParams):
154159
raise TypeError("params must be a SandboxExposeParams instance")
155-
return await self._service.expose(self.id, params, runtime=self.runtime)
160+
result = await self._service.expose(self.id, params, runtime=self.runtime)
161+
exposed_ports = [
162+
port for port in self._detail.exposed_ports if port.port != result.port
163+
]
164+
exposed_ports.append(result)
165+
exposed_ports.sort(key=lambda port: port.port)
166+
self._detail = self._detail.model_copy(update={"exposed_ports": exposed_ports})
167+
return result
168+
169+
async def unexpose(self, port: int) -> SandboxUnexposeResult:
170+
result = await self._service.unexpose(self.id, port)
171+
self._detail = self._detail.model_copy(
172+
update={
173+
"exposed_ports": [
174+
exposed_port
175+
for exposed_port in self._detail.exposed_ports
176+
if exposed_port.port != port
177+
]
178+
}
179+
)
180+
return result
156181

157182
def get_exposed_url(self, port: int) -> str:
158183
return _build_sandbox_exposed_url(self.runtime, port)
@@ -372,11 +397,17 @@ async def expose(
372397
data=params.model_dump(exclude_none=True, by_alias=True),
373398
)
374399
target_runtime = runtime or (await self.get_detail(sandbox_id)).runtime
375-
return SandboxExposeResult(
376-
port=payload["port"],
377-
auth=payload["auth"],
378-
url=_build_sandbox_exposed_url(target_runtime, payload["port"]),
400+
if "url" not in payload:
401+
payload["url"] = _build_sandbox_exposed_url(target_runtime, payload["port"])
402+
return SandboxExposeResult(**payload)
403+
404+
async def unexpose(self, sandbox_id: str, port: int) -> SandboxUnexposeResult:
405+
payload = await self._request(
406+
"POST",
407+
f"/sandbox/{sandbox_id}/unexpose",
408+
data={"port": port},
379409
)
410+
return SandboxUnexposeResult(**payload)
380411

381412
async def _create_detail(self, params: CreateSandboxParams) -> SandboxDetail:
382413
payload = await self._request(

hyperbrowser/client/managers/async_manager/sandboxes/sandbox_files.py

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
from .....sandbox_common import build_headers, to_websocket_transport_target
3333
from ...sandboxes.shared import (
3434
DEFAULT_WATCH_TIMEOUT_MS,
35+
_encode_batch_write_entry,
3536
_copy_model,
3637
_encode_write_data,
3738
_normalize_event_type,
@@ -371,12 +372,7 @@ async def write(
371372
for entry in path_or_files:
372373
if not isinstance(entry, SandboxFileWriteEntry):
373374
raise TypeError("files must contain SandboxFileWriteEntry instances")
374-
encoded_files.append(
375-
{
376-
"path": entry.path,
377-
**_encode_write_data(entry.data),
378-
}
379-
)
375+
encoded_files.append(_encode_batch_write_entry(entry))
380376

381377
payload = await self._transport.request_json(
382378
"/sandbox/files/write",

hyperbrowser/client/managers/async_manager/sandboxes/sandbox_terminal.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import json
33
import socket
44
from typing import AsyncIterator, Dict, Optional, Union
5+
from urllib.parse import urlencode
56

67
from websockets.asyncio.client import connect as async_ws_connect
78
from websockets.exceptions import ConnectionClosed
@@ -160,11 +161,20 @@ async def resize(self, rows: int, cols: int) -> SandboxTerminalStatus:
160161
self._status = _normalize_terminal_status(payload["pty"])
161162
return self.current
162163

163-
async def attach(self) -> SandboxTerminalConnection:
164+
async def attach(
165+
self,
166+
cursor: Optional[int] = None,
167+
) -> SandboxTerminalConnection:
164168
connection = await self._get_connection_info()
169+
query = urlencode(
170+
[
171+
("sessionId", connection.sandbox_id),
172+
*([("cursor", str(cursor))] if cursor is not None else []),
173+
]
174+
)
165175
target = to_websocket_transport_target(
166176
connection.base_url,
167-
f"/sandbox/pty/{self.id}/ws?sessionId={connection.sandbox_id}",
177+
f"/sandbox/pty/{self.id}/ws?{query}",
168178
self._runtime_proxy_override,
169179
)
170180
headers = build_headers(connection.token, host_header=target.host_header)

hyperbrowser/client/managers/sandboxes/shared.py

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from ....exceptions import HyperbrowserError
88
from ....models.sandbox import (
99
SandboxFileInfo,
10+
SandboxFileWriteEntry,
1011
SandboxFileWriteInfo,
1112
SandboxTerminalStatus,
1213
)
@@ -27,7 +28,7 @@ def _build_sandbox_exposed_url(runtime, port: int) -> str:
2728
parsed = urlsplit(runtime.base_url)
2829
hostname = parsed.hostname
2930
if not hostname:
30-
return runtime.base_url.rstrip("/")
31+
return runtime.base_url
3132

3233
exposed_host = f"{port}-{hostname}"
3334
netloc = exposed_host
@@ -39,9 +40,9 @@ def _build_sandbox_exposed_url(runtime, port: int) -> str:
3940
credentials = f"{credentials}:{parsed.password}"
4041
netloc = f"{credentials}@{netloc}"
4142

42-
return urlunsplit(
43-
(parsed.scheme, netloc, parsed.path, parsed.query, parsed.fragment)
44-
).rstrip("/")
43+
path = parsed.path or "/"
44+
45+
return urlunsplit((parsed.scheme, netloc, path, parsed.query, parsed.fragment))
4546

4647

4748
def _expires_within_buffer(expires_at: Optional[datetime]) -> bool:
@@ -188,6 +189,32 @@ def _encode_write_data(data: Union[str, bytes, bytearray]) -> Dict[str, str]:
188189
}
189190

190191

192+
def _encode_batch_write_entry(entry: SandboxFileWriteEntry) -> Dict[str, object]:
193+
if isinstance(entry.data, str):
194+
encoding = entry.encoding or "utf8"
195+
if encoding not in {"utf8", "base64"}:
196+
raise ValueError("encoding should be one of: utf8, base64")
197+
payload: Dict[str, object] = {
198+
"path": entry.path,
199+
"data": entry.data,
200+
"encoding": encoding,
201+
}
202+
else:
203+
if entry.encoding not in {None, "base64"}:
204+
raise ValueError("encoding must be base64 when data is bytes")
205+
payload = {
206+
"path": entry.path,
207+
"data": base64.b64encode(bytes(entry.data)).decode("ascii"),
208+
"encoding": "base64",
209+
}
210+
211+
if entry.append is not None:
212+
payload["append"] = entry.append
213+
if entry.mode is not None:
214+
payload["mode"] = entry.mode
215+
return payload
216+
217+
191218
def _normalize_terminal_output_chunk(entry: Dict[str, object]) -> Dict[str, object]:
192219
raw = base64.b64decode(entry["data"])
193220
return {

hyperbrowser/client/managers/sync_manager/sandbox.py

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
SandboxRuntimeSession,
1616
SandboxSnapshotListParams,
1717
SandboxSnapshotListResponse,
18+
SandboxUnexposeResult,
1819
StartSandboxFromSnapshotParams,
1920
)
2021
from ....models.session import BasicResponse
@@ -113,6 +114,10 @@ def token_expires_at(self):
113114
def session_url(self) -> str:
114115
return self._detail.session_url
115116

117+
@property
118+
def exposed_ports(self):
119+
return self._detail.exposed_ports
120+
116121
def to_dict(self):
117122
return self._detail.model_dump()
118123

@@ -152,7 +157,27 @@ def create_memory_snapshot(
152157
def expose(self, params: SandboxExposeParams) -> SandboxExposeResult:
153158
if not isinstance(params, SandboxExposeParams):
154159
raise TypeError("params must be a SandboxExposeParams instance")
155-
return self._service.expose(self.id, params, runtime=self.runtime)
160+
result = self._service.expose(self.id, params, runtime=self.runtime)
161+
exposed_ports = [
162+
port for port in self._detail.exposed_ports if port.port != result.port
163+
]
164+
exposed_ports.append(result)
165+
exposed_ports.sort(key=lambda port: port.port)
166+
self._detail = self._detail.model_copy(update={"exposed_ports": exposed_ports})
167+
return result
168+
169+
def unexpose(self, port: int) -> SandboxUnexposeResult:
170+
result = self._service.unexpose(self.id, port)
171+
self._detail = self._detail.model_copy(
172+
update={
173+
"exposed_ports": [
174+
exposed_port
175+
for exposed_port in self._detail.exposed_ports
176+
if exposed_port.port != port
177+
]
178+
}
179+
)
180+
return result
156181

157182
def get_exposed_url(self, port: int) -> str:
158183
return _build_sandbox_exposed_url(self.runtime, port)
@@ -372,11 +397,17 @@ def expose(
372397
data=params.model_dump(exclude_none=True, by_alias=True),
373398
)
374399
target_runtime = runtime or self.get_detail(sandbox_id).runtime
375-
return SandboxExposeResult(
376-
port=payload["port"],
377-
auth=payload["auth"],
378-
url=_build_sandbox_exposed_url(target_runtime, payload["port"]),
400+
if "url" not in payload:
401+
payload["url"] = _build_sandbox_exposed_url(target_runtime, payload["port"])
402+
return SandboxExposeResult(**payload)
403+
404+
def unexpose(self, sandbox_id: str, port: int) -> SandboxUnexposeResult:
405+
payload = self._request(
406+
"POST",
407+
f"/sandbox/{sandbox_id}/unexpose",
408+
data={"port": port},
379409
)
410+
return SandboxUnexposeResult(**payload)
380411

381412
def _create_detail(self, params: CreateSandboxParams) -> SandboxDetail:
382413
payload = self._request(

hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_files.py

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
from .....sandbox_common import build_headers, to_websocket_transport_target
3232
from ...sandboxes.shared import (
3333
DEFAULT_WATCH_TIMEOUT_MS,
34+
_encode_batch_write_entry,
3435
_copy_model,
3536
_encode_write_data,
3637
_normalize_event_type,
@@ -352,12 +353,7 @@ def write(
352353
for entry in path_or_files:
353354
if not isinstance(entry, SandboxFileWriteEntry):
354355
raise TypeError("files must contain SandboxFileWriteEntry instances")
355-
encoded_files.append(
356-
{
357-
"path": entry.path,
358-
**_encode_write_data(entry.data),
359-
}
360-
)
356+
encoded_files.append(_encode_batch_write_entry(entry))
361357

362358
payload = self._transport.request_json(
363359
"/sandbox/files/write",

0 commit comments

Comments
 (0)