Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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`.
10 changes: 10 additions & 0 deletions packages/python-sdk/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@ All notable changes to `upstash-box` (Python) are documented here.

## 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
Expand Down
27 changes: 27 additions & 0 deletions packages/python-sdk/PARITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ JS `Run`/`StreamRun` → Python `Run`/`StreamRun` (+ `AsyncRun`/`AsyncStreamRun`
| `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 @@ -122,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/pyproject.toml
Original file line number Diff line number Diff line change
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
Loading
Loading