Skip to content

Commit 73eb995

Browse files
authored
Add python bindings for bonsai (#60)
Added python bindings for `bonsai-bt` - also called `bonsai-bt`. The new folder comes with its `tests/` and `examples/`, where the `examples/` folder should mimic the existing ones in rust. Since the original rust code is still small in size, writing the python bindings was not the hard part, in fact the bindings themselves are a small part of this PR. The hard part was all the devops work surrounding it. Namely: 1. Adding thorough tests to ensure the bindings preserve original behavior. This forms the major bulk of this PR. 2. Adding automation in our pipelines to ensure bindings do not get out of sync due to API changes. Builds will fail if API changes but bindings did not. You are allowed to add new API without creating their bindings, but you are not allowed to change existing API without updating the bindings. 3. Adding automation in our pipelines to automatically build python wheels+sdist on every PR push. Opening up the PR early for reviews. What is left now: 1. Creating a PyPI account and creating a `bonsai-bt` project. 2. Creating github scaffolding to support publishing to PyPI (this will remain a manual process, similar to tagging a release). 3. Doing a first ever `bonsai-bt` release, verify by being able to do `pip install bonsai-bt` and running a test python script. 4. Set up future automation. @Sollimann I am stopping at this point for reviews. I will go ahead with setting up the above python publishing workflow if you are happy with what we currently have. For referece, once the above is done, all future releases workflow will look like this: 1. Bump `bonsai-py/Cargo.toml` version. Merge to main. For this example lets assume the version is 0.13.0 2. `git tag py-test-0.13.0 && git push origin py-test-0.13.0` → TestPyPI dry-run. 3. Verify on TestPyPI: `pip install -i https://test.pypi.org/simple/ bonsai-bt==0.13.0` in a clean venv. 4. `git tag py-v0.13.0 && git push origin py-v0.13.0` → real PyPI release. 5. Approve the publish in GitHub Environments. NO PYTHON PUBLISHING without a manual approval. 6. Users can now `pip install --upgrade bonsai-bt` and get latest version. Disclaimer: Used claude code to generate documentation, verify 100% test coverage, and generate plans for devops automation.
1 parent ce44276 commit 73eb995

44 files changed

Lines changed: 3467 additions & 4 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
name: Python wheels
2+
3+
on:
4+
push:
5+
branches: [main]
6+
tags: ["py-v*", "py-test-*"]
7+
pull_request:
8+
branches: [main]
9+
workflow_dispatch: # manual ad-hoc builds from any branch
10+
11+
concurrency:
12+
# Tag pushes get their own group so publishes never get cancelled.
13+
group: >-
14+
${{ github.workflow }}-${{ github.ref }}-${{ startsWith(github.ref, 'refs/tags/') && 'publish' || 'branch' }}
15+
cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }}
16+
17+
env:
18+
CARGO_TERM_COLOR: always
19+
20+
jobs:
21+
build:
22+
name: Build wheel (${{ matrix.target.label }})
23+
runs-on: ${{ matrix.target.runner }}
24+
strategy:
25+
fail-fast: false
26+
matrix:
27+
target:
28+
# `manylinux: "2_28"` makes maturin-action run the build inside the
29+
# official PyPA manylinux_2_28 container (Rocky Linux 8 / glibc 2.28).
30+
# Without this, the build runs on the host (Ubuntu glibc 2.39) and
31+
# produces a wheel that fails the auditwheel manylinux_2_28 check.
32+
- label: linux-x86_64
33+
runner: ubuntu-latest
34+
target: x86_64-unknown-linux-gnu
35+
manylinux: "2_28"
36+
- label: macos-universal2
37+
runner: macos-latest
38+
target: universal2-apple-darwin
39+
manylinux: "auto"
40+
- label: windows-x86_64
41+
runner: windows-latest
42+
target: x86_64-pc-windows-msvc
43+
manylinux: "auto"
44+
steps:
45+
- uses: actions/checkout@v4
46+
47+
- uses: actions/setup-python@v5
48+
with:
49+
python-version: "3.10" # abi3 — any 3.10+ works for building
50+
51+
- uses: dtolnay/rust-toolchain@stable
52+
with:
53+
targets: ${{ matrix.target.label == 'macos-universal2' && 'x86_64-apple-darwin,aarch64-apple-darwin' || '' }}
54+
55+
- uses: Swatinem/rust-cache@v2
56+
with:
57+
workspaces: bonsai-py
58+
key: ${{ matrix.target.label }}
59+
60+
- name: Tag/version guard (tag pushes only)
61+
if: startsWith(github.ref, 'refs/tags/')
62+
shell: bash
63+
run: |
64+
tag="${GITHUB_REF#refs/tags/}"
65+
version="${tag#py-v}"
66+
version="${version#py-test-}"
67+
cargo_version=$(grep -m1 '^version' bonsai-py/Cargo.toml | sed -E 's/.*"([^"]+)".*/\1/')
68+
if [ "$version" != "$cargo_version" ]; then
69+
echo "::error::Tag version '$version' does not match Cargo.toml version '$cargo_version'."
70+
exit 1
71+
fi
72+
73+
- uses: PyO3/maturin-action@v1
74+
with:
75+
working-directory: bonsai-py
76+
command: build
77+
target: ${{ matrix.target.target }}
78+
manylinux: ${{ matrix.target.manylinux }}
79+
args: --release --out dist --strip
80+
81+
- name: Verify wheel (Linux/macOS only — Windows venv quirks)
82+
if: matrix.target.runner != 'windows-latest'
83+
shell: bash
84+
run: |
85+
python -m venv .venv-test
86+
source .venv-test/bin/activate
87+
pip install --upgrade pip
88+
pip install pytest pytest-timeout mypy
89+
pip install bonsai-py/dist/*.whl
90+
python -c "import bonsai_bt; print(bonsai_bt.__version__)"
91+
pytest bonsai-py/tests/
92+
93+
- name: Wheel size sanity (Linux/macOS only)
94+
if: matrix.target.runner != 'windows-latest'
95+
shell: bash
96+
run: |
97+
size=$(stat -c%s bonsai-py/dist/*.whl 2>/dev/null || stat -f%z bonsai-py/dist/*.whl)
98+
ceiling=$((5 * 1024 * 1024))
99+
if [ "$size" -gt "$ceiling" ]; then
100+
echo "::error::Wheel size $size exceeds 5MB ceiling."
101+
exit 1
102+
fi
103+
echo "Wheel size: $size bytes (under 5MB ceiling)."
104+
105+
- uses: actions/upload-artifact@v4
106+
with:
107+
name: wheel-${{ matrix.target.label }}
108+
path: bonsai-py/dist/*.whl
109+
retention-days: ${{ startsWith(github.ref, 'refs/tags/') && 90 || 14 }}
110+
111+
sdist:
112+
name: Build sdist
113+
runs-on: ubuntu-latest
114+
steps:
115+
- uses: actions/checkout@v4
116+
- uses: actions/setup-python@v5
117+
with:
118+
python-version: "3.10"
119+
- uses: PyO3/maturin-action@v1
120+
with:
121+
working-directory: bonsai-py
122+
command: sdist
123+
args: --out dist
124+
- uses: actions/upload-artifact@v4
125+
with:
126+
name: sdist
127+
path: bonsai-py/dist/*.tar.gz
128+
retention-days: ${{ startsWith(github.ref, 'refs/tags/') && 90 || 14 }}
129+
130+
verify-sdist:
131+
name: Verify sdist builds from source
132+
needs: sdist
133+
runs-on: ubuntu-latest
134+
steps:
135+
- uses: actions/setup-python@v5
136+
with:
137+
python-version: "3.10"
138+
- uses: dtolnay/rust-toolchain@stable
139+
- uses: actions/download-artifact@v4
140+
with:
141+
name: sdist
142+
path: dist
143+
- name: Install + smoke-test from sdist
144+
run: |
145+
python -m venv .venv-sdist
146+
source .venv-sdist/bin/activate
147+
pip install --upgrade pip
148+
pip install --no-binary :all: dist/*.tar.gz
149+
python -c "import bonsai_bt; print(bonsai_bt.__version__)"
150+
151+
publish:
152+
name: Publish to PyPI / TestPyPI
153+
needs: [build, sdist, verify-sdist]
154+
if: startsWith(github.ref, 'refs/tags/py-v') || startsWith(github.ref, 'refs/tags/py-test-')
155+
runs-on: ubuntu-latest
156+
environment:
157+
name: ${{ startsWith(github.ref, 'refs/tags/py-test-') && 'testpypi' || 'pypi' }}
158+
permissions:
159+
id-token: write # OIDC for Trusted Publishing
160+
steps:
161+
- uses: actions/download-artifact@v4
162+
with:
163+
path: dist
164+
pattern: wheel-*
165+
merge-multiple: true
166+
167+
- uses: actions/download-artifact@v4
168+
with:
169+
name: sdist
170+
path: dist
171+
172+
- name: List artifacts to publish
173+
run: ls -la dist/
174+
175+
- uses: pypa/gh-action-pypi-publish@release/v1
176+
with:
177+
repository-url: >-
178+
${{ startsWith(github.ref, 'refs/tags/py-test-') && 'https://test.pypi.org/legacy/' || 'https://upload.pypi.org/legacy/' }}
179+
packages-dir: dist
180+
skip-existing: true

.github/workflows/rust-pr.yml

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,29 @@ jobs:
2828
run: cargo build --examples
2929
- name: Run tests
3030
run: cargo test --verbose
31+
pytest:
32+
runs-on: ubuntu-latest
33+
strategy:
34+
matrix:
35+
python: ["3.10", "3.13"]
36+
steps:
37+
- uses: actions/checkout@v6.0.2
38+
- uses: actions/setup-python@v6.2.0
39+
with:
40+
python-version: ${{ matrix.python }}
41+
- uses: dtolnay/rust-toolchain@stable
42+
- name: Create + activate venv (maturin develop requires one)
43+
# $GITHUB_PATH prepends to PATH for every subsequent step;
44+
# $GITHUB_ENV exports VIRTUAL_ENV (which maturin/pip detect)
45+
# so we don't need to `source venv/bin/activate` in each step.
46+
run: |
47+
python -m venv $HOME/.venv
48+
echo "$HOME/.venv/bin" >> $GITHUB_PATH
49+
echo "VIRTUAL_ENV=$HOME/.venv" >> $GITHUB_ENV
50+
- name: Install maturin and test deps
51+
run: pip install "maturin>=1.7,<2.0" pytest pytest-timeout mypy
52+
- name: Build and install bonsai-py
53+
working-directory: bonsai-py
54+
run: maturin develop --release
55+
- name: Run pytest
56+
run: pytest -v bonsai-py/tests/

.gitignore

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,16 @@ Cargo.lock
1010
**/*.rs.bk
1111

