From bed4a6dbddcaf3436a4aaaee4e57a70d7b783b96 Mon Sep 17 00:00:00 2001 From: parnexcodes Date: Sun, 11 Oct 2026 06:33:48 +0530 Subject: [PATCH 1/2] fix: file helper polish (statuses, early size check, docs, CI skips) Align error statuses with dock-js and dock-go (200 for file_command_failed and output_truncated, 0 for file_too_large), restore a cheap early size check in files.write before base64-encoding, document non-UTF-8 names, list on a regular file and symlinked dirs, and fail CI when unit tests skip. Fixes #11 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 5 ++++- README.md | 11 ++++++++--- src/usedock/client.py | 19 +++++++++++++++++-- tests/test_client.py | 30 ++++++++++++++++++++++++++++-- 4 files changed, 57 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 96a34f9..01655d2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,7 +24,10 @@ 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. + run: | + 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" diff --git a/README.md b/README.md index b96963a..388313e 100644 --- a/README.md +++ b/README.md @@ -53,14 +53,19 @@ 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 match: | 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 | diff --git a/src/usedock/client.py b/src/usedock/client.py index 7bb6204..29d137a 100644 --- a/src/usedock/client.py +++ b/src/usedock/client.py @@ -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 @@ -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 = ( diff --git a/tests/test_client.py b/tests/test_client.py index cdff6b4..24eb5bf 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -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: @@ -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 ( @@ -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 @@ -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") @@ -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: From cfa0fe4f536f61963efeefe1b93975e4f6a7a284 Mon Sep 17 00:00:00 2001 From: parnexcodes Date: Sun, 11 Oct 2026 06:38:06 +0530 Subject: [PATCH 2/2] fix: fail CI when unit tests fail behind tee; fix README example The default Actions shell is `bash -e` without pipefail, so `pytest | tee unit.log` exited with tee's status and a failing unit test passed CI. Set pipefail in the step. README: the files.write example had "hi\n" split across two lines, and the status note implied dock-js and dock-go have file_not_utf8. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 2 ++ README.md | 6 +++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 01655d2..e257ac4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,7 +25,9 @@ jobs: - run: mypy - name: Unit tests # -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 diff --git a/README.md b/README.md index 388313e..fdb64a1 100644 --- a/README.md +++ b/README.md @@ -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) @@ -61,7 +60,8 @@ raises `file_command_failed`, and a symlink to a directory lists its target. 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 match: +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 | |---|---|