boxd 0.2.10.dev609__tar.gz → 0.2.10.dev616__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 (41) hide show
  1. {boxd-0.2.10.dev609/src/boxd.egg-info → boxd-0.2.10.dev616}/PKG-INFO +31 -3
  2. {boxd-0.2.10.dev609 → boxd-0.2.10.dev616}/README.md +30 -2
  3. {boxd-0.2.10.dev609 → boxd-0.2.10.dev616}/pyproject.toml +1 -1
  4. {boxd-0.2.10.dev609 → boxd-0.2.10.dev616}/src/boxd/models.py +10 -3
  5. {boxd-0.2.10.dev609 → boxd-0.2.10.dev616}/src/boxd/resources/machines.py +59 -16
  6. {boxd-0.2.10.dev609 → boxd-0.2.10.dev616/src/boxd.egg-info}/PKG-INFO +31 -3
  7. {boxd-0.2.10.dev609 → boxd-0.2.10.dev616}/tests/test_namespaces.py +32 -0
  8. {boxd-0.2.10.dev609 → boxd-0.2.10.dev616}/LICENSE +0 -0
  9. {boxd-0.2.10.dev609 → boxd-0.2.10.dev616}/setup.cfg +0 -0
  10. {boxd-0.2.10.dev609 → boxd-0.2.10.dev616}/src/boxd/__init__.py +0 -0
  11. {boxd-0.2.10.dev609 → boxd-0.2.10.dev616}/src/boxd/_client.py +0 -0
  12. {boxd-0.2.10.dev609 → boxd-0.2.10.dev616}/src/boxd/_credentials.py +0 -0
  13. {boxd-0.2.10.dev609 → boxd-0.2.10.dev616}/src/boxd/_generated/__init__.py +0 -0
  14. {boxd-0.2.10.dev609 → boxd-0.2.10.dev616}/src/boxd/_generated/api_pb2.py +0 -0
  15. {boxd-0.2.10.dev609 → boxd-0.2.10.dev616}/src/boxd/_generated/api_pb2_grpc.py +0 -0
  16. {boxd-0.2.10.dev609 → boxd-0.2.10.dev616}/src/boxd/_mappers.py +0 -0
  17. {boxd-0.2.10.dev609 → boxd-0.2.10.dev616}/src/boxd/_requests.py +0 -0
  18. {boxd-0.2.10.dev609 → boxd-0.2.10.dev616}/src/boxd/_streaming.py +0 -0
  19. {boxd-0.2.10.dev609 → boxd-0.2.10.dev616}/src/boxd/_transport.py +0 -0
  20. {boxd-0.2.10.dev609 → boxd-0.2.10.dev616}/src/boxd/_urls.py +0 -0
  21. {boxd-0.2.10.dev609 → boxd-0.2.10.dev616}/src/boxd/_version_check.py +0 -0
  22. {boxd-0.2.10.dev609 → boxd-0.2.10.dev616}/src/boxd/errors.py +0 -0
  23. {boxd-0.2.10.dev609 → boxd-0.2.10.dev616}/src/boxd/resources/__init__.py +0 -0
  24. {boxd-0.2.10.dev609 → boxd-0.2.10.dev616}/src/boxd/resources/account.py +0 -0
  25. {boxd-0.2.10.dev609 → boxd-0.2.10.dev616}/src/boxd/resources/credentials.py +0 -0
  26. {boxd-0.2.10.dev609 → boxd-0.2.10.dev616}/src/boxd/resources/disks.py +0 -0
  27. {boxd-0.2.10.dev609 → boxd-0.2.10.dev616}/src/boxd/resources/domains.py +0 -0
  28. {boxd-0.2.10.dev609 → boxd-0.2.10.dev616}/src/boxd/resources/orgs.py +0 -0
  29. {boxd-0.2.10.dev609 → boxd-0.2.10.dev616}/src/boxd/resources/snapshots.py +0 -0
  30. {boxd-0.2.10.dev609 → boxd-0.2.10.dev616}/src/boxd/resources/vars.py +0 -0
  31. {boxd-0.2.10.dev609 → boxd-0.2.10.dev616}/src/boxd.egg-info/SOURCES.txt +0 -0
  32. {boxd-0.2.10.dev609 → boxd-0.2.10.dev616}/src/boxd.egg-info/dependency_links.txt +0 -0
  33. {boxd-0.2.10.dev609 → boxd-0.2.10.dev616}/src/boxd.egg-info/requires.txt +0 -0
  34. {boxd-0.2.10.dev609 → boxd-0.2.10.dev616}/src/boxd.egg-info/top_level.txt +0 -0
  35. {boxd-0.2.10.dev609 → boxd-0.2.10.dev616}/tests/test_credentials.py +0 -0
  36. {boxd-0.2.10.dev609 → boxd-0.2.10.dev616}/tests/test_mappers.py +0 -0
  37. {boxd-0.2.10.dev609 → boxd-0.2.10.dev616}/tests/test_requests.py +0 -0
  38. {boxd-0.2.10.dev609 → boxd-0.2.10.dev616}/tests/test_streaming.py +0 -0
  39. {boxd-0.2.10.dev609 → boxd-0.2.10.dev616}/tests/test_transport.py +0 -0
  40. {boxd-0.2.10.dev609 → boxd-0.2.10.dev616}/tests/test_urls.py +0 -0
  41. {boxd-0.2.10.dev609 → boxd-0.2.10.dev616}/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.10.dev609
