quick-sandbox 0.11.1__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.
- quick_sandbox-0.11.1.dist-info/METADATA +137 -0
- quick_sandbox-0.11.1.dist-info/RECORD +16 -0
- quick_sandbox-0.11.1.dist-info/WHEEL +4 -0
- quick_sandbox-0.11.1.dist-info/entry_points.txt +2 -0
- quicksand/__init__.py +158 -0
- quicksand/__main__.py +3 -0
- quicksand/benchmark.py +256 -0
- quicksand/cli/__init__.py +58 -0
- quicksand/cli/clean.py +78 -0
- quicksand/cli/dev.py +124 -0
- quicksand/cli/images.py +192 -0
- quicksand/cli/install.py +643 -0
- quicksand/cli/qemu.py +78 -0
- quicksand/cli/release_save.py +275 -0
- quicksand/cli/run.py +287 -0
- quicksand/cli/uninstall.py +99 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: quick-sandbox
|
|
3
|
+
Version: 0.11.1
|
|
4
|
+
Summary: A VM harness for AI agents that works on macOS, Linux, and Windows.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Requires-Dist: quicksand-core<0.12.0,>=0.11.1
|
|
8
|
+
Provides-Extra: all
|
|
9
|
+
Requires-Dist: quicksand-agent>=0.3.7; extra == 'all'
|
|
10
|
+
Requires-Dist: quicksand-alpine-desktop>=0.7.5; extra == 'all'
|
|
11
|
+
Requires-Dist: quicksand-alpine>=0.7.2; extra == 'all'
|
|
12
|
+
Requires-Dist: quicksand-cua>=0.2.7; extra == 'all'
|
|
13
|
+
Requires-Dist: quicksand-image-tools>=0.3.2; extra == 'all'
|
|
14
|
+
Requires-Dist: quicksand-qemu>=0.4.3; extra == 'all'
|
|
15
|
+
Requires-Dist: quicksand-ubuntu-desktop>=0.7.5; extra == 'all'
|
|
16
|
+
Requires-Dist: quicksand-ubuntu>=0.7.2; extra == 'all'
|
|
17
|
+
Provides-Extra: alpine
|
|
18
|
+
Requires-Dist: quicksand-alpine>=0.7.2; extra == 'alpine'
|
|
19
|
+
Provides-Extra: alpine-desktop
|
|
20
|
+
Requires-Dist: quicksand-alpine-desktop>=0.7.5; extra == 'alpine-desktop'
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: quicksand-base-scaffold>=0.1.3; extra == 'dev'
|
|
23
|
+
Requires-Dist: quicksand-image-tools>=0.3.2; extra == 'dev'
|
|
24
|
+
Requires-Dist: quicksand-overlay-scaffold>=0.1.4; extra == 'dev'
|
|
25
|
+
Provides-Extra: qemu
|
|
26
|
+
Requires-Dist: quicksand-qemu>=0.4.3; extra == 'qemu'
|
|
27
|
+
Provides-Extra: quicksand-agent
|
|
28
|
+
Requires-Dist: quicksand-agent>=0.3.7; extra == 'quicksand-agent'
|
|
29
|
+
Provides-Extra: quicksand-cua
|
|
30
|
+
Requires-Dist: quicksand-cua>=0.2.7; extra == 'quicksand-cua'
|
|
31
|
+
Provides-Extra: ubuntu
|
|
32
|
+
Requires-Dist: quicksand-ubuntu>=0.7.2; extra == 'ubuntu'
|
|
33
|
+
Provides-Extra: ubuntu-desktop
|
|
34
|
+
Requires-Dist: quicksand-ubuntu-desktop>=0.7.5; extra == 'ubuntu-desktop'
|
|
35
|
+
Description-Content-Type: text/markdown
|
|
36
|
+
|
|
37
|
+
# Quicksand
|
|
38
|
+
|
|
39
|
+
Quicksand is a VM harness for AI agents that works on macOS, Linux, and Windows.
|
|
40
|
+
|
|
41
|
+
## Quick Start
|
|
42
|
+
|
|
43
|
+
**1. Install quicksand**
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pip install 'quick-sandbox[qemu,ubuntu]'
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
**2. Run the sandbox in python**
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
import asyncio
|
|
53
|
+
from quicksand import UbuntuSandbox
|
|
54
|
+
|
|
55
|
+
async def main():
|
|
56
|
+
async with UbuntuSandbox() as sb:
|
|
57
|
+
result = await sb.execute("echo 'Hello from the sandbox!'")
|
|
58
|
+
print(result.stdout)
|
|
59
|
+
|
|
60
|
+
asyncio.run(main())
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
That's it. Docker? Don't need it. WSL2? Nope. Batteries? Included.
|
|
64
|
+
|
|
65
|
+
## Usage
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
import asyncio
|
|
69
|
+
from quicksand import Sandbox, Mount, NetworkMode
|
|
70
|
+
|
|
71
|
+
async def main():
|
|
72
|
+
async with Sandbox(
|
|
73
|
+
image="ubuntu",
|
|
74
|
+
mounts=[Mount("./workspace", "/mnt/workspace")],
|
|
75
|
+
network_mode=NetworkMode.FULL, # Full internet access
|
|
76
|
+
) as sb:
|
|
77
|
+
await sb.execute("pip install requests")
|
|
78
|
+
await sb.execute("python /mnt/workspace/script.py")
|
|
79
|
+
print((await sb.execute("cat /tmp/output.txt")).stdout)
|
|
80
|
+
await sb.save("my-save") # Save disk state, VM keeps running
|
|
81
|
+
|
|
82
|
+
asyncio.run(main())
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
See [examples/](examples/) for more. For implementation details, see [Under the Hood](../../docs/under-the-hood/).
|
|
86
|
+
|
|
87
|
+
## How It Works
|
|
88
|
+
|
|
89
|
+
```mermaid
|
|
90
|
+
graph LR
|
|
91
|
+
subgraph Host["Host"]
|
|
92
|
+
HostFiles["Host Files"]
|
|
93
|
+
subgraph Process["Python Process"]
|
|
94
|
+
Code["Your Code"]
|
|
95
|
+
subgraph SB["quicksand.Sandbox"]
|
|
96
|
+
TCP["TCP Client"]
|
|
97
|
+
FileSync["FileSync (SMB/CIFS)"]
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
subgraph QEMU["QEMU Process (bundled)"]
|
|
101
|
+
subgraph Accel["KVM / HVF / WHPX (host)"]
|
|
102
|
+
subgraph VM["Linux VM (bundled)"]
|
|
103
|
+
Agent["quicksand-guest-agent"]
|
|
104
|
+
Mount["/mnt/data"]
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
Code <-->|".execute"| SB
|
|
111
|
+
SB -->|launches| QEMU
|
|
112
|
+
TCP <-->|commands| Agent
|
|
113
|
+
HostFiles <--> FileSync
|
|
114
|
+
FileSync <-.->|sync| Mount
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Each sandbox runs in a real virtual machine with hypervisor-level isolation:
|
|
118
|
+
|
|
119
|
+
| Platform | Accelerator | Machine | File Sharing | Performance |
|
|
120
|
+
|----------|-------------|---------|--------------|-------------|
|
|
121
|
+
| Linux x86_64 | KVM | q35 | SMB/CIFS | io_uring + IOThreads |
|
|
122
|
+
| Linux ARM64 | KVM | virt | SMB/CIFS | io_uring + IOThreads |
|
|
123
|
+
| macOS | HVF | q35/virt | SMB/CIFS | IOThreads |
|
|
124
|
+
| Windows | WHPX | q35 | SMB/CIFS | IOThreads |
|
|
125
|
+
|
|
126
|
+
Key components:
|
|
127
|
+
- **Bundled QEMU**: No system installation required
|
|
128
|
+
- **Guest agent**: Lightweight TCP server for command execution
|
|
129
|
+
- **Disposable overlays**: Base image unchanged, writes go to temp overlay
|
|
130
|
+
- **SMB/CIFS mounts**: Mount host directories into the VM
|
|
131
|
+
- **Platform optimizations**: io_uring (~50% lower disk latency), IOThreads
|
|
132
|
+
|
|
133
|
+
## Requirements
|
|
134
|
+
|
|
135
|
+
- Python 3.11+
|
|
136
|
+
- No system dependencies (QEMU is bundled)
|
|
137
|
+
- For custom images: Docker
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
quicksand/__init__.py,sha256=hwdfDVORlg5ZmCRpbEwWaxdPQwVo4Qdxd0x-YjUAzF4,4450
|
|
2
|
+
quicksand/__main__.py,sha256=l56FIy4SIE8GBMQCYIzHqIo3m1CKat1RArihLJN3gsg,39
|
|
3
|
+
quicksand/benchmark.py,sha256=fut9Fwxv3UYGwpEAHSS62_mOgdDo0OABcusEYHxkxU0,7960
|
|
4
|
+
quicksand/cli/__init__.py,sha256=iRq95BmJGr1Eal3W_z_0kJNB_lFwKroOlNoJxrl8M74,1511
|
|
5
|
+
quicksand/cli/clean.py,sha256=YUvemKj-4RzltKVxnCOt4XAs4d3Zz9Gm3jgPTKZgdzs,1805
|
|
6
|
+
quicksand/cli/dev.py,sha256=huscVWozDCzht05Ha2YEvER8IR-ukFeWuHIqyy_Yq38,4431
|
|
7
|
+
quicksand/cli/images.py,sha256=vyeoky_YyNlfY7slwZaKt5bbOUsbxvahXn0kwKTtIOk,5790
|
|
8
|
+
quicksand/cli/install.py,sha256=3QwXDdXTSH_bucihk9bB6lMFcRGwrG117QBCjaCK4MI,21977
|
|
9
|
+
quicksand/cli/qemu.py,sha256=H7RdlVvBMWDAKFiNQjBTcZ8JepggtXvGLJOD9Cnmz60,2065
|
|
10
|
+
quicksand/cli/release_save.py,sha256=x4GGzKccuYfpwdSwozQpF8KQYnpvCqA4-cggAM1gJ2Q,8725
|
|
11
|
+
quicksand/cli/run.py,sha256=jDTYvBVfQjj88q1W1wk7ot3KcuzirHMzn0d5V8TOiG4,8584
|
|
12
|
+
quicksand/cli/uninstall.py,sha256=YAqQrLQJuKvbBBNuhQkE0fpSR_K2_32eVgP_REWF1JU,2408
|
|
13
|
+
quick_sandbox-0.11.1.dist-info/METADATA,sha256=FO9gFROHvZa8-bgCwaU8-un4mZv84xJ9SluoyMitqoU,4443
|
|
14
|
+
quick_sandbox-0.11.1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
15
|
+
quick_sandbox-0.11.1.dist-info/entry_points.txt,sha256=a2k3tA0k2ebK1AC8O5qkKbbTHCU7Q49d9LtAd5D3qtc,49
|
|
16
|
+
quick_sandbox-0.11.1.dist-info/RECORD,,
|
quicksand/__init__.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Quicksand is a VM harness for AI agents.
|
|
3
|
+
|
|
4
|
+
It gives agents a real computer to work with. They can install packages, run
|
|
5
|
+
scripts, browse the web, and manipulate files inside a sandboxed VM.
|
|
6
|
+
|
|
7
|
+
Quick start (pip install quick-sandbox[ubuntu]):
|
|
8
|
+
import asyncio
|
|
9
|
+
from quicksand import Sandbox
|
|
10
|
+
|
|
11
|
+
async def main():
|
|
12
|
+
async with Sandbox(image="ubuntu") as sb:
|
|
13
|
+
result = await sb.execute("ls -la /")
|
|
14
|
+
print(result.stdout)
|
|
15
|
+
|
|
16
|
+
asyncio.run(main())
|
|
17
|
+
|
|
18
|
+
Or with Alpine (pip install quick-sandbox[alpine]):
|
|
19
|
+
from quicksand import Sandbox
|
|
20
|
+
|
|
21
|
+
async with Sandbox(image="alpine") as sb:
|
|
22
|
+
result = await sb.execute("cat /etc/os-release")
|
|
23
|
+
|
|
24
|
+
Or with custom configuration:
|
|
25
|
+
from quicksand import Sandbox
|
|
26
|
+
|
|
27
|
+
async with Sandbox(image="ubuntu", memory="2G", cpus=4) as sb:
|
|
28
|
+
result = await sb.execute("cat /etc/os-release")
|
|
29
|
+
|
|
30
|
+
Install extras:
|
|
31
|
+
pip install quick-sandbox[ubuntu] # Bundled Ubuntu image (~341MB)
|
|
32
|
+
pip install quick-sandbox[alpine] # Bundled Alpine image (~78MB, faster boot)
|
|
33
|
+
pip install quick-sandbox[dev] # Build custom images from Dockerfiles
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
# Re-export everything from quicksand-core
|
|
37
|
+
from quicksand_core import (
|
|
38
|
+
OS,
|
|
39
|
+
Accelerator,
|
|
40
|
+
AcceleratorStatus,
|
|
41
|
+
Architecture,
|
|
42
|
+
BootTiming,
|
|
43
|
+
ExecuteResult,
|
|
44
|
+
Key,
|
|
45
|
+
MachineType,
|
|
46
|
+
Mount,
|
|
47
|
+
MountType,
|
|
48
|
+
NetworkMode,
|
|
49
|
+
PlatformConfig,
|
|
50
|
+
PortForward,
|
|
51
|
+
RuntimeInfo,
|
|
52
|
+
Sandbox,
|
|
53
|
+
SandboxConfig,
|
|
54
|
+
SandboxConfigParams,
|
|
55
|
+
SaveManifest,
|
|
56
|
+
detect_accelerator,
|
|
57
|
+
ensure_runtime,
|
|
58
|
+
get_accelerator,
|
|
59
|
+
get_machine_type,
|
|
60
|
+
get_platform_config,
|
|
61
|
+
get_runtime,
|
|
62
|
+
install_qemu,
|
|
63
|
+
is_runtime_available,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
from quicksand.cli.install import install
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class _MissingOptionalSandbox:
|
|
70
|
+
"""Placeholder for optional sandbox types that aren't installed."""
|
|
71
|
+
|
|
72
|
+
def __init__(self, install_extra: str, sandbox_type: str):
|
|
73
|
+
self._install_extra = install_extra
|
|
74
|
+
self._sandbox_type = sandbox_type
|
|
75
|
+
|
|
76
|
+
def __call__(self, *args, **kwargs):
|
|
77
|
+
raise ImportError(
|
|
78
|
+
f"{self._sandbox_type} requires the '{self._install_extra}' extra.\n"
|
|
79
|
+
f"Install with: pip install 'quicksand[{self._install_extra}]'"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
def __repr__(self):
|
|
83
|
+
return f"<{self._sandbox_type}: not installed>"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# Conditionally import image extras if available
|
|
87
|
+
try:
|
|
88
|
+
from quicksand_ubuntu import UbuntuSandbox
|
|
89
|
+
except ImportError:
|
|
90
|
+
UbuntuSandbox = _MissingOptionalSandbox("ubuntu", "UbuntuSandbox") # type: ignore[misc, assignment] # ty: ignore[invalid-assignment]
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
from quicksand_alpine import AlpineSandbox
|
|
94
|
+
except ImportError:
|
|
95
|
+
AlpineSandbox = _MissingOptionalSandbox("alpine", "AlpineSandbox") # type: ignore[misc, assignment] # ty: ignore[invalid-assignment]
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
from quicksand_alpine_desktop import AlpineDesktopSandbox
|
|
99
|
+
except ImportError:
|
|
100
|
+
AlpineDesktopSandbox = _MissingOptionalSandbox("alpine-desktop", "AlpineDesktopSandbox") # type: ignore[misc, assignment] # ty: ignore[invalid-assignment]
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
from quicksand_ubuntu_desktop import UbuntuDesktopSandbox
|
|
104
|
+
except ImportError:
|
|
105
|
+
UbuntuDesktopSandbox = _MissingOptionalSandbox("ubuntu-desktop", "UbuntuDesktopSandbox") # type: ignore[misc, assignment] # ty: ignore[invalid-assignment]
|
|
106
|
+
|
|
107
|
+
__all__ = [ # noqa: RUF022
|
|
108
|
+
# Core
|
|
109
|
+
"Sandbox",
|
|
110
|
+
"SandboxConfig",
|
|
111
|
+
"SandboxConfigParams",
|
|
112
|
+
"Mount",
|
|
113
|
+
"MountType",
|
|
114
|
+
"PortForward",
|
|
115
|
+
"ExecuteResult",
|
|
116
|
+
# Boot timing
|
|
117
|
+
"BootTiming",
|
|
118
|
+
# Save
|
|
119
|
+
"SaveManifest",
|
|
120
|
+
# Runtime management
|
|
121
|
+
"get_runtime",
|
|
122
|
+
"RuntimeInfo",
|
|
123
|
+
"is_runtime_available",
|
|
124
|
+
"get_machine_type",
|
|
125
|
+
"ensure_runtime",
|
|
126
|
+
"install_qemu",
|
|
127
|
+
# Accelerator
|
|
128
|
+
"get_accelerator",
|
|
129
|
+
"detect_accelerator",
|
|
130
|
+
"AcceleratorStatus",
|
|
131
|
+
# Platform
|
|
132
|
+
"get_platform_config",
|
|
133
|
+
"PlatformConfig",
|
|
134
|
+
# Types
|
|
135
|
+
"Key",
|
|
136
|
+
"Accelerator",
|
|
137
|
+
"Architecture",
|
|
138
|
+
"MachineType",
|
|
139
|
+
"NetworkMode",
|
|
140
|
+
"OS",
|
|
141
|
+
# Install API
|
|
142
|
+
"install",
|
|
143
|
+
# Ubuntu extras (available with quicksand[ubuntu])
|
|
144
|
+
"UbuntuSandbox",
|
|
145
|
+
# Alpine extras (available with quicksand[alpine])
|
|
146
|
+
"AlpineSandbox",
|
|
147
|
+
# Alpine Desktop extras (available with quicksand[alpine-desktop])
|
|
148
|
+
"AlpineDesktopSandbox",
|
|
149
|
+
# Ubuntu Desktop extras (available with quicksand[ubuntu-desktop])
|
|
150
|
+
"UbuntuDesktopSandbox",
|
|
151
|
+
]
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
from importlib.metadata import version as _get_version
|
|
155
|
+
|
|
156
|
+
__version__ = _get_version("quicksand")
|
|
157
|
+
except Exception:
|
|
158
|
+
__version__ = "0.0.0"
|
quicksand/__main__.py
ADDED
quicksand/benchmark.py
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""Benchmark sandbox boot times.
|
|
2
|
+
|
|
3
|
+
Usage::
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from quicksand.benchmark import benchmark
|
|
7
|
+
|
|
8
|
+
result = asyncio.run(benchmark("ubuntu", iterations=5))
|
|
9
|
+
print(result)
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import asyncio
|
|
16
|
+
import math
|
|
17
|
+
import sys
|
|
18
|
+
import time
|
|
19
|
+
|
|
20
|
+
from pydantic import BaseModel, ConfigDict
|
|
21
|
+
|
|
22
|
+
from quicksand import BootTiming, Mount, Sandbox, install
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class BootSample(BaseModel):
|
|
26
|
+
"""A single boot-time measurement."""
|
|
27
|
+
|
|
28
|
+
model_config = ConfigDict(frozen=True)
|
|
29
|
+
|
|
30
|
+
iteration: int
|
|
31
|
+
boot_time_s: float
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class BenchmarkResult(BaseModel):
|
|
35
|
+
"""Aggregate result of a boot-time benchmark run."""
|
|
36
|
+
|
|
37
|
+
model_config = ConfigDict(frozen=True)
|
|
38
|
+
|
|
39
|
+
image: str
|
|
40
|
+
iterations: int
|
|
41
|
+
qemu_command: list[str]
|
|
42
|
+
samples: list[BootSample]
|
|
43
|
+
boot_timing: BootTiming | None
|
|
44
|
+
min_s: float
|
|
45
|
+
p50_s: float
|
|
46
|
+
p90_s: float
|
|
47
|
+
p95_s: float
|
|
48
|
+
p99_s: float
|
|
49
|
+
max_s: float
|
|
50
|
+
|
|
51
|
+
def __str__(self) -> str:
|
|
52
|
+
lines = [
|
|
53
|
+
"Quicksand Boot Benchmark",
|
|
54
|
+
f" Image: {self.image}",
|
|
55
|
+
f" Iterations: {self.iterations}",
|
|
56
|
+
"",
|
|
57
|
+
"QEMU Command:",
|
|
58
|
+
self._format_qemu_command(),
|
|
59
|
+
"",
|
|
60
|
+
]
|
|
61
|
+
for s in self.samples:
|
|
62
|
+
bar_len = int(min(s.boot_time_s / self.max_s, 1.0) * 20)
|
|
63
|
+
bar = "█" * bar_len + "░" * (20 - bar_len)
|
|
64
|
+
lines.append(f" #{s.iteration + 1:<3} {bar} {s.boot_time_s:>7.3f}s")
|
|
65
|
+
lines.append("")
|
|
66
|
+
for label, val in [
|
|
67
|
+
("min", self.min_s),
|
|
68
|
+
("p50", self.p50_s),
|
|
69
|
+
("p90", self.p90_s),
|
|
70
|
+
("p95", self.p95_s),
|
|
71
|
+
("p99", self.p99_s),
|
|
72
|
+
("max", self.max_s),
|
|
73
|
+
]:
|
|
74
|
+
lines.append(f" {label:<4} {val:>7.3f}s")
|
|
75
|
+
if self.boot_timing:
|
|
76
|
+
lines.append("")
|
|
77
|
+
lines.append("Boot Phase Breakdown (last run):")
|
|
78
|
+
lines.append(str(self.boot_timing))
|
|
79
|
+
return "\n".join(lines)
|
|
80
|
+
|
|
81
|
+
def _format_qemu_command(self) -> str:
|
|
82
|
+
"""Format the QEMU command as a readable, arg-per-line shell command."""
|
|
83
|
+
if not self.qemu_command:
|
|
84
|
+
return " (not captured)"
|
|
85
|
+
parts = list(self.qemu_command)
|
|
86
|
+
result_lines: list[str] = [f" {parts[0]}"]
|
|
87
|
+
i = 1
|
|
88
|
+
while i < len(parts):
|
|
89
|
+
arg = parts[i]
|
|
90
|
+
if arg.startswith("-") and i + 1 < len(parts) and not parts[i + 1].startswith("-"):
|
|
91
|
+
result_lines.append(f" {arg} {parts[i + 1]} \\")
|
|
92
|
+
i += 2
|
|
93
|
+
else:
|
|
94
|
+
result_lines.append(f" {arg} \\")
|
|
95
|
+
i += 1
|
|
96
|
+
# Remove trailing backslash from last line
|
|
97
|
+
if result_lines:
|
|
98
|
+
result_lines[-1] = result_lines[-1].rstrip(" \\")
|
|
99
|
+
return "\n".join(result_lines)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
async def benchmark(
|
|
103
|
+
image: str,
|
|
104
|
+
*,
|
|
105
|
+
iterations: int = 5,
|
|
106
|
+
install_extras: list[str] | None = None,
|
|
107
|
+
mounts: list[Mount] | None = None,
|
|
108
|
+
) -> BenchmarkResult:
|
|
109
|
+
"""Measure boot time over *iterations* runs.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
image: Image name to benchmark (e.g. ``"ubuntu"``).
|
|
113
|
+
iterations: Number of boot cycles.
|
|
114
|
+
install_extras: Extras to ``quicksand.install()`` before benchmarking.
|
|
115
|
+
Pass an explicit list to install packages (e.g. ``["qemu", "ubuntu"]``).
|
|
116
|
+
By default nothing is installed, so a system QEMU is used.
|
|
117
|
+
mounts: Optional list of :class:`Mount` specs to include during boot.
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
A :class:`BenchmarkResult` with per-iteration samples and summary stats.
|
|
121
|
+
"""
|
|
122
|
+
if install_extras:
|
|
123
|
+
install(*install_extras)
|
|
124
|
+
|
|
125
|
+
samples: list[BootSample] = []
|
|
126
|
+
qemu_command: list[str] = []
|
|
127
|
+
boot_timing: BootTiming | None = None
|
|
128
|
+
|
|
129
|
+
for i in range(iterations):
|
|
130
|
+
_print_progress(i, iterations)
|
|
131
|
+
|
|
132
|
+
sandbox = Sandbox(image=image, mounts=mounts or [])
|
|
133
|
+
t0 = time.perf_counter()
|
|
134
|
+
await sandbox.start()
|
|
135
|
+
boot_time = time.perf_counter() - t0
|
|
136
|
+
|
|
137
|
+
if not qemu_command:
|
|
138
|
+
qemu_command = sandbox.qemu_command or []
|
|
139
|
+
boot_timing = sandbox.boot_timing
|
|
140
|
+
|
|
141
|
+
await sandbox.stop()
|
|
142
|
+
|
|
143
|
+
samples.append(
|
|
144
|
+
BootSample(
|
|
145
|
+
iteration=i,
|
|
146
|
+
boot_time_s=round(boot_time, 4),
|
|
147
|
+
)
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
_print_progress(iterations, iterations)
|
|
151
|
+
sys.stdout.write("\n")
|
|
152
|
+
|
|
153
|
+
times = sorted(s.boot_time_s for s in samples)
|
|
154
|
+
return BenchmarkResult(
|
|
155
|
+
image=image,
|
|
156
|
+
iterations=iterations,
|
|
157
|
+
qemu_command=qemu_command,
|
|
158
|
+
boot_timing=boot_timing,
|
|
159
|
+
samples=samples,
|
|
160
|
+
min_s=times[0],
|
|
161
|
+
p50_s=_percentile(times, 50),
|
|
162
|
+
p90_s=_percentile(times, 90),
|
|
163
|
+
p95_s=_percentile(times, 95),
|
|
164
|
+
p99_s=_percentile(times, 99),
|
|
165
|
+
max_s=times[-1],
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _percentile(sorted_values: list[float], p: float) -> float:
|
|
170
|
+
"""Compute the p-th percentile from a sorted list (nearest-rank)."""
|
|
171
|
+
k = (p / 100) * (len(sorted_values) - 1)
|
|
172
|
+
f = math.floor(k)
|
|
173
|
+
c = math.ceil(k)
|
|
174
|
+
if f == c:
|
|
175
|
+
return sorted_values[int(k)]
|
|
176
|
+
return round(sorted_values[f] * (c - k) + sorted_values[c] * (k - f), 4)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# ── CLI integration ───────────────────────────────────────────────────
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _print_progress(completed: int, total: int) -> None:
|
|
183
|
+
"""Print a cross-platform progress bar matching the install download style."""
|
|
184
|
+
bar_width = 30
|
|
185
|
+
filled = int(bar_width * completed / total) if total > 0 else 0
|
|
186
|
+
bar = "#" * filled + "-" * (bar_width - filled)
|
|
187
|
+
percent = (completed / total * 100) if total > 0 else 0
|
|
188
|
+
sys.stdout.write(f"\r {bar} {percent:5.1f}% ({completed}/{total} iterations)")
|
|
189
|
+
sys.stdout.flush()
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _parse_mount(value: str) -> Mount:
|
|
193
|
+
"""Parse ``HOST:GUEST[:TYPE]`` into a :class:`Mount`."""
|
|
194
|
+
parts = value.split(":")
|
|
195
|
+
if len(parts) < 2:
|
|
196
|
+
raise argparse.ArgumentTypeError(
|
|
197
|
+
f"Invalid mount format: {value} (expected HOST:GUEST[:TYPE])"
|
|
198
|
+
)
|
|
199
|
+
host, guest = parts[0], parts[1]
|
|
200
|
+
mount_type = parts[2] if len(parts) > 2 else "cifs"
|
|
201
|
+
if mount_type not in ("cifs", "9p"):
|
|
202
|
+
raise argparse.ArgumentTypeError(f"Invalid mount type: {mount_type} (expected cifs or 9p)")
|
|
203
|
+
from typing import cast
|
|
204
|
+
|
|
205
|
+
from quicksand import MountType
|
|
206
|
+
|
|
207
|
+
return Mount(host=host, guest=guest, type=cast(MountType, mount_type))
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def register_args(parser: argparse.ArgumentParser) -> None:
|
|
211
|
+
"""Add benchmark arguments to a parser."""
|
|
212
|
+
parser.add_argument("image", help="Image to benchmark (e.g. ubuntu)")
|
|
213
|
+
parser.add_argument(
|
|
214
|
+
"-n", "--iterations", type=int, default=5, help="Number of boot iterations (default: 5)"
|
|
215
|
+
)
|
|
216
|
+
parser.add_argument(
|
|
217
|
+
"-v",
|
|
218
|
+
"--mount",
|
|
219
|
+
action="append",
|
|
220
|
+
metavar="HOST:GUEST[:TYPE]",
|
|
221
|
+
help="Mount host directory into sandbox during boot (repeatable)",
|
|
222
|
+
)
|
|
223
|
+
parser.add_argument(
|
|
224
|
+
"--json", action="store_true", dest="json_output", help="Output results as JSON"
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def register(subparsers: argparse._SubParsersAction) -> None:
|
|
229
|
+
"""Register the benchmark subcommand."""
|
|
230
|
+
parser = subparsers.add_parser(
|
|
231
|
+
"benchmark",
|
|
232
|
+
help="Benchmark sandbox boot times",
|
|
233
|
+
)
|
|
234
|
+
register_args(parser)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def cmd(args: argparse.Namespace) -> int:
|
|
238
|
+
"""Run the benchmark CLI command."""
|
|
239
|
+
mounts = [_parse_mount(m) for m in args.mount] if args.mount else None
|
|
240
|
+
result = asyncio.run(benchmark(args.image, iterations=args.iterations, mounts=mounts))
|
|
241
|
+
if args.json_output:
|
|
242
|
+
print(result.model_dump_json(indent=2))
|
|
243
|
+
else:
|
|
244
|
+
print(result)
|
|
245
|
+
return 0
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
if __name__ == "__main__":
|
|
249
|
+
import argparse as _argparse
|
|
250
|
+
|
|
251
|
+
_parser = _argparse.ArgumentParser(description="Benchmark quicksand boot times")
|
|
252
|
+
register_args(_parser)
|
|
253
|
+
_args = _parser.parse_args()
|
|
254
|
+
|
|
255
|
+
_result = asyncio.run(benchmark(_args.image, iterations=_args.iterations))
|
|
256
|
+
print(_result)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Quicksand CLI - run sandboxes from the command line."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from . import clean, dev, install, qemu, release_save, run, uninstall
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def main() -> int:
|
|
12
|
+
"""Main entry point for the quicksand CLI."""
|
|
13
|
+
parser = argparse.ArgumentParser(
|
|
14
|
+
prog="quicksand",
|
|
15
|
+
description="A VM harness for AI agents.",
|
|
16
|
+
)
|
|
17
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
18
|
+
|
|
19
|
+
# Register subcommands
|
|
20
|
+
run.register(subparsers)
|
|
21
|
+
install.register(subparsers)
|
|
22
|
+
uninstall.register(subparsers)
|
|
23
|
+
clean.register(subparsers)
|
|
24
|
+
release_save.register(subparsers)
|
|
25
|
+
qemu.register(subparsers)
|
|
26
|
+
dev.register(subparsers)
|
|
27
|
+
|
|
28
|
+
from .. import benchmark as benchmark_mod
|
|
29
|
+
|
|
30
|
+
benchmark_mod.register(subparsers)
|
|
31
|
+
|
|
32
|
+
args = parser.parse_args()
|
|
33
|
+
|
|
34
|
+
if args.command == "run":
|
|
35
|
+
return run.cmd(args)
|
|
36
|
+
elif args.command == "install":
|
|
37
|
+
return install.cmd(args)
|
|
38
|
+
elif args.command == "uninstall":
|
|
39
|
+
return uninstall.cmd(args)
|
|
40
|
+
elif args.command == "release":
|
|
41
|
+
return release_save.cmd(args)
|
|
42
|
+
elif args.command == "qemu":
|
|
43
|
+
return qemu.cmd(args)
|
|
44
|
+
elif args.command == "clean":
|
|
45
|
+
return clean.cmd(args)
|
|
46
|
+
elif args.command == "dev":
|
|
47
|
+
return dev.cmd(args)
|
|
48
|
+
elif args.command == "benchmark":
|
|
49
|
+
from .. import benchmark as benchmark_mod
|
|
50
|
+
|
|
51
|
+
return benchmark_mod.cmd(args)
|
|
52
|
+
else:
|
|
53
|
+
parser.print_help()
|
|
54
|
+
return 0
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
if __name__ == "__main__":
|
|
58
|
+
sys.exit(main())
|