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 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,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ sandforge
2
+ tests
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()