3
+ Version: 0.2.10.dev616
4
4
  Summary: Python SDK for the boxd cloud VM platform
5
5
  Author: Azin
6
6
  License-Expression: MIT
@@ -358,6 +358,7 @@ result.success # bool
358
358
 
359
359
  boxd.machines.exec(id, ["echo", "a b"]) # a list is quoted for you
360
360
  boxd.machines.exec(id, "env", env={"FOO": "bar"})
361
+ boxd.machines.exec(id, "make", cwd="/srv/app") # run from a working directory
361
362
  boxd.machines.exec(id, "cargo build", timeout=30) # seconds
362
363
 
363
364
  # Under a PTY, stderr merges into stdout and `stderr` comes back empty.
@@ -365,9 +366,22 @@ boxd.machines.exec(id, "top -b -n1", tty=True, cols=120, rows=40)
365
366
  ```
366
367
 
367
368
  `command` takes a list of argv — shell-quoted for you — or a ready-made command
368
- line as a string. `timeout` gives up on the call; whatever it started inside the
369
+ line as a string. `cwd` runs the command from that directory; a `cwd` that
370
+ doesn't exist fails fast instead of silently running in the default
371
+ directory. `timeout` gives up on the call; whatever it started inside the
369
372
  machine may well still be running.
370
373
 
374
+ **`result.stdout`/`result.stderr` are UTF-8-decoded with `errors="replace"`:**
375
+ any byte sequence that isn't valid UTF-8 is silently replaced with `U+FFFD`,
376
+ changing the length with no error raised. Fine for text output; corrupts
377
+ binary output. For binary or byte-exact output, read from `stream_exec`
378
+ instead — its chunks are raw `bytes`, never decoded:
379
+
380
+ ```python
381
+ with boxd.machines.stream_exec(id, command="cat file.bin") as stream:
382
+ data = b"".join(stream) # byte-exact
383
+ ```
384
+
371
385
  For anything interactive, `stream_exec` gives you a live session — the one
372
386
  handle in the SDK, because a bidirectional stream really is stateful:
373
387
 
@@ -420,13 +434,17 @@ from pathlib import Path
420
434
 
421
435
  written = boxd.machines.files.upload(id, "/app/config.json", '{"debug": true}')
422
436
  boxd.machines.files.upload(id, "/app/data.bin", Path("local.bin").read_bytes())
437
+ boxd.machines.files.upload(id, "notes.txt", "relative to /home/boxd")
423
438
  data = boxd.machines.files.download(id, "/app/output.json") # bytes
424
439
  ```
