Skip to content
Open
Show file tree
Hide file tree
Changes from 19 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
pytest bonsai-py/tests/
python bonsai-py/scripts/verify.py

- 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_py; print(bonsai_py.__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
28 changes: 28 additions & 0 deletions .github/workflows/rust-pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,31 @@ 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/
- name: Run verify.py (sanity)
run: python bonsai-py/scripts/verify.py
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"]
28 changes: 28 additions & 0 deletions bonsai-py/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
[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 <kristoffer.solberg@cognite.com>"]
repository = "https://github.com/sollimann/bonsai.git"
homepage = "https://github.com/sollimann/bonsai"
publish = false

[lib]
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"
19 changes: 19 additions & 0 deletions bonsai-py/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# bonsai-py - 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_py; print(bonsai_py.__version__)"
```

## License

MIT - see [LICENSE](../LICENSE).
71 changes: 71 additions & 0 deletions bonsai-py/examples/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# bonsai-py examples

Pure-Python examples mirroring `examples/` in the Rust workspace. Each example is a single self-contained `.py` file.
Comment on lines +1 to +3
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we done in a separate PR, but I think it would be nice to have a python example where we serialize and deserialise the BT with json. ref

fn test_deserialize_behavior() {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Created #66


## Prerequisites

Create and activate a Python venv (one-time), then build & install the extension:

```bash
# 1. Create a venv (only needed the first time)
python3 -m venv .venv

# 2. Activate it (every new shell)
source .venv/bin/activate # macOS / Linux / WSL
# .\.venv\Scripts\Activate.ps1 # Windows PowerShell

# 3. Install build deps + build the extension into the venv
pip install maturin
cd bonsai-py && maturin develop --release && cd ..
```

After that, just `source .venv/bin/activate` + `python bonsai-py/examples/<name>.py` in any new shell.

## Examples (6)

### [simple_npc_ai.py](simple_npc_ai.py) — console NPC
NPC runs and shoots until action points are exhausted, then rests and dies. Demonstrates `WhileAll`, blackboard mutation via `@dataclass`, structural-`match` callback.

```bash
python bonsai-py/examples/simple_npc_ai.py
```

### [race_timeout.py](race_timeout.py) — `Race` between work and timeout
A simulated long-running job (random 200–1200 ms on a `threading.Thread`) races a 600 ms timeout. The callback polls the work's `queue.Queue` non-blockingly. Demonstrates `Race`, asyncio main loop + threading worker, the unsendable-BT constraint.

```bash
python bonsai-py/examples/race_timeout.py
```

### [graphviz_demo.py](graphviz_demo.py) — tree visualization
Builds an attack-drone tree (mix of plain-string and `@dataclass(frozen=True)` payload actions) and prints the graphviz DOT representation. Paste the output into <https://dreampuf.github.io/GraphvizOnline/> to render it.

```bash
python bonsai-py/examples/graphviz_demo.py
python bonsai-py/examples/graphviz_demo.py > tree.dot
```

### [visualizer_smoke.py](visualizer_smoke.py) — live web visualizer
Drives a deliberately rich 27-node tree at ~400 ms/tick with a 5-step status rotation and per-leaf phase offset; the browser shows continuous color animation. Demonstrates `BT.with_telemetry(port)`, `reset_bt()`, and every major factory.

```bash
python bonsai-py/examples/visualizer_smoke.py
```

Then open <http://127.0.0.1:8910/> in a browser. `Ctrl-C` to stop.

### [boids_console.py](boids_console.py) — shared BT across N agents
Builds **one** `Behavior` tree and binds it to 10 independent `BT` instances (each with its own `Boid` dataclass blackboard). Updates positions every tick for 30 frames. Demonstrates the shared-subtree pattern, real-time-loop dt, `WhenAll` for parallel updates.

```bash
python bonsai-py/examples/boids_console.py
```
Comment thread
kmolan marked this conversation as resolved.

### [async_drone.py](async_drone.py) — multi-job mission
Drone mission: takeoff → check battery → fly (or fall back to land) → land → repeat. Each long-running step runs on a background thread; the BT polls per-job queues. Prints the tree's `graphviz()` at the start, then runs the mission for ~8 seconds.

```bash
python bonsai-py/examples/async_drone.py
```
Comment thread
kmolan marked this conversation as resolved.
Outdated

Demonstrates `Select` for prioritized fallback, multi-job orchestration via per-job channels, asyncio + threading.
Empty file added bonsai-py/examples/__init__.py
Empty file.
Loading
Loading