solari-sandbox 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# build artifacts
|
|
2
|
+
dist/
|
|
3
|
+
**/dist/
|
|
4
|
+
**/node_modules/
|
|
5
|
+
**/target/
|
|
6
|
+
*.log
|
|
7
|
+
|
|
8
|
+
# VM images / snapshots / kernels — large binaries, never committed
|
|
9
|
+
*.img
|
|
10
|
+
*.ext4
|
|
11
|
+
*.qcow2
|
|
12
|
+
*.raw
|
|
13
|
+
*.snapshot
|
|
14
|
+
/snapshots/build/
|
|
15
|
+
/guest/rootfs/build/
|
|
16
|
+
/guest/kernel/build/
|
|
17
|
+
vmlinux
|
|
18
|
+
bzImage
|
|
19
|
+
|
|
20
|
+
# terraform state
|
|
21
|
+
*.tfstate
|
|
22
|
+
*.tfstate.*
|
|
23
|
+
.terraform/
|
|
24
|
+
*.tfvars.local
|
|
25
|
+
|
|
26
|
+
# secrets / env
|
|
27
|
+
.env
|
|
28
|
+
.env.*
|
|
29
|
+
*.pem
|
|
30
|
+
|
|
31
|
+
# Python
|
|
32
|
+
__pycache__/
|
|
33
|
+
*.pyc
|
|
34
|
+
.venv/
|
|
35
|
+
|
|
36
|
+
# NOTE: tests/sdk/ts-request-fixtures.json is deliberately NOT ignored.
|
|
37
|
+
#
|
|
38
|
+
# It was, and that quietly disabled two gates at once:
|
|
39
|
+
#
|
|
40
|
+
# 1. ci.yml's "Committed fixture must match what the SDK emits today" step runs
|
|
41
|
+
# `git diff --exit-code tests/sdk/ts-request-fixtures.json`. On an ignored,
|
|
42
|
+
# never-tracked file that command can only ever succeed — it had never once
|
|
43
|
+
# been able to fail.
|
|
44
|
+
# 2. The Go/Rust/C++/Python contract suites now assert against that fixture from
|
|
45
|
+
# inside their OWN CI jobs, none of which run the TypeScript half. If the file
|
|
46
|
+
# is not committed, it is simply absent there.
|
|
47
|
+
#
|
|
48
|
+
# It is generated, but it is generated from the REFERENCE SDK and checked in as
|
|
49
|
+
# the baseline the other four bindings are held to — regenerating it and diffing
|
|
50
|
+
# is exactly how a wire change is made to announce itself.
|
|
51
|
+
|
|
52
|
+
# Built guest-agent binaries (produced by go build / build-rootfs staging)
|
|
53
|
+
guest/agent/agent
|
|
54
|
+
guest/rootfs/guest-agent
|
|
55
|
+
|
|
56
|
+
# build-rootfs.sh template staging + generated Dockerfile (regenerated each run)
|
|
57
|
+
guest/rootfs/guest-agent.service
|
|
58
|
+
guest/rootfs/template.packages
|
|
59
|
+
guest/rootfs/template-files/
|
|
60
|
+
guest/rootfs/.Dockerfile.gen
|
|
61
|
+
guest/rootfs/out/
|
|
62
|
+
|
|
63
|
+
# local test artifacts
|
|
64
|
+
staging-desktop.png
|
|
65
|
+
desktop.png
|
|
66
|
+
infra/terraform/snapshots-volumes/.build/
|
|
67
|
+
|
|
68
|
+
# deploy-e2e scratch state: holds a presigned REPO_URL with live STS creds
|
|
69
|
+
# (ASIA... + X-Amz-Security-Token). Never commit. Also stale PHASE_* here makes
|
|
70
|
+
# a bare re-run skip bake+verify and exit 0 with zero assertions.
|
|
71
|
+
infra/.deploy-e2e/
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: solari-sandbox
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Python SDK for Solari Sandboxes — fast, ephemeral code sandboxes (micro-VMs) with a code interpreter, filesystem, and git.
|
|
5
|
+
Project-URL: Homepage, https://getsolari.com
|
|
6
|
+
Author: Solari
|
|
7
|
+
License: MIT
|
|
8
|
+
Keywords: code-interpreter,microvm,sandbox,solari
|
|
9
|
+
Requires-Python: >=3.9
|
|
10
|
+
Requires-Dist: solari-core==0.2.0
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# solari-sandbox (Python)
|
|
14
|
+
|
|
15
|
+
Python SDK for **Solari Sandboxes** — fast, ephemeral code sandboxes (micro-VMs)
|
|
16
|
+
with a code interpreter, filesystem, ports, and git. Mirrors the TypeScript
|
|
17
|
+
`@solarisdk/sandbox` package.
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
```sh
|
|
21
|
+
pip install solari-sandbox
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Quickstart
|
|
25
|
+
```python
|
|
26
|
+
import asyncio, os
|
|
27
|
+
from solari_sandbox import SandboxClient
|
|
28
|
+
|
|
29
|
+
async def main():
|
|
30
|
+
client = SandboxClient(api_key=os.environ["SOLARI_API_KEY"])
|
|
31
|
+
sb = await client.create(template="base")
|
|
32
|
+
await sb.connect()
|
|
33
|
+
result = await sb.run_code("print(6 * 7)")
|
|
34
|
+
print(result.stdout) # -> 42
|
|
35
|
+
await sb.kill()
|
|
36
|
+
|
|
37
|
+
asyncio.run(main())
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
`SandboxClient(...)` accepts `api_key`, `base_url` (default
|
|
41
|
+
`https://api.getsolari.com`), and `call_timeout_ms`. The same `slr_live_…` key
|
|
42
|
+
authenticates against both the browser and desktop/sandbox APIs.
|
|
43
|
+
|
|
44
|
+
Docs: <https://getsolari.com>
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# solari-sandbox (Python)
|
|
2
|
+
|
|
3
|
+
Python SDK for **Solari Sandboxes** — fast, ephemeral code sandboxes (micro-VMs)
|
|
4
|
+
with a code interpreter, filesystem, ports, and git. Mirrors the TypeScript
|
|
5
|
+
`@solarisdk/sandbox` package.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
```sh
|
|
9
|
+
pip install solari-sandbox
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Quickstart
|
|
13
|
+
```python
|
|
14
|
+
import asyncio, os
|
|
15
|
+
from solari_sandbox import SandboxClient
|
|
16
|
+
|
|
17
|
+
async def main():
|
|
18
|
+
client = SandboxClient(api_key=os.environ["SOLARI_API_KEY"])
|
|
19
|
+
sb = await client.create(template="base")
|
|
20
|
+
await sb.connect()
|
|
21
|
+
result = await sb.run_code("print(6 * 7)")
|
|
22
|
+
print(result.stdout) # -> 42
|
|
23
|
+
await sb.kill()
|
|
24
|
+
|
|
25
|
+
asyncio.run(main())
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
`SandboxClient(...)` accepts `api_key`, `base_url` (default
|
|
29
|
+
`https://api.getsolari.com`), and `call_timeout_ms`. The same `slr_live_…` key
|
|
30
|
+
authenticates against both the browser and desktop/sandbox APIs.
|
|
31
|
+
|
|
32
|
+
Docs: <https://getsolari.com>
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "solari-sandbox"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "Python SDK for Solari Sandboxes — fast, ephemeral code sandboxes (micro-VMs) with a code interpreter, filesystem, and git."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Solari" }]
|
|
13
|
+
keywords = ["solari", "sandbox", "code-interpreter", "microvm"]
|
|
14
|
+
dependencies = ["solari-core==0.2.0"]
|
|
15
|
+
|
|
16
|
+
[project.urls]
|
|
17
|
+
Homepage = "https://getsolari.com"
|
|
18
|
+
|
|
19
|
+
[tool.hatch.build.targets.wheel]
|
|
20
|
+
packages = ["solari_sandbox"]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""solari_sandbox — fast, ephemeral code sandboxes (micro-VMs) with a code
|
|
2
|
+
interpreter, filesystem, and git. Mirrors the TypeScript ``@solarisdk/sandbox``
|
|
3
|
+
package.
|
|
4
|
+
|
|
5
|
+
Import :class:`SandboxClient` to create and drive sandboxes; the shared handles
|
|
6
|
+
and types are re-exported from :mod:`solari_core`.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from solari_core import * # noqa: F401,F403 (shared surface)
|
|
11
|
+
from solari_core import __all__ as _core_all
|
|
12
|
+
from .sandbox_client import SandboxClient, SyncSandboxClient
|
|
13
|
+
|
|
14
|
+
__version__ = "0.2.0"
|
|
15
|
+
__all__ = list(_core_all) + ["SandboxClient", "SyncSandboxClient"]
|
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
"""``SandboxClient`` — talks the SDK <-> Gateway ``/sandboxes`` + ``/snapshots``
|
|
2
|
+
HTTP API (CONTRACTS-V2 §1/§2) and hands back
|
|
3
|
+
:class:`~solari_desktop.sandbox.Sandbox` handles. Mirrors
|
|
4
|
+
``sdk/src/sandbox-client.ts``.
|
|
5
|
+
|
|
6
|
+
Async (httpx) with a thin :class:`SyncSandboxClient` wrapper, matching the
|
|
7
|
+
desktop client split.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
from typing import Any, Dict, List, Optional
|
|
14
|
+
from urllib.parse import quote, urlencode
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
from solari_core._http import HttpTransport, new_idempotency_key
|
|
19
|
+
from solari_core.desktop import Desktop, DesktopConfig
|
|
20
|
+
from solari_core.errors import SolariError
|
|
21
|
+
from solari_core.handle import SessionConfig, SessionHooks
|
|
22
|
+
from solari_core.sandbox import Sandbox
|
|
23
|
+
from solari_core.volume_client import SyncVolumeClient, VolumeClient
|
|
24
|
+
from solari_core.types import (
|
|
25
|
+
CreateDesktopResponse,
|
|
26
|
+
CreateSandboxResponse,
|
|
27
|
+
MetricsResult,
|
|
28
|
+
SandboxKind,
|
|
29
|
+
SandboxState,
|
|
30
|
+
SandboxView,
|
|
31
|
+
SnapshotView,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class SandboxClient:
|
|
36
|
+
"""Async client for the Solari sandbox gateway."""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
*,
|
|
41
|
+
api_key: str,
|
|
42
|
+
base_url: str,
|
|
43
|
+
http: Optional[httpx.AsyncClient] = None,
|
|
44
|
+
call_timeout_ms: Optional[int] = None,
|
|
45
|
+
kind: SandboxKind = "sandbox",
|
|
46
|
+
) -> None:
|
|
47
|
+
if not api_key:
|
|
48
|
+
raise SolariError("SandboxClient requires an api_key")
|
|
49
|
+
if not base_url:
|
|
50
|
+
raise SolariError("SandboxClient requires a base_url")
|
|
51
|
+
self._api_key = api_key
|
|
52
|
+
self._base_url = base_url.rstrip("/")
|
|
53
|
+
self._call_timeout_ms = call_timeout_ms
|
|
54
|
+
self._kind = kind
|
|
55
|
+
self._t = HttpTransport(api_key=api_key, base_url=base_url, http=http)
|
|
56
|
+
#: Persistent volumes CRUD (``/volumes``). Attach via ``create(volumes=...)``.
|
|
57
|
+
self.volumes = VolumeClient(api_key=api_key, base_url=base_url, http=http)
|
|
58
|
+
|
|
59
|
+
async def create(
|
|
60
|
+
self,
|
|
61
|
+
*,
|
|
62
|
+
template: Optional[str] = None,
|
|
63
|
+
cpu: Optional[int] = None,
|
|
64
|
+
mem_mb: Optional[int] = None,
|
|
65
|
+
disk_gb: Optional[int] = None,
|
|
66
|
+
envs: Optional[Dict[str, str]] = None,
|
|
67
|
+
metadata: Optional[Dict[str, str]] = None,
|
|
68
|
+
timeout_ms: Optional[int] = None,
|
|
69
|
+
from_snapshot: Optional[str] = None,
|
|
70
|
+
lifecycle: Optional[Dict[str, Any]] = None,
|
|
71
|
+
volumes: Optional[List[Dict[str, str]]] = None,
|
|
72
|
+
) -> Sandbox:
|
|
73
|
+
"""Create a new sandbox (``POST /sandboxes``). ``lifecycle`` is the idle
|
|
74
|
+
policy, e.g. ``{"onTimeout": "pause", "autoResume": True}``. ``volumes``
|
|
75
|
+
attaches persistent volumes, e.g.
|
|
76
|
+
``[{"volumeId": "vol_x", "path": "/data"}]``."""
|
|
77
|
+
body: Dict[str, Any] = {
|
|
78
|
+
"template": template,
|
|
79
|
+
"kind": self._kind,
|
|
80
|
+
"cpu": cpu,
|
|
81
|
+
"memMb": mem_mb,
|
|
82
|
+
"diskGb": disk_gb,
|
|
83
|
+
"envs": envs,
|
|
84
|
+
"metadata": metadata,
|
|
85
|
+
"timeoutMs": timeout_ms,
|
|
86
|
+
"fromSnapshot": from_snapshot,
|
|
87
|
+
"lifecycle": lifecycle,
|
|
88
|
+
"volumes": volumes,
|
|
89
|
+
}
|
|
90
|
+
# Drop unset fields so we don't send JSON null (TS omits undefined). The
|
|
91
|
+
# gateway rejects e.g. `fromSnapshot: null` ("must be a snapshot id").
|
|
92
|
+
body = {k: v for k, v in body.items() if v is not None}
|
|
93
|
+
data = await self._request(
|
|
94
|
+
"POST", "/sandboxes", body, idempotency_key=new_idempotency_key()
|
|
95
|
+
)
|
|
96
|
+
return Sandbox(_parse_create(data), self._handle_config())
|
|
97
|
+
|
|
98
|
+
async def create_desktop(
|
|
99
|
+
self,
|
|
100
|
+
*,
|
|
101
|
+
template: Optional[str] = None,
|
|
102
|
+
cpu: Optional[int] = None,
|
|
103
|
+
mem_mb: Optional[int] = None,
|
|
104
|
+
disk_gb: Optional[int] = None,
|
|
105
|
+
envs: Optional[Dict[str, str]] = None,
|
|
106
|
+
metadata: Optional[Dict[str, str]] = None,
|
|
107
|
+
timeout_ms: Optional[int] = None,
|
|
108
|
+
from_snapshot: Optional[str] = None,
|
|
109
|
+
resolution: Optional[str] = None,
|
|
110
|
+
record: Optional[bool] = None,
|
|
111
|
+
lifecycle: Optional[Dict[str, Any]] = None,
|
|
112
|
+
volumes: Optional[List[Dict[str, str]]] = None,
|
|
113
|
+
) -> Desktop:
|
|
114
|
+
"""Create a GUI desktop via the unified route (``POST /sandboxes`` with
|
|
115
|
+
``kind:"desktop"``). Mirrors TS ``createDesktop``."""
|
|
116
|
+
body: Dict[str, Any] = {
|
|
117
|
+
"template": template,
|
|
118
|
+
"kind": "desktop",
|
|
119
|
+
"cpu": cpu,
|
|
120
|
+
"memMb": mem_mb,
|
|
121
|
+
"diskGb": disk_gb,
|
|
122
|
+
"envs": envs,
|
|
123
|
+
"metadata": metadata,
|
|
124
|
+
"timeoutMs": timeout_ms,
|
|
125
|
+
"fromSnapshot": from_snapshot,
|
|
126
|
+
"resolution": resolution,
|
|
127
|
+
"record": record,
|
|
128
|
+
"lifecycle": lifecycle,
|
|
129
|
+
"volumes": volumes,
|
|
130
|
+
}
|
|
131
|
+
body = {k: v for k, v in body.items() if v is not None}
|
|
132
|
+
data = await self._request(
|
|
133
|
+
"POST", "/sandboxes", body, idempotency_key=new_idempotency_key()
|
|
134
|
+
)
|
|
135
|
+
base = self._handle_config()
|
|
136
|
+
cfg = DesktopConfig(headers=base.headers, hooks=base.hooks)
|
|
137
|
+
if base.callTimeoutMs is not None:
|
|
138
|
+
cfg.callTimeoutMs = base.callTimeoutMs
|
|
139
|
+
session = CreateDesktopResponse(
|
|
140
|
+
sessionId=data["sandboxId"],
|
|
141
|
+
controlUrl=data["controlUrl"],
|
|
142
|
+
streamUrl=data.get("streamUrl", ""),
|
|
143
|
+
expiresAt=data["expiresAt"],
|
|
144
|
+
)
|
|
145
|
+
return Desktop(session, cfg)
|
|
146
|
+
|
|
147
|
+
async def connect(self, sandbox_id: str) -> Sandbox:
|
|
148
|
+
"""Re-attach to a running sandbox by id."""
|
|
149
|
+
view = await self.get(sandbox_id)
|
|
150
|
+
origin = self._t.ws_origin()
|
|
151
|
+
session = CreateSandboxResponse(
|
|
152
|
+
sandboxId=view.sandboxId,
|
|
153
|
+
kind=view.kind,
|
|
154
|
+
controlUrl=f"{origin}/control/{quote(sandbox_id, safe='')}",
|
|
155
|
+
expiresAt=view.expiresAt,
|
|
156
|
+
)
|
|
157
|
+
return Sandbox(session, self._handle_config())
|
|
158
|
+
|
|
159
|
+
async def get(self, sandbox_id: str) -> SandboxView:
|
|
160
|
+
"""``GET /sandboxes/:id``."""
|
|
161
|
+
data = await self._request("GET", f"/sandboxes/{quote(sandbox_id, safe='')}")
|
|
162
|
+
return _parse_view(data)
|
|
163
|
+
|
|
164
|
+
async def list(
|
|
165
|
+
self,
|
|
166
|
+
*,
|
|
167
|
+
metadata: Optional[Dict[str, str]] = None,
|
|
168
|
+
state: Optional[SandboxState] = None,
|
|
169
|
+
kind: Optional[SandboxKind] = None,
|
|
170
|
+
limit: Optional[int] = None,
|
|
171
|
+
cursor: Optional[str] = None,
|
|
172
|
+
) -> Dict[str, Any]:
|
|
173
|
+
"""``GET /sandboxes`` — filter + paginate. Returns ``{sandboxes, nextCursor?}``."""
|
|
174
|
+
params: List[tuple] = []
|
|
175
|
+
for k, v in (metadata or {}).items():
|
|
176
|
+
params.append((f"metadata.{k}", v))
|
|
177
|
+
if state:
|
|
178
|
+
params.append(("state", state))
|
|
179
|
+
if kind:
|
|
180
|
+
params.append(("kind", kind))
|
|
181
|
+
if limit is not None:
|
|
182
|
+
params.append(("limit", str(limit)))
|
|
183
|
+
if cursor:
|
|
184
|
+
params.append(("cursor", cursor))
|
|
185
|
+
qs = ("?" + urlencode(params)) if params else ""
|
|
186
|
+
data = await self._request("GET", f"/sandboxes{qs}")
|
|
187
|
+
return {
|
|
188
|
+
"sandboxes": [_parse_view(s) for s in data.get("sandboxes", [])],
|
|
189
|
+
"nextCursor": data.get("nextCursor"),
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async def list_all(
|
|
193
|
+
self,
|
|
194
|
+
*,
|
|
195
|
+
metadata: Optional[Dict[str, str]] = None,
|
|
196
|
+
state: Optional[SandboxState] = None,
|
|
197
|
+
kind: Optional[SandboxKind] = None,
|
|
198
|
+
limit: Optional[int] = None,
|
|
199
|
+
):
|
|
200
|
+
"""Auto-paginate :meth:`list`: async-iterate every matching sandbox
|
|
201
|
+
across all pages (follows ``nextCursor``). Mirrors TS ``listAll``.
|
|
202
|
+
|
|
203
|
+
async for s in sbxs.list_all(state="running"):
|
|
204
|
+
...
|
|
205
|
+
"""
|
|
206
|
+
cursor: Optional[str] = None
|
|
207
|
+
while True:
|
|
208
|
+
page = await self.list(
|
|
209
|
+
metadata=metadata, state=state, kind=kind, limit=limit, cursor=cursor
|
|
210
|
+
)
|
|
211
|
+
for s in page["sandboxes"]:
|
|
212
|
+
yield s
|
|
213
|
+
cursor = page.get("nextCursor")
|
|
214
|
+
if not cursor:
|
|
215
|
+
break
|
|
216
|
+
|
|
217
|
+
async def kill(self, sandbox_id: str) -> None:
|
|
218
|
+
"""``DELETE /sandboxes/:id``. Idempotent."""
|
|
219
|
+
await self._request("DELETE", f"/sandboxes/{quote(sandbox_id, safe='')}")
|
|
220
|
+
|
|
221
|
+
# --- snapshots ------------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
async def list_snapshots(
|
|
224
|
+
self,
|
|
225
|
+
*,
|
|
226
|
+
template: Optional[str] = None,
|
|
227
|
+
kind: Optional[SandboxKind] = None,
|
|
228
|
+
limit: Optional[int] = None,
|
|
229
|
+
) -> List[SnapshotView]:
|
|
230
|
+
"""``GET /snapshots``."""
|
|
231
|
+
params: List[tuple] = []
|
|
232
|
+
if template:
|
|
233
|
+
params.append(("template", template))
|
|
234
|
+
if kind:
|
|
235
|
+
params.append(("kind", kind))
|
|
236
|
+
if limit is not None:
|
|
237
|
+
params.append(("limit", str(limit)))
|
|
238
|
+
qs = ("?" + urlencode(params)) if params else ""
|
|
239
|
+
data = await self._request("GET", f"/snapshots{qs}")
|
|
240
|
+
return [_parse_snapshot(s) for s in data.get("snapshots", [])]
|
|
241
|
+
|
|
242
|
+
async def get_snapshot(self, snapshot_id: str) -> SnapshotView:
|
|
243
|
+
"""``GET /snapshots/:id``."""
|
|
244
|
+
data = await self._request("GET", f"/snapshots/{quote(snapshot_id, safe='')}")
|
|
245
|
+
return _parse_snapshot(data)
|
|
246
|
+
|
|
247
|
+
async def delete_snapshot(self, snapshot_id: str) -> None:
|
|
248
|
+
"""``DELETE /snapshots/:id`` (refused if it has live children)."""
|
|
249
|
+
await self._request("DELETE", f"/snapshots/{quote(snapshot_id, safe='')}")
|
|
250
|
+
|
|
251
|
+
async def promote_snapshot(self, snapshot_id: str, name: str) -> Dict[str, Any]:
|
|
252
|
+
"""``POST /snapshots/:id/promote`` → template."""
|
|
253
|
+
return await self._request(
|
|
254
|
+
"POST", f"/snapshots/{quote(snapshot_id, safe='')}/promote", {"name": name}
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
async def aclose(self) -> None:
|
|
258
|
+
await self._t.aclose()
|
|
259
|
+
await self.volumes.aclose()
|
|
260
|
+
|
|
261
|
+
async def __aenter__(self) -> "SandboxClient":
|
|
262
|
+
return self
|
|
263
|
+
|
|
264
|
+
async def __aexit__(self, *_exc: Any) -> None:
|
|
265
|
+
await self.aclose()
|
|
266
|
+
|
|
267
|
+
# --- handle wiring --------------------------------------------------------
|
|
268
|
+
|
|
269
|
+
def _handle_config(self) -> SessionConfig:
|
|
270
|
+
hooks = SessionHooks(
|
|
271
|
+
metrics=self._hook_metrics,
|
|
272
|
+
snapshot=self._hook_snapshot,
|
|
273
|
+
revert=self._hook_revert,
|
|
274
|
+
pause=self._hook_pause,
|
|
275
|
+
resume=self._hook_resume,
|
|
276
|
+
set_timeout=self._hook_set_timeout,
|
|
277
|
+
download_url=self._hook_download_url,
|
|
278
|
+
upload_url=self._hook_upload_url,
|
|
279
|
+
preview_url=self._hook_preview_url,
|
|
280
|
+
kill=self.kill,
|
|
281
|
+
)
|
|
282
|
+
cfg = SessionConfig(headers=self._t.auth_headers(), hooks=hooks)
|
|
283
|
+
if self._call_timeout_ms is not None:
|
|
284
|
+
cfg.callTimeoutMs = self._call_timeout_ms
|
|
285
|
+
return cfg
|
|
286
|
+
|
|
287
|
+
async def _hook_metrics(self, sandbox_id: str) -> MetricsResult:
|
|
288
|
+
d = await self._request("GET", f"/sandboxes/{quote(sandbox_id, safe='')}/metrics")
|
|
289
|
+
return MetricsResult(
|
|
290
|
+
cpuPct=float(d.get("cpuPct", 0)),
|
|
291
|
+
memBytes=int(d.get("memBytes", 0)),
|
|
292
|
+
memTotalBytes=int(d.get("memTotalBytes", 0)),
|
|
293
|
+
diskBytes=int(d.get("diskBytes", 0)),
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
async def _hook_snapshot(self, sandbox_id: str, name: Optional[str]) -> str:
|
|
297
|
+
body = {"name": name} if name else {}
|
|
298
|
+
d = await self._request("POST", f"/sandboxes/{quote(sandbox_id, safe='')}/snapshots", body)
|
|
299
|
+
return d["snapshotId"]
|
|
300
|
+
|
|
301
|
+
async def _hook_revert(self, sandbox_id: str, snapshot_id: str) -> None:
|
|
302
|
+
await self._request(
|
|
303
|
+
"POST", f"/sandboxes/{quote(sandbox_id, safe='')}/revert", {"snapshotId": snapshot_id}
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
async def _hook_pause(self, sandbox_id: str) -> None:
|
|
307
|
+
await self._request("POST", f"/sandboxes/{quote(sandbox_id, safe='')}/pause")
|
|
308
|
+
|
|
309
|
+
async def _hook_resume(self, sandbox_id: str) -> str:
|
|
310
|
+
d = await self._request("POST", f"/sandboxes/{quote(sandbox_id, safe='')}/resume")
|
|
311
|
+
origin = self._t.ws_origin()
|
|
312
|
+
return (d or {}).get("controlUrl") or f"{origin}/control/{quote(sandbox_id, safe='')}"
|
|
313
|
+
|
|
314
|
+
async def _hook_set_timeout(self, sandbox_id: str, timeout_ms: int) -> Dict[str, Any]:
|
|
315
|
+
return await self._request(
|
|
316
|
+
"POST", f"/sandboxes/{quote(sandbox_id, safe='')}/timeout", {"timeoutMs": timeout_ms}
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
async def _hook_download_url(self, sandbox_id: str, path: str) -> Dict[str, Any]:
|
|
320
|
+
return await self._request(
|
|
321
|
+
"GET",
|
|
322
|
+
f"/sandboxes/{quote(sandbox_id, safe='')}/files/download-url?path={quote(path, safe='')}",
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
async def _hook_upload_url(self, sandbox_id: str, path: Optional[str] = None) -> Dict[str, Any]:
|
|
326
|
+
suffix = f"?path={quote(path, safe='')}" if path else ""
|
|
327
|
+
return await self._request(
|
|
328
|
+
"GET", f"/sandboxes/{quote(sandbox_id, safe='')}/files/upload-url{suffix}"
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
async def _hook_preview_url(self, sandbox_id: str, port: int) -> Dict[str, Any]:
|
|
332
|
+
return await self._request(
|
|
333
|
+
"GET", f"/sandboxes/{quote(sandbox_id, safe='')}/ports/{quote(str(port), safe='')}"
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
# --- transport ------------------------------------------------------------
|
|
337
|
+
|
|
338
|
+
async def _request(
|
|
339
|
+
self,
|
|
340
|
+
method: str,
|
|
341
|
+
path: str,
|
|
342
|
+
body: Optional[Any] = None,
|
|
343
|
+
*,
|
|
344
|
+
idempotency_key: Optional[str] = None,
|
|
345
|
+
) -> Any:
|
|
346
|
+
return await self._t.request(method, path, body, idempotency_key=idempotency_key)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
class SyncSandboxClient:
|
|
350
|
+
"""Thin synchronous wrapper over :class:`SandboxClient`."""
|
|
351
|
+
|
|
352
|
+
def __init__(
|
|
353
|
+
self,
|
|
354
|
+
*,
|
|
355
|
+
api_key: str,
|
|
356
|
+
base_url: str,
|
|
357
|
+
call_timeout_ms: Optional[int] = None,
|
|
358
|
+
kind: SandboxKind = "sandbox",
|
|
359
|
+
) -> None:
|
|
360
|
+
self._inner = SandboxClient(
|
|
361
|
+
api_key=api_key, base_url=base_url, call_timeout_ms=call_timeout_ms, kind=kind
|
|
362
|
+
)
|
|
363
|
+
#: Persistent volumes CRUD (``/volumes``), sync flavour.
|
|
364
|
+
self.volumes = SyncVolumeClient(api_key=api_key, base_url=base_url)
|
|
365
|
+
self._loop = asyncio.new_event_loop()
|
|
366
|
+
|
|
367
|
+
def _run(self, coro: Any) -> Any:
|
|
368
|
+
return self._loop.run_until_complete(coro)
|
|
369
|
+
|
|
370
|
+
def create(self, **kwargs: Any) -> Sandbox:
|
|
371
|
+
return self._run(self._inner.create(**kwargs))
|
|
372
|
+
|
|
373
|
+
def create_desktop(self, **kwargs: Any) -> Desktop:
|
|
374
|
+
"""Create a GUI desktop via the unified ``/sandboxes`` {kind:desktop} route."""
|
|
375
|
+
return self._run(self._inner.create_desktop(**kwargs))
|
|
376
|
+
|
|
377
|
+
def connect(self, sandbox_id: str) -> Sandbox:
|
|
378
|
+
return self._run(self._inner.connect(sandbox_id))
|
|
379
|
+
|
|
380
|
+
def get(self, sandbox_id: str) -> SandboxView:
|
|
381
|
+
return self._run(self._inner.get(sandbox_id))
|
|
382
|
+
|
|
383
|
+
def list(self, **kwargs: Any) -> Dict[str, Any]:
|
|
384
|
+
return self._run(self._inner.list(**kwargs))
|
|
385
|
+
|
|
386
|
+
def list_all(self, **kwargs: Any) -> List[SandboxView]:
|
|
387
|
+
"""Auto-paginate ``list`` and return every matching sandbox as a list
|
|
388
|
+
(the sync analogue of the async generator)."""
|
|
389
|
+
async def _collect() -> List[SandboxView]:
|
|
390
|
+
return [s async for s in self._inner.list_all(**kwargs)]
|
|
391
|
+
|
|
392
|
+
return self._run(_collect())
|
|
393
|
+
|
|
394
|
+
def kill(self, sandbox_id: str) -> None:
|
|
395
|
+
return self._run(self._inner.kill(sandbox_id))
|
|
396
|
+
|
|
397
|
+
def list_snapshots(self, **kwargs: Any) -> List[SnapshotView]:
|
|
398
|
+
return self._run(self._inner.list_snapshots(**kwargs))
|
|
399
|
+
|
|
400
|
+
def get_snapshot(self, snapshot_id: str) -> SnapshotView:
|
|
401
|
+
return self._run(self._inner.get_snapshot(snapshot_id))
|
|
402
|
+
|
|
403
|
+
def delete_snapshot(self, snapshot_id: str) -> None:
|
|
404
|
+
return self._run(self._inner.delete_snapshot(snapshot_id))
|
|
405
|
+
|
|
406
|
+
def promote_snapshot(self, snapshot_id: str, name: str) -> Dict[str, Any]:
|
|
407
|
+
return self._run(self._inner.promote_snapshot(snapshot_id, name))
|
|
408
|
+
|
|
409
|
+
def close(self) -> None:
|
|
410
|
+
self._run(self._inner.aclose())
|
|
411
|
+
self.volumes.close()
|
|
412
|
+
self._loop.close()
|
|
413
|
+
|
|
414
|
+
def __enter__(self) -> "SyncSandboxClient":
|
|
415
|
+
return self
|
|
416
|
+
|
|
417
|
+
def __exit__(self, *_exc: Any) -> None:
|
|
418
|
+
self.close()
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _parse_create(data: Any) -> CreateSandboxResponse:
|
|
422
|
+
return CreateSandboxResponse(
|
|
423
|
+
sandboxId=data["sandboxId"],
|
|
424
|
+
kind=data["kind"],
|
|
425
|
+
controlUrl=data["controlUrl"],
|
|
426
|
+
expiresAt=data["expiresAt"],
|
|
427
|
+
streamUrl=data.get("streamUrl"),
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _parse_view(data: Any) -> SandboxView:
|
|
432
|
+
return SandboxView(
|
|
433
|
+
sandboxId=data["sandboxId"],
|
|
434
|
+
kind=data["kind"],
|
|
435
|
+
state=data["state"],
|
|
436
|
+
metadata=data.get("metadata", {}),
|
|
437
|
+
expiresAt=data["expiresAt"],
|
|
438
|
+
cpu=int(data.get("cpu", 0)),
|
|
439
|
+
memMb=int(data.get("memMb", 0)),
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _parse_snapshot(data: Any) -> SnapshotView:
|
|
444
|
+
return SnapshotView(
|
|
445
|
+
id=data["id"],
|
|
446
|
+
parent=data.get("parent"),
|
|
447
|
+
name=data.get("name"),
|
|
448
|
+
sizeBytes=int(data.get("sizeBytes", 0)),
|
|
449
|
+
createdAt=data.get("createdAt", ""),
|
|
450
|
+
kind=data.get("kind", "sandbox"),
|
|
451
|
+
template=data.get("template", ""),
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
__all__ = ["SandboxClient", "SyncSandboxClient"]
|