From 00e325c702e0c670f5d556f3a7600899a3d23fec Mon Sep 17 00:00:00 2001 From: alitariksahin Date: Wed, 19 Aug 2026 18:54:30 +0300 Subject: [PATCH 1/3] feat(sdk): filesystem metadata operations in the JS and Python SDKs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Client support for the file-metadata endpoints (backend: DX-2944), in both SDKs so the published surface stays at parity. - `files.stat(path, { follow })` / `files.stat(path, follow=...)` — returns type (file/directory/symlink/other), size, mtime, inode, and an opaque `version` token for optimistic-concurrency guards. Defaults to lstat so a symlink is reported as such; `follow` dereferences it. - `files.mkdir(path, { parents })`, `files.rename(from, to)`, `files.remove(path, { recursive })`. - `files.read(path, { offset, length })` — bounded byte-range read. The range is selected by the presence of `length`, not its value, so an explicit length of 0 reads zero bytes instead of falling back to the whole file. Python mirrors the JS surface (async source of truth, sync client regenerated); ruff, mypy, the JS<->Python parity gate, and both test suites pass. --- .changeset/file-metadata-ops.md | 12 ++ packages/python-sdk/CHANGELOG.md | 10 ++ packages/python-sdk/PARITY.md | 1 + .../python-sdk/tests/_async/test_box_files.py | 137 ++++++++++++++++++ packages/python-sdk/upstash_box/__init__.py | 2 + .../python-sdk/upstash_box/_async/client.py | 73 +++++++++- .../python-sdk/upstash_box/_sync/client.py | 73 +++++++++- packages/python-sdk/upstash_box/types.py | 16 ++ packages/sdk/src/__tests__/box-files.test.ts | 130 +++++++++++++++++ packages/sdk/src/client.ts | 82 ++++++++++- packages/sdk/src/index.ts | 1 + packages/sdk/src/types.ts | 20 +++ 12 files changed, 545 insertions(+), 12 deletions(-) create mode 100644 .changeset/file-metadata-ops.md diff --git a/.changeset/file-metadata-ops.md b/.changeset/file-metadata-ops.md new file mode 100644 index 00000000..7600885e --- /dev/null +++ b/.changeset/file-metadata-ops.md @@ -0,0 +1,12 @@ +--- +"@upstash/box": patch +--- + +Add filesystem metadata operations to `box.files`: + +- `stat(path, { follow })` — type (file/directory/symlink/other), size, mtime, + inode, and an opaque `version` token for optimistic-concurrency guards. + Defaults to lstat; `follow: true` dereferences a final symlink. +- `mkdir(path, { parents })`, `rename(from, to)`, `remove(path, { recursive })`. +- `read(path, { offset, length })` — bounded byte-range read, so a large file can + be sliced instead of pulled whole. diff --git a/packages/python-sdk/CHANGELOG.md b/packages/python-sdk/CHANGELOG.md index 810a69cf..05766449 100644 --- a/packages/python-sdk/CHANGELOG.md +++ b/packages/python-sdk/CHANGELOG.md @@ -4,6 +4,16 @@ All notable changes to `upstash-box` (Python) are documented here. ## Unreleased +- `files.stat(path, follow=...)`, `files.mkdir(path, parents=...)`, + `files.rename(from_path, to_path)`, and `files.remove(path, recursive=...)` — + filesystem metadata and mutation operations. `stat` returns the entry type + (`file`/`directory`/`symlink`/`other`), size, mtime, inode, and an opaque + `version` token for optimistic-concurrency guards; it defaults to lstat, so a + symlink is reported as one unless `follow=True`. +- `files.read(path, offset=..., length=...)` — bounded byte-range read. Passing + `length` selects the range (an explicit `length=0` reads zero bytes); the + server rejects a length above 8 MiB. Mirrors `@upstash/box`. + - **Removed** `tab.run()` (the autonomous multi-step browser agent) and the `BrowserRunResult` / `BrowserRunStep` types. Stagehand v4 removed the agent primitive, so the browser exposes `observe` / `act` / `extract` only. For diff --git a/packages/python-sdk/PARITY.md b/packages/python-sdk/PARITY.md index 91b782b2..b19dfe89 100644 --- a/packages/python-sdk/PARITY.md +++ b/packages/python-sdk/PARITY.md @@ -26,6 +26,7 @@ JS `Run`/`StreamRun` → Python `Run`/`StreamRun` (+ `AsyncRun`/`AsyncStreamRun` | `agent.run` / `agent.stream` | `agent.run` / `agent.stream` | | `exec.command` / `code` / `stream` / `streamCode` | `exec.command` / `code` / `stream` / `stream_code` | | `files.read/write/list/upload/download` | `files.read/write/list/upload/download` | +| `files.stat/mkdir/rename/remove` | `files.stat/mkdir/rename/remove` | | `git.clone/diff/status/commit/updateConfig/push/createPR/exec/checkout` | `git.clone/diff/status/commit/update_config/push/create_pr/exec/checkout` | | `schedule.exec/agent/list/get/update/pause/resume/delete` | same (snake) | | `skills.add/remove/list` | `skills.add/remove/list` | diff --git a/packages/python-sdk/tests/_async/test_box_files.py b/packages/python-sdk/tests/_async/test_box_files.py index 497a13ee..8ca2269f 100644 --- a/packages/python-sdk/tests/_async/test_box_files.py +++ b/packages/python-sdk/tests/_async/test_box_files.py @@ -162,3 +162,140 @@ async def test_download_rejects_dotdot_name(tmp_path, monkeypatch): with pytest.raises(BoxError, match="Unsafe download filename"): await box.files.download(folder="sub") await box.aclose() + + +@respx.mock +async def test_read_file_range(): + box = await make_async_box(respx.mock) + route = respx.get(url__startswith=f"{BASE}/files/read").mock( + return_value=httpx.Response(200, json={"content": "EFG"}) + ) + content = await box.files.read("big.log", offset=4, length=3) + assert content == "EFG" + url = str(route.calls.last.request.url) + assert "offset=4" in url and "length=3" in url + await box.aclose() + + +@respx.mock +async def test_read_file_explicit_zero_length_is_not_whole_file(): + box = await make_async_box(respx.mock) + route = respx.get(url__startswith=f"{BASE}/files/read").mock( + return_value=httpx.Response(200, json={"content": ""}) + ) + await box.files.read("big.log", length=0) + assert "length=0" in str(route.calls.last.request.url) + await box.aclose() + + +@respx.mock +async def test_read_file_omits_range_when_not_requested(): + box = await make_async_box(respx.mock) + route = respx.get(url__startswith=f"{BASE}/files/read").mock( + return_value=httpx.Response(200, json={"content": "whole"}) + ) + await box.files.read("f.txt") + assert "length=" not in str(route.calls.last.request.url) + await box.aclose() + + +@respx.mock +async def test_stat_file(): + box = await make_async_box(respx.mock) + route = respx.get(url__startswith=f"{BASE}/files/stat").mock( + return_value=httpx.Response( + 200, + json={ + "type": "file", + "size": 12, + "mod_time": "2026-08-19T11:56:59Z", + "inode": 42, + "version": "42-1787-12", + }, + ) + ) + st = await box.files.stat("a.txt") + assert st.type == "file" + assert st.size == 12 + assert st.version == "42-1787-12" + url = str(route.calls.last.request.url) + assert "path=%2Fworkspace%2Fhome%2Fa.txt" in url + assert "follow=true" not in url + await box.aclose() + + +@respx.mock +async def test_stat_file_follow(): + box = await make_async_box(respx.mock) + route = respx.get(url__startswith=f"{BASE}/files/stat").mock( + return_value=httpx.Response( + 200, + json={"type": "file", "size": 0, "mod_time": "", "inode": 1, "version": "1"}, + ) + ) + await box.files.stat("link", follow=True) + assert "follow=true" in str(route.calls.last.request.url) + await box.aclose() + + +@respx.mock +async def test_mkdir(): + box = await make_async_box(respx.mock) + route = respx.post(f"{BASE}/files/mkdir").mock(return_value=httpx.Response(200, json={})) + await box.files.mkdir("a/b", parents=True) + assert last_json_body(route) == {"path": "/workspace/home/a/b", "parents": True} + await box.aclose() + + +@respx.mock +async def test_rename_file(): + box = await make_async_box(respx.mock) + route = respx.post(f"{BASE}/files/rename").mock(return_value=httpx.Response(200, json={})) + await box.files.rename("a.txt", "b.txt") + assert last_json_body(route) == { + "from": "/workspace/home/a.txt", + "to": "/workspace/home/b.txt", + } + await box.aclose() + + +@respx.mock +async def test_remove_file(): + box = await make_async_box(respx.mock) + route = respx.post(f"{BASE}/files/remove").mock(return_value=httpx.Response(200, json={})) + await box.files.remove("dir", recursive=True) + assert last_json_body(route) == {"path": "/workspace/home/dir", "recursive": True} + await box.aclose() + + +@respx.mock +async def test_mkdir_defaults_parents_false(): + box = await make_async_box(respx.mock) + route = respx.post(f"{BASE}/files/mkdir").mock(return_value=httpx.Response(200, json={})) + await box.files.mkdir("dir") + assert last_json_body(route) == {"path": "/workspace/home/dir", "parents": False} + await box.aclose() + + +@respx.mock +async def test_remove_defaults_recursive_false(): + box = await make_async_box(respx.mock) + route = respx.post(f"{BASE}/files/remove").mock(return_value=httpx.Response(200, json={})) + await box.files.remove("f.txt") + assert last_json_body(route) == {"path": "/workspace/home/f.txt", "recursive": False} + await box.aclose() + + +@respx.mock +async def test_stat_file_rejects_unknown_type(): + """FileStat.type is a closed set; an unexpected value is a validation error.""" + box = await make_async_box(respx.mock) + respx.get(url__startswith=f"{BASE}/files/stat").mock( + return_value=httpx.Response( + 200, + json={"type": "socket", "size": 0, "mod_time": "", "inode": 1, "version": "1"}, + ) + ) + with pytest.raises(Exception): + await box.files.stat("weird") + await box.aclose() diff --git a/packages/python-sdk/upstash_box/__init__.py b/packages/python-sdk/upstash_box/__init__.py index affd52cd..81853366 100644 --- a/packages/python-sdk/upstash_box/__init__.py +++ b/packages/python-sdk/upstash_box/__init__.py @@ -81,6 +81,7 @@ ExecScheduleOptions, ExecStreamChunk, FileEntry, + FileStat, FinishChunk, FinishUsage, GitCommitResult, @@ -219,6 +220,7 @@ "BrowserRecordingMarker", "EphemeralBoxData", "FileEntry", + "FileStat", "GitCommitResult", "GitConfigResult", "LogEntry", diff --git a/packages/python-sdk/upstash_box/_async/client.py b/packages/python-sdk/upstash_box/_async/client.py index fe03e669..cb1375c3 100644 --- a/packages/python-sdk/upstash_box/_async/client.py +++ b/packages/python-sdk/upstash_box/_async/client.py @@ -56,6 +56,7 @@ ExecOutputChunk, ExecStreamChunk, FileEntry, + FileStat, FinishChunk, FinishUsage, GitCommitResult, @@ -300,8 +301,18 @@ class AsyncFilesNamespace: def __init__(self, box: "AsyncBox") -> None: self._box = box - async def read(self, path: str, *, encoding: Optional[str] = None) -> str: - return await self._box._read_file(path, encoding) + async def read( + self, + path: str, + *, + encoding: Optional[str] = None, + offset: Optional[int] = None, + length: Optional[int] = None, + ) -> str: + """Read a file. Supplying ``length`` reads a bounded byte range starting + at ``offset`` (default 0) instead of the whole file. The server rejects a + ``length`` above 8 MiB.""" + return await self._box._read_file(path, encoding, offset, length) async def write(self, *, path: str, content: str, encoding: Optional[str] = None) -> None: await self._box._write_file(path, content, encoding) @@ -309,6 +320,23 @@ async def write(self, *, path: str, content: str, encoding: Optional[str] = None async def list(self, path: Optional[str] = None) -> List[FileEntry]: return await self._box._list_files(path) + async def stat(self, path: str, *, follow: bool = False) -> FileStat: + """Return filesystem metadata for a path. ``follow`` dereferences a + final symlink; the default is lstat, which reports it as ``symlink``.""" + return await self._box._stat_file(path, follow) + + async def mkdir(self, path: str, *, parents: bool = False) -> None: + """Create a directory. ``parents`` mirrors ``mkdir -p``.""" + await self._box._make_dir(path, parents) + + async def rename(self, from_path: str, to_path: str) -> None: + """Move/rename a path.""" + await self._box._rename_file(from_path, to_path) + + async def remove(self, path: str, *, recursive: bool = False) -> None: + """Remove a path. ``recursive`` is required to remove a directory.""" + await self._box._remove_file(path, recursive) + async def upload(self, files: List[UploadFileEntry]) -> None: await self._box._upload_files(files) @@ -1250,14 +1278,53 @@ def _resolve_path(self, p: str) -> str: # ==================== Files ==================== - async def _read_file(self, path: str, encoding: Optional[str]) -> str: + async def _read_file( + self, + path: str, + encoding: Optional[str], + offset: Optional[int] = None, + length: Optional[int] = None, + ) -> str: resolved = self._resolve_path(path) url = f"/v2/box/{self.id}/files/read?path={_q(resolved)}" if encoding: url += f"&encoding={_q(encoding)}" + # Presence of length (not its value) selects a bounded range, so an + # explicit length=0 reads zero bytes rather than the whole file. + if length is not None: + url += f"&offset={offset or 0}&length={length}" data = await self._request("GET", url) return data.get("content", "") + async def _stat_file(self, path: str, follow: bool) -> FileStat: + resolved = self._resolve_path(path) + url = f"/v2/box/{self.id}/files/stat?path={_q(resolved)}" + if follow: + url += "&follow=true" + data = await self._request("GET", url) + return FileStat.model_validate(data) + + async def _make_dir(self, path: str, parents: bool) -> None: + resolved = self._resolve_path(path) + await self._request( + "POST", f"/v2/box/{self.id}/files/mkdir", body={"path": resolved, "parents": parents} + ) + + async def _rename_file(self, from_path: str, to_path: str) -> None: + await self._request( + "POST", + f"/v2/box/{self.id}/files/rename", + body={"from": self._resolve_path(from_path), "to": self._resolve_path(to_path)}, + ) + + async def _remove_file(self, path: str, recursive: bool) -> None: + resolved = self._resolve_path(path) + await self._request( + "POST", + f"/v2/box/{self.id}/files/remove", + body={"path": resolved, "recursive": recursive}, + ) + async def _write_file(self, path, content, encoding) -> None: resolved = self._resolve_path(path) body: Dict[str, Any] = {"path": resolved, "content": content} diff --git a/packages/python-sdk/upstash_box/_sync/client.py b/packages/python-sdk/upstash_box/_sync/client.py index f6c7aae5..7d104e12 100644 --- a/packages/python-sdk/upstash_box/_sync/client.py +++ b/packages/python-sdk/upstash_box/_sync/client.py @@ -55,6 +55,7 @@ ExecOutputChunk, ExecStreamChunk, FileEntry, + FileStat, FinishChunk, FinishUsage, GitCommitResult, @@ -295,8 +296,18 @@ class FilesNamespace: def __init__(self, box: "Box") -> None: self._box = box - def read(self, path: str, *, encoding: Optional[str] = None) -> str: - return self._box._read_file(path, encoding) + def read( + self, + path: str, + *, + encoding: Optional[str] = None, + offset: Optional[int] = None, + length: Optional[int] = None, + ) -> str: + """Read a file. Supplying ``length`` reads a bounded byte range starting + at ``offset`` (default 0) instead of the whole file. The server rejects a + ``length`` above 8 MiB.""" + return self._box._read_file(path, encoding, offset, length) def write(self, *, path: str, content: str, encoding: Optional[str] = None) -> None: self._box._write_file(path, content, encoding) @@ -304,6 +315,23 @@ def write(self, *, path: str, content: str, encoding: Optional[str] = None) -> N def list(self, path: Optional[str] = None) -> List[FileEntry]: return self._box._list_files(path) + def stat(self, path: str, *, follow: bool = False) -> FileStat: + """Return filesystem metadata for a path. ``follow`` dereferences a + final symlink; the default is lstat, which reports it as ``symlink``.""" + return self._box._stat_file(path, follow) + + def mkdir(self, path: str, *, parents: bool = False) -> None: + """Create a directory. ``parents`` mirrors ``mkdir -p``.""" + self._box._make_dir(path, parents) + + def rename(self, from_path: str, to_path: str) -> None: + """Move/rename a path.""" + self._box._rename_file(from_path, to_path) + + def remove(self, path: str, *, recursive: bool = False) -> None: + """Remove a path. ``recursive`` is required to remove a directory.""" + self._box._remove_file(path, recursive) + def upload(self, files: List[UploadFileEntry]) -> None: self._box._upload_files(files) @@ -1239,14 +1267,53 @@ def _resolve_path(self, p: str) -> str: # ==================== Files ==================== - def _read_file(self, path: str, encoding: Optional[str]) -> str: + def _read_file( + self, + path: str, + encoding: Optional[str], + offset: Optional[int] = None, + length: Optional[int] = None, + ) -> str: resolved = self._resolve_path(path) url = f"/v2/box/{self.id}/files/read?path={_q(resolved)}" if encoding: url += f"&encoding={_q(encoding)}" + # Presence of length (not its value) selects a bounded range, so an + # explicit length=0 reads zero bytes rather than the whole file. + if length is not None: + url += f"&offset={offset or 0}&length={length}" data = self._request("GET", url) return data.get("content", "") + def _stat_file(self, path: str, follow: bool) -> FileStat: + resolved = self._resolve_path(path) + url = f"/v2/box/{self.id}/files/stat?path={_q(resolved)}" + if follow: + url += "&follow=true" + data = self._request("GET", url) + return FileStat.model_validate(data) + + def _make_dir(self, path: str, parents: bool) -> None: + resolved = self._resolve_path(path) + self._request( + "POST", f"/v2/box/{self.id}/files/mkdir", body={"path": resolved, "parents": parents} + ) + + def _rename_file(self, from_path: str, to_path: str) -> None: + self._request( + "POST", + f"/v2/box/{self.id}/files/rename", + body={"from": self._resolve_path(from_path), "to": self._resolve_path(to_path)}, + ) + + def _remove_file(self, path: str, recursive: bool) -> None: + resolved = self._resolve_path(path) + self._request( + "POST", + f"/v2/box/{self.id}/files/remove", + body={"path": resolved, "recursive": recursive}, + ) + def _write_file(self, path, content, encoding) -> None: resolved = self._resolve_path(path) body: Dict[str, Any] = {"path": resolved, "content": content} diff --git a/packages/python-sdk/upstash_box/types.py b/packages/python-sdk/upstash_box/types.py index 8c73f2c8..bc0d722e 100644 --- a/packages/python-sdk/upstash_box/types.py +++ b/packages/python-sdk/upstash_box/types.py @@ -516,6 +516,22 @@ class FileEntry(_Model): mod_time: str +class FileStat(_Model): + """Filesystem metadata for a single path, returned by ``files.stat``. + + ``version`` is an opaque freshness token (derived from inode, mtime, and + size) for optimistic-concurrency guards. Compare it for equality; do not + parse it. + """ + + type: Literal["file", "directory", "symlink", "other"] + """Kind of entry. ``other`` is the server's catch-all, so the set is closed.""" + size: int + mod_time: str + inode: int + version: str + + class GitConfigResult(_Model): git_user_name: str git_user_email: str diff --git a/packages/sdk/src/__tests__/box-files.test.ts b/packages/sdk/src/__tests__/box-files.test.ts index f47c4976..60d5c809 100644 --- a/packages/sdk/src/__tests__/box-files.test.ts +++ b/packages/sdk/src/__tests__/box-files.test.ts @@ -105,4 +105,134 @@ describe("Box file operations", () => { expect(url).not.toContain("path="); }); }); + + describe("files.read range", () => { + it("sends offset and length for a bounded read", async () => { + const { box, fetchMock } = await createTestBox(); + fetchMock.mockResolvedValueOnce(mockResponse({ content: "partial" })); + + const out = await box.files.read("big.log", { offset: 100, length: 50 }); + expect(out).toBe("partial"); + + const [url] = fetchMock.mock.calls[1]!; + expect(url).toContain("offset=100"); + expect(url).toContain("length=50"); + }); + + it("sends an explicit length=0 instead of falling back to a whole-file read", async () => { + const { box, fetchMock } = await createTestBox(); + fetchMock.mockResolvedValueOnce(mockResponse({ content: "" })); + + const out = await box.files.read("big.log", { length: 0 }); + expect(out).toBe(""); + + const [url] = fetchMock.mock.calls[1]!; + expect(url).toContain("length=0"); + }); + + it("omits range params for a whole-file read", async () => { + const { box, fetchMock } = await createTestBox(); + fetchMock.mockResolvedValueOnce(mockResponse({ content: "whole" })); + await box.files.read("f.txt"); + const [url] = fetchMock.mock.calls[1]!; + expect(url).not.toContain("length="); + }); + }); + + describe("files.stat", () => { + it("stats a path (lstat by default) and returns metadata", async () => { + const { box, fetchMock } = await createTestBox(); + fetchMock.mockResolvedValueOnce( + mockResponse({ + type: "file", + size: 12, + mod_time: "2026-08-19T11:56:59Z", + inode: 42, + version: "42-1787-12", + }), + ); + + const st = await box.files.stat("a.txt"); + expect(st.type).toBe("file"); + expect(st.size).toBe(12); + expect(st.version).toBe("42-1787-12"); + + const [url] = fetchMock.mock.calls[1]!; + expect(url).toContain("/files/stat"); + expect(url).toContain(encodeURIComponent("/workspace/home/a.txt")); + expect(url).not.toContain("follow=true"); + }); + + it("sends follow=true when requested", async () => { + const { box, fetchMock } = await createTestBox(); + fetchMock.mockResolvedValueOnce(mockResponse({ type: "file", size: 0, mod_time: "", inode: 1, version: "1" })); + + await box.files.stat("/link", { follow: true }); + const [url] = fetchMock.mock.calls[1]!; + expect(url).toContain("follow=true"); + }); + }); + + describe("files.mkdir", () => { + it("creates a directory with parents", async () => { + const { box, fetchMock } = await createTestBox(); + fetchMock.mockResolvedValueOnce(mockResponse({ path: "/workspace/home/a/b" })); + + await box.files.mkdir("a/b", { parents: true }); + + const [url, init] = fetchMock.mock.calls[1]!; + expect(url).toContain("/files/mkdir"); + const body = JSON.parse(init?.body as string); + expect(body.path).toBe("/workspace/home/a/b"); + expect(body.parents).toBe(true); + }); + + it("defaults parents to false", async () => { + const { box, fetchMock } = await createTestBox(); + fetchMock.mockResolvedValueOnce(mockResponse({})); + + await box.files.mkdir("dir"); + const body = JSON.parse(fetchMock.mock.calls[1]![1]?.body as string); + expect(body.parents).toBe(false); + }); + }); + + describe("files.rename", () => { + it("renames resolving both paths", async () => { + const { box, fetchMock } = await createTestBox(); + fetchMock.mockResolvedValueOnce(mockResponse({})); + + await box.files.rename("a.txt", "b.txt"); + + const [url, init] = fetchMock.mock.calls[1]!; + expect(url).toContain("/files/rename"); + const body = JSON.parse(init?.body as string); + expect(body.from).toBe("/workspace/home/a.txt"); + expect(body.to).toBe("/workspace/home/b.txt"); + }); + }); + + describe("files.remove", () => { + it("removes recursively when requested", async () => { + const { box, fetchMock } = await createTestBox(); + fetchMock.mockResolvedValueOnce(mockResponse({})); + + await box.files.remove("dir", { recursive: true }); + + const [url, init] = fetchMock.mock.calls[1]!; + expect(url).toContain("/files/remove"); + const body = JSON.parse(init?.body as string); + expect(body.path).toBe("/workspace/home/dir"); + expect(body.recursive).toBe(true); + }); + + it("defaults recursive to false", async () => { + const { box, fetchMock } = await createTestBox(); + fetchMock.mockResolvedValueOnce(mockResponse({})); + + await box.files.remove("f.txt"); + const body = JSON.parse(fetchMock.mock.calls[1]![1]?.body as string); + expect(body.recursive).toBe(false); + }); + }); }); diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index 0595b7b4..78bf5802 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -21,6 +21,7 @@ import { type ExecStreamChunk, type ErrorResponse, type FileEntry, + type FileStat, type GitCloneOptions, type GitExecOptions, type GitExecResult, @@ -593,9 +594,25 @@ export class Box { /** File operations namespace */ readonly files: { - read: (path: string, options?: { encoding?: "base64" }) => Promise; + /** + * Read a file. Passing `length` reads a bounded byte range starting at + * `offset` (default 0) instead of the whole file; the server rejects a + * `length` above 8 MiB. + */ + read: ( + path: string, + options?: { encoding?: "base64"; offset?: number; length?: number }, + ) => Promise; write: (options: { path: string; content: string; encoding?: "base64" }) => Promise; list: (path?: string) => Promise; + /** Return filesystem metadata for a path. `follow` dereferences a final symlink (default: lstat). */ + stat: (path: string, options?: { follow?: boolean }) => Promise; + /** Create a directory. `parents` mirrors `mkdir -p`. */ + mkdir: (path: string, options?: { parents?: boolean }) => Promise; + /** Move/rename a path. */ + rename: (from: string, to: string) => Promise; + /** Remove a path. `recursive` is required to remove a directory. */ + remove: (path: string, options?: { recursive?: boolean }) => Promise; upload: (files: UploadFileEntry[]) => Promise; /** * Download files from the box to the local filesystem. @@ -807,9 +824,13 @@ export class Box { }; this.files = { - read: (path, options) => this._readFile(path, options?.encoding), + read: (path, options) => this._readFile(path, options), write: (opts) => this._writeFile(opts.path, opts.content, opts.encoding), list: (path) => this._listFiles(path), + stat: (path, options) => this._statFile(path, options?.follow), + mkdir: (path, options) => this._makeDir(path, options?.parents), + rename: (from, to) => this._renameFile(from, to), + remove: (path, options) => this._removeFile(path, options?.recursive), upload: (files) => this._uploadFiles(files), download: (opts) => this._downloadFiles(opts?.folder), }; @@ -2028,10 +2049,18 @@ export class Box { return `${this._cwd}/${p}`; } - private async _readFile(path: string, encoding?: "base64"): Promise { + private async _readFile( + path: string, + options?: { encoding?: "base64"; offset?: number; length?: number }, + ): Promise { const resolved = this._resolvePath(path); let url = `/v2/box/${this.id}/files/read?path=${encodeURIComponent(resolved)}`; - if (encoding) url += `&encoding=${encodeURIComponent(encoding)}`; + if (options?.encoding) url += `&encoding=${encodeURIComponent(options.encoding)}`; + // Presence of `length` (not its value) selects a bounded range starting at + // offset, so an explicit length: 0 reads zero bytes rather than the whole file. + if (options?.length !== undefined) { + url += `&offset=${options.offset ?? 0}&length=${options.length}`; + } const data = await this._request<{ content: string }>("GET", url); return data.content; } @@ -2059,6 +2088,33 @@ export class Box { return data.files ?? []; } + private async _statFile(path: string, follow?: boolean): Promise { + const resolved = this._resolvePath(path); + let url = `/v2/box/${this.id}/files/stat?path=${encodeURIComponent(resolved)}`; + if (follow) url += `&follow=true`; + return this._request("GET", url); + } + + private async _makeDir(path: string, parents?: boolean): Promise { + const resolved = this._resolvePath(path); + await this._request("POST", `/v2/box/${this.id}/files/mkdir`, { + body: { path: resolved, parents: parents ?? false }, + }); + } + + private async _renameFile(from: string, to: string): Promise { + await this._request("POST", `/v2/box/${this.id}/files/rename`, { + body: { from: this._resolvePath(from), to: this._resolvePath(to) }, + }); + } + + private async _removeFile(path: string, recursive?: boolean): Promise { + const resolved = this._resolvePath(path); + await this._request("POST", `/v2/box/${this.id}/files/remove`, { + body: { path: resolved, recursive: recursive ?? false }, + }); + } + private async _uploadFiles(files: UploadFileEntry[]): Promise { const fs = await this._getFs(); @@ -2905,15 +2961,21 @@ export class EphemeralBox { /** File operations namespace */ readonly files: { /** - * Read a file from the box. + * Read a file from the box. Passing `length` reads a bounded byte range + * starting at `offset` (default 0) instead of the whole file; the server + * rejects a `length` above 8 MiB. * * @example * ```ts * const content = await box.files.read("index.js"); * const b64 = await box.files.read("image.png", { encoding: "base64" }); + * const head = await box.files.read("big.log", { length: 64 * 1024 }); * ``` */ - read: (path: string, options?: { encoding?: "base64" }) => Promise; + read: ( + path: string, + options?: { encoding?: "base64"; offset?: number; length?: number }, + ) => Promise; /** * Write a file to the box. * @@ -2932,6 +2994,14 @@ export class EphemeralBox { * ``` */ list: (path?: string) => Promise; + /** Return filesystem metadata for a path. `follow` dereferences a final symlink (default: lstat). */ + stat: (path: string, options?: { follow?: boolean }) => Promise; + /** Create a directory. `parents` mirrors `mkdir -p`. */ + mkdir: (path: string, options?: { parents?: boolean }) => Promise; + /** Move/rename a path. */ + rename: (from: string, to: string) => Promise; + /** Remove a path. `recursive` is required to remove a directory. */ + remove: (path: string, options?: { recursive?: boolean }) => Promise; /** * Upload local files to the box. * diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index af6156c9..6fb2d2bd 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -49,6 +49,7 @@ export type { McpServerConfig, UploadFileEntry, FileEntry, + FileStat, GitCloneOptions, GitExecOptions, GitExecResult, diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index 7187cc74..fa790604 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -899,6 +899,26 @@ export interface FileEntry { mod_time: string; } +/** + * Filesystem metadata for a single path, returned by `files.stat`. + * + * `version` is an opaque freshness token (derived from inode, mtime, and size) + * for optimistic-concurrency guards — compare it for equality, but do not parse + * it. + */ +export interface FileStat { + /** Kind of entry at the path. */ + type: "file" | "directory" | "symlink" | "other"; + /** Size in bytes. */ + size: number; + /** Last-modification time, RFC 3339. */ + mod_time: string; + /** Inode number. */ + inode: number; + /** Opaque freshness token; compare for equality, do not parse. */ + version: string; +} + export interface GitCloneOptions { repo: string; branch?: string; From 2c2cb4b5e707197666f0c81d0ad37e2ac763e73f Mon Sep 17 00:00:00 2001 From: alitariksahin Date: Wed, 19 Aug 2026 19:43:21 +0300 Subject: [PATCH 2/3] chore(python-sdk): release 0.3.0 Cuts the accumulated Unreleased work as 0.3.0: the filesystem metadata operations added here, plus the previously unreleased browser (Stagehand v4 `act` replay, `tab.run()` removal), recordings download, shallow clone, schedule update, and model-constant changes. Bumps `pyproject.toml` and `upstash_box/_version.py`, promotes the CHANGELOG heading, and records the JS parity point in RELEASE.md. Tagging `python-sdk-v0.3.0` triggers the PyPI release, so that should wait until the backend file-metadata endpoints are in production. --- packages/python-sdk/CHANGELOG.md | 2 +- packages/python-sdk/RELEASE.md | 1 + packages/python-sdk/pyproject.toml | 2 +- packages/python-sdk/upstash_box/_version.py | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/python-sdk/CHANGELOG.md b/packages/python-sdk/CHANGELOG.md index 05766449..61a178cb 100644 --- a/packages/python-sdk/CHANGELOG.md +++ b/packages/python-sdk/CHANGELOG.md @@ -2,7 +2,7 @@ All notable changes to `upstash-box` (Python) are documented here. -## Unreleased +## 0.3.0 - `files.stat(path, follow=...)`, `files.mkdir(path, parents=...)`, `files.rename(from_path, to_path)`, and `files.remove(path, recursive=...)` — diff --git a/packages/python-sdk/RELEASE.md b/packages/python-sdk/RELEASE.md index 91524b5f..46068769 100644 --- a/packages/python-sdk/RELEASE.md +++ b/packages/python-sdk/RELEASE.md @@ -15,6 +15,7 @@ Each release records the JS feature level it reached parity with: | 0.1.3 | 0.5.4 | | 0.1.4 | 0.5.5 | | 0.2.0 | 0.6.0 (browser API) | +| 0.3.0 | 0.7.1 (browser `act` replay, file metadata ops) | When a JS feature is mirrored, bump the Python patch/minor version and update the row above (and `__version__` in `upstash_box/_version.py` + `version` in diff --git a/packages/python-sdk/pyproject.toml b/packages/python-sdk/pyproject.toml index c7b2048c..35e0f2a6 100644 --- a/packages/python-sdk/pyproject.toml +++ b/packages/python-sdk/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "upstash-box" -version = "0.2.0" +version = "0.3.0" description = "Upstash Box SDK - Python client for async and parallel AI coding agents" readme = "README.md" license = { text = "MIT" } diff --git a/packages/python-sdk/upstash_box/_version.py b/packages/python-sdk/upstash_box/_version.py index e921ee8a..704784e3 100644 --- a/packages/python-sdk/upstash_box/_version.py +++ b/packages/python-sdk/upstash_box/_version.py @@ -3,4 +3,4 @@ Keep in sync with pyproject.toml — bumped by the release process. """ -__version__ = "0.2.0" +__version__ = "0.3.0" From 7d8f1a61a1f062bce580a9b3a8bbffea6e9a722d Mon Sep 17 00:00:00 2001 From: alitariksahin Date: Wed, 19 Aug 2026 19:50:16 +0300 Subject: [PATCH 3/3] chore: satisfy lint gates for the new SDK code - prettier: reformat box-files.test.ts (JS ci:lint runs `prettier --check`). - ruff B017: assert `ValidationError` instead of a blind `Exception` in the FileStat closed-set test. - ruff I001: sort the test imports. The earlier runs only linted `upstash_box/`, so the test-directory findings and prettier were missed. --- packages/python-sdk/tests/_async/test_box_files.py | 3 ++- packages/sdk/src/__tests__/box-files.test.ts | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/python-sdk/tests/_async/test_box_files.py b/packages/python-sdk/tests/_async/test_box_files.py index 8ca2269f..ca2b4051 100644 --- a/packages/python-sdk/tests/_async/test_box_files.py +++ b/packages/python-sdk/tests/_async/test_box_files.py @@ -2,6 +2,7 @@ import pytest import respx from helpers import TEST_BASE_URL, last_json_body, make_async_box +from pydantic import ValidationError from upstash_box import BoxError @@ -296,6 +297,6 @@ async def test_stat_file_rejects_unknown_type(): json={"type": "socket", "size": 0, "mod_time": "", "inode": 1, "version": "1"}, ) ) - with pytest.raises(Exception): + with pytest.raises(ValidationError): await box.files.stat("weird") await box.aclose() diff --git a/packages/sdk/src/__tests__/box-files.test.ts b/packages/sdk/src/__tests__/box-files.test.ts index 60d5c809..c2d14f0f 100644 --- a/packages/sdk/src/__tests__/box-files.test.ts +++ b/packages/sdk/src/__tests__/box-files.test.ts @@ -165,7 +165,9 @@ describe("Box file operations", () => { it("sends follow=true when requested", async () => { const { box, fetchMock } = await createTestBox(); - fetchMock.mockResolvedValueOnce(mockResponse({ type: "file", size: 0, mod_time: "", inode: 1, version: "1" })); + fetchMock.mockResolvedValueOnce( + mockResponse({ type: "file", size: 0, mod_time: "", inode: 1, version: "1" }), + ); await box.files.stat("/link", { follow: true }); const [url] = fetchMock.mock.calls[1]!;