425
440
 
426
441
  `upload` takes `str` or `bytes`, streams it in chunks so large files are fine,
427
442
  and returns the number of bytes the machine confirmed it wrote. `download`
428
443
  streams too and returns the whole file as `bytes` — there is no size cap, but
429
- it is all held in memory.
444
+ it is all held in memory. Paths are either absolute (`/app/config.json`) or
445
+ relative to the VM user's home directory (`/home/boxd`) — a bare filename
446
+ like `"notes.txt"` resolves to `/home/boxd/notes.txt`, same as `"./notes.txt"`
447
+ or `"sub/notes.txt"` would.
430
448
 
431
449
  ### Ports and proxies
432
450
 
@@ -468,6 +486,16 @@ with one. `create` answers as soon as the route is accepted, so it confirms the
468
486
  subdomain and the port it was pointed at; `list()` reports the full domain and
469
487
  the resolved port.
470
488
 
489
+ `port_mode="auto"` detection runs continuously for the whole life of the
490
+ machine, not just once at boot: whenever the app binds one of a curated list
491
+ of common ports (80, 443, 8080, 8000, 3000, 5000, 5173), that becomes a
492
+ candidate, and it's adopted once roughly 750ms have passed with no *different*
493
+ candidate port seen (an already-adopted port won't be replaced again more
494
+ than once every 5s). So a redeploy or restart that moves your app onto a new
495
+ port is picked up on its own within a few seconds — no action needed. If your
496
+ app doesn't listen on one of those ports, or you don't want to wait out the
497
+ detection window, call `set_port` with the port explicitly instead of `"auto"`.
498
+
471
499
  ### Checkpoints
472
500
 
473
501
  Per-machine captures, restored in place. They are deleted with the machine.
@@ -326,6 +326,7 @@ result.success # bool
326
326
 
327
327
  boxd.machines.exec(id, ["echo", "a b"]) # a list is quoted for you
328
328
  boxd.machines.exec(id, "env", env={"FOO": "bar"})
329
+ boxd.machines.exec(id, "make", cwd="/srv/app") # run from a working directory
329
330
  boxd.machines.exec(id, "cargo build", timeout=30) # seconds
330
331
 
331
332
  # Under a PTY, stderr merges into stdout and `stderr` comes back empty.
@@ -333,9 +334,22 @@ boxd.machines.exec(id, "top -b -n1", tty=True, cols=120, rows=40)
333
334
  ```
334
335
 
335
336
  `command` takes a list of argv — shell-quoted for you — or a ready-made command
336
- line as a string. `timeout` gives up on the call; whatever it started inside the
337
+ line as a string. `cwd` runs the command from that directory; a `cwd` that
338
+ doesn't exist fails fast instead of silently running in the default
339
+ directory. `timeout` gives up on the call; whatever it started inside the
337
340
  machine may well still be running.
338
341
 
342
+ **`result.stdout`/`result.stderr` are UTF-8-decoded with `errors="replace"`:**
343
+ any byte sequence that isn't valid UTF-8 is silently replaced with `U+FFFD`,
344
+ changing the length with no error raised. Fine for text output; corrupts
345
+ binary output. For binary or byte-exact output, read from `stream_exec`
346
+ instead — its chunks are raw `bytes`, never decoded:
347
+
348
+ ```python
349
+ with boxd.machines.stream_exec(id, command="cat file.bin") as stream:
350
+ data = b"".join(stream) # byte-exact
351
+ ```
352
+
339
353
  For anything interactive, `stream_exec` gives you a live session — the one
340
354
  handle in the SDK, because a bidirectional stream really is stateful:
341
355
 
@@ -388,13 +402,17 @@ from pathlib import Path
388
402
 
389
403
  written = boxd.machines.files.upload(id, "/app/config.json", '{"debug": true}')
390
404
  boxd.machines.files.upload(id, "/app/data.bin", Path("local.bin").read_bytes())
405
+ boxd.machines.files.upload(id, "notes.txt", "relative to /home/boxd")
391
406
  data = boxd.machines.files.download(id, "/app/output.json") # bytes
392
407
  ```
