boxd 0.2.0.dev38__tar.gz → 0.2.0.dev40__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 (40) hide show
  1. {boxd-0.2.0.dev38/src/boxd.egg-info → boxd-0.2.0.dev40}/PKG-INFO +222 -57
  2. {boxd-0.2.0.dev38 → boxd-0.2.0.dev40}/README.md +221 -56
  3. {boxd-0.2.0.dev38 → boxd-0.2.0.dev40}/pyproject.toml +1 -1
  4. {boxd-0.2.0.dev38 → boxd-0.2.0.dev40}/src/boxd/resources/snapshots.py +5 -0
  5. {boxd-0.2.0.dev38 → boxd-0.2.0.dev40/src/boxd.egg-info}/PKG-INFO +222 -57
  6. {boxd-0.2.0.dev38 → boxd-0.2.0.dev40}/tests/test_namespaces.py +11 -0
  7. {boxd-0.2.0.dev38 → boxd-0.2.0.dev40}/LICENSE +0 -0
  8. {boxd-0.2.0.dev38 → boxd-0.2.0.dev40}/setup.cfg +0 -0
  9. {boxd-0.2.0.dev38 → boxd-0.2.0.dev40}/src/boxd/__init__.py +0 -0
  10. {boxd-0.2.0.dev38 → boxd-0.2.0.dev40}/src/boxd/_client.py +0 -0
  11. {boxd-0.2.0.dev38 → boxd-0.2.0.dev40}/src/boxd/_credentials.py +0 -0
  12. {boxd-0.2.0.dev38 → boxd-0.2.0.dev40}/src/boxd/_generated/__init__.py +0 -0
  13. {boxd-0.2.0.dev38 → boxd-0.2.0.dev40}/src/boxd/_generated/api_pb2.py +0 -0
  14. {boxd-0.2.0.dev38 → boxd-0.2.0.dev40}/src/boxd/_generated/api_pb2_grpc.py +0 -0
  15. {boxd-0.2.0.dev38 → boxd-0.2.0.dev40}/src/boxd/_mappers.py +0 -0
  16. {boxd-0.2.0.dev38 → boxd-0.2.0.dev40}/src/boxd/_requests.py +0 -0
  17. {boxd-0.2.0.dev38 → boxd-0.2.0.dev40}/src/boxd/_streaming.py +0 -0
  18. {boxd-0.2.0.dev38 → boxd-0.2.0.dev40}/src/boxd/_transport.py +0 -0
  19. {boxd-0.2.0.dev38 → boxd-0.2.0.dev40}/src/boxd/_urls.py +0 -0
  20. {boxd-0.2.0.dev38 → boxd-0.2.0.dev40}/src/boxd/_version_check.py +0 -0
  21. {boxd-0.2.0.dev38 → boxd-0.2.0.dev40}/src/boxd/errors.py +0 -0
  22. {boxd-0.2.0.dev38 → boxd-0.2.0.dev40}/src/boxd/models.py +0 -0
  23. {boxd-0.2.0.dev38 → boxd-0.2.0.dev40}/src/boxd/resources/__init__.py +0 -0
  24. {boxd-0.2.0.dev38 → boxd-0.2.0.dev40}/src/boxd/resources/account.py +0 -0
  25. {boxd-0.2.0.dev38 → boxd-0.2.0.dev40}/src/boxd/resources/credentials.py +0 -0
  26. {boxd-0.2.0.dev38 → boxd-0.2.0.dev40}/src/boxd/resources/disks.py +0 -0
  27. {boxd-0.2.0.dev38 → boxd-0.2.0.dev40}/src/boxd/resources/machines.py +0 -0
  28. {boxd-0.2.0.dev38 → boxd-0.2.0.dev40}/src/boxd/resources/orgs.py +0 -0
  29. {boxd-0.2.0.dev38 → boxd-0.2.0.dev40}/src/boxd/resources/vars.py +0 -0
  30. {boxd-0.2.0.dev38 → boxd-0.2.0.dev40}/src/boxd.egg-info/SOURCES.txt +0 -0
  31. {boxd-0.2.0.dev38 → boxd-0.2.0.dev40}/src/boxd.egg-info/dependency_links.txt +0 -0
  32. {boxd-0.2.0.dev38 → boxd-0.2.0.dev40}/src/boxd.egg-info/requires.txt +0 -0
  33. {boxd-0.2.0.dev38 → boxd-0.2.0.dev40}/src/boxd.egg-info/top_level.txt +0 -0
  34. {boxd-0.2.0.dev38 → boxd-0.2.0.dev40}/tests/test_credentials.py +0 -0
  35. {boxd-0.2.0.dev38 → boxd-0.2.0.dev40}/tests/test_mappers.py +0 -0
  36. {boxd-0.2.0.dev38 → boxd-0.2.0.dev40}/tests/test_requests.py +0 -0
  37. {boxd-0.2.0.dev38 → boxd-0.2.0.dev40}/tests/test_streaming.py +0 -0
  38. {boxd-0.2.0.dev38 → boxd-0.2.0.dev40}/tests/test_transport.py +0 -0
  39. {boxd-0.2.0.dev38 → boxd-0.2.0.dev40}/tests/test_urls.py +0 -0
  40. {boxd-0.2.0.dev38 → boxd-0.2.0.dev40}/tests/test_version_check.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: boxd
