boxd 0.1.9.dev37__tar.gz → 0.2.0.dev38__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.dev38/PKG-INFO +469 -0
  2. boxd-0.2.0.dev38/README.md +437 -0
  3. {boxd-0.1.9.dev37 → boxd-0.2.0.dev38}/pyproject.toml +8 -6
  4. boxd-0.2.0.dev38/src/boxd/__init__.py +132 -0
  5. boxd-0.2.0.dev38/src/boxd/_client.py +148 -0
  6. boxd-0.2.0.dev38/src/boxd/_credentials.py +304 -0
  7. boxd-0.2.0.dev38/src/boxd/_generated/api_pb2.py +358 -0
  8. {boxd-0.1.9.dev37 → boxd-0.2.0.dev38}/src/boxd/_generated/api_pb2_grpc.py +458 -102
  9. boxd-0.2.0.dev38/src/boxd/_mappers.py +331 -0
  10. boxd-0.2.0.dev38/src/boxd/_requests.py +246 -0
  11. boxd-0.2.0.dev38/src/boxd/_streaming.py +195 -0
  12. boxd-0.2.0.dev38/src/boxd/_transport.py +208 -0
  13. boxd-0.2.0.dev38/src/boxd/_urls.py +101 -0
  14. {boxd-0.1.9.dev37 → boxd-0.2.0.dev38}/src/boxd/_version_check.py +31 -71
  15. boxd-0.2.0.dev38/src/boxd/errors.py +97 -0
  16. boxd-0.2.0.dev38/src/boxd/models.py +402 -0
  17. boxd-0.2.0.dev38/src/boxd/resources/__init__.py +15 -0
  18. boxd-0.2.0.dev38/src/boxd/resources/account.py +79 -0
  19. boxd-0.2.0.dev38/src/boxd/resources/credentials.py +97 -0
  20. boxd-0.2.0.dev38/src/boxd/resources/disks.py +112 -0
  21. boxd-0.2.0.dev38/src/boxd/resources/machines.py +1046 -0
  22. boxd-0.2.0.dev38/src/boxd/resources/orgs.py +27 -0
  23. boxd-0.2.0.dev38/src/boxd/resources/snapshots.py +78 -0
  24. boxd-0.2.0.dev38/src/boxd/resources/vars.py +249 -0
  25. boxd-0.2.0.dev38/src/boxd.egg-info/PKG-INFO +469 -0
  26. boxd-0.2.0.dev38/src/boxd.egg-info/SOURCES.txt +38 -0
  27. {boxd-0.1.9.dev37 → boxd-0.2.0.dev38}/src/boxd.egg-info/requires.txt +1 -0
  28. boxd-0.2.0.dev38/tests/test_credentials.py +305 -0
  29. boxd-0.2.0.dev38/tests/test_mappers.py +453 -0
  30. boxd-0.2.0.dev38/tests/test_namespaces.py +832 -0
  31. boxd-0.2.0.dev38/tests/test_requests.py +203 -0
  32. boxd-0.2.0.dev38/tests/test_streaming.py +132 -0
  33. boxd-0.2.0.dev38/tests/test_transport.py +298 -0
  34. boxd-0.2.0.dev38/tests/test_urls.py +139 -0
  35. boxd-0.2.0.dev38/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.dev38}/LICENSE +0 -0
  71. {boxd-0.1.9.dev37 → boxd-0.2.0.dev38}/setup.cfg +0 -0
  72. {boxd-0.1.9.dev37 → boxd-0.2.0.dev38}/src/boxd/_generated/__init__.py +0 -0
  73. {boxd-0.1.9.dev37 → boxd-0.2.0.dev38}/src/boxd.egg-info/dependency_links.txt +0 -0
  74. {boxd-0.1.9.dev37 → boxd-0.2.0.dev38}/src/boxd.egg-info/top_level.txt +0 -0