393
408
 
394
409
  `upload` takes `str` or `bytes`, streams it in chunks so large files are fine,
395
410
  and returns the number of bytes the machine confirmed it wrote. `download`
396
411
  streams too and returns the whole file as `bytes` — there is no size cap, but
397
- it is all held in memory.
412
+ it is all held in memory. Paths are either absolute (`/app/config.json`) or
413
+ relative to the VM user's home directory (`/home/boxd`) — a bare filename
414
+ like `"notes.txt"` resolves to `/home/boxd/notes.txt`, same as `"./notes.txt"`
415
+ or `"sub/notes.txt"` would.
398
416
 
399
417
  ### Ports and proxies
400
418
 
@@ -436,6 +454,16 @@ with one. `create` answers as soon as the route is accepted, so it confirms the
436
454
  subdomain and the port it was pointed at; `list()` reports the full domain and
437
455
  the resolved port.
438
456
 
457
+ `port_mode="auto"` detection runs continuously for the whole life of the
458
+ machine, not just once at boot: whenever the app binds one of a curated list
459
+ of common ports (80, 443, 8080, 8000, 3000, 5000, 5173), that becomes a
460
+ candidate, and it's adopted once roughly 750ms have passed with no *different*
461
+ candidate port seen (an already-adopted port won't be replaced again more
462
+ than once every 5s). So a redeploy or restart that moves your app onto a new
463
+ port is picked up on its own within a few seconds — no action needed. If your
464
+ app doesn't listen on one of those ports, or you don't want to wait out the
465
+ detection window, call `set_port` with the port explicitly instead of `"auto"`.
466
+
439
467
  ### Checkpoints
440
468
 
441
469
  Per-machine captures, restored in place. They are deleted with the machine.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "boxd"
3
- version = "0.2.10.dev609"
3
+ version = "0.2.10.dev616"
4
4
  description = "Python SDK for the boxd cloud VM platform"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -172,10 +172,17 @@ class Machine(BaseModel):
172
172
 
173
173
 
174
174
  class ExecResult(BaseModel):
175
- """A one-shot `machines.exec`."""
175
+ """A one-shot `machines.exec`.
176
176
 
177
- stdout: str
178
- stderr: str
177
+ `stdout`/`stderr` are `str`, UTF-8-decoded, unless `encoding="buffer"`
178
+ was passed to `exec`, in which case they're the raw `bytes` instead. The
179
+ default decode is lossy — any invalid byte sequence is replaced with
180
+ U+FFFD — so binary output (or output you need byte-exact) should use
181
+ `encoding="buffer"` or `stream_exec`, which is always byte-exact.
182
+ """
183
+
184
+ stdout: str | bytes
185
+ stderr: str | bytes
179
186
  exit_code: int
180
187
 
181
188
  @property
@@ -119,6 +119,7 @@ def _exec_init(
119
119
  tty: bool,
120
120
  cols: int,
121
121
  rows: int,
122
+ cwd: str | None = None,
122
123
  ) -> api_pb2.ExecChunk:
123
124
  """The first chunk of an `Exec` stream: command, PTY flags, geometry."""
124
125
  line = req.join_command(command)
