Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
00e325c
feat(sdk): filesystem metadata operations in the JS and Python SDKs
alitariksahin Aug 19, 2026
2c2cb4b
chore(python-sdk): release 0.3.0
alitariksahin Aug 19, 2026
7d8f1a6
chore: satisfy lint gates for the new SDK code
alitariksahin Aug 19, 2026
579128d
feat(sdk): box.exec.session() — live interactive command sessions
alitariksahin Aug 20, 2026
608d712
docs(sdk): correct exec.session handle docs
alitariksahin Aug 21, 2026
1623c11
test(sdk): integration coverage for exec.session against a real box
alitariksahin Aug 21, 2026
dc5f2b6
feat(python-sdk): box.exec.session() — live interactive command sessions
alitariksahin Aug 21, 2026
0125054
test(python-sdk): exec.session unit and integration coverage
alitariksahin Aug 21, 2026
29befc3
docs(sdk): document box.exec.session() in the README
alitariksahin Aug 21, 2026
a27d19b
fix(sdk,python-sdk): enforce the documented non-zero pid on exec.session
alitariksahin Aug 21, 2026
2252567
chore(python-sdk): fold exec.session into the pending 0.3.0 release
alitariksahin Aug 21, 2026
92a161c
fix(sdk): close the socket on an exec-session error frame
alitariksahin Aug 21, 2026
8dac28e
docs(sdk,python-sdk): terminate() only takes effect once
alitariksahin Aug 24, 2026
2bc4e29
Merge branch 'main' into DX-2945
alitariksahin Aug 24, 2026
417cd5c
fix(sdk): contain a throwing output callback instead of crashing the …
alitariksahin Aug 24, 2026
5198ff8
fix(python-sdk): give the handshake one deadline, not one per frame
alitariksahin Aug 24, 2026
0f62eda
docs(sdk): note that exec.session is Node-only in the README
alitariksahin Aug 24, 2026
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
23 changes: 23 additions & 0 deletions .changeset/exec-session.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
"@upstash/box": patch
---

Add `box.exec.session()` — a live, interactive command session over a WebSocket.

Unlike `exec.command` / `exec.stream`, which run a command and hand back its
result, a session returns a handle to a *running* process:

- `argv` runs a program directly (no shell); `cmd` runs one via `bash -lc`.
- `write()` sends stdin, `endStdin()` closes it so a command reading to EOF can
finish, and `onStdout` / `onStderr` receive output as it arrives (separate
streams unless `tty` is set).
- `tty` allocates a real PTY sized by `rows`/`cols`, with `resize()` for later
changes — enough for interactive programs and terminal UIs.
- `kill(signal)` sends an allowlisted signal; `terminate(graceMs)` asks the
server for SIGTERM then SIGKILL after the grace.
- `wait()` resolves with the exit code; `close()` hangs up, which also stops the
process.

Node-only: authentication uses a request header, which browsers cannot set on a
WebSocket handshake. `ws` moves from a dev dependency to a runtime dependency;
the public types stay free of `@types/ws`.
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.
22 changes: 21 additions & 1 deletion packages/python-sdk/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,27 @@

All notable changes to `upstash-box` (Python) are documented here.

## Unreleased
## 0.3.0

