platinum-sdk 0.1.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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kortix AI
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,108 @@
1
+ Metadata-Version: 2.4
2
+ Name: platinum-sdk
3
+ Version: 0.1.0
4
+ Summary: Platinum Python SDK — client for hardware-isolated sandbox microVMs.
5
+ License: MIT
6
+ Project-URL: Homepage, https://github.com/kortix-ai/platinum
7
+ Requires-Python: >=3.9
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Requires-Dist: httpx>=0.25
11
+ Dynamic: license-file
12
+
13
+ # platinum-sdk (Python)
14
+
15
+ Python client for [Platinum](https://github.com/kortix-ai/platinum) — hardware-isolated
16
+ sandbox microVMs (one Cloud Hypervisor VM per sandbox, sub-second boots via a warm pool).
17
+
18
+ > **Not on PyPI yet.** Until the first publish, install from source:
19
+ > `pip install -e packages/sdk-py` (the import name is `platinum`).
20
+
21
+ ## Quickstart
22
+
23
+ ```python
24
+ from platinum import Platinum
25
+
26
+ dn = Platinum(token="pt_live_...", api_url="https://api.platinum.dev")
27
+ # or: PT_TOKEN / PT_API_URL env vars
28
+
29
+ sbx = dn.sandboxes.create(template="pt-base", wait_for_running=True)
30
+ r = sbx.exec(["uname", "-a"]).check() # .check() raises on non-zero exit
31
+ print(r.stdout)
32
+ sbx.delete()
33
+ ```
34
+
35
+ Expose a port at create time (URL comes back in the same response):
36
+
37
+ ```python
38
+ sbx = dn.sandboxes.create(
39
+ template="pt-base",
40
+ expose=[{"port": 8000, "public": True}],
41
+ wait_for_running=True,
42
+ )
43
+ print(sbx.exposed_url(8000))
44
+ ```
45
+
46
+ ## Configuration
47
+
48
+ | Arg / env | Default | Meaning |
49
+ |---|---|---|
50
+ | `token` / `PT_TOKEN` | — (required) | API key `pt_live_…`, org-scoped bearer token |
51
+ | `api_url` / `PT_API_URL` | `http://127.0.0.1:3000` | Control-plane URL |
52
+ | `timeout` | `60.0` | Per-request timeout (seconds) |
53
+
54
+ Python ≥ 3.9. **Synchronous only** (httpx). No automatic retries — a 429 or a failed
55
+ create is surfaced to you, never silently retried. Server-side use only; never ship an
56
+ API key into client-side code.
57
+
58
+ ## Surface (at parity with the TypeScript SDK)
59
+
60
+ The two SDKs expose the same operations — enforced in CI by `verify/sdk-parity.sh`.
61
+
62
+ | Area | Methods |
63
+ |---|---|
64
+ | Sandboxes | `sandboxes.create(...)` · `get` · `list` · `rename` · `delete` |
65
+ | Lifecycle | `pause` · `resume` · `fork` · `snapshot` · `archive` · `rename` · `backup` · `wait_running` · `refresh` |
66
+ | Run | `exec(argv)` · `sh(script)` · `run_code(code, lang)` |
67
+ | Networking | `expose(port)` · `unexpose(port)` · `exposed_url(port)` |
68
+ | Files (vsock) | `files.read` · `write` · `delete` · `list` · `stat` · `mkdir` |
69
+ | Share (virtio-fs) | `share.info` · `list` · `stat` · `get` · `put` · `mkdir` · `delete` |
70
+ | Platform | `templates.list()` · `regions.list()` · `health.check()` · `me()` |
71
+ | Results | `ExecResult(stdout, stderr, exit_code)` with `.check()` |
72
+
73
+ Build images declaratively with the `Template` builder (parity with the TS `Template`):
74
+
75
+ ```python
76
+ from platinum import Platinum, Template
77
+
78
+ dn = Platinum()
79
+ image = (Template.from_python_image("3.12-slim")
80
+ .pip_install(["fastapi", "uvicorn"])
81
+ .workdir("/app"))
82
+ sbx = dn.sandboxes.create(image=image, wait_for_running=True, wait_timeout_ms=600_000)
83
+ ```
84
+
85
+ ## Limits worth knowing
86
+
87
+ - `exec` is buffered, not streaming — output arrives after the command exits
88
+ (default timeout 30 s via `timeout_ms`).
89
+ - The default `pt-base` template is busybox-based — no git/pip/node/`httpd` inside
90
+ (`nc` and `wget` are available); build a template from a real distro image when you
91
+ need tooling.
92
+ - Background processes are reaped when their `exec` call returns. To leave a server
93
+ running, daemonize it: `setsid sh -c '<server loop>' >/dev/null 2>&1 < /dev/null &`
94
+ — see `examples/02_expose_service.py`.
95
+ - Errors raise `PlatinumError` with `.status` (HTTP status; `0` for non-HTTP failures).
96
+
97
+ ## Examples
98
+
99
+ Runnable scripts in [`examples/`](./examples):
100
+
101
+ ```sh
102
+ pip install -e packages/sdk-py
103
+ PT_API_URL=… PT_TOKEN=… python packages/sdk-py/examples/01_create_exec_delete.py
104
+ ```
105
+
106
+ ## License
107
+
108
+ MIT
@@ -0,0 +1,96 @@
1
+ # platinum-sdk (Python)
2
+
3
+ Python client for [Platinum](https://github.com/kortix-ai/platinum) — hardware-isolated
4
+ sandbox microVMs (one Cloud Hypervisor VM per sandbox, sub-second boots via a warm pool).
5
+
6
+ > **Not on PyPI yet.** Until the first publish, install from source:
7
+ > `pip install -e packages/sdk-py` (the import name is `platinum`).
8
+
9
+ ## Quickstart
10
+
11
+ ```python
12
+ from platinum import Platinum
13
+
14
+ dn = Platinum(token="pt_live_...", api_url="https://api.platinum.dev")
15
+ # or: PT_TOKEN / PT_API_URL env vars
16
+
17
+ sbx = dn.sandboxes.create(template="pt-base", wait_for_running=True)
18
+ r = sbx.exec(["uname", "-a"]).check() # .check() raises on non-zero exit
19
+ print(r.stdout)
20
+ sbx.delete()
21
+ ```
22
+
23
+ Expose a port at create time (URL comes back in the same response):
24
+
25
+ ```python
26
+ sbx = dn.sandboxes.create(
27
+ template="pt-base",
28
+ expose=[{"port": 8000, "public": True}],
29
+ wait_for_running=True,
30
+ )
31
+ print(sbx.exposed_url(8000))
32
+ ```
33
+
34
+ ## Configuration
35
+
36
+ | Arg / env | Default | Meaning |
37
+ |---|---|---|
38
+ | `token` / `PT_TOKEN` | — (required) | API key `pt_live_…`, org-scoped bearer token |
39
+ | `api_url` / `PT_API_URL` | `http://127.0.0.1:3000` | Control-plane URL |
40
+ | `timeout` | `60.0` | Per-request timeout (seconds) |
41
+
42
+ Python ≥ 3.9. **Synchronous only** (httpx). No automatic retries — a 429 or a failed
43
+ create is surfaced to you, never silently retried. Server-side use only; never ship an
44
+ API key into client-side code.
45
+
46
+ ## Surface (at parity with the TypeScript SDK)
47
+
48
+ The two SDKs expose the same operations — enforced in CI by `verify/sdk-parity.sh`.
49
+
50
+ | Area | Methods |
51
+ |---|---|
52
+ | Sandboxes | `sandboxes.create(...)` · `get` · `list` · `rename` · `delete` |
53
+ | Lifecycle | `pause` · `resume` · `fork` · `snapshot` · `archive` · `rename` · `backup` · `wait_running` · `refresh` |
54
+ | Run | `exec(argv)` · `sh(script)` · `run_code(code, lang)` |
55
+ | Networking | `expose(port)` · `unexpose(port)` · `exposed_url(port)` |
56
+ | Files (vsock) | `files.read` · `write` · `delete` · `list` · `stat` · `mkdir` |
57
+ | Share (virtio-fs) | `share.info` · `list` · `stat` · `get` · `put` · `mkdir` · `delete` |
58
+ | Platform | `templates.list()` · `regions.list()` · `health.check()` · `me()` |
59
+ | Results | `ExecResult(stdout, stderr, exit_code)` with `.check()` |
60
+
61
+ Build images declaratively with the `Template` builder (parity with the TS `Template`):
62
+
63
+ ```python
64
+ from platinum import Platinum, Template
65
+
66
+ dn = Platinum()
67
+ image = (Template.from_python_image("3.12-slim")
68
+ .pip_install(["fastapi", "uvicorn"])
69
+ .workdir("/app"))
70
+ sbx = dn.sandboxes.create(image=image, wait_for_running=True, wait_timeout_ms=600_000)
71
+ ```
72
+
73
+ ## Limits worth knowing
74
+
75
+ - `exec` is buffered, not streaming — output arrives after the command exits
76
+ (default timeout 30 s via `timeout_ms`).
77
+ - The default `pt-base` template is busybox-based — no git/pip/node/`httpd` inside
78
+ (`nc` and `wget` are available); build a template from a real distro image when you
79
+ need tooling.
80
+ - Background processes are reaped when their `exec` call returns. To leave a server
81
+ running, daemonize it: `setsid sh -c '<server loop>' >/dev/null 2>&1 < /dev/null &`
82
+ — see `examples/02_expose_service.py`.
83
+ - Errors raise `PlatinumError` with `.status` (HTTP status; `0` for non-HTTP failures).
84
+
85
+ ## Examples
86
+
87
+ Runnable scripts in [`examples/`](./examples):
88
+
89
+ ```sh
90
+ pip install -e packages/sdk-py
91
+ PT_API_URL=… PT_TOKEN=… python packages/sdk-py/examples/01_create_exec_delete.py
92
+ ```
93
+
94
+ ## License
95
+
96
+ MIT
@@ -0,0 +1,577 @@
1
+ """Platinum Python SDK — client for hardware-isolated sandbox microVMs.
2
+
3
+ Quick start:
4
+
5
+ from platinum import Platinum
6
+ dn = Platinum(token="pt_live_...", api_url="https://api.platinum.dev")
7
+
8
+ sbx = dn.sandboxes.create(template="pt-base", wait_for_running=True)
9
+ print(sbx.exec(["uname", "-a"]).stdout)
10
+ print(sbx.expose(8000)["url"])
11
+ sbx.delete()
12
+
13
+ Inline declarative image — build + boot in one call (cache-hit on repeat):
14
+
15
+ from platinum import Platinum, Template
16
+ image = Template.from_python_image("3.12-slim").pip_install(["fastapi", "uvicorn"])
17
+ sbx = dn.sandboxes.create(image=image, env={"PORT": "8080"},
18
+ wait_for_running=True, wait_timeout_ms=600_000)
19
+
20
+ The client is synchronous (httpx.Client). It performs no automatic retries —
21
+ a 429 or a failed create is surfaced to the caller, never silently retried.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import base64
27
+ import os
28
+ import time
29
+ from dataclasses import dataclass, field
30
+ from typing import Any, Dict, List, Optional, Union
31
+
32
+ import httpx
33
+
34
+ __all__ = ["Platinum", "Sandbox", "Template", "PlatinumError", "ExecResult"]
35
+
36
+ # Default per-request timeout (seconds). Share/file bulk I/O overrides per call.
37
+ _DEFAULT_TIMEOUT = 60.0
38
+ _SHARE_TIMEOUT = 5 * 60.0
39
+
40
+ _Bytes = Union[bytes, bytearray, str]
41
+
42
+
43
+ class PlatinumError(Exception):
44
+ """Raised on any non-2xx response, or on a client-side terminal condition
45
+ (status=0 for the latter, e.g. a wait timeout or a terminal sandbox state)."""
46
+
47
+ def __init__(self, status: int, body: Any, message: str):
48
+ super().__init__(message)
49
+ self.status = status
50
+ self.body = body
51
+
52
+
53
+ @dataclass
54
+ class ExecResult:
55
+ stdout: str
56
+ stderr: str
57
+ exit_code: int
58
+ error: Optional[str] = None
59
+
60
+ def check(self) -> "ExecResult":
61
+ """Raise if exit_code != 0 (analogous to subprocess.check_output)."""
62
+ if self.exit_code != 0:
63
+ raise PlatinumError(0, self, f"exec exit_code={self.exit_code}: {self.stderr or self.error}")
64
+ return self
65
+
66
+
67
+ # ───────────────────────── client ─────────────────────────
68
+
69
+
70
+ class Platinum:
71
+ def __init__(self, token: Optional[str] = None, api_url: Optional[str] = None,
72
+ timeout: float = _DEFAULT_TIMEOUT):
73
+ self.token = token or os.environ.get("PT_TOKEN")
74
+ if not self.token:
75
+ raise ValueError("Platinum(token=...) or PT_TOKEN env required")
76
+ self.api_url = (api_url or os.environ.get("PT_API_URL") or "http://127.0.0.1:3000").rstrip("/")
77
+ # No retries: httpx.Client does not retry by default, and we never wrap
78
+ # it in a retrying transport — the no-auto-retry / no-429-retry contract.
79
+ self._http = httpx.Client(timeout=timeout, headers={"authorization": f"Bearer {self.token}"})
80
+ self.sandboxes = _Sandboxes(self)
81
+ self.templates = _Templates(self)
82
+ self.regions = _Regions(self)
83
+ self.health = _Health(self)
84
+
85
+ def __enter__(self) -> "Platinum":
86
+ return self
87
+
88
+ def __exit__(self, *a) -> None:
89
+ self._http.close()
90
+
91
+ def close(self) -> None:
92
+ self._http.close()
93
+
94
+ # ──────────── low-level request (escape hatch) ────────────
95
+ def request(self, method: str, path: str, *,
96
+ json: Optional[dict] = None,
97
+ query: Optional[Dict[str, Any]] = None,
98
+ content: Optional[_Bytes] = None,
99
+ raw: bool = False,
100
+ timeout: Optional[float] = None) -> Any:
101
+ """Issue a raw request. Returns parsed JSON, or raw ``bytes`` when
102
+ ``raw=True``. Drops ``None``/empty query values. Raises PlatinumError
103
+ on any status >= 300."""
104
+ kwargs: dict = {}
105
+ if json is not None:
106
+ kwargs["json"] = json
107
+ if content is not None:
108
+ kwargs["content"] = content
109
+ if query:
110
+ params = {k: v for k, v in query.items() if v is not None and v != ""}
111
+ if params:
112
+ kwargs["params"] = params
113
+ if timeout is not None:
114
+ kwargs["timeout"] = timeout
115
+ r = self._http.request(method, self.api_url + path, **kwargs)
116
+ if raw:
117
+ if r.status_code >= 300:
118
+ raise PlatinumError(r.status_code, r.text, f"{method} {path} -> {r.status_code}")
119
+ return r.content
120
+ try:
121
+ body: Any = r.json()
122
+ except Exception:
123
+ body = r.text
124
+ if r.status_code >= 300:
125
+ raise PlatinumError(r.status_code, body, f"{method} {path} -> {r.status_code}")
126
+ return body
127
+
128
+ # Backward-compatible internal alias.
129
+ def _req(self, method: str, path: str, json: Optional[dict] = None) -> Any:
130
+ return self.request(method, path, json=json)
131
+
132
+ def me(self) -> dict:
133
+ """Identity + role of the current bearer token."""
134
+ return self.request("GET", "/v1/me")
135
+
136
+
137
+ class _Sandboxes:
138
+ def __init__(self, client: "Platinum"):
139
+ self._c = client
140
+
141
+ def create(self, *,
142
+ template: Optional[str] = None,
143
+ image: Optional[Union["Template", dict]] = None,
144
+ type: Optional[str] = None,
145
+ region: Optional[str] = None,
146
+ name: Optional[str] = None,
147
+ cpu: Optional[int] = None,
148
+ ram_mb: Optional[int] = None,
149
+ disk_gb: Optional[int] = None,
150
+ gpus: Optional[int] = None,
151
+ env: Optional[dict] = None,
152
+ env_vars: Optional[dict] = None,
153
+ volume_ids: Optional[List[str]] = None,
154
+ ssh_keys: Optional[List[str]] = None,
155
+ language: Optional[str] = None,
156
+ auto_stop_minutes: Optional[int] = None,
157
+ auto_archive_days: Optional[int] = None,
158
+ auto_delete_days: Optional[int] = None,
159
+ metadata: Optional[dict] = None,
160
+ expose: Optional[list] = None,
161
+ wait_for_running: bool = False,
162
+ wait_timeout_ms: int = 60_000) -> "Sandbox":
163
+ """Create a sandbox. Pass ``template`` (pre-built name/id) or ``image``
164
+ (a ``Template`` builder or a raw image-spec dict) — mutually exclusive.
165
+
166
+ With ``wait_for_running=True`` the server holds the POST open until the
167
+ sandbox is ``running`` (no client-side polling). Inline-image builds can
168
+ run minutes — raise ``wait_timeout_ms`` accordingly (e.g. 600_000).
169
+
170
+ Each ``expose`` entry: ``{"port": int, "public": bool=False,
171
+ "ttl_seconds": int?}``. Max 8; the URL(s) fold into the create response.
172
+ """
173
+ body: Dict[str, Any] = {}
174
+ if template is not None:
175
+ body["template"] = template
176
+ if image is not None:
177
+ body["image"] = image.to_image_spec() if isinstance(image, Template) else image
178
+ for k, v in (
179
+ ("type", type), ("region", region), ("name", name), ("cpu", cpu),
180
+ ("ram_mb", ram_mb), ("disk_gb", disk_gb), ("gpus", gpus),
181
+ ("env", env), ("envVars", env_vars), ("volume_ids", volume_ids),
182
+ ("ssh_keys", ssh_keys), ("language", language),
183
+ ("auto_stop_minutes", auto_stop_minutes),
184
+ ("auto_archive_days", auto_archive_days),
185
+ ("auto_delete_days", auto_delete_days),
186
+ ("metadata", metadata), ("expose", expose),
187
+ ):
188
+ if v is not None:
189
+ body[k] = v
190
+
191
+ query = None
192
+ rpc_timeout = None
193
+ if wait_for_running:
194
+ query = {"wait_for_state": "running", "wait_timeout_ms": wait_timeout_ms}
195
+ # Keep the connection open slightly beyond the server-side hold.
196
+ rpc_timeout = wait_timeout_ms / 1000.0 + 30.0
197
+
198
+ r = self._c.request("POST", "/v1/sandboxes", json=body, query=query, timeout=rpc_timeout)
199
+ return Sandbox(self._c, r["id"], state=r.get("state"), name=r.get("name"),
200
+ host_id=r.get("host_id"), warm=r.get("warm"), exposed=r.get("exposed") or [])
201
+
202
+ def get(self, sandbox_id: str) -> dict:
203
+ return self._c.request("GET", f"/v1/sandboxes/{sandbox_id}")
204
+
205
+ def list(self, *, limit: int = 50, offset: int = 0,
206
+ state: Optional[str] = None) -> dict:
207
+ """List sandboxes (paginated). Returns ``{rows, total, has_more}``."""
208
+ return self._c.request("GET", "/v1/sandboxes",
209
+ query={"paginated": "true", "limit": limit,
210
+ "offset": offset, "state": state})
211
+
212
+ def delete(self, sandbox_id: str) -> None:
213
+ self._c.request("DELETE", f"/v1/sandboxes/{sandbox_id}")
214
+
215
+ def rename(self, sandbox_id: str, name: Optional[str]) -> dict:
216
+ """Set or clear a sandbox's display label. Pass ``None``/``""`` to clear.
217
+ Raises PlatinumError(409) if another active sandbox uses the name."""
218
+ return self._c.request("PATCH", f"/v1/sandboxes/{sandbox_id}", json={"name": name})
219
+
220
+
221
+ class _Templates:
222
+ def __init__(self, client: "Platinum"):
223
+ self._c = client
224
+
225
+ def list(self) -> list:
226
+ return self._c.request("GET", "/v1/templates")
227
+
228
+
229
+ class _Regions:
230
+ def __init__(self, client: "Platinum"):
231
+ self._c = client
232
+
233
+ def list(self) -> list:
234
+ """Regions with at least one ready host."""
235
+ return self._c.request("GET", "/v1/regions")
236
+
237
+
238
+ class _Health:
239
+ def __init__(self, client: "Platinum"):
240
+ self._c = client
241
+
242
+ def check(self) -> dict:
243
+ return self._c.request("GET", "/health")
244
+
245
+
246
+ # ───────────────────────── sandbox handle ─────────────────────────
247
+
248
+
249
+ class Sandbox:
250
+ def __init__(self, client: Platinum, sandbox_id: str, *,
251
+ state: Optional[str] = None, name: Optional[str] = None,
252
+ host_id: Optional[str] = None, warm: Optional[bool] = None,
253
+ exposed: Optional[list] = None):
254
+ self._c = client
255
+ self.id = sandbox_id
256
+ # True if create() returned a warm-pool VM (claim, not fresh spawn).
257
+ self.warm = bool(warm)
258
+ self.name = name
259
+ self.host_id = host_id
260
+ # Ports pre-exposed via the inline `expose:` field on create — list of
261
+ # {port, url, token?, public}. Empty when the spawn wasn't expose-on-create.
262
+ self.exposed: list = exposed or []
263
+ self.files = _SandboxFiles(self)
264
+ self.share = _SandboxShare(self)
265
+
266
+ def exposed_url(self, port: int) -> Optional[str]:
267
+ """URL of a port pre-exposed at create-time, or None. For ports added
268
+ later, call .expose()."""
269
+ for e in self.exposed:
270
+ if e.get("port") == port:
271
+ return e.get("url")
272
+ return None
273
+
274
+ # ── lifecycle ──
275
+ def refresh(self) -> dict:
276
+ s = self._c.sandboxes.get(self.id)
277
+ self.host_id = s.get("hostId", self.host_id)
278
+ self.name = s.get("name")
279
+ return s
280
+
281
+ def wait_running(self, timeout: float = 30.0, interval: float = 0.25) -> dict:
282
+ """Block until the sandbox reaches state='running'."""
283
+ deadline = time.time() + timeout
284
+ last: dict = {}
285
+ while time.time() < deadline:
286
+ last = self.refresh()
287
+ st = last.get("state")
288
+ if st == "running":
289
+ return last
290
+ if (st or "").startswith("failed") or st in ("lost", "deleted"):
291
+ raise PlatinumError(0, last, f"sandbox {self.id} terminal: {st}")
292
+ time.sleep(interval)
293
+ raise PlatinumError(0, last, f"timeout waiting for running ({last.get('state')})")
294
+
295
+ def delete(self) -> None:
296
+ self._c.sandboxes.delete(self.id)
297
+
298
+ def pause(self) -> None:
299
+ self._c.request("POST", f"/v1/sandboxes/{self.id}/pause", json={})
300
+
301
+ def resume(self) -> None:
302
+ self._c.request("POST", f"/v1/sandboxes/{self.id}/resume", json={})
303
+
304
+ def fork(self) -> dict:
305
+ return self._c.request("POST", f"/v1/sandboxes/{self.id}/fork", json={})
306
+
307
+ def snapshot(self, name: Optional[str] = None) -> dict:
308
+ return self._c.request("POST", f"/v1/sandboxes/{self.id}/snapshot",
309
+ json={"name": name} if name else {})
310
+
311
+ def archive(self) -> None:
312
+ self._c.request("POST", f"/v1/sandboxes/{self.id}/archive", json={})
313
+
314
+ def rename(self, name: Optional[str]) -> dict:
315
+ """Set or clear this sandbox's display label. Raises PlatinumError(409)
316
+ if another active sandbox already uses the name."""
317
+ s = self._c.sandboxes.rename(self.id, name)
318
+ self.name = s.get("name")
319
+ return s
320
+
321
+ def backup(self) -> dict:
322
+ return self._c.request("POST", f"/v1/sandboxes/{self.id}/backup", json={})
323
+
324
+ # ── exec / run-code ──
325
+ def exec(self, cmd: list, timeout_ms: int = 30_000) -> ExecResult:
326
+ r = self._c.request("POST", f"/v1/sandboxes/{self.id}/exec",
327
+ json={"cmd": cmd, "timeout_ms": timeout_ms},
328
+ timeout=timeout_ms / 1000.0 + 5.0)
329
+ if r.get("error"):
330
+ raise PlatinumError(0, r, r["error"])
331
+ rr = r["result"]
332
+ return ExecResult(stdout=rr.get("stdout", ""), stderr=rr.get("stderr", ""),
333
+ exit_code=rr.get("exit_code", 0), error=rr.get("error"))
334
+
335
+ def sh(self, script: str, timeout_ms: int = 30_000) -> ExecResult:
336
+ return self.exec(["sh", "-c", script], timeout_ms=timeout_ms)
337
+
338
+ def run_code(self, code: str, lang: str = "python", timeout_ms: int = 30_000) -> ExecResult:
339
+ r = self._c.request("POST", f"/v1/sandboxes/{self.id}/run-code",
340
+ json={"code": code, "lang": lang, "timeout_ms": timeout_ms},
341
+ timeout=timeout_ms / 1000.0 + 5.0)
342
+ return ExecResult(stdout=r.get("stdout", ""), stderr=r.get("stderr", ""),
343
+ exit_code=r.get("exit_code", 0), error=r.get("error"))
344
+
345
+ # ── networking ──
346
+ def expose(self, port: int) -> dict:
347
+ return self._c.request("POST", f"/v1/sandboxes/{self.id}/expose", json={"port": port})
348
+
349
+ def unexpose(self, port: int) -> None:
350
+ self._c.request("DELETE", f"/v1/sandboxes/{self.id}/expose/{port}")
351
+
352
+ def __repr__(self) -> str:
353
+ return f"<Sandbox id={self.id}>"
354
+
355
+
356
+ class _SandboxFiles:
357
+ """vsock file ops — small reads/writes anywhere in the guest FS."""
358
+
359
+ def __init__(self, sbx: "Sandbox"):
360
+ self._c = sbx._c
361
+ self._id = sbx.id
362
+
363
+ def read(self, path: str) -> bytes:
364
+ return self._c.request("GET", f"/v1/sandboxes/{self._id}/files",
365
+ query={"path": path}, raw=True)
366
+
367
+ def write(self, path: str, body: _Bytes) -> None:
368
+ self._c.request("PUT", f"/v1/sandboxes/{self._id}/files",
369
+ query={"path": path}, content=body)
370
+
371
+ def delete(self, path: str) -> None:
372
+ self._c.request("DELETE", f"/v1/sandboxes/{self._id}/files", query={"path": path})
373
+
374
+ def list(self, path: str) -> list:
375
+ # API returns {ok, entries?}; unwrap to the entries list (or []).
376
+ r = self._c.request("GET", f"/v1/sandboxes/{self._id}/files/list", query={"path": path})
377
+ if isinstance(r, dict) and r.get("ok") is False:
378
+ raise PlatinumError(0, r, r.get("error") or f"files.list {path} failed")
379
+ return (r or {}).get("entries") or []
380
+
381
+ def stat(self, path: str) -> dict:
382
+ return self._c.request("GET", f"/v1/sandboxes/{self._id}/files/stat", query={"path": path})
383
+
384
+ def mkdir(self, path: str) -> None:
385
+ self._c.request("POST", f"/v1/sandboxes/{self._id}/files/mkdir", query={"path": path})
386
+
387
+
388
+ class _SandboxShare:
389
+ """virtio-fs bulk transfer — works while the sandbox is paused."""
390
+
391
+ def __init__(self, sbx: "Sandbox"):
392
+ self._c = sbx._c
393
+ self._id = sbx.id
394
+
395
+ def info(self) -> dict:
396
+ return self._c.request("GET", f"/v1/sandboxes/{self._id}/share/info")
397
+
398
+ def list(self, path: str = "") -> dict:
399
+ return self._c.request("GET", f"/v1/sandboxes/{self._id}/share/list", query={"path": path})
400
+
401
+ def stat(self, path: str) -> dict:
402
+ return self._c.request("GET", f"/v1/sandboxes/{self._id}/share/stat", query={"path": path})
403
+
404
+ def get(self, path: str, timeout: float = _SHARE_TIMEOUT) -> bytes:
405
+ return self._c.request("GET", f"/v1/sandboxes/{self._id}/share/get",
406
+ query={"path": path}, raw=True, timeout=timeout)
407
+
408
+ def put(self, path: str, body: _Bytes, timeout: float = _SHARE_TIMEOUT) -> dict:
409
+ return self._c.request("POST", f"/v1/sandboxes/{self._id}/share/put",
410
+ query={"path": path}, content=body, timeout=timeout)
411
+
412
+ def mkdir(self, path: str) -> None:
413
+ self._c.request("POST", f"/v1/sandboxes/{self._id}/share/mkdir", query={"path": path})
414
+
415
+ def delete(self, path: str, recursive: bool = False) -> None:
416
+ self._c.request("DELETE", f"/v1/sandboxes/{self._id}/share/delete",
417
+ query={"path": path, "recursive": "1" if recursive else None})
418
+
419
+
420
+ # ───────────────────────── template builder ─────────────────────────
421
+
422
+
423
+ @dataclass
424
+ class Template:
425
+ """Declarative image builder — parity with the TS ``Template``.
426
+
427
+ tpl = (Template.from_python_image("3.12-slim")
428
+ .pip_install(["fastapi", "uvicorn"])
429
+ .workdir("/app")
430
+ .entrypoint("uvicorn app:app --host 0.0.0.0 --port 8000")
431
+ .ready_cmd("curl -fsS http://127.0.0.1:8000/health"))
432
+ result = tpl.build(dn, name="my-agent") # POST /v1/templates/from-spec
433
+
434
+ Or pass it inline to ``sandboxes.create(image=tpl, ...)`` (no explicit name;
435
+ Platinum derives a deterministic ``inline-<hash>`` template).
436
+ """
437
+
438
+ base: str
439
+ steps: List[dict] = field(default_factory=list)
440
+ _entrypoint: Optional[str] = None
441
+ _ready_cmd: Optional[str] = None
442
+
443
+ # ── base image factories ──
444
+ @staticmethod
445
+ def from_image(ref: str) -> "Template":
446
+ return Template(ref)
447
+
448
+ @staticmethod
449
+ def from_python_image(version: str) -> "Template":
450
+ return Template(f"python:{version}")
451
+
452
+ @staticmethod
453
+ def from_node_image(version: str) -> "Template":
454
+ return Template(f"node:{version}")
455
+
456
+ @staticmethod
457
+ def from_bun_image(version: str) -> "Template":
458
+ return Template(f"oven/bun:{version}")
459
+
460
+ @staticmethod
461
+ def from_ubuntu_image(version: str) -> "Template":
462
+ return Template(f"ubuntu:{version}")
463
+
464
+ @staticmethod
465
+ def from_debian_image(version: str) -> "Template":
466
+ return Template(f"debian:{version}")
467
+
468
+ @staticmethod
469
+ def from_alpine_image(version: str) -> "Template":
470
+ return Template(f"alpine:{version}")
471
+
472
+ # ── chain ops (return self for fluent chaining) ──
473
+ def env(self, key: str, value: str) -> "Template":
474
+ self.steps.append({"op": "env", "key": key, "value": value})
475
+ return self
476
+
477
+ def workdir(self, path: str) -> "Template":
478
+ self.steps.append({"op": "workdir", "path": path})
479
+ return self
480
+
481
+ def user(self, user: str) -> "Template":
482
+ self.steps.append({"op": "user", "user": user})
483
+ return self
484
+
485
+ def run_cmd(self, cmd: str) -> "Template":
486
+ self.steps.append({"op": "run", "cmd": cmd})
487
+ return self
488
+
489
+ def pip_install(self, packages: List[str]) -> "Template":
490
+ self.steps.append({"op": "pip", "packages": packages})
491
+ return self
492
+
493
+ def npm_install(self, packages: List[str]) -> "Template":
494
+ self.steps.append({"op": "npm", "packages": packages})
495
+ return self
496
+
497
+ def apt_install(self, packages: List[str]) -> "Template":
498
+ self.steps.append({"op": "apt", "packages": packages})
499
+ return self
500
+
501
+ def copy(self, src: Union[str, bytes], dst: str, mode: Optional[str] = None) -> "Template":
502
+ """Inline copy. Small files only (<256 KiB). ``src`` is a local path
503
+ (read from disk) or raw ``bytes``."""
504
+ raw = open(src, "rb").read() if isinstance(src, str) else bytes(src)
505
+ if len(raw) > 256 * 1024:
506
+ raise ValueError("copy(): file too large for inline (>256 KiB). PUT it after build.")
507
+ step = {"op": "copy", "content_b64": base64.b64encode(raw).decode(), "dst": dst}
508
+ if mode is not None:
509
+ step["mode"] = mode
510
+ self.steps.append(step)
511
+ return self
512
+
513
+ def entrypoint(self, cmd: str) -> "Template":
514
+ self._entrypoint = cmd
515
+ return self
516
+
517
+ def ready_cmd(self, cmd: str) -> "Template":
518
+ self._ready_cmd = cmd
519
+ return self
520
+
521
+ # ── build ──
522
+ def to_spec(self, name: str, version: str = "1.0.0", *,
523
+ default_cpu: int = 1, default_ram_mb: int = 512,
524
+ default_disk_gb: int = 2, size_mb: int = 1024) -> dict:
525
+ return {
526
+ "name": name,
527
+ "version": version,
528
+ "base_image": self.base,
529
+ "steps": self.steps,
530
+ "entrypoint": self._entrypoint,
531
+ "ready_cmd": self._ready_cmd,
532
+ "default_cpu": default_cpu,
533
+ "default_ram_mb": default_ram_mb,
534
+ "default_disk_gb": default_disk_gb,
535
+ "size_mb": size_mb,
536
+ }
537
+
538
+ def to_image_spec(self, *, default_cpu: Optional[int] = None,
539
+ default_ram_mb: Optional[int] = None,
540
+ default_disk_gb: Optional[int] = None,
541
+ size_mb: Optional[int] = None) -> dict:
542
+ """Inline-image shape for ``POST /v1/sandboxes`` (no name/version —
543
+ Platinum derives a deterministic ``inline-<hash>`` template)."""
544
+ return {
545
+ "base_image": self.base,
546
+ "steps": self.steps,
547
+ "entrypoint": self._entrypoint,
548
+ "ready_cmd": self._ready_cmd,
549
+ "default_cpu": default_cpu,
550
+ "default_ram_mb": default_ram_mb,
551
+ "default_disk_gb": default_disk_gb,
552
+ "size_mb": size_mb,
553
+ }
554
+
555
+ def build(self, dn: "Platinum", name: str, version: str = "1.0.0", *,
556
+ default_cpu: int = 1, default_ram_mb: int = 512,
557
+ default_disk_gb: int = 2, size_mb: int = 1024,
558
+ wait_ms: int = 5 * 60_000) -> dict:
559
+ """POST the spec to /v1/templates/from-spec and poll until the template
560
+ leaves 'building'. Returns ``{id, name, state, build_logs?, content_hash?}``.
561
+ ``wait_ms=0`` fires and returns immediately with state='building'."""
562
+ spec = self.to_spec(name, version, default_cpu=default_cpu,
563
+ default_ram_mb=default_ram_mb, default_disk_gb=default_disk_gb,
564
+ size_mb=size_mb)
565
+ queued = dn.request("POST", "/v1/templates/from-spec", json=spec)
566
+ tid = queued["id"]
567
+ if wait_ms == 0:
568
+ return {"id": tid, "name": name, "state": "building"}
569
+ deadline = time.time() + wait_ms / 1000.0
570
+ while time.time() < deadline:
571
+ for t in dn.request("GET", "/v1/templates"):
572
+ if t.get("id") == tid and t.get("state") in ("ready", "failed"):
573
+ return {"id": tid, "name": name, "state": t["state"],
574
+ "build_logs": t.get("buildLogs") or t.get("build_logs"),
575
+ "content_hash": t.get("contentHash") or t.get("content_hash")}
576
+ time.sleep(3.0)
577
+ return {"id": tid, "name": name, "state": "building"}
@@ -0,0 +1,108 @@
1
+ Metadata-Version: 2.4
2
+ Name: platinum-sdk
3
+ Version: 0.1.0
4
+ Summary: Platinum Python SDK — client for hardware-isolated sandbox microVMs.
5
+ License: MIT
6
+ Project-URL: Homepage, https://github.com/kortix-ai/platinum
7
+ Requires-Python: >=3.9
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Requires-Dist: httpx>=0.25
11
+ Dynamic: license-file
12
+
13
+ # platinum-sdk (Python)
14
+
15
+ Python client for [Platinum](https://github.com/kortix-ai/platinum) — hardware-isolated
16
+ sandbox microVMs (one Cloud Hypervisor VM per sandbox, sub-second boots via a warm pool).
17
+
18
+ > **Not on PyPI yet.** Until the first publish, install from source:
19
+ > `pip install -e packages/sdk-py` (the import name is `platinum`).
20
+
21
+ ## Quickstart
22
+
23
+ ```python
24
+ from platinum import Platinum
25
+
26
+ dn = Platinum(token="pt_live_...", api_url="https://api.platinum.dev")
27
+ # or: PT_TOKEN / PT_API_URL env vars
28
+
29
+ sbx = dn.sandboxes.create(template="pt-base", wait_for_running=True)
30
+ r = sbx.exec(["uname", "-a"]).check() # .check() raises on non-zero exit
31
+ print(r.stdout)
32
+ sbx.delete()
33
+ ```
34
+
35
+ Expose a port at create time (URL comes back in the same response):
36
+
37
+ ```python
38
+ sbx = dn.sandboxes.create(
39
+ template="pt-base",
40
+ expose=[{"port": 8000, "public": True}],
41
+ wait_for_running=True,
42
+ )
43
+ print(sbx.exposed_url(8000))
44
+ ```
45
+
46
+ ## Configuration
47
+
48
+ | Arg / env | Default | Meaning |
49
+ |---|---|---|
50
+ | `token` / `PT_TOKEN` | — (required) | API key `pt_live_…`, org-scoped bearer token |
51
+ | `api_url` / `PT_API_URL` | `http://127.0.0.1:3000` | Control-plane URL |
52
+ | `timeout` | `60.0` | Per-request timeout (seconds) |
53
+
54
+ Python ≥ 3.9. **Synchronous only** (httpx). No automatic retries — a 429 or a failed
55
+ create is surfaced to you, never silently retried. Server-side use only; never ship an
56
+ API key into client-side code.
57
+
58
+ ## Surface (at parity with the TypeScript SDK)
59
+
60
+ The two SDKs expose the same operations — enforced in CI by `verify/sdk-parity.sh`.
61
+
62
+ | Area | Methods |
63
+ |---|---|
64
+ | Sandboxes | `sandboxes.create(...)` · `get` · `list` · `rename` · `delete` |
65
+ | Lifecycle | `pause` · `resume` · `fork` · `snapshot` · `archive` · `rename` · `backup` · `wait_running` · `refresh` |
66
+ | Run | `exec(argv)` · `sh(script)` · `run_code(code, lang)` |
67
+ | Networking | `expose(port)` · `unexpose(port)` · `exposed_url(port)` |
68
+ | Files (vsock) | `files.read` · `write` · `delete` · `list` · `stat` · `mkdir` |
69
+ | Share (virtio-fs) | `share.info` · `list` · `stat` · `get` · `put` · `mkdir` · `delete` |
70
+ | Platform | `templates.list()` · `regions.list()` · `health.check()` · `me()` |
71
+ | Results | `ExecResult(stdout, stderr, exit_code)` with `.check()` |
72
+
73
+ Build images declaratively with the `Template` builder (parity with the TS `Template`):
74
+
75
+ ```python
76
+ from platinum import Platinum, Template
77
+
78
+ dn = Platinum()
79
+ image = (Template.from_python_image("3.12-slim")
80
+ .pip_install(["fastapi", "uvicorn"])
81
+ .workdir("/app"))
82
+ sbx = dn.sandboxes.create(image=image, wait_for_running=True, wait_timeout_ms=600_000)
83
+ ```
84
+
85
+ ## Limits worth knowing
86
+
87
+ - `exec` is buffered, not streaming — output arrives after the command exits
88
+ (default timeout 30 s via `timeout_ms`).
89
+ - The default `pt-base` template is busybox-based — no git/pip/node/`httpd` inside
90
+ (`nc` and `wget` are available); build a template from a real distro image when you
91
+ need tooling.
92
+ - Background processes are reaped when their `exec` call returns. To leave a server
93
+ running, daemonize it: `setsid sh -c '<server loop>' >/dev/null 2>&1 < /dev/null &`
94
+ — see `examples/02_expose_service.py`.
95
+ - Errors raise `PlatinumError` with `.status` (HTTP status; `0` for non-HTTP failures).
96
+
97
+ ## Examples
98
+
99
+ Runnable scripts in [`examples/`](./examples):
100
+
101
+ ```sh
102
+ pip install -e packages/sdk-py
103
+ PT_API_URL=… PT_TOKEN=… python packages/sdk-py/examples/01_create_exec_delete.py
104
+ ```
105
+
106
+ ## License
107
+
108
+ MIT
@@ -0,0 +1,9 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ platinum/__init__.py
5
+ platinum_sdk.egg-info/PKG-INFO
6
+ platinum_sdk.egg-info/SOURCES.txt
7
+ platinum_sdk.egg-info/dependency_links.txt
8
+ platinum_sdk.egg-info/requires.txt
9
+ platinum_sdk.egg-info/top_level.txt
@@ -0,0 +1 @@
1
+ httpx>=0.25
@@ -0,0 +1 @@
1
+ platinum
@@ -0,0 +1,18 @@
1
+ [project]
2
+ name = "platinum-sdk"
3
+ version = "0.1.0"
4
+ description = "Platinum Python SDK — client for hardware-isolated sandbox microVMs."
5
+ readme = "README.md"
6
+ license = { text = "MIT" }
7
+ requires-python = ">=3.9"
8
+ dependencies = ["httpx>=0.25"]
9
+
10
+ [project.urls]
11
+ Homepage = "https://github.com/kortix-ai/platinum"
12
+
13
+ [build-system]
14
+ requires = ["setuptools>=68"]
15
+ build-backend = "setuptools.build_meta"
16
+
17
+ [tool.setuptools]
18
+ packages = ["platinum"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+