forgevm 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.
- forgevm-0.1.0/PKG-INFO +86 -0
- forgevm-0.1.0/README.md +57 -0
- forgevm-0.1.0/forgevm/__init__.py +28 -0
- forgevm-0.1.0/forgevm/async_client.py +139 -0
- forgevm-0.1.0/forgevm/async_sandbox.py +171 -0
- forgevm-0.1.0/forgevm/client.py +125 -0
- forgevm-0.1.0/forgevm/exceptions.py +56 -0
- forgevm-0.1.0/forgevm/models.py +43 -0
- forgevm-0.1.0/forgevm/providers.py +26 -0
- forgevm-0.1.0/forgevm/py.typed +0 -0
- forgevm-0.1.0/forgevm/sandbox.py +170 -0
- forgevm-0.1.0/forgevm/streaming.py +33 -0
- forgevm-0.1.0/forgevm/templates.py +59 -0
- forgevm-0.1.0/forgevm.egg-info/PKG-INFO +86 -0
- forgevm-0.1.0/forgevm.egg-info/SOURCES.txt +21 -0
- forgevm-0.1.0/forgevm.egg-info/dependency_links.txt +1 -0
- forgevm-0.1.0/forgevm.egg-info/requires.txt +5 -0
- forgevm-0.1.0/forgevm.egg-info/top_level.txt +1 -0
- forgevm-0.1.0/pyproject.toml +46 -0
- forgevm-0.1.0/setup.cfg +4 -0
- forgevm-0.1.0/tests/test_client.py +155 -0
- forgevm-0.1.0/tests/test_llm.py +408 -0
- forgevm-0.1.0/tests/test_pdf_gen.py +410 -0
forgevm-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: forgevm
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for ForgeVM — self-hosted microVM sandboxes for LLMs
|
|
5
|
+
Author: DohaerisAI
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/DohaerisAI/forgevm
|
|
8
|
+
Project-URL: Repository, https://github.com/DohaerisAI/forgevm
|
|
9
|
+
Project-URL: Documentation, https://github.com/DohaerisAI/forgevm#use-with-python
|
|
10
|
+
Project-URL: Issues, https://github.com/DohaerisAI/forgevm/issues
|
|
11
|
+
Keywords: forgevm,sandbox,microvm,llm,code-execution,firecracker,ai-agent
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
22
|
+
Classifier: Topic :: System :: Emulators
|
|
23
|
+
Requires-Python: >=3.9
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
Requires-Dist: httpx>=0.27.0
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
28
|
+
Requires-Dist: pytest-httpx>=0.30.0; extra == "dev"
|
|
29
|
+
|
|
30
|
+
# ForgeVM Python SDK
|
|
31
|
+
|
|
32
|
+
Python client for [ForgeVM](https://github.com/DohaerisAI/forgevm) -- self-hosted compute sandboxes for LLMs.
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install forgevm
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Quick Start
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from forgevm import Client
|
|
44
|
+
|
|
45
|
+
client = Client("http://localhost:7423")
|
|
46
|
+
|
|
47
|
+
# Spawn a sandbox
|
|
48
|
+
sandbox = client.spawn(image="alpine:latest")
|
|
49
|
+
|
|
50
|
+
# Execute commands
|
|
51
|
+
result = sandbox.exec("echo hello")
|
|
52
|
+
print(result.stdout) # "hello\n"
|
|
53
|
+
|
|
54
|
+
# File operations
|
|
55
|
+
sandbox.write_file("/tmp/hello.txt", "Hello, world!")
|
|
56
|
+
content = sandbox.read_file("/tmp/hello.txt")
|
|
57
|
+
|
|
58
|
+
# Extend TTL
|
|
59
|
+
sandbox.extend_ttl("30m")
|
|
60
|
+
|
|
61
|
+
# Auto-cleanup with context manager
|
|
62
|
+
with client.spawn(image="python:3.12") as sb:
|
|
63
|
+
sb.exec("python3 -c 'print(1+1)'")
|
|
64
|
+
# sandbox destroyed automatically
|
|
65
|
+
|
|
66
|
+
# Streaming output
|
|
67
|
+
for chunk in sandbox.exec_stream("ping -c 3 localhost"):
|
|
68
|
+
print(chunk.data, end="")
|
|
69
|
+
|
|
70
|
+
sandbox.destroy()
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Async Support
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
from forgevm import AsyncClient
|
|
77
|
+
|
|
78
|
+
async with AsyncClient("http://localhost:7423") as client:
|
|
79
|
+
sandbox = await client.spawn(image="alpine:latest")
|
|
80
|
+
result = await sandbox.exec("echo hello")
|
|
81
|
+
await sandbox.destroy()
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## License
|
|
85
|
+
|
|
86
|
+
MIT
|
forgevm-0.1.0/README.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# ForgeVM Python SDK
|
|
2
|
+
|
|
3
|
+
Python client for [ForgeVM](https://github.com/DohaerisAI/forgevm) -- self-hosted compute sandboxes for LLMs.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install forgevm
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from forgevm import Client
|
|
15
|
+
|
|
16
|
+
client = Client("http://localhost:7423")
|
|
17
|
+
|
|
18
|
+
# Spawn a sandbox
|
|
19
|
+
sandbox = client.spawn(image="alpine:latest")
|
|
20
|
+
|
|
21
|
+
# Execute commands
|
|
22
|
+
result = sandbox.exec("echo hello")
|
|
23
|
+
print(result.stdout) # "hello\n"
|
|
24
|
+
|
|
25
|
+
# File operations
|
|
26
|
+
sandbox.write_file("/tmp/hello.txt", "Hello, world!")
|
|
27
|
+
content = sandbox.read_file("/tmp/hello.txt")
|
|
28
|
+
|
|
29
|
+
# Extend TTL
|
|
30
|
+
sandbox.extend_ttl("30m")
|
|
31
|
+
|
|
32
|
+
# Auto-cleanup with context manager
|
|
33
|
+
with client.spawn(image="python:3.12") as sb:
|
|
34
|
+
sb.exec("python3 -c 'print(1+1)'")
|
|
35
|
+
# sandbox destroyed automatically
|
|
36
|
+
|
|
37
|
+
# Streaming output
|
|
38
|
+
for chunk in sandbox.exec_stream("ping -c 3 localhost"):
|
|
39
|
+
print(chunk.data, end="")
|
|
40
|
+
|
|
41
|
+
sandbox.destroy()
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Async Support
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from forgevm import AsyncClient
|
|
48
|
+
|
|
49
|
+
async with AsyncClient("http://localhost:7423") as client:
|
|
50
|
+
sandbox = await client.spawn(image="alpine:latest")
|
|
51
|
+
result = await sandbox.exec("echo hello")
|
|
52
|
+
await sandbox.destroy()
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## License
|
|
56
|
+
|
|
57
|
+
MIT
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""ForgeVM Python SDK — client for the ForgeVM sandbox orchestrator."""
|
|
2
|
+
|
|
3
|
+
from forgevm.client import Client
|
|
4
|
+
from forgevm.sandbox import Sandbox
|
|
5
|
+
from forgevm.async_client import AsyncClient
|
|
6
|
+
from forgevm.async_sandbox import AsyncSandbox
|
|
7
|
+
from forgevm.models import ExecResult, SandboxInfo, Template
|
|
8
|
+
from forgevm.exceptions import (
|
|
9
|
+
ForgevmError,
|
|
10
|
+
SandboxNotFound,
|
|
11
|
+
ProviderError,
|
|
12
|
+
ConnectionError,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__version__ = "0.1.0"
|
|
16
|
+
__all__ = [
|
|
17
|
+
"Client",
|
|
18
|
+
"Sandbox",
|
|
19
|
+
"AsyncClient",
|
|
20
|
+
"AsyncSandbox",
|
|
21
|
+
"ExecResult",
|
|
22
|
+
"SandboxInfo",
|
|
23
|
+
"Template",
|
|
24
|
+
"ForgevmError",
|
|
25
|
+
"SandboxNotFound",
|
|
26
|
+
"ProviderError",
|
|
27
|
+
"ConnectionError",
|
|
28
|
+
]
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""ForgeVM Async Client — async variant using httpx.AsyncClient."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from forgevm.async_sandbox import AsyncSandbox
|
|
8
|
+
from forgevm.exceptions import ConnectionError, handle_response
|
|
9
|
+
from forgevm.models import SandboxInfo
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AsyncClient:
|
|
13
|
+
"""Async client for the ForgeVM API server.
|
|
14
|
+
|
|
15
|
+
Usage:
|
|
16
|
+
async with AsyncClient("http://localhost:7423") as client:
|
|
17
|
+
sandbox = await client.spawn(image="alpine:latest")
|
|
18
|
+
result = await sandbox.exec("echo hello")
|
|
19
|
+
print(result.stdout)
|
|
20
|
+
await sandbox.destroy()
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
base_url: str = "http://localhost:7423",
|
|
26
|
+
api_key: str | None = None,
|
|
27
|
+
user_id: str | None = None,
|
|
28
|
+
timeout: float = 30.0,
|
|
29
|
+
):
|
|
30
|
+
headers = {}
|
|
31
|
+
if api_key:
|
|
32
|
+
headers["X-API-Key"] = api_key
|
|
33
|
+
if user_id:
|
|
34
|
+
headers["X-User-ID"] = user_id
|
|
35
|
+
|
|
36
|
+
self._http = httpx.AsyncClient(
|
|
37
|
+
base_url=base_url,
|
|
38
|
+
headers=headers,
|
|
39
|
+
timeout=timeout,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
async def spawn(
|
|
43
|
+
self,
|
|
44
|
+
image: str = "alpine:latest",
|
|
45
|
+
provider: str | None = None,
|
|
46
|
+
memory_mb: int | None = None,
|
|
47
|
+
vcpus: int | None = None,
|
|
48
|
+
ttl: str | None = None,
|
|
49
|
+
template: str | None = None,
|
|
50
|
+
metadata: dict[str, str] | None = None,
|
|
51
|
+
) -> AsyncSandbox:
|
|
52
|
+
"""Spawn a new sandbox."""
|
|
53
|
+
body: dict = {"image": image}
|
|
54
|
+
if provider:
|
|
55
|
+
body["provider"] = provider
|
|
56
|
+
if memory_mb:
|
|
57
|
+
body["memory_mb"] = memory_mb
|
|
58
|
+
if vcpus:
|
|
59
|
+
body["vcpus"] = vcpus
|
|
60
|
+
if ttl:
|
|
61
|
+
body["ttl"] = ttl
|
|
62
|
+
if template:
|
|
63
|
+
body["template"] = template
|
|
64
|
+
if metadata:
|
|
65
|
+
body["metadata"] = metadata
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
resp = await self._http.post("/api/v1/sandboxes", json=body)
|
|
69
|
+
except httpx.ConnectError as e:
|
|
70
|
+
raise ConnectionError(f"Cannot connect to ForgeVM server: {e}")
|
|
71
|
+
|
|
72
|
+
handle_response(resp)
|
|
73
|
+
data = resp.json()
|
|
74
|
+
return AsyncSandbox(self._http, data["id"], info=data)
|
|
75
|
+
|
|
76
|
+
async def spawn_template(self, template_name: str) -> AsyncSandbox:
|
|
77
|
+
"""Spawn a sandbox from a saved template."""
|
|
78
|
+
try:
|
|
79
|
+
resp = await self._http.post(f"/api/v1/templates/{template_name}/spawn")
|
|
80
|
+
except httpx.ConnectError as e:
|
|
81
|
+
raise ConnectionError(f"Cannot connect to ForgeVM server: {e}")
|
|
82
|
+
|
|
83
|
+
handle_response(resp)
|
|
84
|
+
data = resp.json()
|
|
85
|
+
return AsyncSandbox(self._http, data["id"], info=data)
|
|
86
|
+
|
|
87
|
+
async def get(self, sandbox_id: str) -> AsyncSandbox:
|
|
88
|
+
"""Get an existing sandbox by ID."""
|
|
89
|
+
resp = await self._http.get(f"/api/v1/sandboxes/{sandbox_id}")
|
|
90
|
+
handle_response(resp)
|
|
91
|
+
data = resp.json()
|
|
92
|
+
return AsyncSandbox(self._http, data["id"], info=data)
|
|
93
|
+
|
|
94
|
+
async def list(self) -> list[SandboxInfo]:
|
|
95
|
+
"""List all active sandboxes."""
|
|
96
|
+
resp = await self._http.get("/api/v1/sandboxes")
|
|
97
|
+
handle_response(resp)
|
|
98
|
+
return [
|
|
99
|
+
SandboxInfo(
|
|
100
|
+
id=s["id"],
|
|
101
|
+
state=s["state"],
|
|
102
|
+
provider=s["provider"],
|
|
103
|
+
image=s["image"],
|
|
104
|
+
memory_mb=s.get("memory_mb", 512),
|
|
105
|
+
vcpus=s.get("vcpus", 1),
|
|
106
|
+
created_at=s.get("created_at", ""),
|
|
107
|
+
expires_at=s.get("expires_at", ""),
|
|
108
|
+
metadata=s.get("metadata", {}),
|
|
109
|
+
)
|
|
110
|
+
for s in resp.json()
|
|
111
|
+
]
|
|
112
|
+
|
|
113
|
+
async def prune(self) -> int:
|
|
114
|
+
"""Prune expired sandboxes. Returns count pruned."""
|
|
115
|
+
resp = await self._http.delete("/api/v1/sandboxes")
|
|
116
|
+
handle_response(resp)
|
|
117
|
+
return resp.json().get("pruned", 0)
|
|
118
|
+
|
|
119
|
+
async def pool_status(self) -> dict:
|
|
120
|
+
"""Get VM pool status."""
|
|
121
|
+
resp = await self._http.get("/api/v1/pool/status")
|
|
122
|
+
handle_response(resp)
|
|
123
|
+
return resp.json()
|
|
124
|
+
|
|
125
|
+
async def health(self) -> dict:
|
|
126
|
+
"""Check server health."""
|
|
127
|
+
resp = await self._http.get("/api/v1/health")
|
|
128
|
+
handle_response(resp)
|
|
129
|
+
return resp.json()
|
|
130
|
+
|
|
131
|
+
async def close(self) -> None:
|
|
132
|
+
"""Close the HTTP client."""
|
|
133
|
+
await self._http.aclose()
|
|
134
|
+
|
|
135
|
+
async def __aenter__(self):
|
|
136
|
+
return self
|
|
137
|
+
|
|
138
|
+
async def __aexit__(self, *args):
|
|
139
|
+
await self.close()
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""ForgeVM AsyncSandbox — async variant of Sandbox."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import AsyncIterator
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from forgevm.exceptions import handle_response
|
|
10
|
+
from forgevm.models import ExecResult
|
|
11
|
+
from forgevm.streaming import StreamChunk
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AsyncSandbox:
|
|
15
|
+
"""An async ForgeVM sandbox instance. Supports async context manager for auto-cleanup."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, http: httpx.AsyncClient, sandbox_id: str, info: dict | None = None):
|
|
18
|
+
self._http = http
|
|
19
|
+
self.id = sandbox_id
|
|
20
|
+
self._info = info or {}
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def state(self) -> str:
|
|
24
|
+
return self._info.get("state", "unknown")
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def provider(self) -> str:
|
|
28
|
+
return self._info.get("provider", "")
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def image(self) -> str:
|
|
32
|
+
return self._info.get("image", "")
|
|
33
|
+
|
|
34
|
+
async def exec(
|
|
35
|
+
self,
|
|
36
|
+
command: str,
|
|
37
|
+
args: list[str] | None = None,
|
|
38
|
+
env: dict[str, str] | None = None,
|
|
39
|
+
workdir: str | None = None,
|
|
40
|
+
) -> ExecResult:
|
|
41
|
+
"""Execute a command in the sandbox."""
|
|
42
|
+
body: dict = {"command": command}
|
|
43
|
+
if args:
|
|
44
|
+
body["args"] = args
|
|
45
|
+
if env:
|
|
46
|
+
body["env"] = env
|
|
47
|
+
if workdir:
|
|
48
|
+
body["workdir"] = workdir
|
|
49
|
+
|
|
50
|
+
resp = await self._http.post(f"/api/v1/sandboxes/{self.id}/exec", json=body)
|
|
51
|
+
handle_response(resp)
|
|
52
|
+
data = resp.json()
|
|
53
|
+
return ExecResult(
|
|
54
|
+
exit_code=data["exit_code"],
|
|
55
|
+
stdout=data["stdout"],
|
|
56
|
+
stderr=data["stderr"],
|
|
57
|
+
duration=data.get("duration", ""),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
async def exec_stream(
|
|
61
|
+
self,
|
|
62
|
+
command: str,
|
|
63
|
+
args: list[str] | None = None,
|
|
64
|
+
env: dict[str, str] | None = None,
|
|
65
|
+
workdir: str | None = None,
|
|
66
|
+
) -> AsyncIterator[StreamChunk]:
|
|
67
|
+
"""Execute a command and stream output chunks asynchronously."""
|
|
68
|
+
import json as jsonlib
|
|
69
|
+
|
|
70
|
+
body: dict = {"command": command, "stream": True}
|
|
71
|
+
if args:
|
|
72
|
+
body["args"] = args
|
|
73
|
+
if env:
|
|
74
|
+
body["env"] = env
|
|
75
|
+
if workdir:
|
|
76
|
+
body["workdir"] = workdir
|
|
77
|
+
|
|
78
|
+
async with self._http.stream("POST", f"/api/v1/sandboxes/{self.id}/exec", json=body) as resp:
|
|
79
|
+
handle_response(resp)
|
|
80
|
+
async for line in resp.aiter_lines():
|
|
81
|
+
line = line.strip()
|
|
82
|
+
if not line:
|
|
83
|
+
continue
|
|
84
|
+
data = jsonlib.loads(line)
|
|
85
|
+
yield StreamChunk(
|
|
86
|
+
stream=data.get("stream", "stdout"),
|
|
87
|
+
data=data.get("data", ""),
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
async def write_file(self, path: str, content: str, mode: str | None = None) -> None:
|
|
91
|
+
"""Write a file to the sandbox."""
|
|
92
|
+
body: dict = {"path": path, "content": content}
|
|
93
|
+
if mode:
|
|
94
|
+
body["mode"] = mode
|
|
95
|
+
resp = await self._http.post(f"/api/v1/sandboxes/{self.id}/files", json=body)
|
|
96
|
+
handle_response(resp)
|
|
97
|
+
|
|
98
|
+
async def read_file(self, path: str) -> str:
|
|
99
|
+
"""Read a file from the sandbox."""
|
|
100
|
+
resp = await self._http.get(f"/api/v1/sandboxes/{self.id}/files", params={"path": path})
|
|
101
|
+
handle_response(resp)
|
|
102
|
+
return resp.text
|
|
103
|
+
|
|
104
|
+
async def list_files(self, path: str = "/") -> list[dict]:
|
|
105
|
+
"""List files in a sandbox directory."""
|
|
106
|
+
resp = await self._http.get(f"/api/v1/sandboxes/{self.id}/files/list", params={"path": path})
|
|
107
|
+
handle_response(resp)
|
|
108
|
+
return resp.json()
|
|
109
|
+
|
|
110
|
+
async def delete_file(self, path: str, recursive: bool = False) -> None:
|
|
111
|
+
"""Delete a file or directory from the sandbox."""
|
|
112
|
+
params = {"path": path}
|
|
113
|
+
if recursive:
|
|
114
|
+
params["recursive"] = "true"
|
|
115
|
+
resp = await self._http.delete(f"/api/v1/sandboxes/{self.id}/files", params=params)
|
|
116
|
+
handle_response(resp)
|
|
117
|
+
|
|
118
|
+
async def move_file(self, old_path: str, new_path: str) -> None:
|
|
119
|
+
"""Move/rename a file in the sandbox."""
|
|
120
|
+
resp = await self._http.post(
|
|
121
|
+
f"/api/v1/sandboxes/{self.id}/files/move",
|
|
122
|
+
json={"old_path": old_path, "new_path": new_path},
|
|
123
|
+
)
|
|
124
|
+
handle_response(resp)
|
|
125
|
+
|
|
126
|
+
async def chmod_file(self, path: str, mode: str) -> None:
|
|
127
|
+
"""Change file permissions in the sandbox."""
|
|
128
|
+
resp = await self._http.post(
|
|
129
|
+
f"/api/v1/sandboxes/{self.id}/files/chmod",
|
|
130
|
+
json={"path": path, "mode": mode},
|
|
131
|
+
)
|
|
132
|
+
handle_response(resp)
|
|
133
|
+
|
|
134
|
+
async def stat_file(self, path: str) -> dict:
|
|
135
|
+
"""Get file info for a single file in the sandbox."""
|
|
136
|
+
resp = await self._http.get(
|
|
137
|
+
f"/api/v1/sandboxes/{self.id}/files/stat", params={"path": path}
|
|
138
|
+
)
|
|
139
|
+
handle_response(resp)
|
|
140
|
+
return resp.json()
|
|
141
|
+
|
|
142
|
+
async def glob_files(self, pattern: str) -> list[str]:
|
|
143
|
+
"""Return paths matching a glob pattern in the sandbox."""
|
|
144
|
+
resp = await self._http.get(
|
|
145
|
+
f"/api/v1/sandboxes/{self.id}/files/glob", params={"pattern": pattern}
|
|
146
|
+
)
|
|
147
|
+
handle_response(resp)
|
|
148
|
+
return resp.json()
|
|
149
|
+
|
|
150
|
+
async def destroy(self) -> None:
|
|
151
|
+
"""Destroy this sandbox."""
|
|
152
|
+
resp = await self._http.delete(f"/api/v1/sandboxes/{self.id}")
|
|
153
|
+
handle_response(resp)
|
|
154
|
+
|
|
155
|
+
async def refresh(self) -> None:
|
|
156
|
+
"""Refresh sandbox info from the server."""
|
|
157
|
+
resp = await self._http.get(f"/api/v1/sandboxes/{self.id}")
|
|
158
|
+
handle_response(resp)
|
|
159
|
+
self._info = resp.json()
|
|
160
|
+
|
|
161
|
+
async def __aenter__(self):
|
|
162
|
+
return self
|
|
163
|
+
|
|
164
|
+
async def __aexit__(self, *args):
|
|
165
|
+
try:
|
|
166
|
+
await self.destroy()
|
|
167
|
+
except Exception:
|
|
168
|
+
pass
|
|
169
|
+
|
|
170
|
+
def __repr__(self) -> str:
|
|
171
|
+
return f"AsyncSandbox(id={self.id!r}, state={self.state!r})"
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""ForgeVM Client — main entry point for the SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from forgevm.exceptions import ConnectionError, handle_response
|
|
8
|
+
from forgevm.models import SandboxInfo
|
|
9
|
+
from forgevm.sandbox import Sandbox
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Client:
|
|
13
|
+
"""Client for the ForgeVM API server.
|
|
14
|
+
|
|
15
|
+
Usage:
|
|
16
|
+
with Client("http://localhost:7423") as client:
|
|
17
|
+
sandbox = client.spawn(image="alpine:latest")
|
|
18
|
+
result = sandbox.exec("echo hello")
|
|
19
|
+
print(result.stdout)
|
|
20
|
+
sandbox.destroy()
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
base_url: str = "http://localhost:7423",
|
|
26
|
+
api_key: str | None = None,
|
|
27
|
+
user_id: str | None = None,
|
|
28
|
+
timeout: float = 30.0,
|
|
29
|
+
):
|
|
30
|
+
headers = {}
|
|
31
|
+
if api_key:
|
|
32
|
+
headers["X-API-Key"] = api_key
|
|
33
|
+
if user_id:
|
|
34
|
+
headers["X-User-ID"] = user_id
|
|
35
|
+
|
|
36
|
+
self._http = httpx.Client(
|
|
37
|
+
base_url=base_url,
|
|
38
|
+
headers=headers,
|
|
39
|
+
timeout=timeout,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
def spawn(
|
|
43
|
+
self,
|
|
44
|
+
image: str = "alpine:latest",
|
|
45
|
+
provider: str | None = None,
|
|
46
|
+
memory_mb: int | None = None,
|
|
47
|
+
vcpus: int | None = None,
|
|
48
|
+
ttl: str | None = None,
|
|
49
|
+
metadata: dict[str, str] | None = None,
|
|
50
|
+
) -> Sandbox:
|
|
51
|
+
"""Spawn a new sandbox."""
|
|
52
|
+
body: dict = {"image": image}
|
|
53
|
+
if provider:
|
|
54
|
+
body["provider"] = provider
|
|
55
|
+
if memory_mb:
|
|
56
|
+
body["memory_mb"] = memory_mb
|
|
57
|
+
if vcpus:
|
|
58
|
+
body["vcpus"] = vcpus
|
|
59
|
+
if ttl:
|
|
60
|
+
body["ttl"] = ttl
|
|
61
|
+
if metadata:
|
|
62
|
+
body["metadata"] = metadata
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
resp = self._http.post("/api/v1/sandboxes", json=body)
|
|
66
|
+
except httpx.ConnectError as e:
|
|
67
|
+
raise ConnectionError(f"Cannot connect to ForgeVM server: {e}")
|
|
68
|
+
|
|
69
|
+
handle_response(resp)
|
|
70
|
+
data = resp.json()
|
|
71
|
+
return Sandbox(self._http, data["id"], info=data)
|
|
72
|
+
|
|
73
|
+
def get(self, sandbox_id: str) -> Sandbox:
|
|
74
|
+
"""Get an existing sandbox by ID."""
|
|
75
|
+
resp = self._http.get(f"/api/v1/sandboxes/{sandbox_id}")
|
|
76
|
+
handle_response(resp)
|
|
77
|
+
data = resp.json()
|
|
78
|
+
return Sandbox(self._http, data["id"], info=data)
|
|
79
|
+
|
|
80
|
+
def list(self) -> list[SandboxInfo]:
|
|
81
|
+
"""List all active sandboxes."""
|
|
82
|
+
resp = self._http.get("/api/v1/sandboxes")
|
|
83
|
+
handle_response(resp)
|
|
84
|
+
return [
|
|
85
|
+
SandboxInfo(
|
|
86
|
+
id=s["id"],
|
|
87
|
+
state=s["state"],
|
|
88
|
+
provider=s["provider"],
|
|
89
|
+
image=s["image"],
|
|
90
|
+
memory_mb=s.get("memory_mb", 512),
|
|
91
|
+
vcpus=s.get("vcpus", 1),
|
|
92
|
+
created_at=s.get("created_at", ""),
|
|
93
|
+
expires_at=s.get("expires_at", ""),
|
|
94
|
+
metadata=s.get("metadata", {}),
|
|
95
|
+
)
|
|
96
|
+
for s in resp.json()
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
def prune(self) -> int:
|
|
100
|
+
"""Prune expired sandboxes. Returns count pruned."""
|
|
101
|
+
resp = self._http.delete("/api/v1/sandboxes")
|
|
102
|
+
handle_response(resp)
|
|
103
|
+
return resp.json().get("pruned", 0)
|
|
104
|
+
|
|
105
|
+
def pool_status(self) -> dict:
|
|
106
|
+
"""Get VM pool status."""
|
|
107
|
+
resp = self._http.get("/api/v1/pool/status")
|
|
108
|
+
handle_response(resp)
|
|
109
|
+
return resp.json()
|
|
110
|
+
|
|
111
|
+
def health(self) -> dict:
|
|
112
|
+
"""Check server health."""
|
|
113
|
+
resp = self._http.get("/api/v1/health")
|
|
114
|
+
handle_response(resp)
|
|
115
|
+
return resp.json()
|
|
116
|
+
|
|
117
|
+
def close(self) -> None:
|
|
118
|
+
"""Close the HTTP client."""
|
|
119
|
+
self._http.close()
|
|
120
|
+
|
|
121
|
+
def __enter__(self):
|
|
122
|
+
return self
|
|
123
|
+
|
|
124
|
+
def __exit__(self, *args):
|
|
125
|
+
self.close()
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""ForgeVM SDK exceptions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ForgevmError(Exception):
|
|
9
|
+
"""Base exception for ForgeVM SDK."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, message: str, code: str | None = None):
|
|
12
|
+
super().__init__(message)
|
|
13
|
+
self.code = code
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SandboxNotFound(ForgevmError):
|
|
17
|
+
"""Raised when a sandbox is not found."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, sandbox_id: str):
|
|
20
|
+
super().__init__(f"Sandbox {sandbox_id!r} not found", code="NOT_FOUND")
|
|
21
|
+
self.sandbox_id = sandbox_id
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ProviderError(ForgevmError):
|
|
25
|
+
"""Raised when a provider operation fails."""
|
|
26
|
+
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ConnectionError(ForgevmError):
|
|
31
|
+
"""Raised when the connection to the ForgeVM server fails."""
|
|
32
|
+
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def handle_response(response: httpx.Response) -> None:
|
|
37
|
+
"""Check response status and raise appropriate exceptions."""
|
|
38
|
+
if response.is_success:
|
|
39
|
+
return
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
data = response.json()
|
|
43
|
+
code = data.get("code", "")
|
|
44
|
+
message = data.get("message", response.text)
|
|
45
|
+
except Exception:
|
|
46
|
+
code = ""
|
|
47
|
+
message = response.text
|
|
48
|
+
|
|
49
|
+
if response.status_code == 404:
|
|
50
|
+
raise SandboxNotFound(message)
|
|
51
|
+
if response.status_code == 401:
|
|
52
|
+
raise ForgevmError(message, code="UNAUTHORIZED")
|
|
53
|
+
if response.status_code >= 500:
|
|
54
|
+
raise ProviderError(message, code=code)
|
|
55
|
+
|
|
56
|
+
raise ForgevmError(message, code=code)
|