1212
.idea/
13+
14+
# Python virtual environments
15+
.venv/
16+
venv/
17+
18+
# Python build artifacts (maturin develop output, bytecode caches)
19+
__pycache__/
20+
*.pyc
21+
*.pyo
22+
*.so
23+
*.abi3.so
24+
*.pyd
25+
*.dylib

.pre-commit-config.yaml

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,10 @@ repos:
2929
- id: end-of-file-fixer
3030
- id: file-contents-sorter
3131
- id: fix-byte-order-marker
32-
- id: fix-encoding-pragma
3332
- id: forbid-new-submodules
3433
- id: mixed-line-ending
3534
- id: name-tests-test
35+
args: [--pytest-test-first]
3636
- id: requirements-txt-fixer
3737
- id: sort-simple-yaml
3838
- id: trailing-whitespace
@@ -50,3 +50,10 @@ repos:
5050
pass_filenames: false
5151
types: [file, rust]
5252
language: system
53+
- id: regen-stubs
54+
name: regenerate bonsai_py type stub
55+
description: Regenerate python/bonsai_py/__init__.pyi from #[gen_stub_*] annotations. If the regenerated stub differs from the committed version, the hook fails so the developer can stage the update.
56+
entry: bash bonsai-py/scripts/regen-stubs.sh
57+
language: system
58+
files: ^(bonsai-py/src/.*\.rs|bonsai-py/python/bonsai_py/__init__\.pyi)$
59+
pass_filenames: false

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
[workspace]
22
resolver = "2"
3-
members = ["bonsai", "examples"]
3+
members = ["bonsai", "examples", "bonsai-py"]

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,20 @@
2929
* [Honorable Mentions](#similar-crates)
3030

3131
## Using Bonsai
32+
33+
### Rust
34+
3235
Bonsai is available on crates.io. The recommended way to use it is to add a line into your Cargo.toml such as:
3336

3437
```toml
3538
[dependencies]
3639
bonsai-bt = "*"
3740
```
3841

42+
### Python
43+
44+
Python bindings are available — see [`bonsai-py/`](bonsai-py/) for installation, examples, and a side-by-side comparison of the same BT in Rust and Python. The package wraps the same Rust crate, so the BT semantics are identical; only the API surface differs.
45+
3946
## What is a Behavior Tree?
4047

4148
A _Behavior Tree_ (BT) is a data structure in which we can set the rules of how certain _behavior's_ can occur, and the order in which they would execute. BTs are a very efficient way of creating complex systems that are both modular and reactive. These properties are crucial in many applications, which has led to the spread of BT from computer game programming to many branches of AI and Robotics.

bonsai-py/Cargo.toml

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
[package]
2+
name = "bonsai-py"
3+
version = "0.12.0"
4+
edition = "2021"
5+
rust-version = "1.80.0"
6+
description = "Python bindings for the bonsai-bt behavior tree library"
7+
license = "MIT"
8+
authors = ["Kristoffer Solberg Rakstad <solkristoffer@gmail.com>"]
9+
repository = "https://github.com/sollimann/bonsai.git"
10+
homepage = "https://github.com/sollimann/bonsai"
11+
publish = false
12+
13+
[lib]
14+
# Internal Rust crate name — kept as `bonsai_py` to avoid colliding with the
15+
# workspace's `bonsai-bt` crate at `bonsai/`, which also produces `libbonsai_bt.rlib`.
16+
# The Python-facing module name is controlled separately by
17+
# `[tool.maturin] module-name = "bonsai_bt"` in pyproject.toml.
18+
name = "bonsai_py"
19+
crate-type = ["cdylib", "rlib"]
20+
21+
[[bin]]
22+
name = "stub_gen"
23+
path = "src/bin/stub_gen.rs"
24+
25+
[dependencies]
26+
# Note: `extension-module` is enabled by maturin via pyproject.toml's
27+
# `[tool.maturin].features` setting. Keeping it out of the default feature
28+
# list lets `cargo run --bin stub_gen` link libpython for the regular binary
29+
# path; maturin still activates it for the wheel build.
30+
pyo3 = { version = "0.28", features = ["abi3-py310"] }
31+
bonsai-bt = { path = "../bonsai", version = "0.12", features = ["visualize"] }
32+
pyo3-stub-gen = "0.22.3"

bonsai-py/README.md

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
# bonsai-bt - Python bindings
2+
3+
Python bindings for the [bonsai-bt](https://github.com/sollimann/bonsai)
4+
behavior-tree library.
5+
6+
## Installation (dev)
7+
8+
```bash
9+
python -m venv .venv
10+
source .venv/bin/activate # Windows: .venv\Scripts\Activate.ps1
11+
pip install maturin
12+
cd bonsai-py
13+
maturin develop
14+
python -c "import bonsai_bt; print(bonsai_bt.__version__)"
15+
```
16+
17+
## Same BT in Rust and Python
18+
19+
A minimal three-node tree (`Hello → Wait(1.0) → Goodbye`) implemented in both languages. Semantics are identical because the Python package is a thin wrapper around the Rust crate; only the API surface differs (Rust requires an `enum` + explicit types; Python uses any hashable object as the action payload).
20+
21+
### Rust
22+
23+
```rust
24+
use bonsai_bt::{Behavior, Event, Status, UpdateArgs, BT};
25+
26+
#[derive(Clone, Debug)]
27+
enum Greet { Hello, Goodbye }
28+
29+
fn main() {
30+
let tree = Behavior::Sequence(vec![
31+
Behavior::Action(Greet::Hello),
32+
Behavior::Wait(1.0),
33+
Behavior::Action(Greet::Goodbye),
34+
]);
35+
36+
let mut bt: BT<Greet, ()> = BT::new(tree, ());
37+
38+
for _ in 0..5 {
39+
let e: Event = UpdateArgs { dt: 0.5 }.into();
40+
bt.tick(&e, &mut |args, _bb| {
41+
match *args.action {
42+
Greet::Hello => println!("hello"),
43+
Greet::Goodbye => println!("goodbye"),
44+
}
45+
(Status::Success, args.dt)
46+
});
47+
}
48+
}
49+
```
50+
51+
### Python
52+
53+
```python
54+
import bonsai_bt as bt
55+
56+
tree = bt.Sequence([
57+
bt.Action("hello"),
58+
bt.Wait(1.0),
59+
bt.Action("goodbye"),
60+
])
61+
62+
tree_bt = bt.BT(tree, None)
63+
64+
def cb(args, _bb):
65+
print(args.action)
66+
return (bt.Status.Success, args.dt)
67+
68+
for _ in range(5):
69+
tree_bt.tick(0.5, cb)
70+
```
71+
72+
Output (both):
73+
74+
hello
75+
goodbye
76+
77+
For richer examples — multi-job orchestration, visualizer integration, parallel agents — see [examples/](examples/).
78+
79+
## License
80+
81+
MIT - see [LICENSE](../LICENSE).

0 commit comments

Comments
 (0)