Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
180 changes: 180 additions & 0 deletions .github/workflows/python-wheels.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
name: Python wheels

on:
push:
branches: [main]
tags: ["py-v*", "py-test-*"]
pull_request:
branches: [main]
workflow_dispatch: # manual ad-hoc builds from any branch

concurrency:
# Tag pushes get their own group so publishes never get cancelled.
group: >-
${{ github.workflow }}-${{ github.ref }}-${{ startsWith(github.ref, 'refs/tags/') && 'publish' || 'branch' }}
cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }}

env:
CARGO_TERM_COLOR: always

jobs:
build:
name: Build wheel (${{ matrix.target.label }})
runs-on: ${{ matrix.target.runner }}
strategy:
fail-fast: false
matrix:
target:
# `manylinux: "2_28"` makes maturin-action run the build inside the
# official PyPA manylinux_2_28 container (Rocky Linux 8 / glibc 2.28).
# Without this, the build runs on the host (Ubuntu glibc 2.39) and
# produces a wheel that fails the auditwheel manylinux_2_28 check.
- label: linux-x86_64
runner: ubuntu-latest
target: x86_64-unknown-linux-gnu
manylinux: "2_28"
- label: macos-universal2
runner: macos-latest
target: universal2-apple-darwin
manylinux: "auto"
- label: windows-x86_64
runner: windows-latest
target: x86_64-pc-windows-msvc
manylinux: "auto"
steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: "3.10" # abi3 — any 3.10+ works for building
Comment thread
kmolan marked this conversation as resolved.

- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target.label == 'macos-universal2' && 'x86_64-apple-darwin,aarch64-apple-darwin' || '' }}

- uses: Swatinem/rust-cache@v2
with:
workspaces: bonsai-py
key: ${{ matrix.target.label }}

- name: Tag/version guard (tag pushes only)
if: startsWith(github.ref, 'refs/tags/')
shell: bash
run: |
tag="${GITHUB_REF#refs/tags/}"
version="${tag#py-v}"
version="${version#py-test-}"
cargo_version=$(grep -m1 '^version' bonsai-py/Cargo.toml | sed -E 's/.*"([^"]+)".*/\1/')
if [ "$version" != "$cargo_version" ]; then
echo "::error::Tag version '$version' does not match Cargo.toml version '$cargo_version'."
exit 1
fi

- uses: PyO3/maturin-action@v1
with:
working-directory: bonsai-py
command: build
target: ${{ matrix.target.target }}
manylinux: ${{ matrix.target.manylinux }}
args: --release --out dist --strip

- name: Verify wheel (Linux/macOS only — Windows venv quirks)
if: matrix.target.runner != 'windows-latest'
shell: bash
run: |
python -m venv .venv-test
source .venv-test/bin/activate
pip install --upgrade pip
pip install pytest pytest-timeout mypy
pip install bonsai-py/dist/*.whl
python -c "import bonsai_bt; print(bonsai_bt.__version__)"
pytest bonsai-py/tests/

- name: Wheel size sanity (Linux/macOS only)
if: matrix.target.runner != 'windows-latest'
shell: bash
run: |
size=$(stat -c%s bonsai-py/dist/*.whl 2>/dev/null || stat -f%z bonsai-py/dist/*.whl)
ceiling=$((5 * 1024 * 1024))
if [ "$size" -gt "$ceiling" ]; then
echo "::error::Wheel size $size exceeds 5MB ceiling."
exit 1
fi
echo "Wheel size: $size bytes (under 5MB ceiling)."

- uses: actions/upload-artifact@v4
with:
name: wheel-${{ matrix.target.label }}
path: bonsai-py/dist/*.whl
retention-days: ${{ startsWith(github.ref, 'refs/tags/') && 90 || 14 }}

sdist:
name: Build sdist
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.10"
Comment thread
kmolan marked this conversation as resolved.
- uses: PyO3/maturin-action@v1
with:
working-directory: bonsai-py
command: sdist
args: --out dist
- uses: actions/upload-artifact@v4
with:
name: sdist
path: bonsai-py/dist/*.tar.gz
retention-days: ${{ startsWith(github.ref, 'refs/tags/') && 90 || 14 }}

verify-sdist:
name: Verify sdist builds from source
needs: sdist
runs-on: ubuntu-latest
steps:
- uses: actions/setup-python@v5
with:
python-version: "3.10"
- uses: dtolnay/rust-toolchain@stable
- uses: actions/download-artifact@v4
with:
name: sdist
path: dist
- name: Install + smoke-test from sdist
run: |
python -m venv .venv-sdist
source .venv-sdist/bin/activate
pip install --upgrade pip
pip install --no-binary :all: dist/*.tar.gz
python -c "import bonsai_bt; print(bonsai_bt.__version__)"

publish:
name: Publish to PyPI / TestPyPI
needs: [build, sdist, verify-sdist]
if: startsWith(github.ref, 'refs/tags/py-v') || startsWith(github.ref, 'refs/tags/py-test-')
runs-on: ubuntu-latest
environment:
name: ${{ startsWith(github.ref, 'refs/tags/py-test-') && 'testpypi' || 'pypi' }}
permissions:
id-token: write # OIDC for Trusted Publishing
steps:
- uses: actions/download-artifact@v4
with:
path: dist
pattern: wheel-*
merge-multiple: true

- uses: actions/download-artifact@v4
with:
name: sdist
path: dist

- name: List artifacts to publish
run: ls -la dist/

- uses: pypa/gh-action-pypi-publish@release/v1
with:
repository-url: >-
${{ startsWith(github.ref, 'refs/tags/py-test-') && 'https://test.pypi.org/legacy/' || 'https://upload.pypi.org/legacy/' }}
packages-dir: dist
skip-existing: true
26 changes: 26 additions & 0 deletions .github/workflows/rust-pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,29 @@ jobs:
run: cargo build --examples
- name: Run tests
run: cargo test --verbose
pytest:
runs-on: ubuntu-latest
strategy:
matrix:
python: ["3.10", "3.13"]
steps:
- uses: actions/checkout@v6.0.2
- uses: actions/setup-python@v6.2.0
with:
python-version: ${{ matrix.python }}
- uses: dtolnay/rust-toolchain@stable
- name: Create + activate venv (maturin develop requires one)
# $GITHUB_PATH prepends to PATH for every subsequent step;
# $GITHUB_ENV exports VIRTUAL_ENV (which maturin/pip detect)
# so we don't need to `source venv/bin/activate` in each step.
run: |
python -m venv $HOME/.venv
echo "$HOME/.venv/bin" >> $GITHUB_PATH
echo "VIRTUAL_ENV=$HOME/.venv" >> $GITHUB_ENV
- name: Install maturin and test deps
run: pip install "maturin>=1.7,<2.0" pytest pytest-timeout mypy
- name: Build and install bonsai-py
working-directory: bonsai-py
run: maturin develop --release
- name: Run pytest
run: pytest -v bonsai-py/tests/
13 changes: 13 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,16 @@ Cargo.lock
**/*.rs.bk

.idea/

# Python virtual environments
.venv/
venv/