3
- Version: 0.2.0.dev38
3
+ Version: 0.2.0.dev40
4
4
  Summary: Python SDK for the boxd cloud VM platform
5
5
  Author: Azin
6
6
  License-Expression: MIT
@@ -57,6 +57,7 @@ result = boxd.machines.exec(machine.id, "uname -a")
57
57
  print(result.stdout)
58
58
 
59
59
  boxd.machines.delete(machine.id)
60
+ boxd.close()
60
61
  ```
61
62
 
62
63
  Everything follows the same shape: **`boxd.<resource>.<verb>(id, ...)`**.
@@ -70,16 +71,25 @@ Boxd(api_key="bxd_...")
70
71
  Boxd(base_url="https://boxd.example.com:9443") # any other cluster
71
72
  ```
72
73
 
74
+ Every argument is keyword-only.
75
+
73
76
  | Argument | Environment variable | Default |
74
77
  |---|---|---|
75
78
  | `api_key` | `BOXD_API_KEY` | — |
76
79
  | `token` | `BOXD_TOKEN` | — |
77
- | `base_url` | `BOXD_BASE_URL` | production |
78
- | `timeout` | — | 60 seconds |
80
+ | `base_url` | `BOXD_BASE_URL` (or the deprecated `BOXD_API_URL`) | `http://boxd.sh:9443` |
81
+ | `timeout` | — | 60.0 seconds |
79
82
  | `max_retries` | — | 2 |
80
83
 
81
- There is **no `environment` argument**. One `base_url` selects a cluster and
82
- everything else follows from it.
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.
83
93
 
84
94
  The client holds a connection, so keep one around rather than making a new one
85
95
  per call. Close it when you're done — or use it as a context manager:
@@ -135,6 +145,17 @@ result = await boxd.machines.exec(machine.id, "echo hello")
135
145
  await boxd.close()
136
146
  ```
137
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
+
138
159
  Use `AsyncBoxd` when you already have an event loop (FastAPI, asyncio scripts,
139
160
  anyio). Use `Boxd` everywhere else — scripts, notebooks, Django views.
140
161
 
@@ -149,17 +170,47 @@ machine = boxd.machines.create(
149
170
  )
150
171
  boxd.machines.get("my-machine") # by name or id
151
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
152
175
  boxd.machines.delete("my-machine")
153
176
  ```
154
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
+
155
206
  State:
156
207
 
157
208
  ```python
158
209
  boxd.machines.start(id)
159
210
  boxd.machines.stop(id)
160
211
  boxd.machines.reboot(id)
161
- boxd.machines.pause(id) # suspend to RAM — fast to resume
162
- boxd.machines.resume(id)
212
+ boxd.machines.pause(id) # suspend to RAM — fast to resume; PauseResult(suspend_us)
213
+ boxd.machines.resume(id) # ResumeResult(resume_us)
163
214
  boxd.machines.hibernate(id) # suspend to disk — cheaper, slower to wake
164
215
  boxd.machines.wake(id)
165
216
  ```
@@ -168,28 +219,33 @@ Everything else:
168
219
 
169
220
  ```python
170
221
  boxd.machines.fork("my-machine", "my-copy") # live clone
171
- boxd.machines.rename(id, "new-name") # reboots the machine
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
172
224
  boxd.machines.share(id) # visible to your whole org
173
225
  boxd.machines.unshare(id)
174
226
  boxd.machines.set_auto_suspend_timeout(id, 300) # seconds idle; 0 disables
175
227
  boxd.machines.set_auto_hibernate_timeout(id, 0)
176
228
  boxd.machines.wait_until_ready(id)
229
+ boxd.machines.wait_until_ready(id, timeout=180.0, poll_interval=1.0) # seconds
177
230
  boxd.machines.suggest_name()
178
231
  ```
179
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
+
180
236
  `create` and `fork` return once the machine is scheduled, not once it is
181
237
  usable. Call `wait_until_ready` before doing anything that depends on it
182
238
  running — especially before forking it again.
