Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
fa380ee
feat(sdk): scaffold the Python package with uv, ruff, pyright gates
AprilNEA Aug 3, 2026
7ffc046
feat(sdk): generate the sandbox wire types into arcbox/_gen
AprilNEA Aug 3, 2026
a89fa96
feat(sdk): typed errors, connection resolution, Connect framing
AprilNEA Aug 3, 2026
5cf7811
feat(sdk): hand-written public DTOs and their wire mappings
AprilNEA Aug 3, 2026
b865aaa
feat(sdk): Connect-over-httpx transport core (async tree)
AprilNEA Aug 3, 2026
cec706b
feat(sdk): async Sandbox surface — create/connect/list, commands, files
AprilNEA Aug 3, 2026
4fc0ae8
feat(sdk): generated sync tree and the public export surface
AprilNEA Aug 3, 2026
3f68755
test(sdk): connection resolution, error boundary, envelope framing
AprilNEA Aug 3, 2026
52f1bdd
test(sdk): mock-daemon run/files loops, sync parity, gated e2e
AprilNEA Aug 3, 2026
2094c17
docs(sdk): Python SDK README and scoped prek hooks
AprilNEA Aug 3, 2026
be43966
test(e2e): sdk_py harness — Python SDK hello world vs isolated daemon
AprilNEA Aug 3, 2026
bb4b56d
docs(sdk): satisfy ruff's markdown code-block formatting in the README
AprilNEA Aug 3, 2026
c5beab3
chore(sdk): reflow gen_proto for the 100-column format config
AprilNEA Aug 3, 2026
14d0925
fix(sdk): require the terminal EndStreamResponse in client_stream
AprilNEA Aug 3, 2026
68b287c
fix(sdk): honor sub-second wait_for_exit deadlines
AprilNEA Aug 3, 2026
b69a8d9
fix(sdk): close SDK-owned HTTP clients deterministically
AprilNEA Aug 3, 2026
a7e5056
fix(sdk): route non-Connect JSON error bodies to the HTTP fallback
AprilNEA Aug 3, 2026
78e0491
fix(sdk): stop connect() from waiting on READY for a PAUSING sandbox
AprilNEA Aug 3, 2026
e96f889
fix(sdk): make early exit from commands.output releasable at the break
AprilNEA Aug 3, 2026
b3fb8bc
fix(sdk): poll before sleeping in the sub-second wait tail
AprilNEA Aug 3, 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
6 changes: 6 additions & 0 deletions sdk/python/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
.venv/
__pycache__/
*.pyc
.pytest_cache/
.ruff_cache/
dist/
1 change: 1 addition & 0 deletions sdk/python/.python-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3.14
156 changes: 156 additions & 0 deletions sdk/python/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
# arcbox

Python SDK for ArcBox sandboxes: isolated microVMs on your Mac, driven
over the local daemon's Unix socket with the Connect protocol. Requires
Python ≥ 3.10.

```sh
uv add arcbox # or: pip install arcbox
```

## Hello world

With the daemon running (`abctl daemon start`):

```python
from arcbox import Sandbox

# Local daemon over ~/.arcbox/run/arcbox.sock — zero config.
with Sandbox.create("", ttl=300) as sandbox:
sandbox.files.write_text("/tmp/hello.txt", "hello from arcbox\n")

check = sandbox.commands.run(["/bin/cat", "/tmp/hello.txt"])
print(check.expect().stdout, "→ exit", check.exit_code)

job = sandbox.commands.run("for i in 1 2 3; do echo line$i; done", background=True)
for chunk in job.output:
print(chunk.data.decode(), end="")
print("background job exited", job.wait_for_exit().exit_code)
# context exit: sandbox killed, nothing leaked
```

Async is a first-class mirror (`AsyncSandbox`, `async with`, `async for`):

```python
from arcbox import AsyncSandbox


async def main() -> None:
sandbox = await AsyncSandbox.create("", ttl=300)
async with sandbox:
await sandbox.files.write_text("/tmp/hello.txt", "hello from arcbox\n")
result = await sandbox.commands.run(["/bin/cat", "/tmp/hello.txt"])
print(result.expect().stdout)
```

