boxd 0.1.9.dev37__tar.gz → 0.2.0.dev39__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- boxd-0.2.0.dev39/PKG-INFO +646 -0
- boxd-0.2.0.dev39/README.md +614 -0
- {boxd-0.1.9.dev37 → boxd-0.2.0.dev39}/pyproject.toml +8 -6
- boxd-0.2.0.dev39/src/boxd/__init__.py +132 -0
- boxd-0.2.0.dev39/src/boxd/_client.py +148 -0
- boxd-0.2.0.dev39/src/boxd/_credentials.py +304 -0
- boxd-0.2.0.dev39/src/boxd/_generated/api_pb2.py +358 -0
- {boxd-0.1.9.dev37 → boxd-0.2.0.dev39}/src/boxd/_generated/api_pb2_grpc.py +458 -102
- boxd-0.2.0.dev39/src/boxd/_mappers.py +331 -0
- boxd-0.2.0.dev39/src/boxd/_requests.py +246 -0
- boxd-0.2.0.dev39/src/boxd/_streaming.py +195 -0
- boxd-0.2.0.dev39/src/boxd/_transport.py +208 -0
- boxd-0.2.0.dev39/src/boxd/_urls.py +101 -0
- {boxd-0.1.9.dev37 → boxd-0.2.0.dev39}/src/boxd/_version_check.py +31 -71
- boxd-0.2.0.dev39/src/boxd/errors.py +97 -0
- boxd-0.2.0.dev39/src/boxd/models.py +402 -0
- boxd-0.2.0.dev39/src/boxd/resources/__init__.py +15 -0
- boxd-0.2.0.dev39/src/boxd/resources/account.py +79 -0
- boxd-0.2.0.dev39/src/boxd/resources/credentials.py +97 -0
- boxd-0.2.0.dev39/src/boxd/resources/disks.py +112 -0
- boxd-0.2.0.dev39/src/boxd/resources/machines.py +1046 -0
- boxd-0.2.0.dev39/src/boxd/resources/orgs.py +27 -0
- boxd-0.2.0.dev39/src/boxd/resources/snapshots.py +83 -0
- boxd-0.2.0.dev39/src/boxd/resources/vars.py +249 -0
- boxd-0.2.0.dev39/src/boxd.egg-info/PKG-INFO +646 -0
- boxd-0.2.0.dev39/src/boxd.egg-info/SOURCES.txt +38 -0
- {boxd-0.1.9.dev37 → boxd-0.2.0.dev39}/src/boxd.egg-info/requires.txt +1 -0
- boxd-0.2.0.dev39/tests/test_credentials.py +305 -0
- boxd-0.2.0.dev39/tests/test_mappers.py +453 -0
- boxd-0.2.0.dev39/tests/test_namespaces.py +843 -0
- boxd-0.2.0.dev39/tests/test_requests.py +203 -0
- boxd-0.2.0.dev39/tests/test_streaming.py +132 -0
- boxd-0.2.0.dev39/tests/test_transport.py +298 -0
- boxd-0.2.0.dev39/tests/test_urls.py +139 -0
- boxd-0.2.0.dev39/tests/test_version_check.py +121 -0
- boxd-0.1.9.dev37/PKG-INFO +0 -499
- boxd-0.1.9.dev37/README.md +0 -468
- boxd-0.1.9.dev37/src/boxd/__init__.py +0 -131
- boxd-0.1.9.dev37/src/boxd/_generated/api_pb2.py +0 -310
- boxd-0.1.9.dev37/src/boxd/_sync.py +0 -547
- boxd-0.1.9.dev37/src/boxd/_utils.py +0 -73
- boxd-0.1.9.dev37/src/boxd/aio.py +0 -98
- boxd-0.1.9.dev37/src/boxd/auth.py +0 -118
- boxd-0.1.9.dev37/src/boxd/billing.py +0 -128
- boxd-0.1.9.dev37/src/boxd/box.py +0 -424
- boxd-0.1.9.dev37/src/boxd/boxes.py +0 -192
- boxd-0.1.9.dev37/src/boxd/client.py +0 -176
- boxd-0.1.9.dev37/src/boxd/disks.py +0 -111
- boxd-0.1.9.dev37/src/boxd/domains.py +0 -54
- boxd-0.1.9.dev37/src/boxd/errors.py +0 -62
- boxd-0.1.9.dev37/src/boxd/exec.py +0 -149
- boxd-0.1.9.dev37/src/boxd/networks.py +0 -43
- boxd-0.1.9.dev37/src/boxd/templates.py +0 -113
- boxd-0.1.9.dev37/src/boxd/tokens.py +0 -51
- boxd-0.1.9.dev37/src/boxd/types.py +0 -151
- boxd-0.1.9.dev37/src/boxd.egg-info/PKG-INFO +0 -499
- boxd-0.1.9.dev37/src/boxd.egg-info/SOURCES.txt +0 -41
- boxd-0.1.9.dev37/tests/test_auth.py +0 -33
- boxd-0.1.9.dev37/tests/test_boxes.py +0 -6
- boxd-0.1.9.dev37/tests/test_e2e.py +0 -324
- boxd-0.1.9.dev37/tests/test_e2e_v2.py +0 -93
- boxd-0.1.9.dev37/tests/test_exec.py +0 -139
- boxd-0.1.9.dev37/tests/test_files.py +0 -21
- boxd-0.1.9.dev37/tests/test_lifecycle.py +0 -21
- boxd-0.1.9.dev37/tests/test_no_shape.py +0 -67
- boxd-0.1.9.dev37/tests/test_proxies.py +0 -33
- boxd-0.1.9.dev37/tests/test_utils.py +0 -47
- boxd-0.1.9.dev37/tests/test_v2.py +0 -62
- boxd-0.1.9.dev37/tests/test_version_check.py +0 -178
- {boxd-0.1.9.dev37 → boxd-0.2.0.dev39}/LICENSE +0 -0
- {boxd-0.1.9.dev37 → boxd-0.2.0.dev39}/setup.cfg +0 -0
- {boxd-0.1.9.dev37 → boxd-0.2.0.dev39}/src/boxd/_generated/__init__.py +0 -0
- {boxd-0.1.9.dev37 → boxd-0.2.0.dev39}/src/boxd.egg-info/dependency_links.txt +0 -0
- {boxd-0.1.9.dev37 → boxd-0.2.0.dev39}/src/boxd.egg-info/top_level.txt +0 -0
|
@@ -0,0 +1,646 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: boxd
|
|
3
|
+
Version: 0.2.0.dev39
|
|
4
|
+
Summary: Python SDK for the boxd cloud VM platform
|
|
5
|
+
Author: Azin
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://boxd.sh
|
|
8
|
+
Keywords: boxd,vm,microvm,sandbox,compute,grpc,sdk
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
17
|
+
Classifier: Topic :: System :: Distributed Computing
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Requires-Dist: grpcio>=1.60
|
|
22
|
+
Requires-Dist: protobuf>=4.25
|
|
23
|
+
Requires-Dist: pydantic>=2
|
|
24
|
+
Requires-Dist: httpx<1,>=0.27
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: grpcio-tools>=1.60; extra == "dev"
|
|
27
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
28
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
29
|
+
Requires-Dist: build>=1.0; extra == "dev"
|
|
30
|
+
Requires-Dist: twine>=4.0; extra == "dev"
|
|
31
|
+
Dynamic: license-file
|
|
32
|
+
|
|
33
|
+
# boxd Python SDK
|
|
34
|
+
|
|
35
|
+
Python SDK for the [boxd](https://boxd.sh) cloud machine platform. Create
|
|
36
|
+
machines, run commands in them, move files, and manage everything around them.
|
|
37
|
+
|
|
38
|
+
Requires Python 3.10+.
|
|
39
|
+
|
|
40
|
+
## Install
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install boxd
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Quick start
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from boxd import Boxd
|
|
50
|
+
|
|
51
|
+
boxd = Boxd(api_key="bxd_...")
|
|
52
|
+
|
|
53
|
+
machine = boxd.machines.create("my-machine")
|
|
54
|
+
boxd.machines.wait_until_ready(machine.id)
|
|
55
|
+
|
|
56
|
+
result = boxd.machines.exec(machine.id, "uname -a")
|
|
57
|
+
print(result.stdout)
|
|
58
|
+
|
|
59
|
+
boxd.machines.delete(machine.id)
|
|
60
|
+
boxd.close()
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Everything follows the same shape: **`boxd.<resource>.<verb>(id, ...)`**.
|
|
64
|
+
Resources return plain data — a `Machine` has fields, not methods.
|
|
65
|
+
|
|
66
|
+
## Client
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
Boxd() # production
|
|
70
|
+
Boxd(api_key="bxd_...")
|
|
71
|
+
Boxd(base_url="https://boxd.example.com:9443") # any other cluster
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Every argument is keyword-only.
|
|
75
|
+
|
|
76
|
+
| Argument | Environment variable | Default |
|
|
77
|
+
|---|---|---|
|
|
78
|
+
| `api_key` | `BOXD_API_KEY` | — |
|
|
79
|
+
| `token` | `BOXD_TOKEN` | — |
|
|
80
|
+
| `base_url` | `BOXD_BASE_URL` (or the deprecated `BOXD_API_URL`) | `http://boxd.sh:9443` |
|
|
81
|
+
| `timeout` | — | 60.0 seconds |
|
|
82
|
+
| `max_retries` | — | 2 |
|
|
83
|
+
|
|
84
|
+
`base_url` accepts an optional scheme that controls TLS:
|
|
85
|
+
|
|
86
|
+
| Value | Transport |
|
|
87
|
+
|---|---|
|
|
88
|
+
| `http://host:port` | plaintext |
|
|
89
|
+
| `https://host:port` | TLS |
|
|
90
|
+
| bare `host:port` | TLS, except `localhost` / `127.*` |
|
|
91
|
+
|
|
92
|
+
`boxd.base_url` reports the cluster the client settled on.
|
|
93
|
+
|
|
94
|
+
The client holds a connection, so keep one around rather than making a new one
|
|
95
|
+
per call. Close it when you're done — or use it as a context manager:
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
with Boxd(api_key="bxd_...") as boxd:
|
|
99
|
+
...
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Authentication
|
|
103
|
+
|
|
104
|
+
The first of these that is present wins:
|
|
105
|
+
|
|
106
|
+
1. `token=` — used as given
|
|
107
|
+
2. `api_key=` — exchanged for a short-lived credential and kept fresh for you
|
|
108
|
+
3. `BOXD_TOKEN`, then `BOXD_API_KEY`
|
|
109
|
+
4. running inside a boxd machine — see below
|
|
110
|
+
5. otherwise `AuthenticationError`
|
|
111
|
+
|
|
112
|
+
If your key is revoked mid-session, the SDK fails fast with
|
|
113
|
+
`AuthenticationError` rather than retrying.
|
|
114
|
+
|
|
115
|
+
### Inside a machine
|
|
116
|
+
|
|
117
|
+
Inside a boxd machine, `Boxd()` authenticates automatically — no API key
|
|
118
|
+
needed, and it talks to that machine's own cluster unless you pass `base_url`.
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
from boxd import Boxd
|
|
122
|
+
|
|
123
|
+
boxd = Boxd()
|
|
124
|
+
for machine in boxd.machines.list():
|
|
125
|
+
print(machine.name, machine.status)
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
One limit: inside a **shared** machine the automatic credential can manage the
|
|
129
|
+
organization's shared machines, but cannot read environment variables or
|
|
130
|
+
secrets, and cannot reach private machines. Pass an API key for those.
|
|
131
|
+
|
|
132
|
+
## Sync and async
|
|
133
|
+
|
|
134
|
+
`Boxd` and `AsyncBoxd` are the same surface — same namespaces, same method
|
|
135
|
+
names, same arguments, same return types. Switching is `await` and an import,
|
|
136
|
+
not a rewrite.
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
from boxd import AsyncBoxd
|
|
140
|
+
|
|
141
|
+
boxd = AsyncBoxd(api_key="bxd_...")
|
|
142
|
+
|
|
143
|
+
machine = await boxd.machines.create("my-machine")
|
|
144
|
+
result = await boxd.machines.exec(machine.id, "echo hello")
|
|
145
|
+
await boxd.close()
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
`AsyncBoxd` is an async context manager too:
|
|
149
|
+
|
|
150
|
+
```python
|
|
151
|
+
async with AsyncBoxd(api_key="bxd_...") as boxd:
|
|
152
|
+
...
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Two methods stay un-awaited, because they hand back something to iterate rather
|
|
156
|
+
than a result: `stream_exec` returns the session object directly, and `logs` is
|
|
157
|
+
an async generator you drive with `async for`.
|
|
158
|
+
|
|
159
|
+
Use `AsyncBoxd` when you already have an event loop (FastAPI, asyncio scripts,
|
|
160
|
+
anyio). Use `Boxd` everywhere else — scripts, notebooks, Django views.
|
|
161
|
+
|
|
162
|
+
## Machines
|
|
163
|
+
|
|
164
|
+
```python
|
|
165
|
+
machine = boxd.machines.create(
|
|
166
|
+
"my-machine",
|
|
167
|
+
vcpu=4,
|
|
168
|
+
memory="16G",
|
|
169
|
+
env={"MODE": "production"},
|
|
170
|
+
)
|
|
171
|
+
boxd.machines.get("my-machine") # by name or id
|
|
172
|
+
boxd.machines.list() # a plain list
|
|
173
|
+
boxd.machines.list(org="acme") # one organization's machines
|
|
174
|
+
boxd.machines.list(all_contexts=True) # every org you belong to
|
|
175
|
+
boxd.machines.delete("my-machine")
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Only `name` is positional; everything else is keyword-only. The full set of
|
|
179
|
+
create options:
|
|
180
|
+
|
|
181
|
+
```python
|
|
182
|
+
boxd.machines.create(
|
|
183
|
+
"builder",
|
|
184
|
+
image="ubuntu:24.04",
|
|
185
|
+
org="acme", # create inside an organization
|
|
186
|
+
shared=True, # and make it visible to every member
|
|
187
|
+
env={"API_URL": "https://example.com"},
|
|
188
|
+
cmd=["/usr/local/bin/start"],
|
|
189
|
+
restart_policy="always", # "always" | "never"
|
|
190
|
+
vcpu=2,
|
|
191
|
+
memory="8G", # "8G", "512M", or a byte count
|
|
192
|
+
disk="100G",
|
|
193
|
+
auto_suspend_timeout=300, # seconds; 0 disables
|
|
194
|
+
auto_destroy_timeout=0,
|
|
195
|
+
ssh=True, # give the machine an SSH port
|
|
196
|
+
proxies=[ProxyEntry(name="api", port=3000)],
|
|
197
|
+
volumes=[VolumeMount(disk_id="d_...", mount_path="/data", read_only=False)],
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
boxd.machines.create() # every option is optional — cluster default image
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
`ProxyEntry` and `VolumeMount` are importable from `boxd`. A `ProxyEntry` with
|
|
204
|
+
`port=0` has its port detected inside the machine.
|
|
205
|
+
|
|
206
|
+
State:
|
|
207
|
+
|
|
208
|
+
```python
|
|
209
|
+
boxd.machines.start(id)
|
|
210
|
+
boxd.machines.stop(id)
|
|
211
|
+
boxd.machines.reboot(id)
|
|
212
|
+
boxd.machines.pause(id) # suspend to RAM — fast to resume; PauseResult(suspend_us)
|
|
213
|
+
boxd.machines.resume(id) # ResumeResult(resume_us)
|
|
214
|
+
boxd.machines.hibernate(id) # suspend to disk — cheaper, slower to wake
|
|
215
|
+
boxd.machines.wake(id)
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
Everything else:
|
|
219
|
+
|
|
220
|
+
```python
|
|
221
|
+
boxd.machines.fork("my-machine", "my-copy") # live clone
|
|
222
|
+
boxd.machines.fork(id, shared=True, vcpu=8) # same sizing options as `create`
|
|
223
|
+
boxd.machines.rename(id, "new-name") # returns the new name; reboots the machine
|
|
224
|
+
boxd.machines.share(id) # visible to your whole org
|
|
225
|
+
boxd.machines.unshare(id)
|
|
226
|
+
boxd.machines.set_auto_suspend_timeout(id, 300) # seconds idle; 0 disables
|
|
227
|
+
boxd.machines.set_auto_hibernate_timeout(id, 0)
|
|
228
|
+
boxd.machines.wait_until_ready(id)
|
|
229
|
+
boxd.machines.wait_until_ready(id, timeout=180.0, poll_interval=1.0) # seconds
|
|
230
|
+
boxd.machines.suggest_name()
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
A fork inherits the source's sizing for anything you leave unset, and is
|
|
234
|
+
private to you unless you pass `shared=True`.
|
|
235
|
+
|
|
236
|
+
`create` and `fork` return once the machine is scheduled, not once it is
|
|
237
|
+
usable. Call `wait_until_ready` before doing anything that depends on it
|
|
238
|
+
running — especially before forking it again.
|
|
239
|
+
|
|
240
|
+
### The `Machine` record
|
|
241
|
+
|
|
242
|
+
```python
|
|
243
|
+
machine.id, machine.name, machine.image_ref
|
|
244
|
+
machine.status # "pending" | "starting" | "running" | "suspended" |
|
|
245
|
+
# "hibernated" | "stopped" | "failed" | "destroyed" |
|
|
246
|
+
# "migrating"
|
|
247
|
+
machine.restart_policy # str | None
|
|
248
|
+
machine.created_at # datetime | None — None when none is on record
|
|
249
|
+
|
|
250
|
+
machine.resources.vcpu # what the machine actually got, not what you
|
|
251
|
+
machine.resources.memory_bytes # asked for — always concrete
|
|
252
|
+
machine.resources.disk_bytes
|
|
253
|
+
|
|
254
|
+
machine.org # OrgRef(id, name) | None — None = personal quota
|
|
255
|
+
machine.shared # shared with that org, or private to you
|
|
256
|
+
|
|
257
|
+
machine.access.ssh_port # int | None — None until allocated
|
|
258
|
+
machine.access.domain
|
|
259
|
+
machine.access.url # https://<name>.<domain>
|
|
260
|
+
|
|
261
|
+
machine.idle.suspend_after # seconds; 0 = that timer is disabled
|
|
262
|
+
machine.idle.hibernate_after
|
|
263
|
+
machine.idle.destroy_after
|
|
264
|
+
|
|
265
|
+
machine.source # MachineSource | None — None = booted from an image
|
|
266
|
+
machine.source.kind # "fork" | "snapshot"
|
|
267
|
+
machine.source.name # source machine, or snapshot name
|
|
268
|
+
machine.source.version # int | None — snapshots only; a fork has none
|
|
269
|
+
machine.source.id # str | None — provenance; may not resolve
|
|
270
|
+
|
|
271
|
+
machine.hibernated_at # datetime | None — None = not hibernated
|
|
272
|
+
machine.last_connected_at # datetime | None — None = never connected
|
|
273
|
+
machine.boot_time_ms # int | None — last boot; None = never booted
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
`None` always means "not set": a port that was never allocated, a boot that
|
|
277
|
+
never happened, an org you do not have. Where `0` is a real answer — a disabled
|
|
278
|
+
idle timer — it stays `0`.
|
|
279
|
+
|
|
280
|
+
`org` is the org the machine belongs to and is billed to; `shared` says whether
|
|
281
|
+
your teammates can see it. A private machine can still be org-billed, so `org`
|
|
282
|
+
set with `shared=False` is normal, not a contradiction.
|
|
283
|
+
|
|
284
|
+
`source.id` points at the machine or snapshot this one came from. It is a record
|
|
285
|
+
of where the machine came from, not a live link — **it may not resolve**, and a
|
|
286
|
+
lookup that finds nothing is normal.
|
|
287
|
+
|
|
288
|
+
`MachineStatus` is importable from `boxd` when you want the literal type; a
|
|
289
|
+
status a newer server introduces is passed through as a plain string.
|
|
290
|
+
|
|
291
|
+
### Creating from a snapshot
|
|
292
|
+
|
|
293
|
+
```python
|
|
294
|
+
boxd.snapshots.create(machine_id, "golden")
|
|
295
|
+
machine = boxd.machines.create("from-golden", from_snapshot="golden")
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
Restoring a snapshot replays the machine as it was captured, so `from_snapshot`
|
|
299
|
+
goes with `name`, `org` and the sizing options. Combining it with `image`,
|
|
300
|
+
`env`, `cmd`, `restart_policy` or `shared` raises `ValueError`.
|
|
301
|
+
|
|
302
|
+
### Exec
|
|
303
|
+
|
|
304
|
+
```python
|
|
305
|
+
result = boxd.machines.exec(id, "cargo build")
|
|
306
|
+
result.stdout # str
|
|
307
|
+
result.stderr # str — populated for non-PTY execs
|
|
308
|
+
result.exit_code # int
|
|
309
|
+
result.success # bool
|
|
310
|
+
|
|
311
|
+
boxd.machines.exec(id, ["echo", "a b"]) # a list is quoted for you
|
|
312
|
+
boxd.machines.exec(id, "env", env={"FOO": "bar"})
|
|
313
|
+
boxd.machines.exec(id, "cargo build", timeout=30) # seconds
|
|
314
|
+
|
|
315
|
+
# Under a PTY, stderr merges into stdout and `stderr` comes back empty.
|
|
316
|
+
boxd.machines.exec(id, "top -b -n1", tty=True, cols=120, rows=40)
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
`command` takes a list of argv — shell-quoted for you — or a ready-made command
|
|
320
|
+
line as a string. `timeout` gives up on the call; whatever it started inside the
|
|
321
|
+
machine may well still be running.
|
|
322
|
+
|
|
323
|
+
For anything interactive, `stream_exec` gives you a live session — the one
|
|
324
|
+
handle in the SDK, because a bidirectional stream really is stateful:
|
|
325
|
+
|
|
326
|
+
```python
|
|
327
|
+
with boxd.machines.stream_exec(id, command="bash", tty=True) as stream:
|
|
328
|
+
stream.write(b"ls\n") # bytes or str
|
|
329
|
+
stream.write_eof() # half-close stdin; the process sees EOF
|
|
330
|
+
for chunk in stream: # bytes — merged output, what a terminal would show
|
|
331
|
+
print(chunk.decode(errors="replace"), end="")
|
|
332
|
+
print("exited", stream.exit_code)
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
`stream_exec` takes `command` and the rest as keywords, and hands back the
|
|
336
|
+
session without a round trip. `exit_code` is `None` until the stream is
|
|
337
|
+
exhausted. Leaving the `with` block — or calling `close()` — ends the session.
|
|
338
|
+
|
|
339
|
+
`iter_chunks()` yields `OutputChunk(data, is_stderr)` when you need the two
|
|
340
|
+
streams apart. Under `tty=True` the terminal merges them, so everything arrives
|
|
341
|
+
as stdout — set `tty=False` if you need the split.
|
|
342
|
+
|
|
343
|
+
For a headless one-shot that reads stdin (`jq`, `cat`, `claude -p`), pass
|
|
344
|
+
`close_stdin=True` so it sees end-of-input immediately instead of hanging.
|
|
345
|
+
Combining it with `tty=True` raises `ValueError` — a shell needs stdin open.
|
|
346
|
+
|
|
347
|
+
Set the terminal size with `cols`/`rows`, and call `stream.resize(cols, rows)`
|
|
348
|
+
when the local terminal changes size:
|
|
349
|
+
|
|
350
|
+
```python
|
|
351
|
+
import shutil, signal
|
|
352
|
+
|
|
353
|
+
cols, rows = shutil.get_terminal_size()
|
|
354
|
+
stream = boxd.machines.stream_exec(id, command="htop", tty=True, cols=cols, rows=rows)
|
|
355
|
+
signal.signal(signal.SIGWINCH, lambda *_: stream.resize(*shutil.get_terminal_size()))
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
### Logs
|
|
359
|
+
|
|
360
|
+
```python
|
|
361
|
+
for chunk in boxd.machines.logs(id):
|
|
362
|
+
print(chunk.decode(errors="replace"), end="")
|
|
363
|
+
|
|
364
|
+
for chunk in boxd.machines.logs(id, follow=True): # stays open
|
|
365
|
+
...
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
### Files
|
|
369
|
+
|
|
370
|
+
```python
|
|
371
|
+
from pathlib import Path
|
|
372
|
+
|
|
373
|
+
written = boxd.machines.files.upload(id, "/app/config.json", '{"debug": true}')
|
|
374
|
+
boxd.machines.files.upload(id, "/app/data.bin", Path("local.bin").read_bytes())
|
|
375
|
+
data = boxd.machines.files.download(id, "/app/output.json") # bytes
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
`upload` takes `str` or `bytes`, streams it in chunks so large files are fine,
|
|
379
|
+
and returns the number of bytes the machine confirmed it wrote.
|
|
380
|
+
|
|
381
|
+
### Ports and proxies
|
|
382
|
+
|
|
383
|
+
```python
|
|
384
|
+
fwd = boxd.machines.ports.expose(id, 8080) # public TCP forward
|
|
385
|
+
boxd.machines.ports.expose(id, 5353, protocol="udp") # "tcp" | "udp" | "both"
|
|
386
|
+
fwd.dns, fwd.public_port, fwd.machine_port, fwd.protocol
|
|
387
|
+
fwd.machine_id, fwd.machine_name
|
|
388
|
+
boxd.machines.ports.unexpose(id, 8080) # echoes back what it removed
|
|
389
|
+
boxd.machines.ports.list(id) # one machine's forwards
|
|
390
|
+
boxd.machines.ports.list() # every forward you own
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
Connect on `dns:public_port`. Max 3 forwards per machine. Re-exposing a machine
|
|
394
|
+
port keeps its public port and just updates the protocol set; `"both"` shares
|
|
395
|
+
one public port across TCP and UDP.
|
|
396
|
+
|
|
397
|
+
`ports.list()` is account-wide — pass a machine to narrow it, or filter on
|
|
398
|
+
`.machine_id` / `.machine_name`.
|
|
399
|
+
|
|
400
|
+
```python
|
|
401
|
+
route = boxd.machines.proxies.create("my-machine", "api", 3001) # api.<machine>...
|
|
402
|
+
route.name, route.port
|
|
403
|
+
routes = boxd.machines.proxies.list("my-machine")
|
|
404
|
+
routes[0].name # str | None — None on the machine's default route
|
|
405
|
+
routes[0].domain # the hostname this route answers on
|
|
406
|
+
routes[0].port # int — where traffic actually goes
|
|
407
|
+
routes[0].port_mode # "locked" (you pinned it) | "auto" (detected for you)
|
|
408
|
+
routes[0].is_default
|
|
409
|
+
routes[0].machine_id, routes[0].machine_name
|
|
410
|
+
boxd.machines.proxies.set_port("my-machine", 3000, name="api")
|
|
411
|
+
boxd.machines.proxies.set_port("my-machine", "auto") # default route, auto-detected
|
|
412
|
+
boxd.machines.proxies.delete("my-machine", "api")
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
These take an id or a name, like everything else on `machines`. `name` is a
|
|
416
|
+
subdomain label: lowercase letters, digits and hyphens, not starting or ending
|
|
417
|
+
with one. `create` answers as soon as the route is accepted, so it confirms the
|
|
418
|
+
subdomain and the port it was pointed at; `list()` reports the full domain and
|
|
419
|
+
the resolved port.
|
|
420
|
+
|
|
421
|
+
### Checkpoints
|
|
422
|
+
|
|
423
|
+
Per-machine captures, restored in place. They are deleted with the machine.
|
|
424
|
+
|
|
425
|
+
```python
|
|
426
|
+
cp = boxd.machines.checkpoints.create(id, "before-upgrade")
|
|
427
|
+
cp.id, cp.name, cp.status
|
|
428
|
+
saved = boxd.machines.checkpoints.list(id)
|
|
429
|
+
saved[0].size_bytes
|
|
430
|
+
saved[0].created_at # datetime
|
|
431
|
+
saved[0].created_by # str | None
|
|
432
|
+
saved[0].available # restorable right now
|
|
433
|
+
boxd.machines.checkpoints.restore(id, "before-upgrade")
|
|
434
|
+
boxd.machines.checkpoints.delete(id, "before-upgrade")
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
The machine must be running to take a checkpoint. `status` is `"pending"` until
|
|
438
|
+
the artifact lands, then `"ready"` (or `"failed"`); `restore` wants one that is
|
|
439
|
+
`"ready"` and `available`.
|
|
440
|
+
|
|
441
|
+
## Environment variables and secrets
|
|
442
|
+
|
|
443
|
+
Two namespaces with identical methods. The difference is that a secret's value
|
|
444
|
+
is write-only — the server never returns it, and the `Secret` model has no
|
|
445
|
+
`value` field at all.
|
|
446
|
+
|
|
447
|
+
```python
|
|
448
|
+
boxd.env.set("MODE", "production", scope="all")
|
|
449
|
+
boxd.env.list() # EnvVar(name, scope, value)
|
|
450
|
+
boxd.env.list(org="acme")
|
|
451
|
+
boxd.env.delete("MODE", scope="all")
|
|
452
|
+
|
|
453
|
+
boxd.secrets.set("API_TOKEN", "s3cr3t", scope="shared")
|
|
454
|
+
boxd.secrets.list() # Secret(name, scope) — no value
|
|
455
|
+
boxd.secrets.delete("API_TOKEN", scope="shared")
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
`scope` defaults to `"shared"` on `set` and `delete`; `list` takes `org` only
|
|
459
|
+
and reports every scope. Pass `org="acme"` to any of these to work in an
|
|
460
|
+
organization instead of your personal scope.
|
|
461
|
+
|
|
462
|
+
`set`, `delete` and `move` each return the server's human-readable
|
|
463
|
+
confirmation of what it did.
|
|
464
|
+
|
|
465
|
+
Scope decides which machines a name applies to:
|
|
466
|
+
|
|
467
|
+
| Scope | Applies to |
|
|
468
|
+
|---|---|
|
|
469
|
+
| `private` | only your machines in that organization |
|
|
470
|
+
| `shared` | the organization's shared machines |
|
|
471
|
+
| `all` | every machine in the organization |
|
|
472
|
+
|
|
473
|
+
Scope is part of a name's identity — the same name can exist in several scopes
|
|
474
|
+
at once — so changing it is a `move` between two addresses, and both ends are
|
|
475
|
+
required:
|
|
476
|
+
|
|
477
|
+
```python
|
|
478
|
+
boxd.secrets.move("API_TOKEN", from_scope="private", to_scope="shared")
|
|
479
|
+
```
|
|
480
|
+
|
|
481
|
+
Calling it twice fails the second time. Environment variables and secrets share
|
|
482
|
+
one namespace within a scope, so an environment variable can block a secret of
|
|
483
|
+
the same name moving in, and vice versa.
|
|
484
|
+
|
|
485
|
+
## Snapshots and disks
|
|
486
|
+
|
|
487
|
+
```python
|
|
488
|
+
snap = boxd.snapshots.create(machine_id, "golden") # re-saving bumps the version
|
|
489
|
+
snap.id, snap.name, snap.version, snap.status
|
|
490
|
+
boxd.snapshots.get("golden") # by name or id
|
|
491
|
+
boxd.snapshots.list()
|
|
492
|
+
boxd.snapshots.delete("golden")
|
|
493
|
+
boxd.snapshots.list(org="acme") # `org` works on get/list/delete too
|
|
494
|
+
|
|
495
|
+
disk = boxd.disks.create("data", "10G") # bytes or a human string
|
|
496
|
+
disk.id, disk.name, disk.size_bytes, disk.status
|
|
497
|
+
boxd.disks.attach(disk.id, machine_id, "/mnt/data")
|
|
498
|
+
boxd.disks.attach(disk.id, machine_id, "/mnt/data", read_only=True)
|
|
499
|
+
boxd.disks.detach(disk.id, machine_id)
|
|
500
|
+
boxd.disks.list()
|
|
501
|
+
boxd.disks.delete(disk.id)
|
|
502
|
+
```
|
|
503
|
+
|
|
504
|
+
The machine must be running to snapshot it, and `create` answers before the
|
|
505
|
+
artifact lands — `status` is `"pending"` until it does. Snapshots stay inside
|
|
506
|
+
one organization.
|
|
507
|
+
|
|
508
|
+
A disk is always created writable; read-only is chosen per attachment. A disk
|
|
509
|
+
can be attached to only one machine at a time.
|
|
510
|
+
|
|
511
|
+
`create` confirms only what the server can answer immediately; the full records
|
|
512
|
+
come back from `get` and `list`:
|
|
513
|
+
|
|
514
|
+
```python
|
|
515
|
+
snapshot = boxd.snapshots.get("golden")
|
|
516
|
+
snapshot.id, snapshot.name
|
|
517
|
+
snapshot.version # int | None — latest ready version; None = nothing captured yet
|
|
518
|
+
snapshot.status # "pending" | "ready" | "failed"
|
|
519
|
+
snapshot.size_bytes
|
|
520
|
+
snapshot.created_at # datetime | None — the first capture; it stays put
|
|
521
|
+
snapshot.updated_at # datetime | None — the most recent capture
|
|
522
|
+
snapshot.vcpu # the sizing the machine was captured at
|
|
523
|
+
snapshot.memory_bytes
|
|
524
|
+
snapshot.use_count # machines restored from it so far
|
|
525
|
+
|
|
526
|
+
volume = boxd.disks.list()[0]
|
|
527
|
+
volume.id, volume.name, volume.size_bytes
|
|
528
|
+
volume.status # "creating" | "ready" | "destroyed" — attach once "ready"
|
|
529
|
+
volume.created_at # datetime | None
|
|
530
|
+
volume.attachments # [DiskAttachment(machine_id, machine_name, mount_path, mount_mode)]
|
|
531
|
+
# mount_mode is "ro" or "rw"
|
|
532
|
+
```
|
|
533
|
+
|
|
534
|
+
## Organizations, credentials, account
|
|
535
|
+
|
|
536
|
+
```python
|
|
537
|
+
orgs = boxd.orgs.list() # a plain list
|
|
538
|
+
orgs[0].id
|
|
539
|
+
orgs[0].name # display label — it can repeat across organizations
|
|
540
|
+
orgs[0].slug # the organization's unique key
|
|
541
|
+
orgs[0].is_admin # you administer it
|
|
542
|
+
orgs[0].is_default # where your personal machines are billed
|
|
543
|
+
```
|
|
544
|
+
|
|
545
|
+
Anywhere a call takes `org`, it accepts an organization's name or id.
|
|
546
|
+
|
|
547
|
+
```python
|
|
548
|
+
key = boxd.api_keys.create(
|
|
549
|
+
"ci",
|
|
550
|
+
org="acme", # the organization the key is fenced to
|
|
551
|
+
kind="member", # "member" (default) acts as you within that org;
|
|
552
|
+
# "org" is a userless service credential, limited
|
|
553
|
+
# to the org's shared fleet, org admin only
|
|
554
|
+
expires_in=60 * 60 * 24 * 30, # seconds; 0 for no expiry
|
|
555
|
+
)
|
|
556
|
+
key.id
|
|
557
|
+
key.api_key # the raw key — shown once, store it now
|
|
558
|
+
key.expires_at # datetime | None
|
|
559
|
+
|
|
560
|
+
keys = boxd.api_keys.list()
|
|
561
|
+
keys[0].name, keys[0].key_prefix, keys[0].created_at
|
|
562
|
+
keys[0].last_used_at # datetime | None — None = never used
|
|
563
|
+
keys[0].expires_at # datetime | None — None = no expiry
|
|
564
|
+
keys[0].org, keys[0].kind # "member" | "org"
|
|
565
|
+
boxd.api_keys.delete(key.id)
|
|
566
|
+
```
|
|
567
|
+
|
|
568
|
+
Every key is fenced to exactly one organization. Deleting one takes effect
|
|
569
|
+
immediately.
|
|
570
|
+
|
|
571
|
+
```python
|
|
572
|
+
from pathlib import Path
|
|
573
|
+
|
|
574
|
+
me = boxd.account.get()
|
|
575
|
+
me.user_id
|
|
576
|
+
me.display_name # str | None — falls back to `user_id`
|
|
577
|
+
me.pubkey_fingerprints # list[str]
|
|
578
|
+
me.billing.subscription_status # "active", "trialing", … | None
|
|
579
|
+
me.billing.past_due_since # datetime | None
|
|
580
|
+
me.billing.max_vms # effective quota
|
|
581
|
+
me.billing.vcpu, me.billing.memory_bytes
|
|
582
|
+
|
|
583
|
+
pubkey = (Path.home() / ".ssh/id_ed25519.pub").read_text()
|
|
584
|
+
boxd.account.link_ssh_key(pubkey)
|
|
585
|
+
boxd.account.link_ssh_key(
|
|
586
|
+
pubkey,
|
|
587
|
+
device_id="laptop", # one key kept per device — re-linking replaces it
|
|
588
|
+
label="MacBook Pro", # shown wherever the device is listed
|
|
589
|
+
)
|
|
590
|
+
|
|
591
|
+
cfg = boxd.account.config()
|
|
592
|
+
cfg.default_image, cfg.zone
|
|
593
|
+
```
|
|
594
|
+
|
|
595
|
+
## Errors
|
|
596
|
+
|
|
597
|
+
```python
|
|
598
|
+
from boxd import (
|
|
599
|
+
BoxdError, # base class — catch this to catch everything
|
|
600
|
+
AuthenticationError, # no usable credential, or it was rejected
|
|
601
|
+
PermissionDeniedError, # authenticated, but not allowed
|
|
602
|
+
NotFoundError,
|
|
603
|
+
ConflictError, # already exists, or fights the current state
|
|
604
|
+
RateLimitError, # rate limit or quota
|
|
605
|
+
APIStatusError, # any other error from the server
|
|
606
|
+
APIConnectionError, # could not reach the server
|
|
607
|
+
)
|
|
608
|
+
|
|
609
|
+
try:
|
|
610
|
+
boxd.machines.get("nope")
|
|
611
|
+
except NotFoundError:
|
|
612
|
+
...
|
|
613
|
+
```
|
|
614
|
+
|
|
615
|
+
Every error carries `.message`, `.code` (the canonical status name, e.g.
|
|
616
|
+
`"not_found"`) and `.grpc_code`, the numeric
|
|
617
|
+
[status code](https://grpc.github.io/grpc/core/md_doc_statuscodes.html).
|
|
618
|
+
|
|
619
|
+
Connection failures are retried with exponential backoff, `max_retries` times.
|
|
620
|
+
Timeouts are never retried — the server may already have applied the request —
|
|
621
|
+
and neither is `AuthenticationError`.
|
|
622
|
+
|
|
623
|
+
## Update notices
|
|
624
|
+
|
|
625
|
+
The SDK prints a one-time note to stderr if the server reports a newer release:
|
|
626
|
+
|
|
627
|
+
```
|
|
628
|
+
A new version of boxd is available (v0.2.0, you have v0.1.9). Update with:
|
|
629
|
+
pip install --upgrade boxd
|
|
630
|
+
```
|
|
631
|
+
|
|
632
|
+
It fires at most once per process and never causes a request to fail.
|
|
633
|
+
|
|
634
|
+
The installed version is available as `boxd.__version__`.
|
|
635
|
+
|
|
636
|
+
## Development
|
|
637
|
+
|
|
638
|
+
```bash
|
|
639
|
+
cd sdk/python
|
|
640
|
+
python -m venv .venv
|
|
641
|
+
source .venv/bin/activate
|
|
642
|
+
pip install -e ".[dev]"
|
|
643
|
+
|
|
644
|
+
pytest # unit tests
|
|
645
|
+
bash scripts/compile_proto.sh # regenerate stubs after an API change
|
|
646
|
+
```
|