183
239
 
184
240
  ### The `Machine` record
185
241
 
186
- Related fields travel together, so you read one object instead of remembering
187
- which flat field pairs with which.
188
-
189
242
  ```python
190
- machine.id, machine.name, machine.status, machine.image_ref
243
+ machine.id, machine.name, machine.image_ref
244
+ machine.status # "pending" | "starting" | "running" | "suspended" |
245
+ # "hibernated" | "stopped" | "failed" | "destroyed" |
246
+ # "migrating"
191
247
  machine.restart_policy # str | None
192
- machine.created_at # datetime | None — None on older machines
248
+ machine.created_at # datetime | None — None when none is on record
193
249
 
194
250
  machine.resources.vcpu # what the machine actually got, not what you
195
251
  machine.resources.memory_bytes # asked for — always concrete
@@ -229,6 +285,9 @@ set with `shared=False` is normal, not a contradiction.
229
285
  of where the machine came from, not a live link — **it may not resolve**, and a
230
286
  lookup that finds nothing is normal.
231
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
+
232
291
  ### Creating from a snapshot
233
292
 
234
293
  ```python
@@ -236,6 +295,10 @@ boxd.snapshots.create(machine_id, "golden")
236
295
  machine = boxd.machines.create("from-golden", from_snapshot="golden")
237
296
  ```
238
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
+
239
302
  ### Exec
240
303
 
241
304
  ```python
@@ -253,21 +316,29 @@ boxd.machines.exec(id, "cargo build", timeout=30) # seconds
253
316
  boxd.machines.exec(id, "top -b -n1", tty=True, cols=120, rows=40)
254
317
  ```
255
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
+
256
323
  For anything interactive, `stream_exec` gives you a live session — the one
257
324
  handle in the SDK, because a bidirectional stream really is stateful:
258
325
 
259
326
  ```python
260
327
  with boxd.machines.stream_exec(id, command="bash", tty=True) as stream:
261
- stream.write(b"ls\n")
262
- stream.write_eof()
263
- for chunk in 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
264
331
  print(chunk.decode(errors="replace"), end="")
265
332
  print("exited", stream.exit_code)
266
333
  ```
267
334
 
268
- `iter_chunks()` tags each slice with `is_stderr` when you need the two streams
269
- apart. Under `tty=True` the terminal merges them, so everything arrives as
270
- stdoutset `tty=False` if you need the split.
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.
271
342
 
272
343
  For a headless one-shot that reads stdin (`jq`, `cat`, `claude -p`), pass
273
344
  `close_stdin=True` so it sees end-of-input immediately instead of hanging.
@@ -297,47 +368,76 @@ for chunk in boxd.machines.logs(id, follow=True): # stays open
297
368
  ### Files
298
369
 
299
370
  ```python
300
- boxd.machines.files.upload(id, "/app/config.json", '{"debug": true}')
301
- boxd.machines.files.upload(id, "/app/data.bin", open("local.bin", "rb").read())
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())
302
375
  data = boxd.machines.files.download(id, "/app/output.json") # bytes
303
376
  ```
304
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
+
305
381
  ### Ports and proxies
306
382
 
307
383
  ```python
308
- boxd.machines.ports.expose(id, 8080) # public TCP forward
309
- boxd.machines.ports.expose(id, 5353, protocol="udp")
310
- boxd.machines.ports.unexpose(id, 8080)
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
311
390
  boxd.machines.ports.list() # every forward you own
312
391
  ```
313
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
+
314
397
  `ports.list()` is account-wide — pass a machine to narrow it, or filter on
315
398
  `.machine_id` / `.machine_name`.
316
399
 
317
400
  ```python
318
- boxd.machines.proxies.create("my-machine", "api", 3001) # api.<machine>...
401
+ route = boxd.machines.proxies.create("my-machine", "api", 3001) # api.<machine>...
402
+ route.name, route.port
319
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
320
406
  routes[0].port # int — where traffic actually goes
321
407
  routes[0].port_mode # "locked" (you pinned it) | "auto" (detected for you)
322
- routes[0].machine_id
408
+ routes[0].is_default
409
+ routes[0].machine_id, routes[0].machine_name
323
410
  boxd.machines.proxies.set_port("my-machine", 3000, name="api")
324
411
  boxd.machines.proxies.set_port("my-machine", "auto") # default route, auto-detected
325
412
  boxd.machines.proxies.delete("my-machine", "api")
326
413
  ```
327
414
 
328
- These take an id or a name, like everything else on `machines`.
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.
329
420
 
330
421
  ### Checkpoints
331
422
 
332
423
  Per-machine captures, restored in place. They are deleted with the machine.
333
424
 
334
425
  ```python