# Python build artifacts (maturin develop output, bytecode caches)
__pycache__/
*.pyc
*.pyo
*.so
*.abi3.so
*.pyd
*.dylib
9 changes: 8 additions & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,10 @@ repos:
- id: end-of-file-fixer
- id: file-contents-sorter
- id: fix-byte-order-marker
- id: fix-encoding-pragma
- id: forbid-new-submodules
- id: mixed-line-ending
- id: name-tests-test
args: [--pytest-test-first]
- id: requirements-txt-fixer
- id: sort-simple-yaml
- id: trailing-whitespace
Expand All @@ -50,3 +50,10 @@ repos:
pass_filenames: false
types: [file, rust]
language: system
- id: regen-stubs
name: regenerate bonsai_py type stub
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.
entry: bash bonsai-py/scripts/regen-stubs.sh
language: system
files: ^(bonsai-py/src/.*\.rs|bonsai-py/python/bonsai_py/__init__\.pyi)$
pass_filenames: false
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
[workspace]
resolver = "2"
members = ["bonsai", "examples"]
members = ["bonsai", "examples", "bonsai-py"]
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,20 @@
* [Honorable Mentions](#similar-crates)

## Using Bonsai

### Rust

Bonsai is available on crates.io. The recommended way to use it is to add a line into your Cargo.toml such as:

```toml
[dependencies]
bonsai-bt = "*"
```

### Python

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.

## What is a Behavior Tree?

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.
Expand Down
32 changes: 32 additions & 0 deletions bonsai-py/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
[package]
name = "bonsai-py"
version = "0.12.0"
edition = "2021"
rust-version = "1.80.0"
description = "Python bindings for the bonsai-bt behavior tree library"
license = "MIT"
authors = ["Kristoffer Solberg Rakstad <solkristoffer@gmail.com>"]
repository = "https://github.com/sollimann/bonsai.git"
homepage = "https://github.com/sollimann/bonsai"
publish = false

[lib]
# Internal Rust crate name — kept as `bonsai_py` to avoid colliding with the
# workspace's `bonsai-bt` crate at `bonsai/`, which also produces `libbonsai_bt.rlib`.
# The Python-facing module name is controlled separately by
# `[tool.maturin] module-name = "bonsai_bt"` in pyproject.toml.
name = "bonsai_py"
crate-type = ["cdylib", "rlib"]

[[bin]]
name = "stub_gen"
path = "src/bin/stub_gen.rs"

[dependencies]
# Note: `extension-module` is enabled by maturin via pyproject.toml's
# `[tool.maturin].features` setting. Keeping it out of the default feature
# list lets `cargo run --bin stub_gen` link libpython for the regular binary
# path; maturin still activates it for the wheel build.
pyo3 = { version = "0.28", features = ["abi3-py310"] }
bonsai-bt = { path = "../bonsai", version = "0.12", features = ["visualize"] }
pyo3-stub-gen = "0.22.3"
81 changes: 81 additions & 0 deletions bonsai-py/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# bonsai-bt - Python bindings

Python bindings for the [bonsai-bt](https://github.com/sollimann/bonsai)
behavior-tree library.

## Installation (dev)

```bash
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\Activate.ps1
pip install maturin
cd bonsai-py
maturin develop
python -c "import bonsai_bt; print(bonsai_bt.__version__)"
```

## Same BT in Rust and Python

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).

### Rust

```rust
use bonsai_bt::{Behavior, Event, Status, UpdateArgs, BT};

#[derive(Clone, Debug)]
enum Greet { Hello, Goodbye }

fn main() {
let tree = Behavior::Sequence(vec![
Behavior::Action(Greet::Hello),
Behavior::Wait(1.0),
Behavior::Action(Greet::Goodbye),
]);

let mut bt: BT<Greet, ()> = BT::new(tree, ());

for _ in 0..5 {
let e: Event = UpdateArgs { dt: 0.5 }.into();
bt.tick(&e, &mut |args, _bb| {
match *args.action {
Greet::Hello => println!("hello"),
Greet::Goodbye => println!("goodbye"),
}
(Status::Success, args.dt)
});
}
}
```

### Python

```python
import bonsai_bt as bt

tree = bt.Sequence([
bt.Action("hello"),
bt.Wait(1.0),
bt.Action("goodbye"),
])

tree_bt = bt.BT(tree, None)

def cb(args, _bb):
print(args.action)
return (bt.Status.Success, args.dt)

for _ in range(5):
tree_bt.tick(0.5, cb)
```

Output (both):

hello
goodbye

For richer examples — multi-job orchestration, visualizer integration, parallel agents — see [examples/](examples/).

## License

MIT - see [LICENSE](../LICENSE).
Loading
Loading