- `exec.session(...)` — live command sessions over a WebSocket, matching
`@upstash/box`. Returns a handle once the process is running, with `pid`,
`exec_id`, `write`, `end_stdin`, `resize`, `kill`, `terminate`, `wait`, and
`close`. Pass `argv` to run a program without a shell or `cmd` to go through
`bash -lc`, `tty=True` for a PTY (with `rows`/`cols`), plus `cwd` and `env`
overlays. `on_stdout`/`on_stderr` receive `bytes` as they arrive. The handle
owns the process: closing it, or losing the connection, kills the command.
Available on both clients and usable as a context manager.
- Adds a `websockets>=13` dependency, imported lazily so it only loads when a
session is opened.
- `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
28 changes: 28 additions & 0 deletions packages/python-sdk/PARITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ 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` |
| `exec.session` (live WebSocket session) | `exec.session` |
| `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 Expand Up @@ -121,3 +123,29 @@ statics `create`, `from_snapshot`, `get_by_name`, `delete_boxes`,
`helpers` → covered by `tests/helpers.py`; `box-instance` → `test_box_instance`;
models → `test_models`; helpers/common → `test_common`. Sync coverage:
`tests/_sync/test_sync_client` + `test_sse_golden`.

## `exec.session`

`exec.session()` is a live command session (stdin, PTY, signals, streaming) over
a WebSocket, so it is the one feature not carried by `httpx`. It adds a
`websockets` dependency, imported lazily so it only loads when a session is
opened.

Both handles are hand-written in `upstash_box/_exec_session.py` rather than
generated: the async handle pumps frames with an asyncio task and the sync
handle with a reader thread, an asymmetry `scripts/generate_sync.py` cannot
produce by token substitution. Frame construction, signal validation, and
decoding are shared between them so the wire protocol has one definition.
`generate_sync.py` maps `AsyncExecSessionHandle`/`open_async_exec_session` to
the sync pair by name.

Naming follows the SDK's snake_case convention, so the handle is
`end_stdin`/`exec_id` where JS is `endStdin`/`execId`, and callbacks are
`on_stdout`/`on_stderr` taking `bytes`. The sync `wait()` additionally accepts a
`timeout`, since blocking forever on a thread has no async equivalent to
cancellation.

Note that `scripts/check_parity.py` does **not** gate any of this. Its extractor
walks one level deep — it sees `Box.exec` and `Box.files`, not `exec.session` or
`files.stat` — so nested namespace methods are outside the gate. Treat this file
as the source of truth for namespace-level parity until the extractor recurses.
33 changes: 33 additions & 0 deletions packages/python-sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,39 @@ print(run.result) # stdout on success, stderr on failure
print(run.stdout, run.stderr, run.exit_code) # raw streams + exit code
```

### Live sessions

`exec.command` returns after the command finishes. `exec.session` returns as soon
as it starts, so you can write to stdin, resize a PTY, and signal the process
while it runs.

```python
chunks = []
session = await box.exec.session(
argv=["sort"], # exact program + args, no shell
on_stdout=chunks.append, # receives bytes as they arrive
)
await session.write("banana\napple\n")
await session.end_stdin() # EOF, so sort finishes
assert await session.wait() == 0
```

Use `cmd="..."` instead of `argv` to go through `bash -lc`, `tty=True` (with
`rows`/`cols`) for a PTY, and `cwd`/`env` to place the process. Control it with
`resize`, `kill(signal)`, `terminate(grace_ms)`, and `close`.

The session owns the process: closing the handle or losing the connection kills
the command, and sessions cannot be reattached. A context manager makes that
teardown explicit.

```python
async with await box.exec.session(cmd="npm run dev", tty=True, rows=24, cols=80) as dev:
await dev.write("rs\n")
```

The sync client mirrors this without `await`; its `wait(timeout=None)` blocks and
raises `TimeoutError` if the timeout elapses.

### Files

```python
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
3 changes: 2 additions & 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 Expand Up @@ -36,6 +36,7 @@ classifiers = [
]
dependencies = [
"httpx>=0.27",
"websockets>=13",
"pydantic>=2",
"typing-extensions>=4.7",
]
Expand Down
4 changes: 4 additions & 0 deletions packages/python-sdk/scripts/generate_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@
"AsyncSkillsNamespace": "SkillsNamespace",
"AsyncLabelsNamespace": "LabelsNamespace",
"AsyncClient": "Client",
# Live-exec handles are hand-written in upstash_box/_exec_session.py
# (asyncio task vs reader thread); swap in the sync pair by name.
"AsyncExecSessionHandle": "ExecSessionHandle",
"open_async_exec_session": "open_exec_session",
"AsyncIterator": "Iterator",
"aiter_bytes": "iter_bytes",
"aclose": "close",
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():
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()
Loading
Loading