@@ -127,6 +128,12 @@ def _exec_init(
127
128
  # shell, so prefix assignments do the job.
128
129
  prefix = " ".join(f"{k}={shlex.quote(v)}" for k, v in env.items())
129
130
  line = f"{prefix} {line}"
131
+ if cwd:
132
+ # Same reasoning as `env`: no cwd field on ExecChunk, so `cd` ahead of
133
+ # the command through the same shell. `&&` means a `cwd` that doesn't
134
+ # exist fails fast with `cd`'s own error instead of silently running
135
+ # in the default directory (BOX-292).
136
+ line = f"cd {shlex.quote(cwd)} && {line}"
130
137
  return req.exec_chunk(
131
138
  machine_id=machine_id, command=line, tty=tty, cols=cols, rows=rows
132
139
  )
@@ -151,7 +158,26 @@ def _exec_timeout(timeout: float | None):
151
158
  raise
152
159
 
153
160
 
154
- def _collect_exec(chunks) -> ExecResult:
161
+ def _finish_exec(
162
+ stdout: bytes, stderr: bytes, exit_code: int, encoding: str
163
+ ) -> ExecResult:
164
+ """Build the final `ExecResult`, honoring `encoding`.
165
+
166
+ `"buffer"` returns the raw bytes untouched — byte-exact, same as
167
+ `stream_exec`. Anything else (default `"utf-8"`) decodes lossily: any
168
+ invalid byte sequence is replaced with U+FFFD, so binary output should
169
+ use `"buffer"` instead (see `ExecResult`).
170
+ """
171
+ if encoding == "buffer":
172
+ return ExecResult(stdout=stdout, stderr=stderr, exit_code=exit_code)
173
+ return ExecResult(
174
+ stdout=stdout.decode("utf-8", errors="replace"),
175
+ stderr=stderr.decode("utf-8", errors="replace"),
176
+ exit_code=exit_code,
177
+ )
178
+
179
+
180
+ def _collect_exec(chunks, encoding: str = "utf-8") -> ExecResult:
155
181
  """Fold an `Exec` response stream into a one-shot result."""
156
182
  stdout: list[bytes] = []
157
183
  stderr: list[bytes] = []
@@ -160,11 +186,7 @@ def _collect_exec(chunks) -> ExecResult:
160
186
  if chunk.data:
161
187
  (stderr if chunk.is_stderr else stdout).append(chunk.data)
162
188
  exit_code = chunk.exit_code
163
- return ExecResult(
164
- stdout=b"".join(stdout).decode("utf-8", errors="replace"),
165
- stderr=b"".join(stderr).decode("utf-8", errors="replace"),
166
- exit_code=exit_code,
167
- )
189
+ return _finish_exec(b"".join(stdout), b"".join(stderr), exit_code, encoding)
168
190
 
169
191
 
170
192
  def _ready_check(machine: Machine, name: str) -> bool:
@@ -473,27 +495,39 @@ class Machines(Namespace):
473
495
  command: str | Sequence[str],
474
496
  *,
475
497
  env: Mapping[str, str] | None = None,
498
+ cwd: str | None = None,
476
499
  tty: bool = False,
477
500
  cols: int = 0,
478
501
  rows: int = 0,
479
502
  timeout: float | None = None,
503
+ encoding: Literal["utf-8", "buffer"] = "utf-8",
480
504
  ) -> ExecResult:
481
505
  """Run one command and collect its output.
482
506
 
483
507
  With `tty` the command runs under a PTY, which merges stderr into
484
508
  stdout — `stderr` comes back empty and everything lands in `stdout`.
485
509
 
510
+ `cwd` runs the command in that directory; a `cwd` that doesn't exist
511
+ fails fast (the remote `cd` errors and the command never runs)
512
+ instead of silently running in the default directory.
513
+
514
+ `stdout`/`stderr` are UTF-8-decoded by default, which silently
515
+ replaces any invalid byte sequence with U+FFFD — pass
516
+ `encoding="buffer"` for byte-exact `bytes` output instead (e.g.
517
+ `head -c 4096 /dev/urandom`).
518
+
486
519
  `timeout` is in seconds and gives up on the call; the server kills
487
520
  the remote process's whole process group (SIGTERM, then SIGKILL
488
521
  after a short grace period if it hasn't exited) rather than leaving
489
522
  it running with no one listening.
490
523
  """
491
524
  init = _exec_init(
492
- machine_id, command, env=env, tty=tty, cols=cols, rows=rows
525
+ machine_id, command, env=env, tty=tty, cols=cols, rows=rows, cwd=cwd
493
526
  )
494
527
  with _exec_timeout(timeout):
495
528
  return _collect_exec(
496
- self._transport.bidi("Exec", iter([init]), timeout=timeout)
529
+ self._transport.bidi("Exec", iter([init]), timeout=timeout),
530
+ encoding=encoding,
497
531
  )
498
532
 
499
533
  def stream_exec(
@@ -502,6 +536,7 @@ class Machines(Namespace):
502
536
  *,
503
537
  command: str | Sequence[str],
504
538
  env: Mapping[str, str] | None = None,
539
+ cwd: str | None = None,
505
540
  tty: bool = False,
506
541
  cols: int = 0,
507
542
  rows: int = 0,
@@ -514,7 +549,7 @@ class Machines(Namespace):
514
549
  "a PTY shell needs stdin open"
515
550
  )
516
551
  init = _exec_init(
517
- machine_id, command, env=env, tty=tty, cols=cols, rows=rows
552
+ machine_id, command, env=env, tty=tty, cols=cols, rows=rows, cwd=cwd
518
553
  )
519
554
  return ExecStream(self._transport, init, close_stdin=close_stdin)
520
555
 
@@ -1101,23 +1136,34 @@ class AsyncMachines(Namespace):
1101
1136
  command: str | Sequence[str],
1102
1137
  *,
1103
1138
  env: Mapping[str, str] | None = None,
1139
+ cwd: str | None = None,
1104
1140
  tty: bool = False,
1105
1141
  cols: int = 0,
1106
1142
  rows: int = 0,
1107
1143
  timeout: float | None = None,
1144
+ encoding: Literal["utf-8", "buffer"] = "utf-8",
1108
1145
  ) -> ExecResult:
1109
1146
  """Run one command and collect its output.
1110
1147
 
1111
1148
  With `tty` the command runs under a PTY, which merges stderr into
1112
1149
  stdout — `stderr` comes back empty and everything lands in `stdout`.
1113
1150
 
1151
+ `cwd` runs the command in that directory; a `cwd` that doesn't exist
1152
+ fails fast (the remote `cd` errors and the command never runs)
1153
+ instead of silently running in the default directory.
1154
+
1155
+ `stdout`/`stderr` are UTF-8-decoded by default, which silently
1156
+ replaces any invalid byte sequence with U+FFFD — pass
1157
+ `encoding="buffer"` for byte-exact `bytes` output instead (e.g.
1158
+ `head -c 4096 /dev/urandom`).
1159
+
1114
1160
  `timeout` is in seconds and gives up on the call; the server kills
1115
1161
  the remote process's whole process group (SIGTERM, then SIGKILL
1116
1162
  after a short grace period if it hasn't exited) rather than leaving
1117
1163
  it running with no one listening.
1118
1164
  """
1119
1165
  init = _exec_init(
1120
- machine_id, command, env=env, tty=tty, cols=cols, rows=rows
1166
+ machine_id, command, env=env, tty=tty, cols=cols, rows=rows, cwd=cwd
1121
1167
  )
1122
1168
 
1123
1169
  async def once():
@@ -1132,11 +1178,7 @@ class AsyncMachines(Namespace):
1132
1178
  if chunk.data:
1133
1179
  (stderr if chunk.is_stderr else stdout).append(chunk.data)
1134
1180
  exit_code = chunk.exit_code
1135
- return ExecResult(
1136
- stdout=b"".join(stdout).decode("utf-8", errors="replace"),
1137
- stderr=b"".join(stderr).decode("utf-8", errors="replace"),
1138
- exit_code=exit_code,
1139
- )
1181
+ return _finish_exec(b"".join(stdout), b"".join(stderr), exit_code, encoding)
1140
1182
 
1141
1183
  def stream_exec(
1142
1184
  self,
@@ -1144,6 +1186,7 @@ class AsyncMachines(Namespace):
1144
1186
  *,
1145
1187
  command: str | Sequence[str],
1146
1188
  env: Mapping[str, str] | None = None,
1189
+ cwd: str | None = None,
1147
1190
  tty: bool = False,
1148
1191
  cols: int = 0,
1149
1192
  rows: int = 0,
@@ -1156,7 +1199,7 @@ class AsyncMachines(Namespace):
1156
1199
  "a PTY shell needs stdin open"
1157
1200
  )
