opencomputer-sdk 0.3.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.
- opencomputer_sdk-0.3.0/.gitignore +49 -0
- opencomputer_sdk-0.3.0/PKG-INFO +72 -0
- opencomputer_sdk-0.3.0/README.md +44 -0
- opencomputer_sdk-0.3.0/opencomputer/__init__.py +19 -0
- opencomputer_sdk-0.3.0/opencomputer/commands.py +56 -0
- opencomputer_sdk-0.3.0/opencomputer/filesystem.py +100 -0
- opencomputer_sdk-0.3.0/opencomputer/pty.py +96 -0
- opencomputer_sdk-0.3.0/opencomputer/sandbox.py +201 -0
- opencomputer_sdk-0.3.0/opencomputer/template.py +75 -0
- opencomputer_sdk-0.3.0/pyproject.toml +46 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# OS files
|
|
2
|
+
.DS_Store
|
|
3
|
+
Thumbs.db
|
|
4
|
+
|
|
5
|
+
# Editor/IDE files
|
|
6
|
+
.cursor/
|
|
7
|
+
*.swp
|
|
8
|
+
*.swo
|
|
9
|
+
*~
|
|
10
|
+
.idea/
|
|
11
|
+
.vscode/
|
|
12
|
+
|
|
13
|
+
# Environment files
|
|
14
|
+
.env
|
|
15
|
+
.env.*
|
|
16
|
+
!.env.example
|
|
17
|
+
|
|
18
|
+
# Python
|
|
19
|
+
__pycache__/
|
|
20
|
+
*.pyc
|
|
21
|
+
*.pyo
|
|
22
|
+
*.egg-info/
|
|
23
|
+
.pytest_cache/
|
|
24
|
+
|
|
25
|
+
# Node
|
|
26
|
+
node_modules/
|
|
27
|
+
|
|
28
|
+
# Build artifacts
|
|
29
|
+
dist/
|
|
30
|
+
|
|
31
|
+
# Compiled binaries
|
|
32
|
+
bin/opensandbox-server
|
|
33
|
+
bin/opensandbox-worker
|
|
34
|
+
opensandbox-worker
|
|
35
|
+
|
|
36
|
+
# Worker env (secrets)
|
|
37
|
+
worker.env
|
|
38
|
+
caddy.env
|
|
39
|
+
|
|
40
|
+
# Loom demo (private)
|
|
41
|
+
sdks/typescript/examples/loom/
|
|
42
|
+
|
|
43
|
+
# Media files
|
|
44
|
+
*.mp4
|
|
45
|
+
*.gif
|
|
46
|
+
|
|
47
|
+
# Temp files
|
|
48
|
+
*.tmp
|
|
49
|
+
*.log
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: opencomputer-sdk
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Python SDK for OpenComputer - cloud sandbox platform
|
|
5
|
+
Project-URL: Homepage, https://github.com/diggerhq/opensandbox
|
|
6
|
+
Project-URL: Repository, https://github.com/diggerhq/opensandbox
|
|
7
|
+
Project-URL: Documentation, https://github.com/diggerhq/opensandbox/tree/main/sdks/python
|
|
8
|
+
Project-URL: Issues, https://github.com/diggerhq/opensandbox/issues
|
|
9
|
+
Author: OpenComputer
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
Keywords: cloud,code-execution,containers,opencomputer,sandbox
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Requires-Dist: httpx>=0.27.0
|
|
23
|
+
Requires-Dist: websockets>=12.0
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# opencomputer
|
|
30
|
+
|
|
31
|
+
Python SDK for [OpenComputer](https://github.com/diggerhq/opensandbox) — cloud sandbox platform.
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install opencomputer
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Quick Start
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
import asyncio
|
|
43
|
+
from opencomputer import Sandbox
|
|
44
|
+
|
|
45
|
+
async def main():
|
|
46
|
+
sandbox = await Sandbox.create(template="base")
|
|
47
|
+
|
|
48
|
+
# Execute commands
|
|
49
|
+
result = await sandbox.commands.run("echo hello")
|
|
50
|
+
print(result.stdout) # "hello\n"
|
|
51
|
+
|
|
52
|
+
# Read and write files
|
|
53
|
+
await sandbox.files.write("/tmp/test.txt", "Hello, world!")
|
|
54
|
+
content = await sandbox.files.read("/tmp/test.txt")
|
|
55
|
+
|
|
56
|
+
# Clean up
|
|
57
|
+
await sandbox.kill()
|
|
58
|
+
await sandbox.close()
|
|
59
|
+
|
|
60
|
+
asyncio.run(main())
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Configuration
|
|
64
|
+
|
|
65
|
+
| Parameter | Env Variable | Default |
|
|
66
|
+
|------------|------------------------|-------------------------|
|
|
67
|
+
| `api_url` | `OPENSANDBOX_API_URL` | `https://app.opencomputer.dev` |
|
|
68
|
+
| `api_key` | `OPENSANDBOX_API_KEY` | (none) |
|
|
69
|
+
|
|
70
|
+
## License
|
|
71
|
+
|
|
72
|
+
MIT
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# opencomputer
|
|
2
|
+
|
|
3
|
+
Python SDK for [OpenComputer](https://github.com/diggerhq/opensandbox) — cloud sandbox platform.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install opencomputer
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
import asyncio
|
|
15
|
+
from opencomputer import Sandbox
|
|
16
|
+
|
|
17
|
+
async def main():
|
|
18
|
+
sandbox = await Sandbox.create(template="base")
|
|
19
|
+
|
|
20
|
+
# Execute commands
|
|
21
|
+
result = await sandbox.commands.run("echo hello")
|
|
22
|
+
print(result.stdout) # "hello\n"
|
|
23
|
+
|
|
24
|
+
# Read and write files
|
|
25
|
+
await sandbox.files.write("/tmp/test.txt", "Hello, world!")
|
|
26
|
+
content = await sandbox.files.read("/tmp/test.txt")
|
|
27
|
+
|
|
28
|
+
# Clean up
|
|
29
|
+
await sandbox.kill()
|
|
30
|
+
await sandbox.close()
|
|
31
|
+
|
|
32
|
+
asyncio.run(main())
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Configuration
|
|
36
|
+
|
|
37
|
+
| Parameter | Env Variable | Default |
|
|
38
|
+
|------------|------------------------|-------------------------|
|
|
39
|
+
| `api_url` | `OPENSANDBOX_API_URL` | `https://app.opencomputer.dev` |
|
|
40
|
+
| `api_key` | `OPENSANDBOX_API_KEY` | (none) |
|
|
41
|
+
|
|
42
|
+
## License
|
|
43
|
+
|
|
44
|
+
MIT
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""OpenComputer Python SDK - cloud sandbox platform."""
|
|
2
|
+
|
|
3
|
+
from opencomputer.sandbox import Sandbox
|
|
4
|
+
from opencomputer.filesystem import Filesystem
|
|
5
|
+
from opencomputer.commands import Commands, ProcessResult
|
|
6
|
+
from opencomputer.pty import Pty, PtySession
|
|
7
|
+
from opencomputer.template import Template
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"Sandbox",
|
|
11
|
+
"Filesystem",
|
|
12
|
+
"Commands",
|
|
13
|
+
"ProcessResult",
|
|
14
|
+
"Pty",
|
|
15
|
+
"PtySession",
|
|
16
|
+
"Template",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
__version__ = "0.3.0"
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Command execution inside a sandbox."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class ProcessResult:
|
|
13
|
+
"""Result of a command execution."""
|
|
14
|
+
|
|
15
|
+
exit_code: int
|
|
16
|
+
stdout: str
|
|
17
|
+
stderr: str
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class Commands:
|
|
22
|
+
"""Command execution for a sandbox."""
|
|
23
|
+
|
|
24
|
+
_client: httpx.AsyncClient
|
|
25
|
+
_sandbox_id: str
|
|
26
|
+
|
|
27
|
+
async def run(
|
|
28
|
+
self,
|
|
29
|
+
command: str,
|
|
30
|
+
timeout: int = 60,
|
|
31
|
+
env: dict[str, str] | None = None,
|
|
32
|
+
cwd: str | None = None,
|
|
33
|
+
) -> ProcessResult:
|
|
34
|
+
"""Run a command and wait for completion."""
|
|
35
|
+
body: dict[str, Any] = {
|
|
36
|
+
"cmd": command,
|
|
37
|
+
"timeout": timeout,
|
|
38
|
+
}
|
|
39
|
+
if env:
|
|
40
|
+
body["envs"] = env
|
|
41
|
+
if cwd:
|
|
42
|
+
body["cwd"] = cwd
|
|
43
|
+
|
|
44
|
+
resp = await self._client.post(
|
|
45
|
+
f"/sandboxes/{self._sandbox_id}/commands",
|
|
46
|
+
json=body,
|
|
47
|
+
timeout=timeout + 5,
|
|
48
|
+
)
|
|
49
|
+
resp.raise_for_status()
|
|
50
|
+
data = resp.json()
|
|
51
|
+
|
|
52
|
+
return ProcessResult(
|
|
53
|
+
exit_code=data.get("exitCode", -1),
|
|
54
|
+
stdout=data.get("stdout", ""),
|
|
55
|
+
stderr=data.get("stderr", ""),
|
|
56
|
+
)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Filesystem operations inside a sandbox."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class EntryInfo:
|
|
12
|
+
"""File or directory entry."""
|
|
13
|
+
|
|
14
|
+
name: str
|
|
15
|
+
is_dir: bool
|
|
16
|
+
path: str
|
|
17
|
+
size: int = 0
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class Filesystem:
|
|
22
|
+
"""Filesystem operations for a sandbox."""
|
|
23
|
+
|
|
24
|
+
_client: httpx.AsyncClient
|
|
25
|
+
_sandbox_id: str
|
|
26
|
+
|
|
27
|
+
async def read(self, path: str) -> str:
|
|
28
|
+
"""Read a file as text."""
|
|
29
|
+
resp = await self._client.get(
|
|
30
|
+
f"/sandboxes/{self._sandbox_id}/files",
|
|
31
|
+
params={"path": path},
|
|
32
|
+
)
|
|
33
|
+
resp.raise_for_status()
|
|
34
|
+
return resp.text
|
|
35
|
+
|
|
36
|
+
async def read_bytes(self, path: str) -> bytes:
|
|
37
|
+
"""Read a file as bytes."""
|
|
38
|
+
resp = await self._client.get(
|
|
39
|
+
f"/sandboxes/{self._sandbox_id}/files",
|
|
40
|
+
params={"path": path},
|
|
41
|
+
)
|
|
42
|
+
resp.raise_for_status()
|
|
43
|
+
return resp.content
|
|
44
|
+
|
|
45
|
+
async def write(self, path: str, content: str | bytes) -> None:
|
|
46
|
+
"""Write content to a file."""
|
|
47
|
+
data = content if isinstance(content, bytes) else content.encode()
|
|
48
|
+
resp = await self._client.put(
|
|
49
|
+
f"/sandboxes/{self._sandbox_id}/files",
|
|
50
|
+
params={"path": path},
|
|
51
|
+
content=data,
|
|
52
|
+
)
|
|
53
|
+
resp.raise_for_status()
|
|
54
|
+
|
|
55
|
+
async def list(self, path: str = "/") -> list[EntryInfo]:
|
|
56
|
+
"""List directory contents."""
|
|
57
|
+
resp = await self._client.get(
|
|
58
|
+
f"/sandboxes/{self._sandbox_id}/files/list",
|
|
59
|
+
params={"path": path},
|
|
60
|
+
)
|
|
61
|
+
resp.raise_for_status()
|
|
62
|
+
data = resp.json()
|
|
63
|
+
if data is None:
|
|
64
|
+
return []
|
|
65
|
+
return [
|
|
66
|
+
EntryInfo(
|
|
67
|
+
name=entry["name"],
|
|
68
|
+
is_dir=entry.get("isDir", False),
|
|
69
|
+
path=entry.get("path", ""),
|
|
70
|
+
size=entry.get("size", 0),
|
|
71
|
+
)
|
|
72
|
+
for entry in data
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
async def make_dir(self, path: str) -> None:
|
|
76
|
+
"""Create a directory (and parents)."""
|
|
77
|
+
resp = await self._client.post(
|
|
78
|
+
f"/sandboxes/{self._sandbox_id}/files/mkdir",
|
|
79
|
+
params={"path": path},
|
|
80
|
+
)
|
|
81
|
+
resp.raise_for_status()
|
|
82
|
+
|
|
83
|
+
async def remove(self, path: str) -> None:
|
|
84
|
+
"""Remove a file or directory."""
|
|
85
|
+
resp = await self._client.delete(
|
|
86
|
+
f"/sandboxes/{self._sandbox_id}/files",
|
|
87
|
+
params={"path": path},
|
|
88
|
+
)
|
|
89
|
+
resp.raise_for_status()
|
|
90
|
+
|
|
91
|
+
async def exists(self, path: str) -> bool:
|
|
92
|
+
"""Check if a path exists."""
|
|
93
|
+
try:
|
|
94
|
+
resp = await self._client.get(
|
|
95
|
+
f"/sandboxes/{self._sandbox_id}/files",
|
|
96
|
+
params={"path": path},
|
|
97
|
+
)
|
|
98
|
+
return resp.status_code == 200
|
|
99
|
+
except httpx.HTTPError:
|
|
100
|
+
return False
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""PTY terminal sessions inside a sandbox."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Callable
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
import websockets
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class PtySession:
|
|
15
|
+
"""An active PTY terminal session."""
|
|
16
|
+
|
|
17
|
+
session_id: str
|
|
18
|
+
sandbox_id: str
|
|
19
|
+
_ws: websockets.WebSocketClientProtocol | None = None
|
|
20
|
+
_read_task: asyncio.Task | None = None
|
|
21
|
+
|
|
22
|
+
async def send(self, data: str | bytes) -> None:
|
|
23
|
+
"""Send input to the terminal."""
|
|
24
|
+
if self._ws is None:
|
|
25
|
+
raise RuntimeError("PTY session not connected")
|
|
26
|
+
payload = data if isinstance(data, bytes) else data.encode()
|
|
27
|
+
await self._ws.send(payload)
|
|
28
|
+
|
|
29
|
+
async def recv(self) -> bytes:
|
|
30
|
+
"""Receive output from the terminal."""
|
|
31
|
+
if self._ws is None:
|
|
32
|
+
raise RuntimeError("PTY session not connected")
|
|
33
|
+
data = await self._ws.recv()
|
|
34
|
+
return data if isinstance(data, bytes) else data.encode()
|
|
35
|
+
|
|
36
|
+
async def close(self) -> None:
|
|
37
|
+
"""Close the PTY session."""
|
|
38
|
+
if self._read_task and not self._read_task.done():
|
|
39
|
+
self._read_task.cancel()
|
|
40
|
+
if self._ws:
|
|
41
|
+
await self._ws.close()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class Pty:
|
|
46
|
+
"""PTY terminal session manager for a sandbox."""
|
|
47
|
+
|
|
48
|
+
_client: httpx.AsyncClient
|
|
49
|
+
_sandbox_id: str
|
|
50
|
+
_api_url: str
|
|
51
|
+
_api_key: str
|
|
52
|
+
|
|
53
|
+
async def create(
|
|
54
|
+
self,
|
|
55
|
+
cols: int = 80,
|
|
56
|
+
rows: int = 24,
|
|
57
|
+
on_output: Callable[[bytes], None] | None = None,
|
|
58
|
+
) -> PtySession:
|
|
59
|
+
"""Create a new PTY session and connect via WebSocket."""
|
|
60
|
+
# Create session via REST
|
|
61
|
+
resp = await self._client.post(
|
|
62
|
+
f"/sandboxes/{self._sandbox_id}/pty",
|
|
63
|
+
json={"cols": cols, "rows": rows},
|
|
64
|
+
)
|
|
65
|
+
resp.raise_for_status()
|
|
66
|
+
data = resp.json()
|
|
67
|
+
session_id = data["sessionID"]
|
|
68
|
+
|
|
69
|
+
# Connect via WebSocket
|
|
70
|
+
ws_url = self._api_url.replace("http://", "ws://").replace("https://", "wss://")
|
|
71
|
+
ws_url = f"{ws_url}/sandboxes/{self._sandbox_id}/pty/{session_id}"
|
|
72
|
+
|
|
73
|
+
headers = {}
|
|
74
|
+
if self._api_key:
|
|
75
|
+
headers["X-API-Key"] = self._api_key
|
|
76
|
+
|
|
77
|
+
ws = await websockets.connect(ws_url, additional_headers=headers)
|
|
78
|
+
|
|
79
|
+
session = PtySession(
|
|
80
|
+
session_id=session_id,
|
|
81
|
+
sandbox_id=self._sandbox_id,
|
|
82
|
+
_ws=ws,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
if on_output:
|
|
86
|
+
async def _reader() -> None:
|
|
87
|
+
try:
|
|
88
|
+
async for msg in ws:
|
|
89
|
+
output = msg if isinstance(msg, bytes) else msg.encode()
|
|
90
|
+
on_output(output)
|
|
91
|
+
except websockets.ConnectionClosed:
|
|
92
|
+
pass
|
|
93
|
+
|
|
94
|
+
session._read_task = asyncio.create_task(_reader())
|
|
95
|
+
|
|
96
|
+
return session
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Sandbox class - main entry point for the OpenSandbox SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from opencomputer.commands import Commands
|
|
12
|
+
from opencomputer.filesystem import Filesystem
|
|
13
|
+
from opencomputer.pty import Pty
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class Sandbox:
|
|
18
|
+
"""E2B-compatible sandbox interface."""
|
|
19
|
+
|
|
20
|
+
sandbox_id: str
|
|
21
|
+
status: str = "running"
|
|
22
|
+
template: str = ""
|
|
23
|
+
domain: str = ""
|
|
24
|
+
_api_url: str = ""
|
|
25
|
+
_api_key: str = ""
|
|
26
|
+
_connect_url: str = ""
|
|
27
|
+
_token: str = ""
|
|
28
|
+
_client: httpx.AsyncClient = field(default=None, repr=False)
|
|
29
|
+
_data_client: httpx.AsyncClient = field(default=None, repr=False)
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
async def create(
|
|
33
|
+
cls,
|
|
34
|
+
template: str = "base",
|
|
35
|
+
timeout: int = 300,
|
|
36
|
+
api_key: str | None = None,
|
|
37
|
+
api_url: str | None = None,
|
|
38
|
+
envs: dict[str, str] | None = None,
|
|
39
|
+
metadata: dict[str, str] | None = None,
|
|
40
|
+
) -> Sandbox:
|
|
41
|
+
"""Create a new sandbox instance."""
|
|
42
|
+
url = api_url or os.environ.get("OPENSANDBOX_API_URL", "https://app.opencomputer.dev")
|
|
43
|
+
url = url.rstrip("/")
|
|
44
|
+
key = api_key or os.environ.get("OPENSANDBOX_API_KEY", "")
|
|
45
|
+
|
|
46
|
+
# Control plane client always uses /api prefix
|
|
47
|
+
api_base = url if url.endswith("/api") else f"{url}/api"
|
|
48
|
+
|
|
49
|
+
headers = {}
|
|
50
|
+
if key:
|
|
51
|
+
headers["X-API-Key"] = key
|
|
52
|
+
|
|
53
|
+
client = httpx.AsyncClient(base_url=api_base, headers=headers, timeout=30.0)
|
|
54
|
+
|
|
55
|
+
body: dict[str, Any] = {
|
|
56
|
+
"templateID": template,
|
|
57
|
+
"timeout": timeout,
|
|
58
|
+
}
|
|
59
|
+
if envs:
|
|
60
|
+
body["envs"] = envs
|
|
61
|
+
if metadata:
|
|
62
|
+
body["metadata"] = metadata
|
|
63
|
+
|
|
64
|
+
resp = await client.post("/sandboxes", json=body)
|
|
65
|
+
resp.raise_for_status()
|
|
66
|
+
data = resp.json()
|
|
67
|
+
|
|
68
|
+
connect_url = data.get("connectURL", "")
|
|
69
|
+
token = data.get("token", "")
|
|
70
|
+
|
|
71
|
+
# If worker returned a direct connectURL, create a separate client for data ops
|
|
72
|
+
data_client = None
|
|
73
|
+
if connect_url and token:
|
|
74
|
+
data_client = httpx.AsyncClient(
|
|
75
|
+
base_url=connect_url,
|
|
76
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
77
|
+
timeout=30.0,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
return cls(
|
|
81
|
+
sandbox_id=data["sandboxID"],
|
|
82
|
+
status=data.get("status", "running"),
|
|
83
|
+
template=template,
|
|
84
|
+
domain=data.get("domain", ""),
|
|
85
|
+
_api_url=url,
|
|
86
|
+
_api_key=key,
|
|
87
|
+
_connect_url=connect_url,
|
|
88
|
+
_token=token,
|
|
89
|
+
_client=client,
|
|
90
|
+
_data_client=data_client,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
@classmethod
|
|
94
|
+
async def connect(
|
|
95
|
+
cls,
|
|
96
|
+
sandbox_id: str,
|
|
97
|
+
api_key: str | None = None,
|
|
98
|
+
api_url: str | None = None,
|
|
99
|
+
) -> Sandbox:
|
|
100
|
+
"""Connect to an existing sandbox."""
|
|
101
|
+
url = api_url or os.environ.get("OPENSANDBOX_API_URL", "https://app.opencomputer.dev")
|
|
102
|
+
url = url.rstrip("/")
|
|
103
|
+
key = api_key or os.environ.get("OPENSANDBOX_API_KEY", "")
|
|
104
|
+
|
|
105
|
+
api_base = url if url.endswith("/api") else f"{url}/api"
|
|
106
|
+
|
|
107
|
+
headers = {}
|
|
108
|
+
if key:
|
|
109
|
+
headers["X-API-Key"] = key
|
|
110
|
+
|
|
111
|
+
client = httpx.AsyncClient(base_url=api_base, headers=headers, timeout=30.0)
|
|
112
|
+
|
|
113
|
+
resp = await client.get(f"/sandboxes/{sandbox_id}")
|
|
114
|
+
resp.raise_for_status()
|
|
115
|
+
data = resp.json()
|
|
116
|
+
|
|
117
|
+
connect_url = data.get("connectURL", "")
|
|
118
|
+
token = data.get("token", "")
|
|
119
|
+
|
|
120
|
+
data_client = None
|
|
121
|
+
if connect_url and token:
|
|
122
|
+
data_client = httpx.AsyncClient(
|
|
123
|
+
base_url=connect_url,
|
|
124
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
125
|
+
timeout=30.0,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
return cls(
|
|
129
|
+
sandbox_id=sandbox_id,
|
|
130
|
+
status=data.get("status", "running"),
|
|
131
|
+
template=data.get("templateID", ""),
|
|
132
|
+
domain=data.get("domain", ""),
|
|
133
|
+
_api_url=url,
|
|
134
|
+
_api_key=key,
|
|
135
|
+
_connect_url=connect_url,
|
|
136
|
+
_token=token,
|
|
137
|
+
_client=client,
|
|
138
|
+
_data_client=data_client,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def _ops_client(self) -> httpx.AsyncClient:
|
|
143
|
+
"""Return the client for data operations (direct worker if available, else CP)."""
|
|
144
|
+
if self._data_client is not None:
|
|
145
|
+
return self._data_client
|
|
146
|
+
return self._client
|
|
147
|
+
|
|
148
|
+
async def kill(self) -> None:
|
|
149
|
+
"""Kill and remove the sandbox."""
|
|
150
|
+
resp = await self._client.delete(f"/sandboxes/{self.sandbox_id}")
|
|
151
|
+
resp.raise_for_status()
|
|
152
|
+
self.status = "stopped"
|
|
153
|
+
|
|
154
|
+
async def is_running(self) -> bool:
|
|
155
|
+
"""Check if the sandbox is still running."""
|
|
156
|
+
try:
|
|
157
|
+
resp = await self._client.get(f"/sandboxes/{self.sandbox_id}")
|
|
158
|
+
resp.raise_for_status()
|
|
159
|
+
data = resp.json()
|
|
160
|
+
self.status = data.get("status", "stopped")
|
|
161
|
+
return self.status == "running"
|
|
162
|
+
except httpx.HTTPStatusError:
|
|
163
|
+
return False
|
|
164
|
+
|
|
165
|
+
async def set_timeout(self, timeout: int) -> None:
|
|
166
|
+
"""Update the sandbox timeout in seconds."""
|
|
167
|
+
resp = await self._client.post(
|
|
168
|
+
f"/sandboxes/{self.sandbox_id}/timeout",
|
|
169
|
+
json={"timeout": timeout},
|
|
170
|
+
)
|
|
171
|
+
resp.raise_for_status()
|
|
172
|
+
|
|
173
|
+
@property
|
|
174
|
+
def files(self) -> Filesystem:
|
|
175
|
+
"""Access filesystem operations."""
|
|
176
|
+
return Filesystem(self._ops_client, self.sandbox_id)
|
|
177
|
+
|
|
178
|
+
@property
|
|
179
|
+
def commands(self) -> Commands:
|
|
180
|
+
"""Access command execution."""
|
|
181
|
+
return Commands(self._ops_client, self.sandbox_id)
|
|
182
|
+
|
|
183
|
+
@property
|
|
184
|
+
def pty(self) -> Pty:
|
|
185
|
+
"""Access PTY terminal sessions."""
|
|
186
|
+
pty_url = self._connect_url or self._api_url
|
|
187
|
+
pty_key = self._token or self._api_key
|
|
188
|
+
return Pty(self._ops_client, self.sandbox_id, pty_url, pty_key)
|
|
189
|
+
|
|
190
|
+
async def close(self) -> None:
|
|
191
|
+
"""Close the HTTP client (does not kill the sandbox)."""
|
|
192
|
+
await self._client.aclose()
|
|
193
|
+
if self._data_client is not None:
|
|
194
|
+
await self._data_client.aclose()
|
|
195
|
+
|
|
196
|
+
async def __aenter__(self) -> Sandbox:
|
|
197
|
+
return self
|
|
198
|
+
|
|
199
|
+
async def __aexit__(self, *args: object) -> None:
|
|
200
|
+
await self.kill()
|
|
201
|
+
await self.close()
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Template management for custom sandbox environments."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class TemplateInfo:
|
|
12
|
+
"""Template metadata."""
|
|
13
|
+
|
|
14
|
+
template_id: str
|
|
15
|
+
name: str
|
|
16
|
+
tag: str
|
|
17
|
+
status: str
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class Template:
|
|
22
|
+
"""Template management operations."""
|
|
23
|
+
|
|
24
|
+
_client: httpx.AsyncClient
|
|
25
|
+
|
|
26
|
+
@classmethod
|
|
27
|
+
def _from_client(cls, client: httpx.AsyncClient) -> Template:
|
|
28
|
+
return cls(_client=client)
|
|
29
|
+
|
|
30
|
+
async def build(self, name: str, dockerfile: str) -> TemplateInfo:
|
|
31
|
+
"""Build a new template from a Dockerfile."""
|
|
32
|
+
resp = await self._client.post(
|
|
33
|
+
"/templates",
|
|
34
|
+
json={"name": name, "dockerfile": dockerfile},
|
|
35
|
+
timeout=300.0,
|
|
36
|
+
)
|
|
37
|
+
resp.raise_for_status()
|
|
38
|
+
data = resp.json()
|
|
39
|
+
return TemplateInfo(
|
|
40
|
+
template_id=data["templateID"],
|
|
41
|
+
name=data["name"],
|
|
42
|
+
tag=data.get("tag", "latest"),
|
|
43
|
+
status=data.get("status", "ready"),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
async def list(self) -> list[TemplateInfo]:
|
|
47
|
+
"""List all available templates."""
|
|
48
|
+
resp = await self._client.get("/templates")
|
|
49
|
+
resp.raise_for_status()
|
|
50
|
+
return [
|
|
51
|
+
TemplateInfo(
|
|
52
|
+
template_id=t["templateID"],
|
|
53
|
+
name=t["name"],
|
|
54
|
+
tag=t.get("tag", "latest"),
|
|
55
|
+
status=t.get("status", "ready"),
|
|
56
|
+
)
|
|
57
|
+
for t in resp.json()
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
async def get(self, name: str) -> TemplateInfo:
|
|
61
|
+
"""Get template details by name."""
|
|
62
|
+
resp = await self._client.get(f"/templates/{name}")
|
|
63
|
+
resp.raise_for_status()
|
|
64
|
+
data = resp.json()
|
|
65
|
+
return TemplateInfo(
|
|
66
|
+
template_id=data["templateID"],
|
|
67
|
+
name=data["name"],
|
|
68
|
+
tag=data.get("tag", "latest"),
|
|
69
|
+
status=data.get("status", "ready"),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
async def delete(self, name: str) -> None:
|
|
73
|
+
"""Delete a template."""
|
|
74
|
+
resp = await self._client.delete(f"/templates/{name}")
|
|
75
|
+
resp.raise_for_status()
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "opencomputer-sdk"
|
|
7
|
+
version = "0.3.0"
|
|
8
|
+
description = "Python SDK for OpenComputer - cloud sandbox platform"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "OpenComputer" },
|
|
14
|
+
]
|
|
15
|
+
keywords = ["sandbox", "opencomputer", "cloud", "containers", "code-execution"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 3 - Alpha",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Programming Language :: Python :: 3.13",
|
|
25
|
+
"Typing :: Typed",
|
|
26
|
+
]
|
|
27
|
+
dependencies = [
|
|
28
|
+
"httpx>=0.27.0",
|
|
29
|
+
"websockets>=12.0",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.optional-dependencies]
|
|
33
|
+
dev = [
|
|
34
|
+
"pytest>=8.0",
|
|
35
|
+
"pytest-asyncio>=0.23",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
[project.urls]
|
|
39
|
+
Homepage = "https://github.com/diggerhq/opensandbox"
|
|
40
|
+
Repository = "https://github.com/diggerhq/opensandbox"
|
|
41
|
+
Documentation = "https://github.com/diggerhq/opensandbox/tree/main/sdks/python"
|
|
42
|
+
Issues = "https://github.com/diggerhq/opensandbox/issues"
|
|
43
|
+
|
|
44
|
+
[tool.hatch.build.targets.wheel]
|
|
45
|
+
packages = ["opencomputer"]
|
|
46
|
+
exclude = ["__pycache__"]
|