From 963b79fc9c5410ec54c68b22d6431b967318255b Mon Sep 17 00:00:00 2001 From: parnexcodes Date: Sun, 11 Oct 2026 06:45:28 +0530 Subject: [PATCH] feat: clear auto-stop on resume; size-check file read/list resume(ttl_seconds=UNSET): None sends ttlSeconds null, a number sets it. files.read/read_bytes/list raise file_too_large (status 0) before sending when the UTF-8 command exceeds the 64 KB exec limit. Fixes #20 Fixes #19 Co-Authored-By: Claude Opus 5.5 --- README.md | 7 +++++-- src/usedock/client.py | 36 ++++++++++++++++++++++++++---------- tests/test_client.py | 20 ++++++++++++++++++++ 3 files changed, 51 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index c3613f7..83b22d4 100644 --- a/README.md +++ b/README.md @@ -46,8 +46,11 @@ Unknown response fields are kept in `.raw`. Omitted arguments are not sent. `update(ttl_seconds=None)` sends JSON `null` to turn auto-stop off; leave it out (the default, `usedock.UNSET`) to keep the -current value. `resume` does not take this: the API ignores `ttl_seconds` on -resume today. `exec`, like `create` and `branch`, accepts `idempotency_key=`. +current value. `resume(ttl_seconds=...)` works the same way: a number sets +auto-stop (counted from ready), `None` clears it, and omitting it keeps the +current value. `files.read` and `files.list` raise `file_too_large` (before +sending) when the path makes the command exceed the 64 KB exec limit. +`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 diff --git a/src/usedock/client.py b/src/usedock/client.py index 70a5b6d..2bee5f3 100644 --- a/src/usedock/client.py +++ b/src/usedock/client.py @@ -242,21 +242,23 @@ def resume( cpus: Optional[int] = None, memory_mib: Optional[int] = None, disk_gib: Optional[int] = None, - ttl_seconds: Optional[int] = None, + ttl_seconds: Optional[int] = UNSET, environment: Optional[str] = None, no_env: Optional[bool] = None, ) -> DockState: data = self._c._request( "POST", f"/v1/docks/{_seg(dock_id)}/resume", - body=_body( - cpus=cpus, - memoryMiB=memory_mib, - diskGiB=disk_gib, - ttlSeconds=ttl_seconds, - environment=environment, - noEnv=no_env, - ), + body={ + **_body( + cpus=cpus, + memoryMiB=memory_mib, + diskGiB=disk_gib, + environment=environment, + noEnv=no_env, + ), + **({} if ttl_seconds is UNSET else {"ttlSeconds": ttl_seconds}), + }, ) return DockState.from_dict(data) @@ -321,6 +323,17 @@ def _run(self, dock_id: str, command: str, what: str) -> ExecResult: ) return result + @staticmethod + def _check_size(command: str, what: str) -> None: + size = len(command.encode("utf-8")) + if size > MAX_COMMAND_BYTES: + raise DockError( + "file_too_large", + 0, + f"{what}: the exec command would be {size} bytes; the limit is " + f"{MAX_COMMAND_BYTES} (the path is too long)", + ) + 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: @@ -349,7 +362,9 @@ def write(self, dock_id: str, path: str, content: Union[str, bytes]) -> str: return path def read_bytes(self, dock_id: str, path: str) -> bytes: - result = self._run(dock_id, f"base64 -w0 -- {shell_quote(path)}", f"read {path}") + command = f"base64 -w0 -- {shell_quote(path)}" + self._check_size(command, f"read {path}") + result = self._run(dock_id, command, f"read {path}") return base64.b64decode(result.stdout) def read(self, dock_id: str, path: str) -> str: @@ -367,6 +382,7 @@ def list(self, dock_id: str, path: str) -> List[str]: 'find -H "$p" -mindepth 1 -maxdepth 1 -exec sh -c ' '\'for f; do printf "%s" "${f##*/}" | base64 -w0; echo; done\' sh {} +' ) + self._check_size(command, f"list {path}") result = self._run(dock_id, command, f"list {path}") return [ base64.b64decode(line).decode("utf-8", errors="replace") diff --git a/tests/test_client.py b/tests/test_client.py index b6ffb20..caa3cc2 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -621,3 +621,23 @@ def test_other_codes_stay_plain_dock_error() -> None: with pytest.raises(DockError) as info: client.docks.get("d") assert type(info.value) is DockError + + +def test_resume_ttl_unset_null_and_number() -> None: + client, seen = recorder(ok(DOCK, 202)) + client.docks.resume("d1") + client.docks.resume("d1", ttl_seconds=None) + client.docks.resume("d1", ttl_seconds=600, cpus=2) + assert body_of(seen[0]) == {} + assert body_of(seen[1]) == {"ttlSeconds": None} + assert body_of(seen[2]) == {"cpus": 2, "ttlSeconds": 600} + + +@pytest.mark.parametrize("op", ["read", "read_bytes", "list"]) +def test_files_read_list_over_command_limit_raise_before_sending(op: str) -> None: + client, seen = recorder(exec_ok()) + path = "/w/" + "é" * 40000 # multibyte: UTF-8 bytes, not characters, are counted + with pytest.raises(DockError) as info: + getattr(client.docks.files, op)("d1", path) + assert (info.value.code, info.value.status) == ("file_too_large", 0) + assert len(seen) == 0