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
150 changes: 150 additions & 0 deletions .github/workflows/python_test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
name: python (pybt)

on:
push:
branches: [master]
paths:
- 'python/**'
- 'include/behaviortree_cpp/**'
- 'src/**'
- 'CMakeLists.txt'
- '.github/workflows/python_test.yml'
pull_request:
types: [opened, synchronize, reopened]
paths:
- 'python/**'
- 'include/behaviortree_cpp/**'
- 'src/**'
- 'CMakeLists.txt'
- '.github/workflows/python_test.yml'
workflow_dispatch:

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
# ---------------------------------------------------------------------------
# Lint, type-check, smoke tests, and benchmarks across the supported Python
# versions. STABLE_ABI means one abi3 wheel covers 3.12+, so the matrix is
# just (3.12, 3.13) — plus 3.13t (free-threaded) as opt-in / allowed-to-fail.
# ---------------------------------------------------------------------------
test:
name: test (cp${{ matrix.python }})
runs-on: ubuntu-22.04
strategy:
fail-fast: false
matrix:
python: ["3.12", "3.13"]
include:
- python: "3.13t"
continue-on-error: true
continue-on-error: ${{ matrix.continue-on-error || false }}
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0 # setuptools-scm needs full history

- uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python }}

- name: Cache pip
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: pip-${{ runner.os }}-py${{ matrix.python }}-${{ hashFiles('python/pyproject.toml') }}

- name: Set up ccache
uses: hendrikmuhs/ccache-action@v1.2
with:
key: ccache-${{ runner.os }}-py${{ matrix.python }}

- name: Install pybt (editable, with dev extras)
run: pip install -e python/[dev] -v

- name: ruff check
run: ruff check python/

- name: mypy
run: mypy python/src/pybt/
continue-on-error: true # mypy on a fresh nanobind extension surfaces noise until stub gen lands

- name: pytest -m smoke
run: pytest python/tests -n auto -m smoke

- name: pytest benchmarks (record only, no thresholds yet)
run: pytest python/benchmarks/ --benchmark-only --benchmark-json=bench.json

- name: Upload benchmark results
if: always()
uses: actions/upload-artifact@v4
with:
name: bench-cp${{ matrix.python }}
path: bench.json
if-no-files-found: ignore

# ---------------------------------------------------------------------------
# Symbol hygiene: assert that no Python/nanobind symbols appear in
# libbehaviortree_cpp.
# ---------------------------------------------------------------------------
symbol-hygiene:
name: symbol hygiene (nm scan)
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v6

- uses: actions/setup-python@v6
with:
python-version: "3.12"

- name: Install nanobind (for CMake config)
run: pip install "nanobind>=2.5,<3"

- name: Configure + build (BTCPP_PYTHON=ON, no examples/tools/tests on core)
run: |
cmake -S . -B build \
-DBTCPP_PYTHON=ON \
-DBTCPP_BUILD_TOOLS=OFF \
-DBTCPP_EXAMPLES=OFF \
-DBUILD_TESTING=OFF \
-DBTCPP_GROOT_INTERFACE=OFF \
-DBTCPP_SQLITE_LOGGING=OFF \
-Dnanobind_DIR=$(python -c "import nanobind, pathlib; print(pathlib.Path(nanobind.__file__).parent / 'cmake')")
cmake --build build --parallel

- name: ctest -R pybt_no_python_symbols_in_core
run: ctest --test-dir build --output-on-failure -R pybt_no_python_symbols_in_core

# ---------------------------------------------------------------------------
# Hidden-visibility + LTO build. `import pybt` must still resolve cleanly here.
# ---------------------------------------------------------------------------
hidden-lto:
name: hidden-visibility + LTO import
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v6

- uses: actions/setup-python@v6
with:
python-version: "3.12"

- name: Install nanobind (for CMake config)
run: pip install "nanobind>=2.5,<3"

- name: Configure + build with -fvisibility=hidden -flto
run: |
cmake -S . -B build \
-DBTCPP_PYTHON=ON \
-DBTCPP_BUILD_TOOLS=OFF \
-DBTCPP_EXAMPLES=OFF \
-DBUILD_TESTING=OFF \
-DBTCPP_GROOT_INTERFACE=OFF \
-DBTCPP_SQLITE_LOGGING=OFF \
-DCMAKE_C_FLAGS="-fvisibility=hidden -flto" \
-DCMAKE_CXX_FLAGS="-fvisibility=hidden -flto" \
-Dnanobind_DIR=$(python -c "import nanobind, pathlib; print(pathlib.Path(nanobind.__file__).parent / 'cmake')")
cmake --build build --parallel