335
- boxd.machines.checkpoints.create(id, "before-upgrade")
336
- boxd.machines.checkpoints.list(id)
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
337
433
  boxd.machines.checkpoints.restore(id, "before-upgrade")
338
434
  boxd.machines.checkpoints.delete(id, "before-upgrade")
339
435
  ```
340
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
+
341
441
  ## Environment variables and secrets
342
442
 
343
443
  Two namespaces with identical methods. The difference is that a secret's value
@@ -347,6 +447,7 @@ is write-only — the server never returns it, and the `Secret` model has no
347
447
  ```python
348
448
  boxd.env.set("MODE", "production", scope="all")
349
449
  boxd.env.list() # EnvVar(name, scope, value)
450
+ boxd.env.list(org="acme")
350
451
  boxd.env.delete("MODE", scope="all")
351
452
 
352
453
  boxd.secrets.set("API_TOKEN", "s3cr3t", scope="shared")
@@ -354,6 +455,10 @@ boxd.secrets.list() # Secret(name, scope) — no value
354
455
  boxd.secrets.delete("API_TOKEN", scope="shared")
355
456
  ```
356
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
+
357
462
  `set`, `delete` and `move` each return the server's human-readable
358
463
  confirmation of what it did.
359
464
 
@@ -380,12 +485,15 @@ the same name moving in, and vice versa.
380
485
  ## Snapshots and disks
381
486
 
382
487
  ```python
383
- boxd.snapshots.create(machine_id, "golden") # re-saving bumps the version
384
- boxd.snapshots.get("golden")
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
385
491
  boxd.snapshots.list()
386
492
  boxd.snapshots.delete("golden")
493
+ boxd.snapshots.list(org="acme") # `org` works on get/list/delete too
387
494
 
388
- disk = boxd.disks.create("data", "10G")
495
+ disk = boxd.disks.create("data", "10G") # bytes or a human string
496
+ disk.id, disk.name, disk.size_bytes, disk.status
389
497
  boxd.disks.attach(disk.id, machine_id, "/mnt/data")
390
498
  boxd.disks.attach(disk.id, machine_id, "/mnt/data", read_only=True)
391
499
  boxd.disks.detach(disk.id, machine_id)
@@ -393,27 +501,95 @@ boxd.disks.list()
393
501
  boxd.disks.delete(disk.id)
394
502
  ```
395
503
 
396
- A `Snapshot` carries both `created_at` (the first capture) and `updated_at` (the
397
- most recent one re-saving under the same name bumps the version). A `Disk`
398
- carries `created_at` and a `status` of `"creating"`, `"ready"` or `"destroyed"`;
399
- it can only be attached once it is `"ready"`.
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`:
400
513
 
401
- ## Organizations, credentials, billing, account
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
402
535
 
403
536
  ```python
404
- orgs = boxd.orgs.list() # a plain list; each org has `is_default`
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.
405
546
 
406
- key = boxd.api_keys.create("ci", org="acme")
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
407
557
  key.api_key # the raw key — shown once, store it now
408
- boxd.api_keys.list()
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"
409
565
  boxd.api_keys.delete(key.id)
566
+ ```
410
567
 
568
+ Every key is fenced to exactly one organization. Deleting one takes effect
569
+ immediately.
411
570
 
571
+ ```python
572
+ from pathlib import Path
412
573
 
413
574
  me = boxd.account.get()
414
- me.user_id, me.display_name, me.pubkey_fingerprints
415
- boxd.account.link_ssh_key(open("~/.ssh/id_ed25519.pub").read())
416
- boxd.account.config() # default image, cluster zone
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
417
593
  ```
418
594
 
419
595
  ## Errors
@@ -436,8 +612,9 @@ except NotFoundError:
436
612
  ...
437
613
  ```
438
614
 
439
- Every error carries `.message` and `.code` (the canonical status name, e.g.
440
- `"not_found"`).
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).
441
618
 
442
619
  Connection failures are retried with exponential backoff, `max_retries` times.
443
620
  Timeouts are never retried — the server may already have applied the request —
@@ -455,15 +632,3 @@ A new version of boxd is available (v0.2.0, you have v0.1.9). Update with:
455
632
  It fires at most once per process and never causes a request to fail.
456
633
 
457
634
  The installed version is available as `boxd.__version__`.
458
-
459
- ## Development
460
-
461
- ```bash
462
- cd sdk/python
463
- python -m venv .venv
464
- source .venv/bin/activate
465
- pip install -e ".[dev]"
466
-
467
- pytest # unit tests
468
- bash scripts/compile_proto.sh # regenerate stubs after an API change
469
- ```