Non-zero exit is data (`result.exit_code`), never an exception —
`result.expect()` (or `run(..., check=True)`, `subprocess.run`-style) is
the opt-in raise. Every daemon error maps to a typed class in
`arcbox.errors` (`SandboxNotFoundError`, `CapabilityError`,
`ConnectionFailedError`, ...) carrying a machine-readable `code`, an
actionable `suggestion`, and the failed `operation`. Time arguments are
seconds (floats) everywhere.

## Connection

Resolution order: explicit option > environment > default.

| Environment | Meaning |
| ---------------- | --------------------------------------------------------------------------------------------- |
| `ARCBOX_SOCKET` | daemon Unix socket (default `$ARCBOX_DATA_DIR/run/arcbox.sock`; data dir default `~/.arcbox`, or `~/.arcbox-dev` under `ARCBOX_PROFILE=development`) |
| `ARCBOX_API_URL` | remote daemon / cloud front door; setting it selects the remote tier (reserved, CORE-63) |
| `ARCBOX_API_KEY` | bearer credential, attached as `Authorization` when set; unused by the local daemon |

Every entry point takes a `connection=Connection(...)` slot
(`socket_path` / `api_url` / `api_key` / `request_timeout` / injected
`http_client` for mocking — pass an `httpx.Client` to the sync surface,
an `httpx.AsyncClient` to the async one).

The `Sandbox` / `AsyncSandbox` classmethods resolve a hidden connection
per call, and the returned handle closes its HTTP client on context
exit. Long-lived programs should hold an `ArcBox` / `AsyncArcBox`
instead: it is a context manager (or call `.close()` / `.aclose()`),
and every handle it creates shares its client. An injected
`http_client` always belongs to the caller and is never closed by the
SDK.

## Development

Inside the arcbox repo (`sdk/python`):

```sh
uv sync # create .venv from uv.lock
uv run python scripts/gen_proto.py # regenerate src/arcbox/_gen from ../../rpc/arcbox-protocol/proto
uv run python scripts/gen_sync.py # regenerate src/arcbox/_sync from src/arcbox/_async
uv run ruff check . && uv run ruff format --check .
uv run pyright
uv run pytest # includes the sync-tree lockstep + parity checks
```

Generated code under `src/arcbox/_gen/` is committed and is **never**
exported from the package — public shapes are hand-written and mapped
at the transport boundary.

The async tree (`src/arcbox/_async/`) is the source of truth; the sync
tree (`src/arcbox/_sync/`) is generated from it by an unasync token
transform and committed. Edit only the async tree, then rerun
`scripts/gen_sync.py`. Lockstep is CI-enforced twice: the transform is
rerun and diffed (`scripts/gen_sync.py --check`, also wired into
pytest), and a parity test asserts identical public surfaces modulo
async markers.

