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.
Files changed (74) hide show
  1. boxd-0.2.0.dev39/PKG-INFO +646 -0
  2. boxd-0.2.0.dev39/README.md +614 -0
  3. {boxd-0.1.9.dev37 → boxd-0.2.0.dev39}/pyproject.toml +8 -6
  4. boxd-0.2.0.dev39/src/boxd/__init__.py +132 -0
  5. boxd-0.2.0.dev39/src/boxd/_client.py +148 -0
  6. boxd-0.2.0.dev39/src/boxd/_credentials.py +304 -0
  7. boxd-0.2.0.dev39/src/boxd/_generated/api_pb2.py +358 -0
  8. {boxd-0.1.9.dev37 → boxd-0.2.0.dev39}/src/boxd/_generated/api_pb2_grpc.py +458 -102
  9. boxd-0.2.0.dev39/src/boxd/_mappers.py +331 -0
  10. boxd-0.2.0.dev39/src/boxd/_requests.py +246 -0
  11. boxd-0.2.0.dev39/src/boxd/_streaming.py +195 -0
  12. boxd-0.2.0.dev39/src/boxd/_transport.py +208 -0
  13. boxd-0.2.0.dev39/src/boxd/_urls.py +101 -0
  14. {boxd-0.1.9.dev37 → boxd-0.2.0.dev39}/src/boxd/_version_check.py +31 -71
  15. boxd-0.2.0.dev39/src/boxd/errors.py +97 -0
  16. boxd-0.2.0.dev39/src/boxd/models.py +402 -0
  17. boxd-0.2.0.dev39/src/boxd/resources/__init__.py +15 -0
  18. boxd-0.2.0.dev39/src/boxd/resources/account.py +79 -0
  19. boxd-0.2.0.dev39/src/boxd/resources/credentials.py +97 -0
  20. boxd-0.2.0.dev39/src/boxd/resources/disks.py +112 -0
  21. boxd-0.2.0.dev39/src/boxd/resources/machines.py +1046 -0
  22. boxd-0.2.0.dev39/src/boxd/resources/orgs.py +27 -0
  23. boxd-0.2.0.dev39/src/boxd/resources/snapshots.py +83 -0
  24. boxd-0.2.0.dev39/src/boxd/resources/vars.py +249 -0
  25. boxd-0.2.0.dev39/src/boxd.egg-info/PKG-INFO +646 -0
  26. boxd-0.2.0.dev39/src/boxd.egg-info/SOURCES.txt +38 -0
  27. {boxd-0.1.9.dev37 → boxd-0.2.0.dev39}/src/boxd.egg-info/requires.txt +1 -0
  28. boxd-0.2.0.dev39/tests/test_credentials.py +305 -0
  29. boxd-0.2.0.dev39/tests/test_mappers.py +453 -0
  30. boxd-0.2.0.dev39/tests/test_namespaces.py +843 -0
  31. boxd-0.2.0.dev39/tests/test_requests.py +203 -0
  32. boxd-0.2.0.dev39/tests/test_streaming.py +132 -0
  33. boxd-0.2.0.dev39/tests/test_transport.py +298 -0
  34. boxd-0.2.0.dev39/tests/test_urls.py +139 -0
  35. boxd-0.2.0.dev39/tests/test_version_check.py +121 -0
  36. boxd-0.1.9.dev37/PKG-INFO +0 -499
  37. boxd-0.1.9.dev37/README.md +0 -468
  38. boxd-0.1.9.dev37/src/boxd/__init__.py +0 -131
  39. boxd-0.1.9.dev37/src/boxd/_generated/api_pb2.py +0 -310
  40. boxd-0.1.9.dev37/src/boxd/_sync.py +0 -547
  41. boxd-0.1.9.dev37/src/boxd/_utils.py +0 -73
  42. boxd-0.1.9.dev37/src/boxd/aio.py +0 -98
  43. boxd-0.1.9.dev37/src/boxd/auth.py +0 -118
  44. boxd-0.1.9.dev37/src/boxd/billing.py +0 -128
  45. boxd-0.1.9.dev37/src/boxd/box.py +0 -424
  46. boxd-0.1.9.dev37/src/boxd/boxes.py +0 -192
  47. boxd-0.1.9.dev37/src/boxd/client.py +0 -176
  48. boxd-0.1.9.dev37/src/boxd/disks.py +0 -111
  49. boxd-0.1.9.dev37/src/boxd/domains.py +0 -54
  50. boxd-0.1.9.dev37/src/boxd/errors.py +0 -62
  51. boxd-0.1.9.dev37/src/boxd/exec.py +0 -149
  52. boxd-0.1.9.dev37/src/boxd/networks.py +0 -43
  53. boxd-0.1.9.dev37/src/boxd/templates.py +0 -113
  54. boxd-0.1.9.dev37/src/boxd/tokens.py +0 -51
  55. boxd-0.1.9.dev37/src/boxd/types.py +0 -151
  56. boxd-0.1.9.dev37/src/boxd.egg-info/PKG-INFO +0 -499
  57. boxd-0.1.9.dev37/src/boxd.egg-info/SOURCES.txt +0 -41
  58. boxd-0.1.9.dev37/tests/test_auth.py +0 -33
  59. boxd-0.1.9.dev37/tests/test_boxes.py +0 -6
  60. boxd-0.1.9.dev37/tests/test_e2e.py +0 -324
  61. boxd-0.1.9.dev37/tests/test_e2e_v2.py +0 -93
  62. boxd-0.1.9.dev37/tests/test_exec.py +0 -139
  63. boxd-0.1.9.dev37/tests/test_files.py +0 -21
  64. boxd-0.1.9.dev37/tests/test_lifecycle.py +0 -21
  65. boxd-0.1.9.dev37/tests/test_no_shape.py +0 -67
  66. boxd-0.1.9.dev37/tests/test_proxies.py +0 -33
  67. boxd-0.1.9.dev37/tests/test_utils.py +0 -47
  68. boxd-0.1.9.dev37/tests/test_v2.py +0 -62
  69. boxd-0.1.9.dev37/tests/test_version_check.py +0 -178
  70. {boxd-0.1.9.dev37 → boxd-0.2.0.dev39}/LICENSE +0 -0
  71. {boxd-0.1.9.dev37 → boxd-0.2.0.dev39}/setup.cfg +0 -0
  72. {boxd-0.1.9.dev37 → boxd-0.2.0.dev39}/src/boxd/_generated/__init__.py +0 -0
  73. {boxd-0.1.9.dev37 → boxd-0.2.0.dev39}/src/boxd.egg-info/dependency_links.txt +0 -0
  74. {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
+ ```