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.
- platinum_sdk-0.1.0/LICENSE +21 -0
- platinum_sdk-0.1.0/PKG-INFO +108 -0
- platinum_sdk-0.1.0/README.md +96 -0
- platinum_sdk-0.1.0/platinum/__init__.py +577 -0
- platinum_sdk-0.1.0/platinum_sdk.egg-info/PKG-INFO +108 -0
- platinum_sdk-0.1.0/platinum_sdk.egg-info/SOURCES.txt +9 -0
- platinum_sdk-0.1.0/platinum_sdk.egg-info/dependency_links.txt +1 -0
- platinum_sdk-0.1.0/platinum_sdk.egg-info/requires.txt +1 -0
- platinum_sdk-0.1.0/platinum_sdk.egg-info/top_level.txt +1 -0
- platinum_sdk-0.1.0/pyproject.toml +18 -0
- platinum_sdk-0.1.0/setup.cfg +4 -0
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -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"]
|