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
12 changes: 12 additions & 0 deletions .changeset/file-metadata-ops.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 11 additions & 1 deletion packages/python-sdk/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,17 @@

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=...)` —
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
Expand Down
1 change: 1 addition & 0 deletions packages/python-sdk/PARITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
1 change: 1 addition & 0 deletions packages/python-sdk/RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/python-sdk/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
138 changes: 138 additions & 0 deletions packages/python-sdk/tests/_async/test_box_files.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -162,3 +163,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():
Comment on lines +168 to +169
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(ValidationError):
await box.files.stat("weird")
await box.aclose()
2 changes: 2 additions & 0 deletions packages/python-sdk/upstash_box/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@
ExecScheduleOptions,
ExecStreamChunk,
FileEntry,
FileStat,
FinishChunk,
FinishUsage,
GitCommitResult,
Expand Down Expand Up @@ -219,6 +220,7 @@
"BrowserRecordingMarker",
"EphemeralBoxData",
"FileEntry",
"FileStat",
"GitCommitResult",
"GitConfigResult",
"LogEntry",
Expand Down
73 changes: 70 additions & 3 deletions packages/python-sdk/upstash_box/_async/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
ExecOutputChunk,
ExecStreamChunk,
FileEntry,
FileStat,
FinishChunk,
FinishUsage,
GitCommitResult,
Expand Down Expand Up @@ -300,15 +301,42 @@ 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)

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)

Expand Down Expand Up @@ -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}
Expand Down
Loading
Loading