Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,12 @@ jobs:
- run: ruff format --check .
- run: mypy
- name: Unit tests
run: pytest tests/test_client.py tests/test_bash_resolver.py
# -rs lists skips; under CI the bash-based tests must run, never skip.
# The default shell is `bash -e` without pipefail, so set it or tee hides a failing pytest.
run: |
set -o pipefail
pytest tests/test_client.py tests/test_bash_resolver.py -rs | tee unit.log
if grep -E '^SKIPPED' unit.log; then echo "tests were skipped in CI"; exit 1; fi
- name: Read pinned scenarios ref
id: ref
run: echo "sha=$(tr -d '[:space:]' < SCENARIOS_REF)" >> "$GITHUB_OUTPUT"
Expand Down
15 changes: 10 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,7 @@ with Dock() as client: # reads DOCK_API_KEY and DOCK_API_URL
run = client.docks.exec(dock.id, "python3 -c 'print(2 ** 10)'")
print(run.stdout, run.exit_code)

client.docks.files.write(dock.id, "/home/user/work/hello.txt", "hi
")
client.docks.files.write(dock.id, "/home/user/work/hello.txt", "hi\n")
print(client.docks.files.read(dock.id, "/home/user/work/hello.txt"))
finally:
client.docks.delete(dock.id)
Expand All @@ -53,14 +52,20 @@ resume today. `exec`, like `create` and `branch`, accepts `idempotency_key=`.
The API has no file routes, so `files.*` run one `exec` call: content travels
as base64, paths are single-quoted, and the command must fit the API's 64 KB
limit (about 48 KB of content minus the path). `files.list` returns every entry
name, hidden ones included, in no fixed order; names may hold newlines.
name, hidden ones included, in no fixed order; names may hold newlines. A name
that is not valid UTF-8 comes back with U+FFFD in place of the bad bytes, so it
cannot be passed back to `files.read` or `files.write`. Listing a regular file
raises `file_command_failed`, and a symlink to a directory lists its target.
`files.read` decodes UTF-8; use `files.read_bytes` for binary files.

File helpers raise `DockError` (status 0) with these codes:
File helpers raise `DockError` with these codes. `status` is 200 when the exec
call itself succeeded (`file_command_failed`, `output_truncated`) and 0 when the
error is local (`file_too_large`, `file_not_utf8`). dock-js and dock-go use the
same statuses for the codes they share (`file_not_utf8` is Python only):

| Code | When |
|---|---|
| `file_too_large` | the command would exceed 64 KB; raised before anything is sent |
| `file_too_large` | content is over 48 KB, or the whole command would exceed 64 KB; raised before anything is sent |
| `file_command_failed` | the command exits non-zero (the message carries stderr) |
| `output_truncated` | the API clipped the output at 1 MiB (for example a read of a file over about 768 KB), so partial content is never returned |
| `file_not_utf8` | `files.read` got bytes that are not valid UTF-8 |
Expand Down
19 changes: 17 additions & 2 deletions src/usedock/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@
# write can never exceed 48 KB. The real limit is the whole command (content
# plus the quoted path, twice, plus the template); ``Files.write`` raises
# ``DockError("file_too_large")`` before sending when it is over.
#
# Statuses on file-helper errors match dock-js and dock-go: ``file_command_failed``
# and ``output_truncated`` carry 200 (the HTTP exec succeeded), ``file_too_large``
# carries 0 (it is raised client-side, nothing was sent).
MAX_WRITE_BYTES = 48 * 1024
MAX_COMMAND_BYTES = 64 * 1024

Expand Down Expand Up @@ -304,15 +308,26 @@ def _run(self, dock_id: str, command: str, what: str) -> ExecResult:
result = self._docks.exec(dock_id, command)
if result.exit_code != 0:
detail = result.stderr.strip() or f"exit code {result.exit_code}"
raise DockError("file_command_failed", 0, f"{what}: {detail}")
raise DockError("file_command_failed", 200, f"{what}: {detail}")
if result.truncated:
# The API clips stdout/stderr at 1 MiB; a clipped base64 prefix still
# decodes, so returning it would silently hand back partial content.
raise DockError("output_truncated", 0, f"{what}: output exceeded the exec output limit")
raise DockError(
"output_truncated", 200, f"{what}: output exceeded the exec output limit"
)
return result

def write(self, dock_id: str, path: str, content: Union[str, bytes]) -> str:
raw = content.encode("utf-8") if isinstance(content, str) else content
if len(raw) > MAX_WRITE_BYTES:
# Cheap early check: over 48 KB always exceeds the command limit, so
# skip base64-encoding (and building a command from) huge input.
raise DockError(
"file_too_large",
0,
f"write {path}: content is {len(raw)} bytes; the limit is {MAX_WRITE_BYTES} "
f"bytes (the exec command is capped at {MAX_COMMAND_BYTES} bytes)",
)
q = shell_quote(path)
encoded = base64.b64encode(raw).decode("ascii")
command = (
Expand Down
30 changes: 28 additions & 2 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,22 @@ def test_files_write_too_large_is_file_too_large() -> None:
assert len(seen) == 0


def test_files_write_huge_input_fails_fast_without_encoding(
monkeypatch: pytest.MonkeyPatch,
) -> None:
def boom(*args: Any, **kwargs: Any) -> bytes:
raise AssertionError("base64 must not run for oversized content")

monkeypatch.setattr(base64, "b64encode", boom)
client, seen = recorder(exec_ok())
huge = bytes(500 * 1024 * 1024) # 500 MB, zero-filled (lazily allocated)
with pytest.raises(DockError) as info:
client.docks.files.write("d1", "/a", huge)
assert (info.value.code, info.value.status) == ("file_too_large", 0)
assert str(len(huge)) in info.value.message
assert len(seen) == 0


def largest_accepted_write(client: Dock, path: str) -> int:
for size in range(MAX_WRITE_BYTES, 0, -3):
try:
Expand Down Expand Up @@ -288,6 +304,14 @@ def test_files_read_and_list() -> None:
assert body_of(seen2[0])["command"] == LIST_COMMAND


def test_files_list_non_utf8_name_becomes_replacement_char() -> None:
# A name that is not valid UTF-8 is decoded with U+FFFD, so it cannot be
# passed back to read or write; the original bytes are not recoverable.
listing = base64.b64encode(b"bad\xffname").decode() + "\n"
client, _ = recorder(exec_ok(listing))
assert client.docks.files.list("d1", "/d") == ["bad\ufffdname"]


def test_files_nonzero_exit_is_error() -> None:
client, _ = recorder(exec_ok("", 1, "cat: no such file\n"))
for call in (
Expand All @@ -297,7 +321,7 @@ def test_files_nonzero_exit_is_error() -> None:
):
with pytest.raises(DockError) as info:
call()
assert (info.value.code, info.value.status) == ("file_command_failed", 0)
assert (info.value.code, info.value.status) == ("file_command_failed", 200)
assert "no such file" in info.value.message


Expand All @@ -308,7 +332,7 @@ def test_files_truncated_output_is_error() -> None:
client, _ = recorder(exec_ok(clipped, truncated=True))
with pytest.raises(DockError) as info:
client.docks.files.read("d1", "/big")
assert (info.value.code, info.value.status) == ("output_truncated", 0)
assert (info.value.code, info.value.status) == ("output_truncated", 200)
client2, _ = recorder(exec_ok(b64("a") + "\n" + b64("b") + "\n", truncated=True))
with pytest.raises(DockError) as info2:
client2.docks.files.list("d1", "/d")
Expand Down Expand Up @@ -404,6 +428,8 @@ def test_files_list_includes_hidden_and_follows_symlinked_dir(tmp_path: Path) ->
try:
(tmp_path / "link").symlink_to(tmp_path / "real", target_is_directory=True)
except OSError:
if os.environ.get("CI"):
pytest.fail("symlinks unavailable in CI")
pytest.skip("symlinks unavailable")
assert sorted(files.list("d1", "link")) == [".hidden", "visible"]
with pytest.raises(DockError) as info:
Expand Down
Loading