Optional pre-commit hooks (scoped to `sdk/python`), via
[prek](https://github.com/j178/prek) or classic pre-commit:

```sh
prek install -c sdk/python/prek.yaml
```

The end-to-end hello-world loop runs only against a live daemon and is
opt-in:

```sh
ARCBOX_SDK_E2E=1 uv run pytest tests/test_e2e.py
```

## Toolchain notes

- **uv** is the package/project manager (`uv_build` backend, `uv.lock`
committed). Publishing: `uv build && uv publish` (credentials via
`UV_PUBLISH_TOKEN`; TestPyPI first via a `[[tool.uv.index]]` entry if
desired).
- **ruff** is both linter and formatter (`E,F,W,I,UP,B,SIM,RUF`).
- **pyright** (strict) is the authoritative type checker. Evaluated
alternatives (2026-08): **ty** 0.0.65 reports 16 false positives here
(all `unresolved-attribute` on protobuf generated-module members) —
kept in dev-deps for `uv run ty check`, may replace pyright when it
stabilizes; **pyrefly** 1.2.0 passes cleanly (it imports the pyright
config) and serves as an informational second opinion — one
authoritative checker avoids double-suppression drift.
- **msgspec** parses the SDK's one JSON seam — Connect error bodies and
`EndStreamResponse` frames — as typed, validated Structs at the
untrusted-input boundary (chosen for the typed decoding, not speed).
- Message types are upstream **protobuf** runtime code generated by the
protoc bundled with grpcio-tools (dev-dep); the bundled protoc version
matches the pinned runtime.
- A future native-acceleration path (if profiling ever demands one) is a
**maturin**/PyO3 extension crate in this repo's workspace; nothing in
the current SDK needs it.

TODO(CI): wire the gates above into `.github/workflows` as an
`sdk-python` job (follow-up; workflow changes are intentionally not part
of this branch).

## Status

Phase 1 of CORE-58 — the hello-world closed loop: `Sandbox` /
`AsyncSandbox` create/connect/list, `kill`/`pause`/`info` (`pause` and
the paused-sandbox reconnect path are wire-complete but reject with an
unimplemented error until the daemon's CORE-21 lands), `commands.run`
(foreground result + background handle with streamed output,
`wait_for_exit`, `kill`), and whole-file `files` read/write. Deferred:
PTY, `ports`, `wait_for_port`/`wait_for_log`, stdin, filesystem path
verbs (stat/list/mkdir/...), `Template` statics, `events()`,
`set_lifecycle`, the capabilities handshake, and the SDK-side default
idle-reaping policy (design decision 4 — applied once the daemon
enforces the lifecycle knobs).
35 changes: 35 additions & 0 deletions sdk/python/prek.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Hooks for the Python SDK only (run with prek, the Rust pre-commit
# runner — https://github.com/j178/prek — or classic pre-commit).
#
# Install from the repo root:
# prek install -c sdk/python/prek.yaml
#
# Scoped to sdk/python paths; deliberately no Rust-side hooks — the
# repo's cargo fmt/clippy discipline stays where it is.
repos:
- repo: local
hooks:
- id: sdk-python-ruff
name: sdk/python ruff (lint + format)
language: system
files: ^sdk/python/
pass_filenames: false
entry: bash -c 'cd sdk/python && uv run ruff check . && uv run ruff format --check .'
- id: sdk-python-pyright
name: sdk/python pyright (strict)
language: system
files: ^sdk/python/
pass_filenames: false
entry: bash -c 'cd sdk/python && uv run pyright'
- id: sdk-python-sync-lockstep
name: sdk/python sync-tree lockstep
language: system
files: ^sdk/python/(src/arcbox/(_async|_sync)/|scripts/gen_sync\.py)
pass_filenames: false
entry: bash -c 'cd sdk/python && uv run python scripts/gen_sync.py --check'
- id: sdk-python-pytest
name: sdk/python pytest
language: system
files: ^sdk/python/
pass_filenames: false
entry: bash -c 'cd sdk/python && uv run pytest -q'
69 changes: 69 additions & 0 deletions sdk/python/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
[project]
name = "arcbox"
version = "0.1.0"
description = "ArcBox Sandbox SDK for Python — run isolated microVM sandboxes on your Mac"
readme = "README.md"
authors = [{ name = "ArcBox, Inc." }]
license = "MIT OR Apache-2.0"
requires-python = ">=3.10"
dependencies = [
"httpx>=0.28.1",
"msgspec>=0.21.1",
"protobuf>=7.35.1",
]

[project.urls]
Repository = "https://github.com/arcboxlabs/arcbox"

[build-system]
requires = ["uv_build>=0.12.1,<0.13.0"]
build-backend = "uv_build"

[tool.ruff]
target-version = "py310"
line-length = 100
extend-exclude = ["src/arcbox/_gen"]

[tool.ruff.lint]
select = ["E", "F", "W", "I", "UP", "B", "SIM", "RUF"]

[tool.ruff.lint.per-file-ignores]
# The sync tree is a mechanical unasync mirror: `async for … yield` has
# no `yield from` form, so its transform cannot satisfy UP028.
# gen_sync.py mirrors this ignore for its scratch directory, which sits
# outside the project root where this pattern cannot anchor.
"src/arcbox/_sync/*" = ["UP028"]

[tool.pyright]
include = ["src", "tests", "scripts"]
exclude = ["src/arcbox/_gen", ".venv"]
typeCheckingMode = "strict"

# The codegen scripts drive tools that ship no type information
# (grpcio-tools' protoc entry point, unasync); only the unknown-type
# diagnostics that follow from those imports are relaxed there.
[[tool.pyright.executionEnvironments]]
root = "scripts"
reportMissingTypeStubs = false
reportUnknownMemberType = false
reportUnknownVariableType = false
reportUnknownArgumentType = false

[tool.pytest.ini_options]
testpaths = ["tests"]

[tool.ty.src]
include = ["src", "tests", "scripts"]
exclude = ["src/arcbox/_gen"]

[dependency-groups]
dev = [
"anyio>=4.14.2",
"grpcio-tools>=1.83.0",
"pyrefly>=1.2.0",
"pyright>=1.1.411",
"pytest>=9.1.1",
"ruff>=0.16.1",
"ty>=0.0.65",
"unasync>=0.6.0",
]
81 changes: 81 additions & 0 deletions sdk/python/scripts/gen_proto.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"""Regenerate `arcbox/_gen` from the repo's sandbox protos.

Runs the protoc bundled with grpcio-tools over
`rpc/arcbox-protocol/proto/arcbox/sandbox/v1`, then flattens the output
package and rewrites the intra-package imports to relative ones so the
generated modules are location-independent under `arcbox._gen`.

Usage: uv run python scripts/gen_proto.py
"""

from __future__ import annotations

import re
import shutil
import sys
import tempfile
from pathlib import Path

from grpc_tools import protoc

SDK_ROOT = Path(__file__).resolve().parent.parent
REPO_PROTO_DIR = SDK_ROOT.parent.parent / "rpc" / "arcbox-protocol" / "proto"
PROTO_PACKAGE_DIR = "arcbox/sandbox/v1"
GEN_DIR = SDK_ROOT / "src" / "arcbox" / "_gen"

GEN_INIT = '"""Generated wire types (protoc). Private — never exported."""\n'

# protoc emits absolute imports following the proto package path; the
# flattened modules live side by side, so both styles become relative.
IMPORT_REWRITES = [
(re.compile(r"^from arcbox\.sandbox\.v1 import "), "from . import "),
(re.compile(r"^import arcbox\.sandbox\.v1\."), "from . import "),
]


def well_known_include() -> str:
"""grpcio-tools bundles the well-known protos next to its package."""
return str(Path(protoc.__file__).parent / "_proto")


def rewrite_imports(text: str) -> str:
lines = []
for line in text.splitlines(keepends=True):
for pattern, replacement in IMPORT_REWRITES:
line = pattern.sub(replacement, line)
lines.append(line)
return "".join(lines)


def main() -> int:
protos = sorted((REPO_PROTO_DIR / PROTO_PACKAGE_DIR).glob("*.proto"))
if not protos:
print(f"no protos found under {REPO_PROTO_DIR / PROTO_PACKAGE_DIR}")
return 1

with tempfile.TemporaryDirectory() as tmp:
args = [
"protoc",
f"--proto_path={REPO_PROTO_DIR}",
f"--proto_path={well_known_include()}",
f"--python_out={tmp}",
f"--pyi_out={tmp}",
*(str(p) for p in protos),
]
if (code := protoc.main(args)) != 0:
return code

if GEN_DIR.exists():
shutil.rmtree(GEN_DIR)
GEN_DIR.mkdir(parents=True)
(GEN_DIR / "__init__.py").write_text(GEN_INIT)

for generated in sorted((Path(tmp) / PROTO_PACKAGE_DIR).iterdir()):
(GEN_DIR / generated.name).write_text(rewrite_imports(generated.read_text()))

print(f"regenerated {GEN_DIR}")
return 0


if __name__ == "__main__":
sys.exit(main())
Loading
Loading