Skip to content

Add brush-py: PyO3 Python bindings for brush (fork prototype)#2

Open
hartsock wants to merge 2 commits into
mainfrom
feat/brush-py-bindings
Open

Add brush-py: PyO3 Python bindings for brush (fork prototype)#2
hartsock wants to merge 2 commits into
mainfrom
feat/brush-py-bindings

Conversation

@hartsock

Copy link
Copy Markdown
Owner

Summary

Adds brush-py — PyO3 Python bindings that let you drive the brush shell from Python (pip installimport brush → run commands). Fork-first prototype (Phase 1); zero changes to any existing brush crate.

  • Standalone cdylib crate with its own [workspace]; depends on the published brush-core = "0.5" + brush-builtins = "0.2" (immune to in-tree API churn).
  • Stateful Shell (vars, exported env, cwd, functions persist across calls). CompletedCommand carries stdout/stderr/exit_code/success.
  • Methods: run, run_c, run_script, call_function, setenv, getenv, cd, cwd, last_exit_status.
  • Ships PEP 561 type stubs (__init__.pyi + py.typed) via a mixed Rust/Python layout (compiled brush._brush, re-exported by a brush package).

Key design decisions (verified against brush-core 0.5.0 source + at runtime)

  • Async — every execute path is async/tokio; the crate owns a multi-thread runtime and block_ons each call inside Python::allow_threads (GIL released while the shell blocks on child processes/IO).
  • Output capture via tempfiles, not pipes — brush-core's concurrent pipe drainer (AsyncPipeReader) is pub(crate)/unreachable, and naive pipe capture deadlocks past the 64 KB OS buffer. Tempfiles also capture external commands. Verified with 50 000 lines, no deadlock.
  • Builtins — a bare builder has no echo/cd/export; the binding calls default_builtins(BuiltinSet::BashMode).
  • Error contract — syntax errors are reported bash-style (exit_code == 2, message on stderr), not as exceptions (verified empirically; the design's initial assumption was corrected).

Test plan

  • cargo check / cargo build — green against published crates (pyo3 0.24.2).
  • maturin build → abi3 manylinux wheel → pip installpython example.py — full end-to-end (capture, exit codes, env export to children, 2>&1, cd/cwd, run_c, call_function).
  • pytest tests/18/18 pass (exit codes, capture, combine_stderr, state persistence, exported vs non-exported env, large output, run_c, call_function, run_script, syntax-error contract).
  • mypy against the stubs — accepts valid usage, flags type errors (stubs work).

Notes / follow-ups

🤖 Generated with Claude Code

hartsock added 2 commits June 9, 2026 21:35
Introduce brush-py, a Python extension module that embeds the brush shell
via PyO3 over the published brush-core 0.5 and brush-builtins 0.2 crates.

Zero-core-change design: brush-py makes NO modifications to any existing
brush crate or the root Cargo.toml. It is its own independent cargo
workspace (empty [workspace] table) so it is not pulled into the parent
brush workspace, and it depends on the crates.io releases rather than path
deps, insulating it from in-tree API churn. Upstreaming later only requires
switching the two deps to path deps and adding it to the root members list.

The binding exposes a stateful Shell (variables, exported env, cwd, and
functions persist across calls) and a CompletedCommand result carrying
stdout/stderr/exit_code/success. Output is captured through tempfiles
(std::fs::File -> OpenFile::File) to avoid the ~64KB OS-pipe-buffer deadlock
a naive pipe capture would hit, and external commands are captured too. Each
async brush-core call is driven on an owned multi-thread Tokio runtime via
block_on, wrapped in Python::allow_threads to release the GIL. The pyclass is
#[pyclass(unsendable)] since one Shell is driven from one thread.

Builds abi3-py39 wheels (one per platform, CPython 3.9+) via maturin;
CI workflow publishes to PyPI on brush-py-v* tags using trusted publishing.

Assisted-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Builds on the initial brush-py crate with the API surface and packaging
polish needed for real use. Still ZERO changes to any existing brush crate.

- New Shell methods: run_c() (bash -c semantics; process-safe -- returns a
  result rather than terminating) and call_function() (invoke a defined
  shell function by name, with output capture). Verified against the
  PUBLISHED brush-core 0.5.0 API (invoke_function takes &ExecutionParameters,
  which differs from the local cap-hook checkout).
- Mixed Rust/Python layout for type stubs: the compiled extension is now
  `brush._brush` and a pure-Python `brush` package re-exports it, carrying
  PEP 561 stubs (python/brush/__init__.pyi + py.typed). `import brush` is
  unchanged; mypy/pyright now type-check the API (verified with mypy).
- pytest regression suite (tests/) covering exit codes, stdout/stderr
  capture, combine_stderr (2>&1), state persistence, env export to children,
  non-exported vars, cd/cwd, >50k-line output (no pipe deadlock), run_c,
  call_function, run_script, and the syntax-error contract. 18/18 pass.
- Corrected the syntax-error contract in docs/stubs/comments: brush reports
  parse errors bash-style (exit_code 2 + message on stderr), it does NOT
  raise. Verified empirically against brush-core 0.5.0.

Assisted-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant