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.
- {boxd-0.2.10.dev609/src/boxd.egg-info → boxd-0.2.10.dev616}/PKG-INFO +31 -3
- {boxd-0.2.10.dev609 → boxd-0.2.10.dev616}/README.md +30 -2
- {boxd-0.2.10.dev609 → boxd-0.2.10.dev616}/pyproject.toml +1 -1
- {boxd-0.2.10.dev609 → boxd-0.2.10.dev616}/src/boxd/models.py +10 -3
- {boxd-0.2.10.dev609 → boxd-0.2.10.dev616}/src/boxd/resources/machines.py +59 -16
- {boxd-0.2.10.dev609 → boxd-0.2.10.dev616/src/boxd.egg-info}/PKG-INFO +31 -3
- {boxd-0.2.10.dev609 → boxd-0.2.10.dev616}/tests/test_namespaces.py +32 -0
- {boxd-0.2.10.dev609 → boxd-0.2.10.dev616}/LICENSE +0 -0
- {boxd-0.2.10.dev609 → boxd-0.2.10.dev616}/setup.cfg +0 -0
- {boxd-0.2.10.dev609 → boxd-0.2.10.dev616}/src/boxd/__init__.py +0 -0
- {boxd-0.2.10.dev609 → boxd-0.2.10.dev616}/src/boxd/_client.py +0 -0
- {boxd-0.2.10.dev609 → boxd-0.2.10.dev616}/src/boxd/_credentials.py +0 -0
- {boxd-0.2.10.dev609 → boxd-0.2.10.dev616}/src/boxd/_generated/__init__.py +0 -0
- {boxd-0.2.10.dev609 → boxd-0.2.10.dev616}/src/boxd/_generated/api_pb2.py +0 -0
- {boxd-0.2.10.dev609 → boxd-0.2.10.dev616}/src/boxd/_generated/api_pb2_grpc.py +0 -0
- {boxd-0.2.10.dev609 → boxd-0.2.10.dev616}/src/boxd/_mappers.py +0 -0
- {boxd-0.2.10.dev609 → boxd-0.2.10.dev616}/src/boxd/_requests.py +0 -0
- {boxd-0.2.10.dev609 → boxd-0.2.10.dev616}/src/boxd/_streaming.py +0 -0
- {boxd-0.2.10.dev609 → boxd-0.2.10.dev616}/src/boxd/_transport.py +0 -0
- {boxd-0.2.10.dev609 → boxd-0.2.10.dev616}/src/boxd/_urls.py +0 -0
- {boxd-0.2.10.dev609 → boxd-0.2.10.dev616}/src/boxd/_version_check.py +0 -0
- {boxd-0.2.10.dev609 → boxd-0.2.10.dev616}/src/boxd/errors.py +0 -0
- {boxd-0.2.10.dev609 → boxd-0.2.10.dev616}/src/boxd/resources/__init__.py +0 -0
- {boxd-0.2.10.dev609 → boxd-0.2.10.dev616}/src/boxd/resources/account.py +0 -0
- {boxd-0.2.10.dev609 → boxd-0.2.10.dev616}/src/boxd/resources/credentials.py +0 -0
- {boxd-0.2.10.dev609 → boxd-0.2.10.dev616}/src/boxd/resources/disks.py +0 -0
- {boxd-0.2.10.dev609 → boxd-0.2.10.dev616}/src/boxd/resources/domains.py +0 -0
- {boxd-0.2.10.dev609 → boxd-0.2.10.dev616}/src/boxd/resources/orgs.py +0 -0
- {boxd-0.2.10.dev609 → boxd-0.2.10.dev616}/src/boxd/resources/snapshots.py +0 -0
- {boxd-0.2.10.dev609 → boxd-0.2.10.dev616}/src/boxd/resources/vars.py +0 -0
- {boxd-0.2.10.dev609 → boxd-0.2.10.dev616}/src/boxd.egg-info/SOURCES.txt +0 -0
- {boxd-0.2.10.dev609 → boxd-0.2.10.dev616}/src/boxd.egg-info/dependency_links.txt +0 -0
- {boxd-0.2.10.dev609 → boxd-0.2.10.dev616}/src/boxd.egg-info/requires.txt +0 -0
- {boxd-0.2.10.dev609 → boxd-0.2.10.dev616}/src/boxd.egg-info/top_level.txt +0 -0
- {boxd-0.2.10.dev609 → boxd-0.2.10.dev616}/tests/test_credentials.py +0 -0
- {boxd-0.2.10.dev609 → boxd-0.2.10.dev616}/tests/test_mappers.py +0 -0
- {boxd-0.2.10.dev609 → boxd-0.2.10.dev616}/tests/test_requests.py +0 -0
- {boxd-0.2.10.dev609 → boxd-0.2.10.dev616}/tests/test_streaming.py +0 -0
- {boxd-0.2.10.dev609 → boxd-0.2.10.dev616}/tests/test_transport.py +0 -0
- {boxd-0.2.10.dev609 → boxd-0.2.10.dev616}/tests/test_urls.py +0 -0
- {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.
|
|
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. `
|
|
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. `
|
|
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.
|
|
@@ -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
|
|
178
|
-
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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. `
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|