Skip to content

Latest commit

 

History

History
336 lines (258 loc) · 11.3 KB

File metadata and controls

336 lines (258 loc) · 11.3 KB

Python Package

Status

Implemented

Abstract

Bashkit ships a Python package as pre-built binary wheels on PyPI. Users install with pip install bashkit and get a native extension — no Rust toolchain needed.

Package Layout

crates/bashkit-python/
├── Cargo.toml              # Rust crate (cdylib via PyO3)
├── pyproject.toml           # Python package metadata (maturin build backend)
├── src/lib.rs               # PyO3 native module (BashTool, ExecResult)
├── bashkit/
│   ├── __init__.py          # Re-exports from native module
│   ├── _bashkit.pyi         # Type stubs (PEP 561)
│   ├── py.typed             # Marker for typed package
│   ├── langchain.py         # LangChain integration
│   ├── deepagents.py        # Deep Agents integration
│   └── pydantic_ai.py       # PydanticAI integration
├── examples/
│   ├── bash_basics.py       # Bash interface walkthrough (runs in CI)
│   └── k8s_orchestrator.py  # ScriptedTool multi-tool demo
└── tests/
    └── test_bashkit.py      # Pytest suite

Build System

  • Build backend: maturin (1.4–2.0)
  • Rust extension: PyO3 0.24 with extension-module feature
  • Async bridge: pyo3-async-runtimes (tokio runtime)
  • Module name: bashkit._bashkit (native), re-exported as bashkit

Versioning

Python package version is read dynamically from workspace Cargo.toml via maturin. pyproject.toml declares dynamic = ["version"] — no manual sync needed.

The version chain: Cargo.toml (workspace) → Cargo.toml (bashkit-python, inherits) → maturin reads it → wheel metadata.

Supported Platforms

Python Versions

3.9, 3.10, 3.11, 3.12, 3.13, 3.14

Wheel Matrix

OS Architecture Variant CI Runner
Linux x86_64 manylinux (glibc) ubuntu-latest
Linux aarch64 manylinux (glibc) ubuntu-latest (cross)
Linux x86_64 musllinux_1_1 ubuntu-latest (Docker)
Linux aarch64 musllinux_1_1 ubuntu-latest (Docker)
macOS x86_64 macos-latest (cross)
macOS aarch64 macos-latest (native)
Windows x86_64 MSVC windows-latest

Total: ~42 wheels (7 platforms × 6 Python versions).

PyPI Publishing

Workflow

File: .github/workflows/publish-python.yml

GitHub Release published
    ├── build-sdist     (source distribution)
    ├── build           (7 platform variants × 6 Python versions)
    ├── inspect         (twine check all artifacts)
    ├── test-builds     (smoke test on Linux/macOS/Windows)
    └── publish         (uv publish → PyPI via OIDC)

Authentication

Uses PyPI trusted publishing (OIDC) — no API tokens needed.

Prerequisites:

  1. GitHub environment release-python exists in repo settings
  2. PyPI trusted publisher configured:
    • Owner: everruns, Repo: bashkit
    • Workflow: publish-python.yml, Environment: release-python

Smoke Test

Each platform runs after wheel build:

from bashkit import BashTool
t = BashTool()
r = t.execute_sync('echo hello')
assert r.exit_code == 0

Public API

BashTool

Primary class. Wraps the Rust Bash interpreter with Arc<Mutex<>> for thread safety.

from bashkit import BashTool

tool = BashTool(
    username="user",           # optional, default "user"
    hostname="sandbox",        # optional, default "sandbox"
    max_commands=10000,        # optional
    max_loop_iterations=100000 # optional
)

# Async
result = await tool.execute("echo hello")

# Sync
result = tool.execute_sync("echo hello")

# Reset state
tool.reset()

# Initial files accept eager strings or lazy sync callables.
tool = BashTool(files={
    "/config/static.txt": "ready\n",
    "/config/generated.json": lambda: '{"ok": true}\n",
})
# Snapshot / restore state
blob = tool.snapshot()
restored = BashTool.from_snapshot(blob, username="user")

# Capture shell state for prompt/UI inspection
state = tool.shell_state()         # -> ShellState

# Direct VFS helpers (text-oriented convenience wrappers)
tool.read_file("/tmp/data.txt")      # -> str
tool.write_file("/tmp/data.txt", "hello")
tool.append_file("/tmp/data.txt", "\nworld")
tool.mkdir("/tmp/nested", recursive=True)
tool.exists("/tmp/data.txt")         # -> bool
tool.remove("/tmp/nested", recursive=True)
tool.stat("/tmp/data.txt")           # -> dict
tool.chmod("/tmp/data.txt", 0o644)
tool.symlink("/tmp/data.txt", "/tmp/link.txt")
tool.read_link("/tmp/link.txt")      # -> str
tool.read_dir("/tmp")                # -> list[dict]
tool.ls("/tmp")                      # -> list[str]
tool.glob("/tmp/*.txt")              # -> list[str]

# LLM metadata
tool.name              # "bashkit"
tool.short_description # str
tool.description()     # token-efficient description
tool.help()            # Markdown help document
tool.system_prompt()   # compact system prompt
tool.input_schema()    # JSON schema string
tool.output_schema()   # JSON schema string
tool.version           # from Rust crate

Snapshot/restore methods also exist on Bash and mirror the Node bindings:

from bashkit import Bash

bash = Bash()
bash.execute_sync("greet() { echo \"hi $1\"; }")
blob = bash.snapshot()              # -> bytes
restored = Bash.from_snapshot(blob) # -> Bash
assert restored.execute_sync("greet agent").stdout.strip() == "hi agent"
shell_only = bash.snapshot(exclude_filesystem=True)

