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"]