@@ -0,0 +1,469 @@
1
+ Metadata-Version: 2.4
2
+ Name: boxd
3
+ Version: 0.2.0.dev38
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
+ ```
61
+
62
+ Everything follows the same shape: **`boxd.<resource>.<verb>(id, ...)`**.
63
+ Resources return plain data — a `Machine` has fields, not methods.
64
+
65
+ ## Client
66
+
67
+ ```python
68
+ Boxd() # production
69
+ Boxd(api_key="bxd_...")
70
+ Boxd(base_url="https://boxd.example.com:9443") # any other cluster
71
+ ```
72
+
73
+ | Argument | Environment variable | Default |
74
+ |---|---|---|
75
+ | `api_key` | `BOXD_API_KEY` | — |
76
+ | `token` | `BOXD_TOKEN` | — |
77
+ | `base_url` | `BOXD_BASE_URL` | production |
78
+ | `timeout` | — | 60 seconds |
79
+ | `max_retries` | — | 2 |
80
+
81
+ There is **no `environment` argument**. One `base_url` selects a cluster and
82
+ everything else follows from it.
83
+
84
+ The client holds a connection, so keep one around rather than making a new one
85
+ per call. Close it when you're done — or use it as a context manager:
86
+
87
+ ```python
88
+ with Boxd(api_key="bxd_...") as boxd:
89
+ ...
90
+ ```
91
+
92
+ ## Authentication
93
+
94
+ The first of these that is present wins:
95
+
96
+ 1. `token=` — used as given
97
+ 2. `api_key=` — exchanged for a short-lived credential and kept fresh for you
98
+ 3. `BOXD_TOKEN`, then `BOXD_API_KEY`
99
+ 4. running inside a boxd machine — see below
100
+ 5. otherwise `AuthenticationError`
101
+
102
+ If your key is revoked mid-session, the SDK fails fast with
103
+ `AuthenticationError` rather than retrying.
104
+
105
+ ### Inside a machine
106
+
107
+ Inside a boxd machine, `Boxd()` authenticates automatically — no API key
108
+ needed, and it talks to that machine's own cluster unless you pass `base_url`.
109
+
110
+ ```python
111
+ from boxd import Boxd
112
+
113
+ boxd = Boxd()
114
+ for machine in boxd.machines.list():
115
+ print(machine.name, machine.status)
116
+ ```
117
+
118
+ One limit: inside a **shared** machine the automatic credential can manage the
119
+ organization's shared machines, but cannot read environment variables or
120
+ secrets, and cannot reach private machines. Pass an API key for those.
121
+
122
+ ## Sync and async
123
+
124
+ `Boxd` and `AsyncBoxd` are the same surface — same namespaces, same method
125
+ names, same arguments, same return types. Switching is `await` and an import,
126
+ not a rewrite.
127
+
128
+ ```python
129
+ from boxd import AsyncBoxd
130
+
131
+ boxd = AsyncBoxd(api_key="bxd_...")
132
+
133
+ machine = await boxd.machines.create("my-machine")
134
+ result = await boxd.machines.exec(machine.id, "echo hello")
135
+ await boxd.close()
136
+ ```
137
+
138
+ Use `AsyncBoxd` when you already have an event loop (FastAPI, asyncio scripts,
139
+ anyio). Use `Boxd` everywhere else — scripts, notebooks, Django views.
140
+
141
+ ## Machines
142
+
143
+ ```python
144
+ machine = boxd.machines.create(
145
+ "my-machine",
146
+ vcpu=4,
147
+ memory="16G",
148
+ env={"MODE": "production"},
149
+ )
150
+ boxd.machines.get("my-machine") # by name or id
151
+ boxd.machines.list() # a plain list
152
+ boxd.machines.delete("my-machine")
153
+ ```
154
+
155
+ State:
156
+
157
+ ```python
158
+ boxd.machines.start(id)
159
+ boxd.machines.stop(id)
160
+ boxd.machines.reboot(id)
161
+ boxd.machines.pause(id) # suspend to RAM — fast to resume
162
+ boxd.machines.resume(id)
163
+ boxd.machines.hibernate(id) # suspend to disk — cheaper, slower to wake
164
+ boxd.machines.wake(id)
165
+ ```
166
+
167
+ Everything else:
168
+
169
+ ```python
170
+ boxd.machines.fork("my-machine", "my-copy") # live clone
171
+ boxd.machines.rename(id, "new-name") # reboots the machine
172
+ boxd.machines.share(id) # visible to your whole org
173
+ boxd.machines.unshare(id)
174
+ boxd.machines.set_auto_suspend_timeout(id, 300) # seconds idle; 0 disables
175
+ boxd.machines.set_auto_hibernate_timeout(id, 0)
176
+ boxd.machines.wait_until_ready(id)
177
+ boxd.machines.suggest_name()
178
+ ```
179
+
180
+ `create` and `fork` return once the machine is scheduled, not once it is
181
+ usable. Call `wait_until_ready` before doing anything that depends on it
182
+ running — especially before forking it again.
183
+
184
+ ### The `Machine` record
185
+
186
+ Related fields travel together, so you read one object instead of remembering
187
+ which flat field pairs with which.
188
+
189
+ ```python
190
+ machine.id, machine.name, machine.status, machine.image_ref
191
+ machine.restart_policy # str | None
192
+ machine.created_at # datetime | None — None on older machines
193
+
194
+ machine.resources.vcpu # what the machine actually got, not what you
195
+ machine.resources.memory_bytes # asked for — always concrete
196
+ machine.resources.disk_bytes
197
+
198
+ machine.org # OrgRef(id, name) | None — None = personal quota
199
+ machine.shared # shared with that org, or private to you
200
+
201
+ machine.access.ssh_port # int | None — None until allocated
202
+ machine.access.domain
203
+ machine.access.url # https://<name>.<domain>
204
+
205
+ machine.idle.suspend_after # seconds; 0 = that timer is disabled
206
+ machine.idle.hibernate_after
207
+ machine.idle.destroy_after
208
+
209
+ machine.source # MachineSource | None — None = booted from an image
210
+ machine.source.kind # "fork" | "snapshot"
211
+ machine.source.name # source machine, or snapshot name
212
+ machine.source.version # int | None — snapshots only; a fork has none
213
+ machine.source.id # str | None — provenance; may not resolve
214
+
215
+ machine.hibernated_at # datetime | None — None = not hibernated
216
+ machine.last_connected_at # datetime | None — None = never connected
217
+ machine.boot_time_ms # int | None — last boot; None = never booted
218
+ ```
219
+
220
+ `None` always means "not set": a port that was never allocated, a boot that
221
+ never happened, an org you do not have. Where `0` is a real answer — a disabled
222
+ idle timer — it stays `0`.
223
+
224
+ `org` is the org the machine belongs to and is billed to; `shared` says whether
225
+ your teammates can see it. A private machine can still be org-billed, so `org`
226
+ set with `shared=False` is normal, not a contradiction.
227
+
228
+ `source.id` points at the machine or snapshot this one came from. It is a record
229
+ of where the machine came from, not a live link — **it may not resolve**, and a
230
+ lookup that finds nothing is normal.
231
+
232
+ ### Creating from a snapshot
233
+
234
+ ```python
235
+ boxd.snapshots.create(machine_id, "golden")
236
+ machine = boxd.machines.create("from-golden", from_snapshot="golden")
237
+ ```
238
+
239
+ ### Exec
240
+
241
+ ```python
242
+ result = boxd.machines.exec(id, "cargo build")
243
+ result.stdout # str
244
+ result.stderr # str — populated for non-PTY execs
245
+ result.exit_code # int
246
+ result.success # bool
247
+
248
+ boxd.machines.exec(id, ["echo", "a b"]) # a list is quoted for you
249
+ boxd.machines.exec(id, "env", env={"FOO": "bar"})
250
+ boxd.machines.exec(id, "cargo build", timeout=30) # seconds
251
+
252
+ # Under a PTY, stderr merges into stdout and `stderr` comes back empty.
253
+ boxd.machines.exec(id, "top -b -n1", tty=True, cols=120, rows=40)
254
+ ```
255
+
256
+ For anything interactive, `stream_exec` gives you a live session — the one
257
+ handle in the SDK, because a bidirectional stream really is stateful:
258
+
259
+ ```python
260
+ 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:
264
+ print(chunk.decode(errors="replace"), end="")
265
+ print("exited", stream.exit_code)
266
+ ```
267
+
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
+ stdout — set `tty=False` if you need the split.
271
+
272
+ For a headless one-shot that reads stdin (`jq`, `cat`, `claude -p`), pass
273
+ `close_stdin=True` so it sees end-of-input immediately instead of hanging.
274
+ Combining it with `tty=True` raises `ValueError` — a shell needs stdin open.
275
+
276
+ Set the terminal size with `cols`/`rows`, and call `stream.resize(cols, rows)`
277
+ when the local terminal changes size:
278
+
279
+ ```python
280
+ import shutil, signal
281
+
282
+ cols, rows = shutil.get_terminal_size()
283
+ stream = boxd.machines.stream_exec(id, command="htop", tty=True, cols=cols, rows=rows)
284
+ signal.signal(signal.SIGWINCH, lambda *_: stream.resize(*shutil.get_terminal_size()))
285
+ ```
286
+
287
+ ### Logs
288
+
289
+ ```python
290
+ for chunk in boxd.machines.logs(id):
291
+ print(chunk.decode(errors="replace"), end="")
292
+
293
+ for chunk in boxd.machines.logs(id, follow=True): # stays open
294
+ ...
295
+ ```
296
+
297
+ ### Files
298
+
299
+ ```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())
302
+ data = boxd.machines.files.download(id, "/app/output.json") # bytes
303
+ ```
304
+
305
+ ### Ports and proxies
306
+
307
+ ```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)
311
+ boxd.machines.ports.list() # every forward you own
312
+ ```
313
+
314
+ `ports.list()` is account-wide — pass a machine to narrow it, or filter on
315
+ `.machine_id` / `.machine_name`.
316
+
317
+ ```python
318
+ boxd.machines.proxies.create("my-machine", "api", 3001) # api.<machine>...
319
+ routes = boxd.machines.proxies.list("my-machine")
320
+ routes[0].port # int — where traffic actually goes
321
+ routes[0].port_mode # "locked" (you pinned it) | "auto" (detected for you)
322
+ routes[0].machine_id
323
+ boxd.machines.proxies.set_port("my-machine", 3000, name="api")
324
+ boxd.machines.proxies.set_port("my-machine", "auto") # default route, auto-detected
325
+ boxd.machines.proxies.delete("my-machine", "api")
326
+ ```
327
+
328
+ These take an id or a name, like everything else on `machines`.
329
+
330
+ ### Checkpoints
331
+
332
+ Per-machine captures, restored in place. They are deleted with the machine.
333
+
334
+ ```python
335
+ boxd.machines.checkpoints.create(id, "before-upgrade")
336
+ boxd.machines.checkpoints.list(id)
337
+ boxd.machines.checkpoints.restore(id, "before-upgrade")
338
+ boxd.machines.checkpoints.delete(id, "before-upgrade")
339
+ ```
340
+
341
+ ## Environment variables and secrets
342
+
343
+ Two namespaces with identical methods. The difference is that a secret's value
344
+ is write-only — the server never returns it, and the `Secret` model has no
345
+ `value` field at all.
346
+
347
+ ```python
348
+ boxd.env.set("MODE", "production", scope="all")
349
+ boxd.env.list() # EnvVar(name, scope, value)
350
+ boxd.env.delete("MODE", scope="all")
351
+
352
+ boxd.secrets.set("API_TOKEN", "s3cr3t", scope="shared")
353
+ boxd.secrets.list() # Secret(name, scope) — no value
354
+ boxd.secrets.delete("API_TOKEN", scope="shared")
355
+ ```
356
+
357
+ `set`, `delete` and `move` each return the server's human-readable
358
+ confirmation of what it did.
359
+
360
+ Scope decides which machines a name applies to:
361
+
362
+ | Scope | Applies to |
363
+ |---|---|
364
+ | `private` | only your machines in that organization |
365
+ | `shared` | the organization's shared machines |
366
+ | `all` | every machine in the organization |
367
+
368
+ Scope is part of a name's identity — the same name can exist in several scopes
369
+ at once — so changing it is a `move` between two addresses, and both ends are
370
+ required:
371
+
372
+ ```python
373
+ boxd.secrets.move("API_TOKEN", from_scope="private", to_scope="shared")
374
+ ```
375
+
376
+ Calling it twice fails the second time. Environment variables and secrets share
377
+ one namespace within a scope, so an environment variable can block a secret of
378
+ the same name moving in, and vice versa.
379
+
380
+ ## Snapshots and disks
381
+
382
+ ```python
383
+ boxd.snapshots.create(machine_id, "golden") # re-saving bumps the version
384
+ boxd.snapshots.get("golden")
385
+ boxd.snapshots.list()
386
+ boxd.snapshots.delete("golden")
387
+
388
+ disk = boxd.disks.create("data", "10G")
389
+ boxd.disks.attach(disk.id, machine_id, "/mnt/data")
390
+ boxd.disks.attach(disk.id, machine_id, "/mnt/data", read_only=True)
391
+ boxd.disks.detach(disk.id, machine_id)
392
+ boxd.disks.list()
393
+ boxd.disks.delete(disk.id)
394
+ ```
395
+
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"`.
400
+
401
+ ## Organizations, credentials, billing, account
402
+
403
+ ```python
404
+ orgs = boxd.orgs.list() # a plain list; each org has `is_default`
405
+
406
+ key = boxd.api_keys.create("ci", org="acme")
407
+ key.api_key # the raw key — shown once, store it now
408
+ boxd.api_keys.list()
409
+ boxd.api_keys.delete(key.id)
410
+
411
+
412
+
413
+ 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
417
+ ```
418
+
419
+ ## Errors
420
+
421
+ ```python
422
+ from boxd import (
423
+ BoxdError, # base class — catch this to catch everything
424
+ AuthenticationError, # no usable credential, or it was rejected
425
+ PermissionDeniedError, # authenticated, but not allowed
426
+ NotFoundError,
427
+ ConflictError, # already exists, or fights the current state
428
+ RateLimitError, # rate limit or quota
429
+ APIStatusError, # any other error from the server
430
+ APIConnectionError, # could not reach the server
431
+ )
432
+
433
+ try:
434
+ boxd.machines.get("nope")
435
+ except NotFoundError:
436
+ ...
437
+ ```
438
+
439
+ Every error carries `.message` and `.code` (the canonical status name, e.g.
440
+ `"not_found"`).
441
+
442
+ Connection failures are retried with exponential backoff, `max_retries` times.
443
+ Timeouts are never retried — the server may already have applied the request —
444
+ and neither is `AuthenticationError`.
445
+
446
+ ## Update notices
447
+
448
+ The SDK prints a one-time note to stderr if the server reports a newer release:
449
+
450
+ ```
451
+ A new version of boxd is available (v0.2.0, you have v0.1.9). Update with:
452
+ pip install --upgrade boxd
453
+ ```
454
+
455
+ It fires at most once per process and never causes a request to fail.
456
+
457
+ 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
+ ```