ShellState

ShellState is a read-only Python object returned by Bash.shell_state() and BashTool.shell_state() for prompt rendering and state inspection. It is a Python-friendly inspection view, not a full Rust ShellState mirror.

state.cwd             # str
state.env             # Mapping[str, str]
state.variables       # Mapping[str, str]
state.arrays          # Mapping[str, Mapping[int, str]]
state.assoc_arrays    # Mapping[str, Mapping[str, str]]
state.last_exit_code  # int
state.aliases         # Mapping[str, str]
state.traps           # Mapping[str, str]

Use snapshot(exclude_filesystem=True) when you need shell-only restore bytes.

Transient fields follow Rust-core semantics: last_exit_code and traps are captured on the shell state object itself, but the next top-level execute() / execute_sync() clears them before running the new command.

ExecResult

result.stdout     # str
result.stderr     # str
result.exit_code  # int
result.error      # Optional[str]
result.success    # bool (exit_code == 0)
result.to_dict()  # dict

create_langchain_tool_spec()

Returns dict with name, description, args_schema for LangChain integration.

custom_builtins and Async Callbacks

Bash and BashTool accept custom_builtins={"name": callback} where each callback is Callable[[BuiltinContext], str | BuiltinResult | Awaitable[str | BuiltinResult]].

Sync callbacks are called directly under the session's captured contextvars snapshot and may return either a stdout string or a BuiltinResult with explicit stdout, stderr, and exit_code.

Async callbacks are driven to completion by one of two mechanisms depending on whether a running asyncio event loop is present on the calling thread:

Calling context Mechanism
await execute() Callback scheduled as a Task on the caller's running loop
execute_sync() — no running loop Callback driven by a private event loop shared across calls on the same Bash instance
execute_sync() — running loop present (e.g. Jupyter / IPython) Callback driven by a background daemon thread with its own fresh event loop

The background-thread path is activated via asyncio.get_running_loop(). If the call succeeds (a loop is already running on the thread), the awaitable is dispatched to a daemon thread whose run_until_complete call is wrapped in context.run() so ContextVars propagate correctly despite the thread switch. The helper is cached on the PyPrivateAsyncLoop to avoid repeated module compilation.

ContextVar propagation: ContextVars set before execute() or execute_sync() are captured at call time and replayed inside each callback invocation regardless of which mechanism is used.

import asyncio, contextvars
from bashkit import Bash, BuiltinContext, BuiltinResult

trace_id = contextvars.ContextVar("trace_id")
trace_id.set("req-42")

async def fetch(ctx: BuiltinContext) -> str:
    await asyncio.sleep(0)            # simulate async I/O
    return f"trace={trace_id.get()}\n"

bash = Bash(custom_builtins={"fetch": fetch})

# Works in plain Python, asyncio.run(), Jupyter, or any async framework:
result = bash.execute_sync("fetch")   # "trace=req-42"
result = await bash.execute("fetch")  # same, callback runs on caller loop
from bashkit import BuiltinResult

def view_image(ctx: BuiltinContext) -> BuiltinResult:
    if not ctx.argv:
        return BuiltinResult(stderr="view-image: missing path\n", exit_code=1)
    return BuiltinResult(stdout="")

Optional Dependencies

pip install bashkit[langchain]     # + langchain-core, langchain-anthropic
pip install bashkit[deepagents]    # + deepagents, langchain-anthropic
pip install bashkit[pydantic-ai]   # + pydantic-ai
pip install bashkit[dev]           # + pytest, pytest-asyncio

CI

File: .github/workflows/python.yml

Runs on push to main and PRs (path-filtered to crates/bashkit-python/, crates/bashkit/, examples/*.py, examples/*.ipynb, Cargo.toml, Cargo.lock).

PR / push to main
    ├── lint          (ruff check + ruff format --check)
    ├── test          (maturin develop + pytest, Python 3.9/3.12/3.13/3.14)
    ├── examples      (build wheel + run crates/bashkit-python/examples/
    │                  + execute examples/*.ipynb via nbconvert)
    ├── build-wheel   (maturin build + twine check)
    └── python-check  (gate job for branch protection)

Notebooks in examples/ are executed with jupyter nbconvert --execute --ExecutePreprocessor.timeout=120. A cell error fails the CI job.

Linting

  • Linter/formatter: ruff (config in pyproject.toml)
  • Rules: E (pycodestyle), F (pyflakes), W (warnings), I (isort), UP (pyupgrade)
  • Target: Python 3.9, line-length 120
ruff check crates/bashkit-python        # lint
ruff format --check crates/bashkit-python  # format check
ruff format crates/bashkit-python        # auto-format

Local Development

cd crates/bashkit-python
pip install maturin
maturin develop          # debug build, installs into current venv
maturin develop --release  # optimized build
pip install pytest pytest-asyncio
pytest tests/ -v         # run tests
ruff check .             # lint
ruff format .            # format

Design Decisions

  • No PGO: Profile-guided optimization adds build complexity for minimal gain. Bashkit is a thin PyO3 extension — hot paths are in Rust, not Python dispatch. Can revisit if profiling shows benefit.
  • No exotic architectures: armv7, ppc64le, s390x, i686 omitted. Target audience is AI agent developers on standard server/desktop platforms.
  • Dynamic version: Eliminates version drift between Rust and Python packages.
  • Trusted publishing: No secrets to rotate. OIDC tokens are scoped per-workflow.