- name: ctest -R pybt_import_under_hidden_lto
run: ctest --test-dir build --output-on-failure -R pybt_import_under_hidden_lto
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,5 @@ TODO.md
/coverage_report/*
/coverage.info
/doc/html/*

.claude/
27 changes: 27 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,30 @@ repos:
- tomli
args:
[--toml=./pyproject.toml]

# Python lint + format (pybt)
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.7.0
hooks:
- id: ruff
files: ^python/
- id: ruff-format
files: ^python/

# Python type-check (pybt)
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.13.0
hooks:
- id: mypy
files: ^python/src/pybt/
additional_dependencies: []

# No C++ references in pybt user-facing docs/examples (allows READMEs).
- repo: local
hooks:
- id: pybt-no-cpp-refs
name: pybt no-C++-refs
entry: python python/docs/check_no_cpp_refs.py
language: system
files: ^python/(src|examples|docs)/
pass_filenames: false
5 changes: 5 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ option(BTCPP_EXAMPLES "Build tutorials and examples" ON)
option(BUILD_TESTING "Build the unit tests" ON)
option(BTCPP_GROOT_INTERFACE "Add Groot2 connection. Requires ZeroMQ" ON)
option(BTCPP_SQLITE_LOGGING "Add SQLite logging." ON)
option(BTCPP_PYTHON "Build Python bindings (pybt)" OFF)
option(BTCPP_ENABLE_ASAN "Enable Address Sanitizer" OFF)
option(BTCPP_ENABLE_UBSAN "Enable Undefined Behavior Sanitizer" OFF)
option(BTCPP_ENABLE_TSAN "Enable Thread Sanitizer" OFF)
Expand Down Expand Up @@ -299,6 +300,10 @@ if(BTCPP_EXAMPLES)
add_subdirectory(examples)
endif()

if(BTCPP_PYTHON)
add_subdirectory(python)
endif()

######################################################
# Generate .clangd configuration file for standalone header checking
file(WRITE ${PROJECT_SOURCE_DIR}/.clangd
Expand Down
26 changes: 26 additions & 0 deletions python/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# scikit-build-core / setuptools build artifacts
_skbuild/
build/
dist/
wheelhouse/
*.egg-info/

# Python caches
__pycache__/
*.py[cod]
.pytest_cache/
.mypy_cache/
.ruff_cache/

# pytest-benchmark result history
benchmarks/.benchmarks/

# Generated extension modules left over from local builds
src/pybt/_pybt*.so
src/pybt/_pybt*.pyd
src/pybt/_pybt*.dylib

# Editable-install redirect files written by scikit-build-core
src/pybt/*.pth

.venv/
22 changes: 22 additions & 0 deletions python/.readthedocs.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# RTD project must be configured to read this file at `python/.readthedocs.yaml`
# (Project Settings → Advanced → "Path for `.readthedocs.yaml`").

version: 2

build:
os: ubuntu-22.04
tools:
python: "3.12"

python:
install:
- method: pip
path: .
extra_requirements:
- dev

sphinx:
configuration: docs/conf.py

formats:
- htmlzip
63 changes: 63 additions & 0 deletions python/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
cmake_minimum_required(VERSION 3.18)

# pybt — Python bindings for BehaviorTree.CPP.
#
# Every pybind/nanobind/Python symbol must live in _pybt only.
# The behaviortree_cpp target must remain Python-unaware.
# Linkage is one-way: _pybt -> behaviortree_cpp.

find_package(Python 3.9
COMPONENTS Interpreter Development.Module
REQUIRED)

find_package(nanobind CONFIG REQUIRED)

nanobind_add_module(_pybt
NB_STATIC
STABLE_ABI
src/_pybt/module.cpp
src/_pybt/exceptions.cpp
src/_pybt/bind_basic_types.cpp
src/_pybt/bind_ports.cpp
src/_pybt/bind_tree_node.cpp
src/_pybt/bind_factory.cpp
src/_pybt/bind_tree.cpp
)

target_link_libraries(_pybt PRIVATE ${BTCPP_LIBRARY})

target_compile_features(_pybt PRIVATE cxx_std_17)

if(UNIX AND NOT APPLE)
set_target_properties(_pybt PROPERTIES
INSTALL_RPATH "\$ORIGIN:\$ORIGIN/../lib"
BUILD_WITH_INSTALL_RPATH TRUE
)
elseif(APPLE)
set_target_properties(_pybt PROPERTIES
INSTALL_RPATH "@loader_path;@loader_path/../lib"
BUILD_WITH_INSTALL_RPATH TRUE
)
endif()

# Install the compiled extension into the pybt package directory so the
# scikit-build-core wheel ships pybt/_pybt*.so alongside pybt/__init__.py.
install(TARGETS _pybt LIBRARY DESTINATION pybt)

if(UNIX AND NOT APPLE)
# No Python/nanobind symbols may appear in libbehaviortree_cpp.
# This must NEVER FAIL. Otherwise the architecture rule is fundamentally broken.
add_test(NAME pybt_no_python_symbols_in_core
COMMAND sh -c "! nm -D $<TARGET_FILE:${BTCPP_LIBRARY}> | grep -qE 'PyObject|pybind|nanobind|nb::'"
)

# Smoke import under the manylinux-equivalent compile flags.
# The workflow re-runs the whole build with those flags and invokes
# this test to verify `import pybt` still resolves cleanly.
add_test(NAME pybt_import_under_hidden_lto
COMMAND ${Python_EXECUTABLE} -c "import pybt; pybt.BehaviorTreeFactory()"
)
set_tests_properties(pybt_import_under_hidden_lto PROPERTIES
ENVIRONMENT "PYTHONPATH=${CMAKE_CURRENT_SOURCE_DIR}/src:$<TARGET_FILE_DIR:_pybt>"
)
endif()
74 changes: 74 additions & 0 deletions python/CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# Contributing to pybt

pybt is the Python binding for [BehaviorTree.CPP](https://github.com/BehaviorTree/BehaviorTree.CPP). This page covers local development; user-facing install lives in [README.md](README.md).

## Setup (once per clone)

```bash
cd python
python3 -m venv .venv
source .venv/bin/activate
pip install -e .[dev] -v
```

Requires Python 3.12+ and a C++17 toolchain (the build compiles the bundled BehaviorTree.CPP source).

## Run tests

```bash
pytest # everything in tests/
pytest -m smoke # only the smoke gate (fast)
pytest tests/test_smoke.py::test_sync_action_node -v
python tests/test_smoke.py # standalone runner, no pytest
```

## Run benchmarks

```bash
pytest benchmarks/ --benchmark-only
```

Results written in `benchmarks/.benchmarks/` (git-ignored). See [`benchmarks/README.md`](benchmarks/README.md).

## Run examples

```bash
python examples/t01_build_your_first_tree.py
python examples/t02_basic_ports.py
python examples/t03_passing_data.py
```

Each script prints what it's doing and exits 0 on success. `pytest tests/test_examples.py` runs all three in subprocesses and asserts clean exits — that's the rot-prevention gate.

## Pre-commit hooks

Install once:

```bash
pip install pre-commit
pre-commit install # in the repo root
```

This runs `ruff`, `mypy`, the no-C++-refs check, and the project's standard hooks before each commit.

## CI

`.github/workflows/python_test.yml` mirrors the local pytest invocation across Python 3.12, 3.13, and 3.13t (free-threaded, allowed to fail). Two extra jobs run a standalone CMake build under default flags and under `-fvisibility=hidden -flto` (the manylinux configuration) to catch symbol-hygiene regressions.

## Where things live

| Path | What |
|---|---|
| `src/pybt/` | Python package (the user-facing surface) |
| `src/_pybt/` | C++ binding code (nanobind) |
| `tests/` | pytest suite — smoke + lifecycle + example runner |
| `examples/` | Runnable tutorial scripts (t01..t03 so far) |
| `benchmarks/` | pytest-benchmark microbenchmarks |
| `docs/` | Sphinx site (stub for now) |
| `pyproject.toml` | Build config (scikit-build-core, nanobind, pytest) |
| `CMakeLists.txt` | nanobind extension + CTest regression guards |

## Style

- Python: `ruff` for lint and format, `mypy` for types.
- C++: project root `.clang-format` (Google C++ with 2-space indent, 90-char line limit).
Loading
Loading