1158
1201
  init = _exec_init(
1159
- machine_id, command, env=env, tty=tty, cols=cols, rows=rows
1202
+ machine_id, command, env=env, tty=tty, cols=cols, rows=rows, cwd=cwd
1160
1203
  )
1161
1204
  return AsyncExecStream(self._transport, init, close_stdin=close_stdin)
1162
1205
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: boxd
3
- Version: 0.2.10.dev609
3
+ Version: 0.2.10.dev616
4
4
  Summary: Python SDK for the boxd cloud VM platform
5
5
  Author: Azin
6
6
  License-Expression: MIT
@@ -358,6 +358,7 @@ result.success # bool
358
358
 
359
359
  boxd.machines.exec(id, ["echo", "a b"]) # a list is quoted for you
360
360
  boxd.machines.exec(id, "env", env={"FOO": "bar"})
361
+ boxd.machines.exec(id, "make", cwd="/srv/app") # run from a working directory
361
362
  boxd.machines.exec(id, "cargo build", timeout=30) # seconds
362
363
 
363
364
  # Under a PTY, stderr merges into stdout and `stderr` comes back empty.
@@ -365,9 +366,22 @@ boxd.machines.exec(id, "top -b -n1", tty=True, cols=120, rows=40)
365
366
  ```
366
367
 
367
368
  `command` takes a list of argv — shell-quoted for you — or a ready-made command
368
- line as a string. `timeout` gives up on the call; whatever it started inside the
369
+ line as a string. `cwd` runs the command from that directory; a `cwd` that
370
+ doesn't exist fails fast instead of silently running in the default
371
+ directory. `timeout` gives up on the call; whatever it started inside the
369
372
  machine may well still be running.
370
373
 
374
+ **`result.stdout`/`result.stderr` are UTF-8-decoded with `errors="replace"`:**
375
+ any byte sequence that isn't valid UTF-8 is silently replaced with `U+FFFD`,
376
+ changing the length with no error raised. Fine for text output; corrupts
377
+ binary output. For binary or byte-exact output, read from `stream_exec`
378
+ instead — its chunks are raw `bytes`, never decoded:
379
+
380
+ ```python
381
+ with boxd.machines.stream_exec(id, command="cat file.bin") as stream:
382
+ data = b"".join(stream) # byte-exact
383
+ ```
384
+
371
385
  For anything interactive, `stream_exec` gives you a live session — the one
372
386
  handle in the SDK, because a bidirectional stream really is stateful:
373
387
 
@@ -420,13 +434,17 @@ from pathlib import Path
420
434
 
421
435
  written = boxd.machines.files.upload(id, "/app/config.json", '{"debug": true}')
422
436
  boxd.machines.files.upload(id, "/app/data.bin", Path("local.bin").read_bytes())
437
+ boxd.machines.files.upload(id, "notes.txt", "relative to /home/boxd")
423
438
  data = boxd.machines.files.download(id, "/app/output.json") # bytes
424
439
  ```
425
440
 
426
441
  `upload` takes `str` or `bytes`, streams it in chunks so large files are fine,
427
442
  and returns the number of bytes the machine confirmed it wrote. `download`
428
443
  streams too and returns the whole file as `bytes` — there is no size cap, but
429
- it is all held in memory.
444
+ it is all held in memory. Paths are either absolute (`/app/config.json`) or
445
+ relative to the VM user's home directory (`/home/boxd`) — a bare filename
446
+ like `"notes.txt"` resolves to `/home/boxd/notes.txt`, same as `"./notes.txt"`
447
+ or `"sub/notes.txt"` would.
430
448
 
431
449
  ### Ports and proxies
432
450
 
@@ -468,6 +486,16 @@ with one. `create` answers as soon as the route is accepted, so it confirms the
468
486
  subdomain and the port it was pointed at; `list()` reports the full domain and
