sandforge-sdk 0.1.0__py3-none-any.whl
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.
- sandforge/__init__.py +66 -0
- sandforge/client.py +448 -0
- sandforge/py.typed +0 -0
- sandforge/types.py +146 -0
- sandforge_sdk-0.1.0.dist-info/METADATA +391 -0
- sandforge_sdk-0.1.0.dist-info/RECORD +10 -0
- sandforge_sdk-0.1.0.dist-info/WHEEL +5 -0
- sandforge_sdk-0.1.0.dist-info/top_level.txt +2 -0
- tests/__init__.py +0 -0
- tests/test_client.py +313 -0
sandforge/__init__.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Sandforge Python SDK.
|
|
2
|
+
|
|
3
|
+
A client library for interacting with the Sandforge hypervisor sandbox platform.
|
|
4
|
+
|
|
5
|
+
Example:
|
|
6
|
+
from sandforge import Client, SandboxSpec
|
|
7
|
+
|
|
8
|
+
# Create a client
|
|
9
|
+
client = Client("http://localhost:8080")
|
|
10
|
+
|
|
11
|
+
# Create a sandbox
|
|
12
|
+
sandbox = client.create_sandbox(SandboxSpec(cpu=2, memory_mb=512))
|
|
13
|
+
|
|
14
|
+
# Run a command
|
|
15
|
+
result = sandbox.commands.run(["echo", "Hello, Sandforge!"])
|
|
16
|
+
print(result.stdout)
|
|
17
|
+
|
|
18
|
+
# Get sandbox info
|
|
19
|
+
info = sandbox.info()
|
|
20
|
+
print(f"Sandbox {info.id} is {info.state}")
|
|
21
|
+
|
|
22
|
+
# Clean up
|
|
23
|
+
sandbox.kill()
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from .client import Client, SandboxHandle, CommandsAPI, FilesAPI, GitAPI
|
|
27
|
+
|
|
28
|
+
# Alias for cleaner ergonomics
|
|
29
|
+
Sandbox = SandboxHandle
|
|
30
|
+
from .types import (
|
|
31
|
+
SandboxSpec,
|
|
32
|
+
WorkspaceMount,
|
|
33
|
+
ExecRequest,
|
|
34
|
+
ExecResult,
|
|
35
|
+
SandboxInfo,
|
|
36
|
+
EntryInfo,
|
|
37
|
+
GitStatus,
|
|
38
|
+
SandforgeException,
|
|
39
|
+
SandboxNotFoundError,
|
|
40
|
+
ExecutionError,
|
|
41
|
+
NetworkError,
|
|
42
|
+
InvalidSpecError,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
__version__ = "0.1.0"
|
|
46
|
+
|
|
47
|
+
__all__ = [
|
|
48
|
+
"Client",
|
|
49
|
+
"Sandbox",
|
|
50
|
+
"SandboxHandle",
|
|
51
|
+
"CommandsAPI",
|
|
52
|
+
"FilesAPI",
|
|
53
|
+
"GitAPI",
|
|
54
|
+
"SandboxSpec",
|
|
55
|
+
"WorkspaceMount",
|
|
56
|
+
"ExecRequest",
|
|
57
|
+
"ExecResult",
|
|
58
|
+
"SandboxInfo",
|
|
59
|
+
"EntryInfo",
|
|
60
|
+
"GitStatus",
|
|
61
|
+
"SandforgeException",
|
|
62
|
+
"SandboxNotFoundError",
|
|
63
|
+
"ExecutionError",
|
|
64
|
+
"NetworkError",
|
|
65
|
+
"InvalidSpecError",
|
|
66
|
+
]
|
sandforge/client.py
ADDED
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
"""HTTP client for communicating with the Sandforge control plane."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import secrets
|
|
5
|
+
from typing import Dict, Any, Optional
|
|
6
|
+
import requests
|
|
7
|
+
|
|
8
|
+
from .types import (
|
|
9
|
+
SandboxSpec,
|
|
10
|
+
ExecRequest,
|
|
11
|
+
ExecResult,
|
|
12
|
+
SandboxInfo,
|
|
13
|
+
EntryInfo,
|
|
14
|
+
GitStatus,
|
|
15
|
+
SandforgeException,
|
|
16
|
+
NetworkError,
|
|
17
|
+
SandboxNotFoundError,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Client:
|
|
22
|
+
"""Sandforge control plane HTTP client.
|
|
23
|
+
|
|
24
|
+
Example:
|
|
25
|
+
client = Client("http://localhost:8080")
|
|
26
|
+
sandbox = client.create_sandbox(SandboxSpec())
|
|
27
|
+
result = client.exec(sandbox.id, ExecRequest(command=["echo", "hello"]))
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, base_url: str, timeout: int = 60):
|
|
31
|
+
"""Initialize the Sandforge client.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
base_url: The control plane base URL (e.g., "http://localhost:8080").
|
|
35
|
+
timeout: Request timeout in seconds.
|
|
36
|
+
"""
|
|
37
|
+
self.base_url = base_url.rstrip("/")
|
|
38
|
+
self.timeout = timeout
|
|
39
|
+
self.session = requests.Session()
|
|
40
|
+
|
|
41
|
+
def __enter__(self):
|
|
42
|
+
return self
|
|
43
|
+
|
|
44
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
45
|
+
self.session.close()
|
|
46
|
+
return False
|
|
47
|
+
|
|
48
|
+
def close(self) -> None:
|
|
49
|
+
"""Close the underlying HTTP session."""
|
|
50
|
+
self.session.close()
|
|
51
|
+
|
|
52
|
+
def create_sandbox(self, spec: Optional[SandboxSpec] = None) -> "SandboxHandle":
|
|
53
|
+
"""Create a new sandbox.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
spec: SandboxSpec for the sandbox. If None, uses defaults.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
SandboxHandle: A handle to the created sandbox.
|
|
60
|
+
|
|
61
|
+
Raises:
|
|
62
|
+
NetworkError: If communication with the control plane fails.
|
|
63
|
+
SandforgeException: If sandbox creation fails.
|
|
64
|
+
"""
|
|
65
|
+
if spec is None:
|
|
66
|
+
spec = SandboxSpec()
|
|
67
|
+
|
|
68
|
+
sandbox_id = self._generate_id()
|
|
69
|
+
payload = {
|
|
70
|
+
"id": sandbox_id,
|
|
71
|
+
"spec": spec.to_dict(),
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
response = self._do("POST", "/v1/sandboxes", payload)
|
|
75
|
+
return SandboxHandle(self, response.get("id", sandbox_id))
|
|
76
|
+
|
|
77
|
+
def exec(self, sandbox_id: str, request: ExecRequest) -> ExecResult:
|
|
78
|
+
"""Execute a command in a sandbox.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
sandbox_id: The sandbox ID.
|
|
82
|
+
request: The execution request.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
ExecResult: The command execution result.
|
|
86
|
+
|
|
87
|
+
Raises:
|
|
88
|
+
NetworkError: If communication with the control plane fails.
|
|
89
|
+
SandforgeException: If execution fails.
|
|
90
|
+
"""
|
|
91
|
+
payload = request.to_dict()
|
|
92
|
+
response = self._do("POST", f"/v1/sandboxes/{sandbox_id}/exec", payload)
|
|
93
|
+
return ExecResult.from_dict(response)
|
|
94
|
+
|
|
95
|
+
def get_status(self, sandbox_id: str) -> str:
|
|
96
|
+
"""Get the current state of a sandbox.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
sandbox_id: The sandbox ID.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
str: The sandbox state (e.g., "ready", "executing", "destroyed").
|
|
103
|
+
|
|
104
|
+
Raises:
|
|
105
|
+
NetworkError: If communication with the control plane fails.
|
|
106
|
+
SandboxNotFoundError: If the sandbox is not found.
|
|
107
|
+
"""
|
|
108
|
+
response = self._do("GET", f"/v1/sandboxes/{sandbox_id}", None)
|
|
109
|
+
return response.get("state", "unknown")
|
|
110
|
+
|
|
111
|
+
def get_info(self, sandbox_id: str) -> SandboxInfo:
|
|
112
|
+
"""Get detailed information about a sandbox.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
sandbox_id: The sandbox ID.
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
SandboxInfo: Information about the sandbox.
|
|
119
|
+
|
|
120
|
+
Raises:
|
|
121
|
+
NetworkError: If communication with the control plane fails.
|
|
122
|
+
SandboxNotFoundError: If the sandbox is not found.
|
|
123
|
+
"""
|
|
124
|
+
response = self._do("GET", f"/v1/sandboxes/{sandbox_id}", None)
|
|
125
|
+
return SandboxInfo.from_dict(response)
|
|
126
|
+
|
|
127
|
+
def destroy(self, sandbox_id: str) -> None:
|
|
128
|
+
"""Destroy a sandbox.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
sandbox_id: The sandbox ID.
|
|
132
|
+
|
|
133
|
+
Raises:
|
|
134
|
+
NetworkError: If communication with the control plane fails.
|
|
135
|
+
SandforgeException: If destruction fails.
|
|
136
|
+
"""
|
|
137
|
+
self._do("DELETE", f"/v1/sandboxes/{sandbox_id}", None)
|
|
138
|
+
|
|
139
|
+
# ─── Private Methods ───────────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
def _do(self, method: str, path: str, body: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
|
142
|
+
"""Execute an HTTP request to the control plane.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
method: HTTP method (GET, POST, DELETE, etc.).
|
|
146
|
+
path: API path (e.g., "/v1/sandboxes").
|
|
147
|
+
body: Request body (or None for GET/DELETE).
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
dict: Parsed JSON response.
|
|
151
|
+
|
|
152
|
+
Raises:
|
|
153
|
+
NetworkError: If the request fails.
|
|
154
|
+
SandboxNotFoundError: If the resource is not found.
|
|
155
|
+
SandforgeException: If the response indicates an error.
|
|
156
|
+
"""
|
|
157
|
+
url = self.base_url + path
|
|
158
|
+
headers = {"Content-Type": "application/json"}
|
|
159
|
+
|
|
160
|
+
try:
|
|
161
|
+
if method == "GET":
|
|
162
|
+
resp = self.session.get(url, headers=headers, timeout=self.timeout)
|
|
163
|
+
elif method == "POST":
|
|
164
|
+
resp = self.session.post(
|
|
165
|
+
url, json=body, headers=headers, timeout=self.timeout
|
|
166
|
+
)
|
|
167
|
+
elif method == "PUT":
|
|
168
|
+
resp = self.session.put(
|
|
169
|
+
url, json=body, headers=headers, timeout=self.timeout
|
|
170
|
+
)
|
|
171
|
+
elif method == "DELETE":
|
|
172
|
+
resp = self.session.delete(url, headers=headers, timeout=self.timeout)
|
|
173
|
+
else:
|
|
174
|
+
raise ValueError(f"Unsupported HTTP method: {method}")
|
|
175
|
+
|
|
176
|
+
except requests.RequestException as e:
|
|
177
|
+
raise NetworkError(f"Request failed: {e}") from e
|
|
178
|
+
|
|
179
|
+
# Handle error responses
|
|
180
|
+
if resp.status_code >= 400:
|
|
181
|
+
self._handle_error_response(resp)
|
|
182
|
+
|
|
183
|
+
# Parse response body
|
|
184
|
+
if resp.text:
|
|
185
|
+
try:
|
|
186
|
+
return resp.json()
|
|
187
|
+
except json.JSONDecodeError as e:
|
|
188
|
+
raise SandforgeException(f"Invalid JSON response: {e}") from e
|
|
189
|
+
return {}
|
|
190
|
+
|
|
191
|
+
def _handle_error_response(self, resp: requests.Response) -> None:
|
|
192
|
+
"""Parse and raise an appropriate exception from an error response.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
resp: The HTTP response object.
|
|
196
|
+
|
|
197
|
+
Raises:
|
|
198
|
+
SandboxNotFoundError: If the resource is not found (404).
|
|
199
|
+
SandforgeException: For other error responses.
|
|
200
|
+
"""
|
|
201
|
+
status = resp.status_code
|
|
202
|
+
try:
|
|
203
|
+
error_data = resp.json()
|
|
204
|
+
error_msg = error_data.get("error", "Unknown error")
|
|
205
|
+
except json.JSONDecodeError:
|
|
206
|
+
error_msg = resp.text or f"HTTP {status}"
|
|
207
|
+
|
|
208
|
+
if status == 404:
|
|
209
|
+
raise SandboxNotFoundError(f"Sandbox not found: {error_msg}")
|
|
210
|
+
else:
|
|
211
|
+
raise SandforgeException(f"HTTP {status}: {error_msg}")
|
|
212
|
+
|
|
213
|
+
@staticmethod
|
|
214
|
+
def _generate_id() -> str:
|
|
215
|
+
"""Generate a unique sandbox ID.
|
|
216
|
+
|
|
217
|
+
Returns:
|
|
218
|
+
str: A sandbox ID in the form "sbx-<hex>".
|
|
219
|
+
"""
|
|
220
|
+
random_bytes = secrets.token_hex(8)
|
|
221
|
+
return f"sbx-{random_bytes}"
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
class SandboxHandle:
|
|
225
|
+
"""A handle to a sandbox, providing convenient command and file operations.
|
|
226
|
+
|
|
227
|
+
Example:
|
|
228
|
+
sandbox = client.create_sandbox()
|
|
229
|
+
result = sandbox.commands.run(["echo", "hello"])
|
|
230
|
+
content = sandbox.files.read("/etc/hostname")
|
|
231
|
+
sandbox.kill()
|
|
232
|
+
info = sandbox.info()
|
|
233
|
+
"""
|
|
234
|
+
|
|
235
|
+
def __init__(self, client: Client, sandbox_id: str):
|
|
236
|
+
"""Initialize a sandbox handle.
|
|
237
|
+
|
|
238
|
+
Args:
|
|
239
|
+
client: The Sandforge client.
|
|
240
|
+
sandbox_id: The sandbox ID.
|
|
241
|
+
"""
|
|
242
|
+
self.id = sandbox_id
|
|
243
|
+
self._client = client
|
|
244
|
+
self.commands = CommandsAPI(self)
|
|
245
|
+
self.files = FilesAPI(self)
|
|
246
|
+
self.git = GitAPI(self)
|
|
247
|
+
|
|
248
|
+
def kill(self) -> None:
|
|
249
|
+
"""Destroy the sandbox.
|
|
250
|
+
|
|
251
|
+
Raises:
|
|
252
|
+
NetworkError: If communication with the control plane fails.
|
|
253
|
+
"""
|
|
254
|
+
self._client.destroy(self.id)
|
|
255
|
+
|
|
256
|
+
def info(self) -> SandboxInfo:
|
|
257
|
+
"""Get information about the sandbox.
|
|
258
|
+
|
|
259
|
+
Returns:
|
|
260
|
+
SandboxInfo: Current sandbox state and ID.
|
|
261
|
+
|
|
262
|
+
Raises:
|
|
263
|
+
NetworkError: If communication with the control plane fails.
|
|
264
|
+
"""
|
|
265
|
+
return self._client.get_info(self.id)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
class CommandsAPI:
|
|
269
|
+
"""Commands API for executing commands in a sandbox."""
|
|
270
|
+
|
|
271
|
+
def __init__(self, sandbox: SandboxHandle):
|
|
272
|
+
"""Initialize the commands API.
|
|
273
|
+
|
|
274
|
+
Args:
|
|
275
|
+
sandbox: The parent SandboxHandle.
|
|
276
|
+
"""
|
|
277
|
+
self._sandbox = sandbox
|
|
278
|
+
|
|
279
|
+
def run(
|
|
280
|
+
self,
|
|
281
|
+
command: list,
|
|
282
|
+
cwd: str = "/",
|
|
283
|
+
env: Optional[Dict[str, str]] = None,
|
|
284
|
+
timeout_sec: int = 60,
|
|
285
|
+
) -> ExecResult:
|
|
286
|
+
"""Run a command in the sandbox.
|
|
287
|
+
|
|
288
|
+
Args:
|
|
289
|
+
command: Command and arguments as a list (e.g., ["echo", "hello"]).
|
|
290
|
+
cwd: Working directory for the command (default: "/").
|
|
291
|
+
env: Environment variables as a dict (default: empty).
|
|
292
|
+
timeout_sec: Command timeout in seconds (default: 60).
|
|
293
|
+
|
|
294
|
+
Returns:
|
|
295
|
+
ExecResult: Command execution result with exit code, stdout, stderr.
|
|
296
|
+
|
|
297
|
+
Raises:
|
|
298
|
+
NetworkError: If communication with the control plane fails.
|
|
299
|
+
SandforgeException: If execution fails.
|
|
300
|
+
"""
|
|
301
|
+
if env is None:
|
|
302
|
+
env = {}
|
|
303
|
+
|
|
304
|
+
request = ExecRequest(
|
|
305
|
+
command=command,
|
|
306
|
+
cwd=cwd,
|
|
307
|
+
env=env,
|
|
308
|
+
timeout_sec=timeout_sec,
|
|
309
|
+
)
|
|
310
|
+
return self._sandbox._client.exec(self._sandbox.id, request)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
class FilesAPI:
|
|
314
|
+
"""Files API for filesystem operations inside a sandbox."""
|
|
315
|
+
|
|
316
|
+
def __init__(self, sandbox: "SandboxHandle"):
|
|
317
|
+
self._sandbox = sandbox
|
|
318
|
+
|
|
319
|
+
def read(self, path: str, as_bytes: bool = False):
|
|
320
|
+
"""Read a file from the sandbox.
|
|
321
|
+
|
|
322
|
+
Args:
|
|
323
|
+
path: Path to the file inside the sandbox.
|
|
324
|
+
as_bytes: If True, return raw bytes. Default returns str.
|
|
325
|
+
|
|
326
|
+
Returns:
|
|
327
|
+
str or bytes: File contents.
|
|
328
|
+
"""
|
|
329
|
+
resp = self._sandbox._client._do(
|
|
330
|
+
"GET", f"/v1/sandboxes/{self._sandbox.id}/files/read?path={path}", None
|
|
331
|
+
)
|
|
332
|
+
data = bytes(resp.get("data", []))
|
|
333
|
+
return data if as_bytes else data.decode()
|
|
334
|
+
|
|
335
|
+
def write(self, path: str, data) -> int:
|
|
336
|
+
"""Write data to a file inside the sandbox.
|
|
337
|
+
|
|
338
|
+
Args:
|
|
339
|
+
path: Destination path inside the sandbox.
|
|
340
|
+
data: str or bytes to write.
|
|
341
|
+
|
|
342
|
+
Returns:
|
|
343
|
+
int: Number of bytes written.
|
|
344
|
+
"""
|
|
345
|
+
if isinstance(data, str):
|
|
346
|
+
data = data.encode()
|
|
347
|
+
payload = {"guest_path": path, "data": list(data)}
|
|
348
|
+
resp = self._sandbox._client._do(
|
|
349
|
+
"PUT", f"/v1/sandboxes/{self._sandbox.id}/files", payload
|
|
350
|
+
)
|
|
351
|
+
return resp.get("size", 0)
|
|
352
|
+
|
|
353
|
+
def list(self, path: str) -> list:
|
|
354
|
+
"""List directory contents inside the sandbox.
|
|
355
|
+
|
|
356
|
+
Args:
|
|
357
|
+
path: Directory path inside the sandbox.
|
|
358
|
+
|
|
359
|
+
Returns:
|
|
360
|
+
List[EntryInfo]: Directory entries.
|
|
361
|
+
"""
|
|
362
|
+
resp = self._sandbox._client._do(
|
|
363
|
+
"GET", f"/v1/sandboxes/{self._sandbox.id}/files?path={path}", None
|
|
364
|
+
)
|
|
365
|
+
return [EntryInfo.from_dict(e) for e in resp.get("entries", [])]
|
|
366
|
+
|
|
367
|
+
def stat(self, path: str) -> EntryInfo:
|
|
368
|
+
"""Return metadata for a path inside the sandbox.
|
|
369
|
+
|
|
370
|
+
Args:
|
|
371
|
+
path: Path inside the sandbox.
|
|
372
|
+
|
|
373
|
+
Returns:
|
|
374
|
+
EntryInfo: Metadata for the path.
|
|
375
|
+
"""
|
|
376
|
+
resp = self._sandbox._client._do(
|
|
377
|
+
"GET", f"/v1/sandboxes/{self._sandbox.id}/stat?path={path}", None
|
|
378
|
+
)
|
|
379
|
+
return EntryInfo.from_dict(resp)
|
|
380
|
+
|
|
381
|
+
def exists(self, path: str) -> bool:
|
|
382
|
+
"""Return True if the path exists inside the sandbox."""
|
|
383
|
+
try:
|
|
384
|
+
self.stat(path)
|
|
385
|
+
return True
|
|
386
|
+
except SandforgeException:
|
|
387
|
+
return False
|
|
388
|
+
|
|
389
|
+
def remove(self, path: str) -> ExecResult:
|
|
390
|
+
"""Delete a file or directory inside the sandbox via `rm -rf`."""
|
|
391
|
+
return self._sandbox._client.exec(
|
|
392
|
+
self._sandbox.id,
|
|
393
|
+
ExecRequest(command=["rm", "-rf", path], cwd="/", timeout_sec=30),
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
class GitAPI:
|
|
398
|
+
"""Git API — shell facade over `commands.run()` for common git operations."""
|
|
399
|
+
|
|
400
|
+
def __init__(self, sandbox: "SandboxHandle"):
|
|
401
|
+
self._sandbox = sandbox
|
|
402
|
+
|
|
403
|
+
def _exec(self, args: list, cwd: str = "/") -> ExecResult:
|
|
404
|
+
return self._sandbox._client.exec(
|
|
405
|
+
self._sandbox.id,
|
|
406
|
+
ExecRequest(command=["git"] + args, cwd=cwd, timeout_sec=120),
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
def clone(self, url: str, dest: str = ".", depth: Optional[int] = None) -> ExecResult:
|
|
410
|
+
args = ["clone"]
|
|
411
|
+
if depth:
|
|
412
|
+
args += ["--depth", str(depth)]
|
|
413
|
+
args += [url, dest]
|
|
414
|
+
return self._exec(args)
|
|
415
|
+
|
|
416
|
+
def init(self, cwd: str) -> ExecResult:
|
|
417
|
+
return self._exec(["init"], cwd)
|
|
418
|
+
|
|
419
|
+
def add(self, paths, cwd: str) -> ExecResult:
|
|
420
|
+
if isinstance(paths, str):
|
|
421
|
+
paths = [paths]
|
|
422
|
+
return self._exec(["add"] + paths, cwd)
|
|
423
|
+
|
|
424
|
+
def commit(self, message: str, cwd: str) -> ExecResult:
|
|
425
|
+
return self._exec(["commit", "-m", message], cwd)
|
|
426
|
+
|
|
427
|
+
def push(self, cwd: str, remote: str = "origin", branch: str = "HEAD") -> ExecResult:
|
|
428
|
+
return self._exec(["push", remote, branch], cwd)
|
|
429
|
+
|
|
430
|
+
def pull(self, cwd: str, remote: str = "origin") -> ExecResult:
|
|
431
|
+
return self._exec(["pull", remote], cwd)
|
|
432
|
+
|
|
433
|
+
def status(self, cwd: str) -> GitStatus:
|
|
434
|
+
branch_result = self._exec(["rev-parse", "--abbrev-ref", "HEAD"], cwd)
|
|
435
|
+
status_result = self._exec(["status", "--porcelain"], cwd)
|
|
436
|
+
return GitStatus(
|
|
437
|
+
branch=branch_result.stdout.strip(),
|
|
438
|
+
clean=status_result.stdout.strip() == "",
|
|
439
|
+
stdout=status_result.stdout,
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
def branches(self, cwd: str) -> list:
|
|
443
|
+
result = self._exec(["branch", "--list"], cwd)
|
|
444
|
+
return [
|
|
445
|
+
b.lstrip("* ").strip()
|
|
446
|
+
for b in result.stdout.splitlines()
|
|
447
|
+
if b.strip()
|
|
448
|
+
]
|
sandforge/py.typed
ADDED
|
File without changes
|
sandforge/types.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Type definitions for the Sandforge Python SDK."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field, asdict
|
|
4
|
+
from typing import Dict, List, Optional, Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class WorkspaceMount:
|
|
9
|
+
"""Represents a mount point for a workspace directory."""
|
|
10
|
+
|
|
11
|
+
host_path: str
|
|
12
|
+
guest_path: str
|
|
13
|
+
read_only: bool = False
|
|
14
|
+
|
|
15
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
16
|
+
"""Convert to dictionary for JSON serialization."""
|
|
17
|
+
return asdict(self)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class SandboxSpec:
|
|
22
|
+
"""Specification for creating a sandbox."""
|
|
23
|
+
|
|
24
|
+
backend: str = "macos-vz" # "linux-kvm", "linux-firecracker", "macos-vz"
|
|
25
|
+
cpu: int = 2
|
|
26
|
+
memory_mb: int = 512
|
|
27
|
+
disk_gb: int = 10
|
|
28
|
+
timeout_sec: int = 3600
|
|
29
|
+
network_mode: str = "offline" # "offline", "fetch", "full"
|
|
30
|
+
task_isolation: str = "container" # "container", "process"
|
|
31
|
+
mounts: List[WorkspaceMount] = field(default_factory=list)
|
|
32
|
+
|
|
33
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
34
|
+
"""Convert to dictionary for JSON serialization."""
|
|
35
|
+
return asdict(self)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class ExecRequest:
|
|
40
|
+
"""Request to execute a command in a sandbox."""
|
|
41
|
+
|
|
42
|
+
command: List[str]
|
|
43
|
+
cwd: str = "/"
|
|
44
|
+
env: Dict[str, str] = field(default_factory=dict)
|
|
45
|
+
timeout_sec: int = 60
|
|
46
|
+
|
|
47
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
48
|
+
"""Convert to dictionary for JSON serialization."""
|
|
49
|
+
return asdict(self)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class ExecResult:
|
|
54
|
+
"""Result of executing a command in a sandbox."""
|
|
55
|
+
|
|
56
|
+
exit_code: int
|
|
57
|
+
stdout: str
|
|
58
|
+
stderr: str
|
|
59
|
+
artifacts: List[str] = field(default_factory=list)
|
|
60
|
+
|
|
61
|
+
@staticmethod
|
|
62
|
+
def from_dict(data: Dict[str, Any]) -> "ExecResult":
|
|
63
|
+
"""Create from dictionary returned by API."""
|
|
64
|
+
return ExecResult(
|
|
65
|
+
exit_code=data.get("exit_code", 0),
|
|
66
|
+
stdout=data.get("stdout", ""),
|
|
67
|
+
stderr=data.get("stderr", ""),
|
|
68
|
+
artifacts=data.get("artifacts", []),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class SandboxInfo:
|
|
74
|
+
"""Information about a sandbox's current state."""
|
|
75
|
+
|
|
76
|
+
id: str
|
|
77
|
+
state: str
|
|
78
|
+
|
|
79
|
+
@staticmethod
|
|
80
|
+
def from_dict(data: Dict[str, Any]) -> "SandboxInfo":
|
|
81
|
+
"""Create from dictionary returned by API."""
|
|
82
|
+
return SandboxInfo(
|
|
83
|
+
id=data.get("id", ""),
|
|
84
|
+
state=data.get("state", ""),
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass
|
|
89
|
+
class EntryInfo:
|
|
90
|
+
"""Metadata for a single filesystem entry inside a sandbox."""
|
|
91
|
+
|
|
92
|
+
name: str
|
|
93
|
+
path: str
|
|
94
|
+
size: int
|
|
95
|
+
is_dir: bool
|
|
96
|
+
mod_time: str
|
|
97
|
+
|
|
98
|
+
@staticmethod
|
|
99
|
+
def from_dict(data: Dict[str, Any]) -> "EntryInfo":
|
|
100
|
+
return EntryInfo(
|
|
101
|
+
name=data.get("name", ""),
|
|
102
|
+
path=data.get("path", ""),
|
|
103
|
+
size=data.get("size", 0),
|
|
104
|
+
is_dir=data.get("isDir", False),
|
|
105
|
+
mod_time=data.get("modTime", ""),
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass
|
|
110
|
+
class GitStatus:
|
|
111
|
+
"""Result of `git status` inside a sandbox."""
|
|
112
|
+
|
|
113
|
+
branch: str
|
|
114
|
+
clean: bool
|
|
115
|
+
stdout: str
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# Custom exceptions
|
|
119
|
+
class SandforgeException(Exception):
|
|
120
|
+
"""Base exception for Sandforge SDK."""
|
|
121
|
+
|
|
122
|
+
pass
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class SandboxNotFoundError(SandforgeException):
|
|
126
|
+
"""Raised when a sandbox is not found."""
|
|
127
|
+
|
|
128
|
+
pass
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class ExecutionError(SandforgeException):
|
|
132
|
+
"""Raised when command execution fails."""
|
|
133
|
+
|
|
134
|
+
pass
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class NetworkError(SandforgeException):
|
|
138
|
+
"""Raised when there's a network error communicating with the control plane."""
|
|
139
|
+
|
|
140
|
+
pass
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class InvalidSpecError(SandforgeException):
|
|
144
|
+
"""Raised when sandbox specification is invalid."""
|
|
145
|
+
|
|
146
|
+
pass
|
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sandforge-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for Sandforge hypervisor sandbox platform
|
|
5
|
+
Home-page: https://github.com/yanurag-dev/sandforge
|
|
6
|
+
Author: Anurag Yadav
|
|
7
|
+
Author-email: Anurag Yadav <yadavanurag1310@gmail.com>
|
|
8
|
+
License: Apache-2.0
|
|
9
|
+
Project-URL: Homepage, https://github.com/yanurag-dev/sandforge
|
|
10
|
+
Project-URL: Repository, https://github.com/yanurag-dev/sandforge
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Requires-Python: >=3.8
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
Requires-Dist: requests>=2.33.0
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: pytest>=6.0; extra == "dev"
|
|
19
|
+
Requires-Dist: pytest-cov>=2.10; extra == "dev"
|
|
20
|
+
Requires-Dist: black>=21.0; extra == "dev"
|
|
21
|
+
Requires-Dist: mypy>=0.910; extra == "dev"
|
|
22
|
+
Requires-Dist: flake8>=3.9; extra == "dev"
|
|
23
|
+
Dynamic: author
|
|
24
|
+
Dynamic: home-page
|
|
25
|
+
Dynamic: requires-python
|
|
26
|
+
|
|
27
|
+
# Sandforge Python SDK
|
|
28
|
+
|
|
29
|
+
The Sandforge Python SDK provides a client library for interacting with the Sandforge hypervisor sandbox platform. It enables you to create, manage, and execute commands in isolated sandboxes programmatically.
|
|
30
|
+
|
|
31
|
+
## Installation
|
|
32
|
+
|
|
33
|
+
Install the SDK from the repository:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install -e .
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Or with development dependencies:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install -e ".[dev]"
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Quick Start
|
|
46
|
+
|
|
47
|
+
### Basic Usage
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from sandforge import Client, SandboxSpec
|
|
51
|
+
|
|
52
|
+
# Create a client pointing to your Sandforge control plane
|
|
53
|
+
client = Client("http://localhost:8080")
|
|
54
|
+
|
|
55
|
+
# Create a sandbox with default configuration
|
|
56
|
+
sandbox = client.create_sandbox()
|
|
57
|
+
|
|
58
|
+
# Run a command
|
|
59
|
+
result = sandbox.commands.run(["echo", "Hello, Sandforge!"])
|
|
60
|
+
print(result.stdout) # "Hello, Sandforge!\n"
|
|
61
|
+
|
|
62
|
+
# Clean up
|
|
63
|
+
sandbox.kill()
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Custom Sandbox Configuration
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from sandforge import Client, SandboxSpec, WorkspaceMount
|
|
70
|
+
|
|
71
|
+
spec = SandboxSpec(
|
|
72
|
+
cpu=4,
|
|
73
|
+
memory_mb=2048,
|
|
74
|
+
disk_gb=20,
|
|
75
|
+
timeout_sec=3600,
|
|
76
|
+
network_mode="fetch", # Allow package downloads
|
|
77
|
+
mounts=[
|
|
78
|
+
WorkspaceMount(
|
|
79
|
+
host_path="/path/to/project",
|
|
80
|
+
guest_path="/workspace",
|
|
81
|
+
read_only=False,
|
|
82
|
+
),
|
|
83
|
+
],
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
client = Client("http://localhost:8080")
|
|
87
|
+
sandbox = client.create_sandbox(spec)
|
|
88
|
+
|
|
89
|
+
# Work with the mounted directory
|
|
90
|
+
result = sandbox.commands.run(["ls", "-la", "/workspace"])
|
|
91
|
+
print(result.stdout)
|
|
92
|
+
|
|
93
|
+
sandbox.kill()
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Command Execution with Environment Variables
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
from sandforge import Client
|
|
100
|
+
|
|
101
|
+
client = Client("http://localhost:8080")
|
|
102
|
+
sandbox = client.create_sandbox()
|
|
103
|
+
|
|
104
|
+
# Run with custom environment variables
|
|
105
|
+
result = sandbox.commands.run(
|
|
106
|
+
command=["python", "-c", "import os; print(os.environ.get('MY_VAR'))"],
|
|
107
|
+
cwd="/",
|
|
108
|
+
env={"MY_VAR": "Hello World"},
|
|
109
|
+
timeout_sec=30,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
print(result.stdout) # "Hello World\n"
|
|
113
|
+
print(result.exit_code) # 0
|
|
114
|
+
|
|
115
|
+
sandbox.kill()
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### Error Handling
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
from sandforge import Client, NetworkError, SandboxNotFoundError
|
|
122
|
+
|
|
123
|
+
client = Client("http://localhost:8080")
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
sandbox = client.create_sandbox()
|
|
127
|
+
result = sandbox.commands.run(["false"]) # Command that fails
|
|
128
|
+
|
|
129
|
+
if result.exit_code != 0:
|
|
130
|
+
print(f"Command failed with exit code {result.exit_code}")
|
|
131
|
+
print(f"stderr: {result.stderr}")
|
|
132
|
+
|
|
133
|
+
sandbox.kill()
|
|
134
|
+
|
|
135
|
+
except NetworkError as e:
|
|
136
|
+
print(f"Connection error: {e}")
|
|
137
|
+
except SandboxNotFoundError as e:
|
|
138
|
+
print(f"Sandbox not found: {e}")
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
### Sandbox Information
|
|
142
|
+
|
|
143
|
+
```python
|
|
144
|
+
from sandforge import Client
|
|
145
|
+
|
|
146
|
+
client = Client("http://localhost:8080")
|
|
147
|
+
sandbox = client.create_sandbox()
|
|
148
|
+
|
|
149
|
+
# Get sandbox information
|
|
150
|
+
info = sandbox.info()
|
|
151
|
+
print(f"Sandbox ID: {info.id}")
|
|
152
|
+
print(f"State: {info.state}") # "ready", "executing", "destroyed", etc.
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
## API Reference
|
|
156
|
+
|
|
157
|
+
### Client
|
|
158
|
+
|
|
159
|
+
The main entry point for interacting with Sandforge.
|
|
160
|
+
|
|
161
|
+
#### Constructor
|
|
162
|
+
|
|
163
|
+
```python
|
|
164
|
+
Client(base_url: str, timeout: int = 60)
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
- `base_url`: The control plane URL (e.g., "http://localhost:8080")
|
|
168
|
+
- `timeout`: Request timeout in seconds (default: 60)
|
|
169
|
+
|
|
170
|
+
#### Methods
|
|
171
|
+
|
|
172
|
+
##### `create_sandbox(spec: Optional[SandboxSpec] = None) -> SandboxHandle`
|
|
173
|
+
|
|
174
|
+
Create a new sandbox.
|
|
175
|
+
|
|
176
|
+
- Returns: A `SandboxHandle` to the created sandbox
|
|
177
|
+
- Raises: `NetworkError` or `SandforgeException`
|
|
178
|
+
|
|
179
|
+
##### `exec(sandbox_id: str, request: ExecRequest) -> ExecResult`
|
|
180
|
+
|
|
181
|
+
Execute a command in a sandbox.
|
|
182
|
+
|
|
183
|
+
- Returns: An `ExecResult` with exit code, stdout, and stderr
|
|
184
|
+
- Raises: `NetworkError` or `SandforgeException`
|
|
185
|
+
|
|
186
|
+
##### `get_status(sandbox_id: str) -> str`
|
|
187
|
+
|
|
188
|
+
Get the current state of a sandbox.
|
|
189
|
+
|
|
190
|
+
- Returns: The sandbox state as a string
|
|
191
|
+
- Raises: `NetworkError`
|
|
192
|
+
|
|
193
|
+
##### `get_info(sandbox_id: str) -> SandboxInfo`
|
|
194
|
+
|
|
195
|
+
Get detailed information about a sandbox.
|
|
196
|
+
|
|
197
|
+
- Returns: A `SandboxInfo` object
|
|
198
|
+
- Raises: `NetworkError`
|
|
199
|
+
|
|
200
|
+
##### `destroy(sandbox_id: str) -> None`
|
|
201
|
+
|
|
202
|
+
Destroy a sandbox.
|
|
203
|
+
|
|
204
|
+
- Raises: `NetworkError` or `SandforgeException`
|
|
205
|
+
|
|
206
|
+
### SandboxHandle
|
|
207
|
+
|
|
208
|
+
A handle to a created sandbox with convenience APIs.
|
|
209
|
+
|
|
210
|
+
#### Properties
|
|
211
|
+
|
|
212
|
+
- `id`: The sandbox ID (string)
|
|
213
|
+
|
|
214
|
+
#### Methods
|
|
215
|
+
|
|
216
|
+
##### `kill() -> None`
|
|
217
|
+
|
|
218
|
+
Destroy the sandbox.
|
|
219
|
+
|
|
220
|
+
```python
|
|
221
|
+
sandbox.kill()
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
##### `info() -> SandboxInfo`
|
|
225
|
+
|
|
226
|
+
Get sandbox information.
|
|
227
|
+
|
|
228
|
+
```python
|
|
229
|
+
info = sandbox.info()
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
#### Nested APIs
|
|
233
|
+
|
|
234
|
+
##### `commands`
|
|
235
|
+
|
|
236
|
+
The `CommandsAPI` for executing commands.
|
|
237
|
+
|
|
238
|
+
###### `run(command, cwd="/", env=None, timeout_sec=60) -> ExecResult`
|
|
239
|
+
|
|
240
|
+
Run a command in the sandbox.
|
|
241
|
+
|
|
242
|
+
- `command`: List of command and arguments
|
|
243
|
+
- `cwd`: Working directory (default: "/")
|
|
244
|
+
- `env`: Dictionary of environment variables (default: {})
|
|
245
|
+
- `timeout_sec`: Command timeout in seconds (default: 60)
|
|
246
|
+
- Returns: `ExecResult` with exit code, stdout, and stderr
|
|
247
|
+
|
|
248
|
+
```python
|
|
249
|
+
result = sandbox.commands.run(
|
|
250
|
+
["python", "script.py"],
|
|
251
|
+
cwd="/workspace",
|
|
252
|
+
env={"PYTHONUNBUFFERED": "1"},
|
|
253
|
+
timeout_sec=300,
|
|
254
|
+
)
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
##### `files`
|
|
258
|
+
|
|
259
|
+
The `FilesAPI` for reading files from the sandbox.
|
|
260
|
+
|
|
261
|
+
###### `read(path: str) -> str`
|
|
262
|
+
|
|
263
|
+
Read a file from the sandbox.
|
|
264
|
+
|
|
265
|
+
**Note:** This method is currently not implemented and raises `NotImplementedError`. VSOCK copyout support is coming soon.
|
|
266
|
+
|
|
267
|
+
```python
|
|
268
|
+
try:
|
|
269
|
+
content = sandbox.files.read("/etc/hostname")
|
|
270
|
+
except NotImplementedError:
|
|
271
|
+
print("files.read() not yet supported")
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
### Types
|
|
275
|
+
|
|
276
|
+
#### SandboxSpec
|
|
277
|
+
|
|
278
|
+
Specification for creating a sandbox.
|
|
279
|
+
|
|
280
|
+
```python
|
|
281
|
+
SandboxSpec(
|
|
282
|
+
backend: str = "macos-vz", # "linux-kvm", "linux-firecracker", "macos-vz"
|
|
283
|
+
cpu: int = 2, # Number of vCPUs
|
|
284
|
+
memory_mb: int = 512, # Memory in MB
|
|
285
|
+
disk_gb: int = 10, # Disk size in GB
|
|
286
|
+
timeout_sec: int = 3600, # Sandbox lifetime in seconds
|
|
287
|
+
network_mode: str = "offline", # "offline", "fetch", "full"
|
|
288
|
+
task_isolation: str = "container", # "container", "process"
|
|
289
|
+
mounts: List[WorkspaceMount] = [], # Mounted directories
|
|
290
|
+
)
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
#### WorkspaceMount
|
|
294
|
+
|
|
295
|
+
A directory mount from host to guest.
|
|
296
|
+
|
|
297
|
+
```python
|
|
298
|
+
WorkspaceMount(
|
|
299
|
+
host_path: str, # Path on the host
|
|
300
|
+
guest_path: str, # Path in the sandbox
|
|
301
|
+
read_only: bool = False, # Whether the mount is read-only
|
|
302
|
+
)
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
#### ExecRequest
|
|
306
|
+
|
|
307
|
+
A request to execute a command.
|
|
308
|
+
|
|
309
|
+
```python
|
|
310
|
+
ExecRequest(
|
|
311
|
+
command: List[str], # Command and arguments
|
|
312
|
+
cwd: str = "/", # Working directory
|
|
313
|
+
env: Dict[str, str] = {}, # Environment variables
|
|
314
|
+
timeout_sec: int = 60, # Timeout in seconds
|
|
315
|
+
)
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
#### ExecResult
|
|
319
|
+
|
|
320
|
+
The result of command execution.
|
|
321
|
+
|
|
322
|
+
```python
|
|
323
|
+
ExecResult(
|
|
324
|
+
exit_code: int, # Command exit code
|
|
325
|
+
stdout: str, # Standard output
|
|
326
|
+
stderr: str, # Standard error
|
|
327
|
+
artifacts: List[str] = [], # Paths to generated artifacts
|
|
328
|
+
)
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
#### SandboxInfo
|
|
332
|
+
|
|
333
|
+
Information about a sandbox.
|
|
334
|
+
|
|
335
|
+
```python
|
|
336
|
+
SandboxInfo(
|
|
337
|
+
id: str, # Sandbox ID
|
|
338
|
+
state: str, # Current state (e.g., "ready", "executing", "destroyed")
|
|
339
|
+
)
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
### Exceptions
|
|
343
|
+
|
|
344
|
+
All exceptions inherit from `SandforgeException`.
|
|
345
|
+
|
|
346
|
+
- **SandforgeException**: Base exception for all Sandforge errors
|
|
347
|
+
- **NetworkError**: Network communication error with the control plane
|
|
348
|
+
- **SandboxNotFoundError**: Sandbox does not exist
|
|
349
|
+
- **ExecutionError**: Command execution failed
|
|
350
|
+
- **InvalidSpecError**: Invalid sandbox specification
|
|
351
|
+
|
|
352
|
+
## Error Handling
|
|
353
|
+
|
|
354
|
+
The SDK provides specific exception types for different error scenarios:
|
|
355
|
+
|
|
356
|
+
```python
|
|
357
|
+
from sandforge import Client, NetworkError, SandboxNotFoundError, SandforgeException
|
|
358
|
+
|
|
359
|
+
client = Client("http://localhost:8080")
|
|
360
|
+
|
|
361
|
+
try:
|
|
362
|
+
sandbox = client.create_sandbox()
|
|
363
|
+
result = sandbox.commands.run(["exit", "1"])
|
|
364
|
+
except NetworkError as e:
|
|
365
|
+
print(f"Network error: {e}")
|
|
366
|
+
except SandboxNotFoundError as e:
|
|
367
|
+
print(f"Sandbox not found: {e}")
|
|
368
|
+
except SandforgeException as e:
|
|
369
|
+
print(f"Sandforge error: {e}")
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
## Running Tests
|
|
373
|
+
|
|
374
|
+
```bash
|
|
375
|
+
pip install -e ".[dev]"
|
|
376
|
+
pytest tests/
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
## Contributing
|
|
380
|
+
|
|
381
|
+
Contributions are welcome! Please ensure code passes linting and type checks:
|
|
382
|
+
|
|
383
|
+
```bash
|
|
384
|
+
black sandforge/
|
|
385
|
+
flake8 sandforge/
|
|
386
|
+
mypy sandforge/
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
## License
|
|
390
|
+
|
|
391
|
+
Apache License 2.0. See LICENSE in the repository root for details.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
sandforge/__init__.py,sha256=sGjKIZiKJYt-LnvnbervLuC0_Xvm2T-qQ7yx5o9sL2M,1342
|
|
2
|
+
sandforge/client.py,sha256=AOxkAK742M-1PsWIxPlv6d3eiRGA-m0mDXb1cCqVJ5I,14150
|
|
3
|
+
sandforge/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
sandforge/types.py,sha256=3yuBRUl9zTc9k98_Ij-ClbK8gqA6x6zTpDbXxOkF4dE,3446
|
|
5
|
+
tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
tests/test_client.py,sha256=D81VzkRfdGCRF_fLWXrkzJ5qIcV8EuvoCQPN_Ei_meI,11886
|
|
7
|
+
sandforge_sdk-0.1.0.dist-info/METADATA,sha256=NWVgoiJ-KZ_tKtGLEUpnljvOnaDy37V-BM6DF4CKeYY,8994
|
|
8
|
+
sandforge_sdk-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
9
|
+
sandforge_sdk-0.1.0.dist-info/top_level.txt,sha256=b_lOeCG9LvqlW9TuzheYA4Nu6K_yuN6Lx9XwKcVpzQI,16
|
|
10
|
+
sandforge_sdk-0.1.0.dist-info/RECORD,,
|
tests/__init__.py
ADDED
|
File without changes
|
tests/test_client.py
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
"""Unit tests for the Sandforge Python SDK client."""
|
|
2
|
+
|
|
3
|
+
import unittest
|
|
4
|
+
import sys
|
|
5
|
+
import os
|
|
6
|
+
from unittest.mock import MagicMock
|
|
7
|
+
|
|
8
|
+
# Allow running tests from the sdks/python directory without installing the package.
|
|
9
|
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
|
10
|
+
|
|
11
|
+
# Stub the `requests` module so tests run without installing it.
|
|
12
|
+
_requests_stub = MagicMock()
|
|
13
|
+
sys.modules.setdefault("requests", _requests_stub)
|
|
14
|
+
|
|
15
|
+
from sandforge import Client, SandboxHandle, Sandbox
|
|
16
|
+
from sandforge.types import ExecResult, SandboxInfo, SandboxSpec
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TestClientCreateSandbox(unittest.TestCase):
|
|
20
|
+
"""Tests for Client.create_sandbox()."""
|
|
21
|
+
|
|
22
|
+
def _make_client(self):
|
|
23
|
+
client = Client("http://localhost:8080")
|
|
24
|
+
client.session = MagicMock()
|
|
25
|
+
return client
|
|
26
|
+
|
|
27
|
+
def test_create_sandbox_posts_to_v1_sandboxes(self):
|
|
28
|
+
"""create_sandbox() should POST to /v1/sandboxes and return a SandboxHandle."""
|
|
29
|
+
client = self._make_client()
|
|
30
|
+
|
|
31
|
+
mock_response = MagicMock()
|
|
32
|
+
mock_response.status_code = 200
|
|
33
|
+
mock_response.text = '{"id": "sbx-abc123"}'
|
|
34
|
+
mock_response.json.return_value = {"id": "sbx-abc123"}
|
|
35
|
+
client.session.post.return_value = mock_response
|
|
36
|
+
|
|
37
|
+
handle = client.create_sandbox(SandboxSpec())
|
|
38
|
+
|
|
39
|
+
# Verify POST was called with the right URL
|
|
40
|
+
call_args = client.session.post.call_args
|
|
41
|
+
self.assertIn("/v1/sandboxes", call_args[0][0])
|
|
42
|
+
|
|
43
|
+
# Verify the returned handle has the right ID
|
|
44
|
+
self.assertIsInstance(handle, SandboxHandle)
|
|
45
|
+
self.assertEqual(handle.id, "sbx-abc123")
|
|
46
|
+
|
|
47
|
+
def test_create_sandbox_uses_default_spec_when_none(self):
|
|
48
|
+
"""create_sandbox(None) should use a default SandboxSpec."""
|
|
49
|
+
client = self._make_client()
|
|
50
|
+
|
|
51
|
+
mock_response = MagicMock()
|
|
52
|
+
mock_response.status_code = 200
|
|
53
|
+
mock_response.text = '{"id": "sbx-def456"}'
|
|
54
|
+
mock_response.json.return_value = {"id": "sbx-def456"}
|
|
55
|
+
client.session.post.return_value = mock_response
|
|
56
|
+
|
|
57
|
+
handle = client.create_sandbox()
|
|
58
|
+
|
|
59
|
+
self.assertIsInstance(handle, SandboxHandle)
|
|
60
|
+
self.assertEqual(handle.id, "sbx-def456")
|
|
61
|
+
|
|
62
|
+
def test_sandbox_alias_equals_sandbox_handle(self):
|
|
63
|
+
"""The Sandbox alias should be the same class as SandboxHandle."""
|
|
64
|
+
self.assertIs(Sandbox, SandboxHandle)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class TestCommandsAPIRun(unittest.TestCase):
|
|
68
|
+
"""Tests for sandbox.commands.run()."""
|
|
69
|
+
|
|
70
|
+
def _make_sandbox(self):
|
|
71
|
+
client = Client("http://localhost:8080")
|
|
72
|
+
client.session = MagicMock()
|
|
73
|
+
return SandboxHandle(client, "sbx-test01"), client
|
|
74
|
+
|
|
75
|
+
def test_run_posts_to_exec_endpoint(self):
|
|
76
|
+
"""commands.run() should POST to /v1/sandboxes/{id}/exec."""
|
|
77
|
+
sandbox, client = self._make_sandbox()
|
|
78
|
+
|
|
79
|
+
mock_response = MagicMock()
|
|
80
|
+
mock_response.status_code = 200
|
|
81
|
+
mock_response.text = '{"exit_code": 0, "stdout": "hi\\n", "stderr": ""}'
|
|
82
|
+
mock_response.json.return_value = {
|
|
83
|
+
"exit_code": 0,
|
|
84
|
+
"stdout": "hi\n",
|
|
85
|
+
"stderr": "",
|
|
86
|
+
}
|
|
87
|
+
client.session.post.return_value = mock_response
|
|
88
|
+
|
|
89
|
+
result = sandbox.commands.run(["echo", "hi"])
|
|
90
|
+
|
|
91
|
+
call_args = client.session.post.call_args
|
|
92
|
+
self.assertIn("/v1/sandboxes/sbx-test01/exec", call_args[0][0])
|
|
93
|
+
|
|
94
|
+
self.assertIsInstance(result, ExecResult)
|
|
95
|
+
self.assertEqual(result.exit_code, 0)
|
|
96
|
+
self.assertEqual(result.stdout, "hi\n")
|
|
97
|
+
self.assertEqual(result.stderr, "")
|
|
98
|
+
|
|
99
|
+
def test_run_returns_exec_result_fields(self):
|
|
100
|
+
"""commands.run() should correctly populate all ExecResult fields."""
|
|
101
|
+
sandbox, client = self._make_sandbox()
|
|
102
|
+
|
|
103
|
+
mock_response = MagicMock()
|
|
104
|
+
mock_response.status_code = 200
|
|
105
|
+
mock_response.text = '{"exit_code": 1, "stdout": "out", "stderr": "err", "artifacts": ["a.txt"]}'
|
|
106
|
+
mock_response.json.return_value = {
|
|
107
|
+
"exit_code": 1,
|
|
108
|
+
"stdout": "out",
|
|
109
|
+
"stderr": "err",
|
|
110
|
+
"artifacts": ["a.txt"],
|
|
111
|
+
}
|
|
112
|
+
client.session.post.return_value = mock_response
|
|
113
|
+
|
|
114
|
+
result = sandbox.commands.run(["false"])
|
|
115
|
+
|
|
116
|
+
self.assertEqual(result.exit_code, 1)
|
|
117
|
+
self.assertEqual(result.stdout, "out")
|
|
118
|
+
self.assertEqual(result.stderr, "err")
|
|
119
|
+
self.assertEqual(result.artifacts, ["a.txt"])
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class TestSandboxKill(unittest.TestCase):
|
|
123
|
+
"""Tests for sandbox.kill()."""
|
|
124
|
+
|
|
125
|
+
def test_kill_calls_delete(self):
|
|
126
|
+
"""sandbox.kill() should send DELETE to /v1/sandboxes/{id}."""
|
|
127
|
+
client = Client("http://localhost:8080")
|
|
128
|
+
client.session = MagicMock()
|
|
129
|
+
sandbox = SandboxHandle(client, "sbx-kill01")
|
|
130
|
+
|
|
131
|
+
mock_response = MagicMock()
|
|
132
|
+
mock_response.status_code = 200
|
|
133
|
+
mock_response.text = ""
|
|
134
|
+
client.session.delete.return_value = mock_response
|
|
135
|
+
|
|
136
|
+
sandbox.kill()
|
|
137
|
+
|
|
138
|
+
call_args = client.session.delete.call_args
|
|
139
|
+
self.assertIn("/v1/sandboxes/sbx-kill01", call_args[0][0])
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class TestSandboxInfo(unittest.TestCase):
|
|
143
|
+
"""Tests for sandbox.info()."""
|
|
144
|
+
|
|
145
|
+
def test_info_calls_get_and_returns_sandbox_info(self):
|
|
146
|
+
"""sandbox.info() should GET /v1/sandboxes/{id} and return SandboxInfo."""
|
|
147
|
+
client = Client("http://localhost:8080")
|
|
148
|
+
client.session = MagicMock()
|
|
149
|
+
sandbox = SandboxHandle(client, "sbx-info01")
|
|
150
|
+
|
|
151
|
+
mock_response = MagicMock()
|
|
152
|
+
mock_response.status_code = 200
|
|
153
|
+
mock_response.text = '{"id": "sbx-info01", "state": "ready"}'
|
|
154
|
+
mock_response.json.return_value = {"id": "sbx-info01", "state": "ready"}
|
|
155
|
+
client.session.get.return_value = mock_response
|
|
156
|
+
|
|
157
|
+
info = sandbox.info()
|
|
158
|
+
|
|
159
|
+
call_args = client.session.get.call_args
|
|
160
|
+
self.assertIn("/v1/sandboxes/sbx-info01", call_args[0][0])
|
|
161
|
+
|
|
162
|
+
self.assertIsInstance(info, SandboxInfo)
|
|
163
|
+
self.assertEqual(info.id, "sbx-info01")
|
|
164
|
+
self.assertEqual(info.state, "ready")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class TestFilesAPI(unittest.TestCase):
|
|
168
|
+
"""Tests for sandbox.files.*"""
|
|
169
|
+
|
|
170
|
+
def _make_sandbox(self):
|
|
171
|
+
client = Client("http://localhost:8080")
|
|
172
|
+
client.session = MagicMock()
|
|
173
|
+
return SandboxHandle(client, "sbx-fs01"), client
|
|
174
|
+
|
|
175
|
+
def _mock_put(self, client, body):
|
|
176
|
+
resp = MagicMock()
|
|
177
|
+
resp.status_code = 200
|
|
178
|
+
resp.text = body
|
|
179
|
+
resp.json.return_value = __import__("json").loads(body)
|
|
180
|
+
client.session.put = MagicMock(return_value=resp)
|
|
181
|
+
|
|
182
|
+
def _mock_get(self, client, body):
|
|
183
|
+
resp = MagicMock()
|
|
184
|
+
resp.status_code = 200
|
|
185
|
+
resp.text = body
|
|
186
|
+
resp.json.return_value = __import__("json").loads(body)
|
|
187
|
+
client.session.get = MagicMock(return_value=resp)
|
|
188
|
+
|
|
189
|
+
def test_read_returns_text_by_default(self):
|
|
190
|
+
sandbox, client = self._make_sandbox()
|
|
191
|
+
payload = __import__("json").dumps({"data": list(b"hello")})
|
|
192
|
+
self._mock_get(client, payload)
|
|
193
|
+
content = sandbox.files.read("/tmp/hello.txt")
|
|
194
|
+
self.assertIsInstance(content, str)
|
|
195
|
+
self.assertEqual(content, "hello")
|
|
196
|
+
|
|
197
|
+
def test_read_returns_bytes_when_requested(self):
|
|
198
|
+
sandbox, client = self._make_sandbox()
|
|
199
|
+
payload = __import__("json").dumps({"data": list(b"hello")})
|
|
200
|
+
self._mock_get(client, payload)
|
|
201
|
+
content = sandbox.files.read("/tmp/hello.txt", as_bytes=True)
|
|
202
|
+
self.assertIsInstance(content, bytes)
|
|
203
|
+
self.assertEqual(content, b"hello")
|
|
204
|
+
|
|
205
|
+
def test_write_puts_to_files_endpoint(self):
|
|
206
|
+
sandbox, client = self._make_sandbox()
|
|
207
|
+
self._mock_put(client, '{"size": 5}')
|
|
208
|
+
n = sandbox.files.write("/tmp/hello.txt", "hello")
|
|
209
|
+
url = client.session.put.call_args[0][0]
|
|
210
|
+
self.assertIn(f"/v1/sandboxes/{sandbox.id}/files", url)
|
|
211
|
+
self.assertEqual(n, 5)
|
|
212
|
+
|
|
213
|
+
def test_list_returns_entry_infos(self):
|
|
214
|
+
from sandforge.types import EntryInfo
|
|
215
|
+
sandbox, client = self._make_sandbox()
|
|
216
|
+
payload = '{"entries": [{"name": "a.txt", "path": "/tmp/a.txt", "size": 3, "isDir": false, "modTime": "2025-01-01T00:00:00Z"}]}'
|
|
217
|
+
self._mock_get(client, payload)
|
|
218
|
+
entries = sandbox.files.list("/tmp")
|
|
219
|
+
self.assertEqual(len(entries), 1)
|
|
220
|
+
self.assertIsInstance(entries[0], EntryInfo)
|
|
221
|
+
self.assertEqual(entries[0].name, "a.txt")
|
|
222
|
+
|
|
223
|
+
def test_stat_returns_entry_info(self):
|
|
224
|
+
from sandforge.types import EntryInfo
|
|
225
|
+
sandbox, client = self._make_sandbox()
|
|
226
|
+
payload = '{"name": "a.txt", "path": "/tmp/a.txt", "size": 3, "isDir": false, "modTime": "2025-01-01T00:00:00Z"}'
|
|
227
|
+
self._mock_get(client, payload)
|
|
228
|
+
info = sandbox.files.stat("/tmp/a.txt")
|
|
229
|
+
self.assertIsInstance(info, EntryInfo)
|
|
230
|
+
self.assertEqual(info.size, 3)
|
|
231
|
+
|
|
232
|
+
def test_exists_true_on_success(self):
|
|
233
|
+
sandbox, client = self._make_sandbox()
|
|
234
|
+
payload = '{"name": "a.txt", "path": "/tmp/a.txt", "size": 3, "isDir": false, "modTime": "2025-01-01T00:00:00Z"}'
|
|
235
|
+
self._mock_get(client, payload)
|
|
236
|
+
self.assertTrue(sandbox.files.exists("/tmp/a.txt"))
|
|
237
|
+
|
|
238
|
+
def test_exists_false_on_error(self):
|
|
239
|
+
sandbox, client = self._make_sandbox()
|
|
240
|
+
resp = MagicMock()
|
|
241
|
+
resp.status_code = 422
|
|
242
|
+
resp.text = '{"error": "not found"}'
|
|
243
|
+
resp.json.return_value = {"error": "not found"}
|
|
244
|
+
client.session.get = MagicMock(return_value=resp)
|
|
245
|
+
self.assertFalse(sandbox.files.exists("/tmp/missing.txt"))
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
class TestGitAPI(unittest.TestCase):
|
|
249
|
+
"""Tests for sandbox.git.*"""
|
|
250
|
+
|
|
251
|
+
def _make_sandbox(self):
|
|
252
|
+
client = Client("http://localhost:8080")
|
|
253
|
+
client.session = MagicMock()
|
|
254
|
+
return SandboxHandle(client, "sbx-git01"), client
|
|
255
|
+
|
|
256
|
+
def _mock_exec(self, client, stdout="", exit_code=0):
|
|
257
|
+
resp = MagicMock()
|
|
258
|
+
resp.status_code = 200
|
|
259
|
+
body = __import__("json").dumps({"exit_code": exit_code, "stdout": stdout, "stderr": ""})
|
|
260
|
+
resp.text = body
|
|
261
|
+
resp.json.return_value = __import__("json").loads(body)
|
|
262
|
+
client.session.post = MagicMock(return_value=resp)
|
|
263
|
+
|
|
264
|
+
def test_clone_runs_git_clone(self):
|
|
265
|
+
sandbox, client = self._make_sandbox()
|
|
266
|
+
self._mock_exec(client)
|
|
267
|
+
sandbox.git.clone("https://github.com/example/repo.git")
|
|
268
|
+
payload = client.session.post.call_args[1]["json"]
|
|
269
|
+
self.assertEqual(payload["command"][0], "git")
|
|
270
|
+
self.assertIn("clone", payload["command"])
|
|
271
|
+
|
|
272
|
+
def test_init_runs_git_init(self):
|
|
273
|
+
sandbox, client = self._make_sandbox()
|
|
274
|
+
self._mock_exec(client)
|
|
275
|
+
sandbox.git.init("/workspace")
|
|
276
|
+
payload = client.session.post.call_args[1]["json"]
|
|
277
|
+
self.assertEqual(payload["command"], ["git", "init"])
|
|
278
|
+
self.assertEqual(payload["cwd"], "/workspace")
|
|
279
|
+
|
|
280
|
+
def test_status_returns_git_status(self):
|
|
281
|
+
from sandforge.types import GitStatus
|
|
282
|
+
sandbox, client = self._make_sandbox()
|
|
283
|
+
call_count = [0]
|
|
284
|
+
responses = [
|
|
285
|
+
{"exit_code": 0, "stdout": "main\n", "stderr": ""},
|
|
286
|
+
{"exit_code": 0, "stdout": "", "stderr": ""},
|
|
287
|
+
]
|
|
288
|
+
def side_effect(url, **kwargs):
|
|
289
|
+
resp = MagicMock()
|
|
290
|
+
resp.status_code = 200
|
|
291
|
+
body = __import__("json").dumps(responses[call_count[0]])
|
|
292
|
+
resp.text = body
|
|
293
|
+
resp.json.return_value = responses[call_count[0]]
|
|
294
|
+
call_count[0] += 1
|
|
295
|
+
return resp
|
|
296
|
+
client.session.post.side_effect = side_effect
|
|
297
|
+
|
|
298
|
+
status = sandbox.git.status("/workspace")
|
|
299
|
+
self.assertIsInstance(status, GitStatus)
|
|
300
|
+
self.assertEqual(status.branch, "main")
|
|
301
|
+
self.assertTrue(status.clean)
|
|
302
|
+
|
|
303
|
+
def test_branches_parses_output(self):
|
|
304
|
+
sandbox, client = self._make_sandbox()
|
|
305
|
+
self._mock_exec(client, stdout="* main\n dev\n feature/x\n")
|
|
306
|
+
result = sandbox.git.branches("/workspace")
|
|
307
|
+
self.assertIn("main", result)
|
|
308
|
+
self.assertIn("dev", result)
|
|
309
|
+
self.assertIn("feature/x", result)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
if __name__ == "__main__":
|
|
313
|
+
unittest.main()
|