469
487
  the resolved port.
470
488
 
489
+ `port_mode="auto"` detection runs continuously for the whole life of the
490
+ machine, not just once at boot: whenever the app binds one of a curated list
491
+ of common ports (80, 443, 8080, 8000, 3000, 5000, 5173), that becomes a
492
+ candidate, and it's adopted once roughly 750ms have passed with no *different*
493
+ candidate port seen (an already-adopted port won't be replaced again more
494
+ than once every 5s). So a redeploy or restart that moves your app onto a new
495
+ port is picked up on its own within a few seconds — no action needed. If your
496
+ app doesn't listen on one of those ports, or you don't want to wait out the
497
+ detection window, call `set_port` with the port explicitly instead of `"auto"`.
498
+
471
499
  ### Checkpoints
472
500
 
473
501
  Per-machine captures, restored in place. They are deleted with the machine.
@@ -186,12 +186,36 @@ def test_exec_folds_the_stream_into_one_result():
186
186
  assert transport.sent[0].vm_id == "alpha"
187
187
 
188
188
 
189
+ def test_exec_default_encoding_corrupts_invalid_utf8():
190
+ # BOX-280: the default string mode is lossy — invalid bytes become
191
+ # U+FFFD and the byte length changes. Documents the failure this issue
192
+ # is about, alongside the encoding="buffer" escape hatch below.
193
+ transport = FakeTransport([[api_pb2.ExecChunk(data=b"\x00\x01\xff\xfe", exit_code=0)]])
194
+ result = Machines(transport).exec("alpha", "printf '\\000\\001\\377\\376'")
195
+ assert result.stdout == "\x00\x01��"
196
+ assert isinstance(result.stdout, str)
197
+
198
+
199
+ def test_exec_buffer_encoding_is_byte_exact():
200
+ raw = bytes(range(256))
201
+ transport = FakeTransport([[api_pb2.ExecChunk(data=raw, exit_code=0)]])
202
+ result = Machines(transport).exec("alpha", "cat", encoding="buffer")
203
+ assert result.stdout == raw
204
+ assert isinstance(result.stdout, bytes)
205
+
206
+
189
207
  def test_exec_prefixes_env_assignments():
190
208
  transport = FakeTransport([[api_pb2.ExecChunk(exit_code=0)]])
191
209
  Machines(transport).exec("alpha", "env", env={"MODE": "a b"})
192
210
  assert transport.sent[0].command == "MODE='a b' env"
193
211
 
194
212
 
213
+ def test_exec_cwd_prepends_a_cd():
214
+ transport = FakeTransport([[api_pb2.ExecChunk(exit_code=0)]])
215
+ Machines(transport).exec("alpha", "pwd", cwd="/opt/my app")
216
+ assert transport.sent[0].command == "cd '/opt/my app' && pwd"
217
+
218
+
195
219
  def test_exec_reports_a_nonzero_exit():
196
220
  transport = FakeTransport([[api_pb2.ExecChunk(exit_code=3)]])
197
221
  result = Machines(transport).exec("alpha", "false")
@@ -666,6 +690,14 @@ async def test_async_exec_folds_the_stream():
666
690
  assert result.exit_code == 0
667
691
 
668
692
 
693
+ async def test_async_exec_buffer_encoding_is_byte_exact():
694
+ raw = bytes(range(256))
695
+ transport = FakeAsyncTransport([[api_pb2.ExecChunk(data=raw, exit_code=0)]])
696
+ result = await AsyncMachines(transport).exec("alpha", "cat", encoding="buffer")
697
+ assert result.stdout == raw
698
+ assert isinstance(result.stdout, bytes)
699
+
700
+
669
701
  async def test_async_list_returns_a_plain_list():
670
702
  transport = FakeAsyncTransport(
671
703
  [api_pb2.ListVmsResponse(vms=[a_vm("alpha"), a_vm("beta")])]
File without changes
File without changes