diff --git a/.github/workflows/python_test.yml b/.github/workflows/python_test.yml new file mode 100644 index 000000000..412a1ca8e --- /dev/null +++ b/.github/workflows/python_test.yml @@ -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 diff --git a/.gitignore b/.gitignore index ddf344b2e..c4cb44235 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,5 @@ TODO.md /coverage_report/* /coverage.info /doc/html/* + +.claude/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ef395d8b9..a5f1b0446 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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 diff --git a/CMakeLists.txt b/CMakeLists.txt index 416c2c332..548a4f6a8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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) @@ -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 diff --git a/python/.gitignore b/python/.gitignore new file mode 100644 index 000000000..575e28bfc --- /dev/null +++ b/python/.gitignore @@ -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/ diff --git a/python/.readthedocs.yaml b/python/.readthedocs.yaml new file mode 100644 index 000000000..778070b74 --- /dev/null +++ b/python/.readthedocs.yaml @@ -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 diff --git a/python/CMakeLists.txt b/python/CMakeLists.txt new file mode 100644 index 000000000..a22bbce4a --- /dev/null +++ b/python/CMakeLists.txt @@ -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 $ | 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:$" + ) +endif() diff --git a/python/CONTRIBUTING.md b/python/CONTRIBUTING.md new file mode 100644 index 000000000..111688de7 --- /dev/null +++ b/python/CONTRIBUTING.md @@ -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). diff --git a/python/README.md b/python/README.md new file mode 100644 index 000000000..78e0cdc1a --- /dev/null +++ b/python/README.md @@ -0,0 +1,23 @@ +# pybt + +Python bindings for [BehaviorTree.CPP](https://github.com/BehaviorTree/BehaviorTree.CPP). + +## Install + +```bash +cd python +python3 -m venv .venv # Only needs to be done once. +source .venv/bin/activate +pip install -e .[dev] -v +python -c "import pybt; print(pybt.__version__, pybt._pybt.__phase__)" + +``` + +Requires Python 3.9+ and a C++17 toolchain. + +## Quick check + +```python +import pybt +print(pybt.__version__) +``` diff --git a/python/benchmarks/README.md b/python/benchmarks/README.md new file mode 100644 index 000000000..a69b4af4b --- /dev/null +++ b/python/benchmarks/README.md @@ -0,0 +1,31 @@ +# pybt benchmarks + +Microbenchmarks for tracking pybt runtime performance. + +## Run + +```bash +cd python +pytest benchmarks/ --benchmark-only +``` + +Results written in `benchmarks/.benchmarks/` (git-ignored). Compare runs with: + +```bash +pytest-benchmark compare +``` + +## What we measure + +| Benchmark | What it captures | +|---|---| +| `bench_tick_rate_100_node_tree` | Pure-C++ throughput: how fast a 100-leaf tree can tick when no Python is involved. | +| `bench_port_get_set_latency` | Round-trip cost of one `get_input` + one `set_output` inside a Python tick (the JSON-bridge tax). | +| `bench_python_node_overhead_cpp_baseline` | Baseline: one C++ `AlwaysSuccess` tick. | +| `bench_python_node_overhead_py` | One Python `SyncActionNode` tick. The delta vs the baseline is the per-Python-node overhead. | + +Total runtime: under 30 seconds on a typical laptop. + +## baseline.json + +`baseline.json` is the committed reference. diff --git a/python/benchmarks/baseline.json b/python/benchmarks/baseline.json new file mode 100644 index 000000000..81a3da8b0 --- /dev/null +++ b/python/benchmarks/baseline.json @@ -0,0 +1,11 @@ +{ + "schema_version": 1, + "machine": null, + "btcpp_version": null, + "captured_at": null, + "metrics": { + "tick_rate_100_node_hz": null, + "port_get_set_ns": null, + "python_node_overhead_us": null + } +} diff --git a/python/benchmarks/bench_pybt.py b/python/benchmarks/bench_pybt.py new file mode 100644 index 000000000..103416c69 --- /dev/null +++ b/python/benchmarks/bench_pybt.py @@ -0,0 +1,84 @@ +"""Microbenchmarks for pybt. + +Run: + pytest benchmarks/ --benchmark-only + +Each benchmark records wall-clock per call. Captures baselines +only, regression thresholds are TODO for later. + +Three signals: + 1. tick_rate_100_node_tree — pure-C++ throughput (no Python overhead). + 2. port_get_set_latency — round-trip get_input + set_output cost. + 3. python_node_overhead_* — delta between (1) and a 1-Python-node tree. +""" + +import pytest + +import pybt + +pytestmark = pytest.mark.benchmark + + +XML_ROOT = '{}' + + +# --------------------------------------------------------------------------- +# 1. Tick rate of a 100-node pure-C++ tree +# --------------------------------------------------------------------------- +def bench_tick_rate_100_node_tree(benchmark): + """Wall-clock per tick of a 100-leaf Sequence of AlwaysSuccess (no Python).""" + children = "" * 100 + xml = XML_ROOT.format(f"{children}") + factory = pybt.BehaviorTreeFactory() + tree = factory.create_tree_from_text(xml) + + def one_tick(): + # Re-tick: trees terminating in SUCCESS need a fresh state, but + # tick_while_running internally handles this since the root is + # idempotent across SUCCESS returns. + tree.tick_while_running() + + benchmark(one_tick) + + +# --------------------------------------------------------------------------- +# 2. Port get/set latency +# --------------------------------------------------------------------------- +def bench_port_get_set_latency(benchmark): + """Cost of one `get_input` + one `set_output` round trip inside a Python tick.""" + + @pybt.ports(inputs=["in"], outputs=["out"]) + class Echo(pybt.SyncActionNode): + def tick(self): + self.set_output("out", self.get_input("in")) + return pybt.NodeStatus.SUCCESS + + factory = pybt.BehaviorTreeFactory() + factory.register_node_type(Echo, "Echo") + xml = XML_ROOT.format('') + tree = factory.create_tree_from_text(xml) + + benchmark(tree.tick_while_running) + + +# --------------------------------------------------------------------------- +# 3. Python-node overhead vs C++ baseline (paired benchmarks) +# --------------------------------------------------------------------------- +def bench_python_node_overhead_cpp_baseline(benchmark): + """Wall-clock baseline: single C++ AlwaysSuccess tick.""" + factory = pybt.BehaviorTreeFactory() + tree = factory.create_tree_from_text(XML_ROOT.format("")) + benchmark(tree.tick_while_running) + + +def bench_python_node_overhead_py(benchmark): + """Wall-clock with one Python SyncActionNode — delta from baseline = overhead.""" + + class Noop(pybt.SyncActionNode): + def tick(self): + return pybt.NodeStatus.SUCCESS + + factory = pybt.BehaviorTreeFactory() + factory.register_node_type(Noop, "Noop") + tree = factory.create_tree_from_text(XML_ROOT.format("")) + benchmark(tree.tick_while_running) diff --git a/python/benchmarks/conftest.py b/python/benchmarks/conftest.py new file mode 100644 index 000000000..1564e22ef --- /dev/null +++ b/python/benchmarks/conftest.py @@ -0,0 +1,18 @@ +"""Benchmark suite configuration — defaults storage to ./benchmarks/.benchmarks.""" + +import os + + +def pytest_configure(config): + """Default `--benchmark-storage` to the benchmarks/.benchmarks directory. + + User can still override on the command line. Without this, pytest-benchmark + writes to the pytest rootdir (`python/.benchmarks`), which would be + confusing in a multi-suite layout. + """ + if not hasattr(config.option, "benchmark_storage"): + return # pytest-benchmark not installed; nothing to do. + if config.option.benchmark_storage: + return # user supplied a path; don't override. + storage_dir = os.path.join(os.path.dirname(__file__), ".benchmarks") + config.option.benchmark_storage = f"file://{storage_dir}" diff --git a/python/docs/check_no_cpp_refs.py b/python/docs/check_no_cpp_refs.py new file mode 100644 index 000000000..c177850c0 --- /dev/null +++ b/python/docs/check_no_cpp_refs.py @@ -0,0 +1,15 @@ +"""This script will scan `python/src/pybt/**.py`, +`python/examples/**.py`, and the built Sphinx HTML for forbidden C++ +references (`BT::`, `.cpp`, `.hpp`, `include/behaviortree_cpp`, etc.) per +the Documentation Standards in the plan. README files are allowlisted. +""" + +import sys + + +def main() -> int: + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/python/docs/conf.py b/python/docs/conf.py new file mode 100644 index 000000000..ac37d6517 --- /dev/null +++ b/python/docs/conf.py @@ -0,0 +1,8 @@ +"""Sphinx config.""" + +project = "pybt" +author = "BehaviorTree.CPP contributors" +extensions = ["myst_parser"] +source_suffix = {".md": "markdown", ".rst": "restructuredtext"} +exclude_patterns = ["_build"] +html_theme = "alabaster" # default; furo theme arrives in Phase 7 diff --git a/python/docs/index.md b/python/docs/index.md new file mode 100644 index 000000000..00e410bab --- /dev/null +++ b/python/docs/index.md @@ -0,0 +1,5 @@ +# pybt + +Python bindings for the BehaviorTree.CPP library. + +Documentation is under construction. See the [project README](../README.md) for install instructions in the meantime. diff --git a/python/examples/t01_build_your_first_tree.py b/python/examples/t01_build_your_first_tree.py new file mode 100644 index 000000000..c8db43a86 --- /dev/null +++ b/python/examples/t01_build_your_first_tree.py @@ -0,0 +1,56 @@ +"""t01 — Build your first tree. + +Shows the minimum needed to register a Python action, build a tree from +XML, and tick it to completion. + +This tree runs two custom actions in sequence: a "check" that returns +SUCCESS or FAILURE based on a boolean, and a "say" that prints a line. + +Run: python t01_build_your_first_tree.py +Expected output: + [check] battery_ok=True + [say] hello from pybt + final status: SUCCESS +""" + +from __future__ import annotations + +import pybt + +XML = """ + + + + + + + + +""" + + +class CheckBatteryOk(pybt.SyncActionNode): + def tick(self) -> pybt.NodeStatus: + battery_ok = True + print(f"[check] battery_ok={battery_ok}") + return pybt.NodeStatus.SUCCESS if battery_ok else pybt.NodeStatus.FAILURE + + +class SaySomething(pybt.SyncActionNode): + def tick(self) -> pybt.NodeStatus: + print("[say] hello from pybt") + return pybt.NodeStatus.SUCCESS + + +def main() -> int: + factory = pybt.BehaviorTreeFactory() + factory.register_node_type(CheckBatteryOk, "CheckBatteryOk") + factory.register_node_type(SaySomething, "SaySomething") + tree = factory.create_tree_from_text(XML) + status = tree.tick_while_running() + print(f"final status: {status.name}") + return 0 if status == pybt.NodeStatus.SUCCESS else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/python/examples/t02_basic_ports.py b/python/examples/t02_basic_ports.py new file mode 100644 index 000000000..e704516a7 --- /dev/null +++ b/python/examples/t02_basic_ports.py @@ -0,0 +1,57 @@ +"""t02 — Basic ports. + +Shows how to declare input and output ports on a custom action and pass +data between nodes through the blackboard. + +The producer writes a string to its `out` port; the consumer reads the +same value from its `in` port and prints it. + +Run: python t02_basic_ports.py +Expected output: + [consume] got: hello world + final status: SUCCESS +""" + +from __future__ import annotations + +import pybt + +XML = """ + + + + + + + + +""" + + +@pybt.ports(outputs=["out"]) +class Produce(pybt.SyncActionNode): + def tick(self) -> pybt.NodeStatus: + self.set_output("out", "hello world") + return pybt.NodeStatus.SUCCESS + + +@pybt.ports(inputs=["in"]) +class Consume(pybt.SyncActionNode): + def tick(self) -> pybt.NodeStatus: + value = self.get_input("in") + print(f"[consume] got: {value}") + return pybt.NodeStatus.SUCCESS + + +def main() -> int: + factory = pybt.BehaviorTreeFactory() + factory.register_node_type(Produce, "Produce") + factory.register_node_type(Consume, "Consume") + tree = factory.create_tree_from_text(XML) + status = tree.tick_while_running() + print(f"final status: {status.name}") + return 0 if status == pybt.NodeStatus.SUCCESS else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/python/examples/t03_passing_data.py b/python/examples/t03_passing_data.py new file mode 100644 index 000000000..0e9e8f113 --- /dev/null +++ b/python/examples/t03_passing_data.py @@ -0,0 +1,62 @@ +"""t03 — Passing multiple values via separate ports. + +Extends t02 with a stateful producer (counts ticks before reporting a +pose) and multiple primitive ports passed to a single consumer. + +A later release adds `register_type` for sending custom Python classes +through a single port — until then, pass each field as its own primitive +port (string / int / float / bool). + +Run: python t03_passing_data.py +Expected output: + [navigate] heading to (1.5, 2.5) at 0.8 m/s + final status: SUCCESS +""" + +from __future__ import annotations + +import pybt + +XML = """ + + + + + + + + +""" + + +@pybt.ports(outputs=["x", "y", "speed"]) +class PlanPose(pybt.SyncActionNode): + def tick(self) -> pybt.NodeStatus: + self.set_output("x", 1.5) + self.set_output("y", 2.5) + self.set_output("speed", 0.8) + return pybt.NodeStatus.SUCCESS + + +@pybt.ports(inputs=["x", "y", "speed"]) +class Navigate(pybt.SyncActionNode): + def tick(self) -> pybt.NodeStatus: + x = float(self.get_input("x")) + y = float(self.get_input("y")) + speed = float(self.get_input("speed")) + print(f"[navigate] heading to ({x}, {y}) at {speed} m/s") + return pybt.NodeStatus.SUCCESS + + +def main() -> int: + factory = pybt.BehaviorTreeFactory() + factory.register_node_type(PlanPose, "PlanPose") + factory.register_node_type(Navigate, "Navigate") + tree = factory.create_tree_from_text(XML) + status = tree.tick_while_running() + print(f"final status: {status.name}") + return 0 if status == pybt.NodeStatus.SUCCESS else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/python/pyproject.toml b/python/pyproject.toml new file mode 100644 index 000000000..f7e5f1b65 --- /dev/null +++ b/python/pyproject.toml @@ -0,0 +1,99 @@ +[build-system] +requires = [ + "scikit-build-core>=0.9", + "nanobind>=2.5,<3", + "setuptools-scm[toml]>=8", + "ninja>=1.11; sys_platform != 'win32'", +] +build-backend = "scikit_build_core.build" + +[project] +name = "pybt" +dynamic = ["version"] +description = "Python bindings for BehaviorTree.CPP — author, compose, and tick behavior trees from Python." +readme = "README.md" +requires-python = ">=3.9" +license = { text = "MIT" } +authors = [ + { name = "BehaviorTree.CPP contributors" }, +] +keywords = ["behavior-tree", "robotics", "ai", "behaviortree", "bt"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: POSIX :: Linux", + "Programming Language :: C++", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Scientific/Engineering :: Artificial Intelligence", + "Topic :: Software Development :: Libraries", +] +dependencies = [] + +[project.optional-dependencies] +dev = [ + "pytest>=8", + "pytest-xdist", + "pytest-benchmark", + "ruff>=0.6", + "mypy>=1.10", + "hypothesis>=6", + "ninja>=1.11; sys_platform != 'win32'", + "nanobind>=2.5,<3", + "scikit-build-core>=0.9", + "setuptools-scm[toml]>=8", +] + +[project.urls] +Homepage = "https://github.com/BehaviorTree/BehaviorTree.CPP" +Repository = "https://github.com/BehaviorTree/BehaviorTree.CPP" + +[tool.scikit-build] +minimum-version = "0.9" +cmake.source-dir = ".." +cmake.args = [ + "-DBTCPP_PYTHON=ON", + "-DBTCPP_BUILD_TOOLS=OFF", + "-DBTCPP_EXAMPLES=OFF", + "-DBUILD_TESTING=OFF", + # For now, keep system deps to zero. Groot2 + SQLite bindings will come in + # later; re-enable these flags then (and either vendor ZMQ/SQLite into + # the wheel or document them as required system packages). + "-DBTCPP_GROOT_INTERFACE=OFF", + "-DBTCPP_SQLITE_LOGGING=OFF", +] +wheel.packages = ["src/pybt"] +build-dir = "build/{wheel_tag}" + +[tool.scikit-build.editable] +mode = "redirect" +rebuild = true + +[tool.scikit-build.metadata.version] +provider = "scikit_build_core.metadata.setuptools_scm" + +[tool.setuptools_scm] +root = ".." +version_scheme = "post-release" +local_scheme = "no-local-version" +fallback_version = "4.9.0" + +[tool.pytest.ini_options] +minversion = "8.0" +addopts = "-ra --strict-markers" +testpaths = ["tests"] +# Discover both unit tests (test_*.py) and benchmarks (bench_*.py) when the +# corresponding directory is passed explicitly. `testpaths` above means +# benchmarks/ is opt-in: `pytest benchmarks/ --benchmark-only`. +python_files = ["test_*.py", "bench_*.py"] +python_functions = ["test_*", "bench_*"] +markers = [ + "smoke: Smoke tests — must always pass to consider pybt healthy.", + "slow: tests taking more than ~1 second; opt-out via `-m 'not slow'`.", + "benchmark: pytest-benchmark microbenchmarks (also receive the `benchmark` fixture).", +] diff --git a/python/src/_pybt/bind_basic_types.cpp b/python/src/_pybt/bind_basic_types.cpp new file mode 100644 index 000000000..c1eb92524 --- /dev/null +++ b/python/src/_pybt/bind_basic_types.cpp @@ -0,0 +1,44 @@ +// bind_basic_types.cpp — enum bindings: NodeStatus, NodeType, PortDirection. + +#include "behaviortree_cpp/basic_types.h" + +#include + +namespace nb = nanobind; + +namespace pybt +{ + +void register_basic_types(nb::module_& m) +{ + nb::enum_(m, "NodeStatus", + "Status returned by every tick. IDLE is the initial " + "state; user-defined nodes should never return IDLE.") + .value("IDLE", BT::NodeStatus::IDLE, "Initial state; no tick has run yet.") + .value("RUNNING", BT::NodeStatus::RUNNING, + "Tick is still in progress; will be called again.") + .value("SUCCESS", BT::NodeStatus::SUCCESS, "Tick completed successfully.") + .value("FAILURE", BT::NodeStatus::FAILURE, "Tick completed unsuccessfully.") + .value("SKIPPED", BT::NodeStatus::SKIPPED, + "Tick was skipped (e.g. by a precondition)."); + + nb::enum_(m, "NodeType", "The kind of node in a behavior tree.") + .value("UNDEFINED", BT::NodeType::UNDEFINED) + .value("ACTION", BT::NodeType::ACTION, "Leaf node that performs work.") + .value("CONDITION", BT::NodeType::CONDITION, + "Leaf node that returns SUCCESS or FAILURE based on a check.") + .value("CONTROL", BT::NodeType::CONTROL, + "Internal node with multiple children (Sequence, Fallback, etc.).") + .value("DECORATOR", BT::NodeType::DECORATOR, + "Internal node with exactly one child that modifies its behavior.") + .value("SUBTREE", BT::NodeType::SUBTREE, + "Reference to a nested tree composed elsewhere."); + + nb::enum_(m, "PortDirection", + "Direction of a port: read-only, write-only, or both.") + .value("INPUT", BT::PortDirection::INPUT, "Read-only port.") + .value("OUTPUT", BT::PortDirection::OUTPUT, "Write-only port.") + .value("INOUT", BT::PortDirection::INOUT, "Read-write port."); +} + +} // namespace pybt diff --git a/python/src/_pybt/bind_factory.cpp b/python/src/_pybt/bind_factory.cpp new file mode 100644 index 000000000..312c96e9a --- /dev/null +++ b/python/src/_pybt/bind_factory.cpp @@ -0,0 +1,202 @@ +// bind_factory.cpp — BehaviorTreeFactory binding. + +#include "behaviortree_cpp/action_node.h" +#include "behaviortree_cpp/basic_types.h" +#include "behaviortree_cpp/bt_factory.h" +#include "behaviortree_cpp/tree_node.h" + +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace nb = nanobind; +using namespace nb::literals; + +namespace pybt +{ + +// Defined in bind_tree_node.cpp — construct an adapter that owns `py_inst` +// and forwards virtual calls into Python. The adapter is allocated via +// standard `new` so Tree's unique_ptr can `delete` it safely. +std::unique_ptr make_sync_action_adapter(const std::string& name, + const BT::NodeConfig& config, + nb::object py_inst); +std::unique_ptr make_stateful_action_adapter(const std::string& name, + const BT::NodeConfig& config, + nb::object py_inst); + +// Defined in bind_tree.cpp — atexit halt walks this registry. +void register_live_tree(std::shared_ptr tree); + +// Wrap a freshly-created Tree in shared_ptr and register it for atexit halt. +static std::shared_ptr wrap_and_track(BT::Tree&& tree) +{ + auto sp = std::make_shared(std::move(tree)); + register_live_tree(sp); + return sp; +} + +namespace +{ + +// Build a PortsList from one of: +// 1. Explicit list of (name, PortInfo) tuples (e.g. [pybt.input_port("x")]) +// 2. Class attributes set by @pybt.ports decorator (cls.input_ports / cls.output_ports — lists of names) +// 3. Nothing (empty PortsList) +BT::PortsList resolve_ports(nb::handle py_cls, nb::object ports_obj) +{ + BT::PortsList ports; + + if(!ports_obj.is_none()) + { + // Expect an iterable of (name, PortInfo) pairs. + for(nb::handle item : ports_obj) + { + auto pair = nb::cast>(item); + ports.insert(pair); + } + return ports; + } + + // Fall back to @pybt.ports decorator attributes. + if(nb::hasattr(py_cls, "input_ports")) + { + for(nb::handle name : py_cls.attr("input_ports")) + { + std::string n = nb::cast(name); + ports.insert(BT::CreatePort(BT::PortDirection::INPUT, n)); + } + } + if(nb::hasattr(py_cls, "output_ports")) + { + for(nb::handle name : py_cls.attr("output_ports")) + { + std::string n = nb::cast(name); + ports.insert(BT::CreatePort(BT::PortDirection::OUTPUT, n)); + } + } + + return ports; +} + +void register_node_type_impl(BT::BehaviorTreeFactory& self, nb::object py_cls, + const std::string& id, nb::object ports_obj) +{ + BT::PortsList ports = resolve_ports(py_cls, ports_obj); + + // For Phase 1 we only support action-flavored nodes (Sync + Stateful). + // Condition / Control / Decorator come in later phases. + BT::TreeNodeManifest manifest{ BT::NodeType::ACTION, id, ports, {} }; + + // Detect once, at registration time, which adapter the user's class needs. + // The choice is captured by reference into the builder lambda. + nb::object pybt_mod = nb::module_::import_("pybt._pybt"); + nb::object stateful_cls = pybt_mod.attr("StatefulActionNode"); + const bool is_stateful = PyObject_IsSubclass(py_cls.ptr(), stateful_cls.ptr()) == 1; + + BT::NodeBuilder builder = + [py_cls, + is_stateful](const std::string& name, + const BT::NodeConfig& config) -> std::unique_ptr { + nb::gil_scoped_acquire gil; + // Construct the user's Python instance. The Python wrapper owns the + // underlying C++ trampoline shell — we don't touch it. We only need + // the Python object so the adapter can forward method calls into it. + nb::object py_inst = py_cls(name, config); + if(is_stateful) + { + return make_stateful_action_adapter(name, config, std::move(py_inst)); + } + return make_sync_action_adapter(name, config, std::move(py_inst)); + }; + + self.registerBuilder(manifest, builder); +} + +void register_simple_action_impl(BT::BehaviorTreeFactory& self, const std::string& id, + nb::object callable, nb::object ports_obj) +{ + BT::PortsList ports = resolve_ports(nb::none(), ports_obj); + + BT::SimpleActionNode::TickFunctor tick_functor = + [callable](BT::TreeNode& node) -> BT::NodeStatus { + nb::gil_scoped_acquire gil; + nb::object result = callable(nb::cast(&node)); + return nb::cast(result); + }; + + self.registerSimpleAction(id, tick_functor, ports); +} + +} // namespace + +void register_factory(nb::module_& m) +{ + nb::class_(m, "BehaviorTreeFactory", + "Registers node types and builds Tree instances " + "from XML.") + .def(nb::init<>(), "Construct an empty factory.") + + .def("register_node_type", ®ister_node_type_impl, "cls"_a, "id"_a, + "ports"_a = nb::none(), + "Register a Python class as a node type. The class must be a " + "subclass of SyncActionNode or StatefulActionNode. Ports may be " + "supplied as a list of `(name, PortInfo)` pairs, or omitted to use " + "`cls.input_ports` / `cls.output_ports` (set by `@pybt.ports`).") + + .def("register_simple_action", ®ister_simple_action_impl, "id"_a, + "tick_functor"_a, "ports"_a = nb::none(), + "Register a callable as a synchronous action. The callable takes a " + "TreeNode and returns a NodeStatus.") + + .def("register_behavior_tree_from_text", + &BT::BehaviorTreeFactory::registerBehaviorTreeFromText, "xml_text"_a, + "Pre-register one or more definitions from an XML " + "string. Instantiate them later with create_tree(name).") + + .def( + "register_behavior_tree_from_file", + [](BT::BehaviorTreeFactory& self, const std::string& path) { + self.registerBehaviorTreeFromFile(std::filesystem::path(path)); + }, + "path"_a, "Pre-register the definitions from a file on disk.") + + .def("registered_behavior_trees", &BT::BehaviorTreeFactory::registeredBehaviorTrees, + "Names of every behavior tree currently registered with the factory.") + + .def("clear_registered_behavior_trees", + &BT::BehaviorTreeFactory::clearRegisteredBehaviorTrees, + "Forget all registered definitions (registered node " + "types remain).") + + .def( + "create_tree_from_text", + [](BT::BehaviorTreeFactory& self, const std::string& xml) { + return wrap_and_track(self.createTreeFromText(xml)); + }, + "xml_text"_a, + "Parse XML and instantiate a Tree in one shot. The XML must contain " + "either a single or set main_tree_to_execute.") + + .def( + "create_tree_from_file", + [](BT::BehaviorTreeFactory& self, const std::string& path) { + return wrap_and_track(self.createTreeFromFile(std::filesystem::path(path))); + }, + "path"_a, "Read XML from a file and instantiate the resulting Tree.") + + .def( + "create_tree", + [](BT::BehaviorTreeFactory& self, const std::string& tree_name) { + return wrap_and_track(self.createTree(tree_name)); + }, + "tree_name"_a, "Instantiate a previously registered behavior tree by name."); +} + +} // namespace pybt diff --git a/python/src/_pybt/bind_ports.cpp b/python/src/_pybt/bind_ports.cpp new file mode 100644 index 000000000..d90d46a6c --- /dev/null +++ b/python/src/_pybt/bind_ports.cpp @@ -0,0 +1,77 @@ +// bind_ports.cpp — PortInfo binding plus input_port/output_port helpers. +// +// Ports declared from Python use AnyTypeAllowed; type checking happens at +// the JSON serialization layer in bind_tree_node.cpp. Custom strongly-typed +// ports are an advanced use case and not needed for Phase 1. + +#include "behaviortree_cpp/basic_types.h" + +#include +#include +#include + +namespace nb = nanobind; +using namespace nb::literals; + +namespace pybt +{ + +void register_ports(nb::module_& m) +{ + nb::class_(m, "PortInfo", + "Describes one port: its direction, description, and " + "(for advanced use) its registered type and default value.") + .def_prop_ro("direction", &BT::PortInfo::direction, + "Whether this port is INPUT, OUTPUT, or INOUT.") + .def_prop_ro("description", &BT::PortInfo::description, + "Human-readable description, or empty string.") + .def_prop_ro("default_value_string", &BT::PortInfo::defaultValueString, + "Default value as a string, or empty if no default was set.") + .def("__repr__", [](const BT::PortInfo& self) { + std::string dir; + switch(self.direction()) + { + case BT::PortDirection::INPUT: + dir = "INPUT"; + break; + case BT::PortDirection::OUTPUT: + dir = "OUTPUT"; + break; + case BT::PortDirection::INOUT: + dir = "INOUT"; + break; + } + return ""; + }); + + m.def( + "input_port", + [](const std::string& name, const std::string& description) { + return BT::CreatePort(BT::PortDirection::INPUT, name, + description); + }, + "name"_a, "description"_a = "", + "Build an input port. Returns a (name, PortInfo) pair suitable for the " + "`ports` argument of `BehaviorTreeFactory.register_node_type`."); + + m.def( + "output_port", + [](const std::string& name, const std::string& description) { + return BT::CreatePort(BT::PortDirection::OUTPUT, name, + description); + }, + "name"_a, "description"_a = "", + "Build an output port. Returns a (name, PortInfo) pair suitable for the " + "`ports` argument of `BehaviorTreeFactory.register_node_type`."); + + m.def( + "bidirectional_port", + [](const std::string& name, const std::string& description) { + return BT::CreatePort(BT::PortDirection::INOUT, name, + description); + }, + "name"_a, "description"_a = "", + "Build a read/write port. Returns a (name, PortInfo) pair."); +} + +} // namespace pybt diff --git a/python/src/_pybt/bind_tree.cpp b/python/src/_pybt/bind_tree.cpp new file mode 100644 index 000000000..127bcc1d7 --- /dev/null +++ b/python/src/_pybt/bind_tree.cpp @@ -0,0 +1,252 @@ +// bind_tree.cpp — Tree binding. +// +// tick_while_running uses the GIL-release pattern from the plan: the +// tick loop runs in pure C++ with the GIL dropped, so other Python threads +// can make progress. Between ticks we briefly reacquire to check signals +// (so Ctrl-C reaches the user within sleep_ms of being pressed). +// +// Live trees are tracked via weak_ptr so a Py_AtExit handler can halt any +// still-running tree at interpreter shutdown — prevents segfaults from a +// background trampoline reaching for a destroyed GIL. + +#include +#include +#include +#include + +#if defined(__unix__) || defined(__APPLE__) +#include +#include +#endif + +#include "behaviortree_cpp/bt_factory.h" + +#include +#include + +namespace nb = nanobind; +using namespace nb::literals; + +namespace pybt +{ + +// -------------------------------------------------------------------------- +// Live-tree tracking + atexit halt +// -------------------------------------------------------------------------- + +class LiveTreeRegistry +{ +public: + static LiveTreeRegistry& get() + { + static LiveTreeRegistry r; + return r; + } + + void add(std::weak_ptr tree) + { + std::lock_guard lock(mu_); + trees_.push_back(std::move(tree)); + } + + void halt_all() + { + std::lock_guard lock(mu_); + for(auto& w : trees_) + { + if(auto t = w.lock()) + { + try + { + t->haltTree(); + } + catch(...) + { + // Swallow during shutdown — we can't surface errors here. + } + } + } + trees_.clear(); + } + +private: + std::mutex mu_; + std::vector> trees_; +}; + +// Called from bind_factory.cpp. +void register_live_tree(std::shared_ptr tree) +{ + LiveTreeRegistry::get().add(tree); +} + +namespace +{ + +void on_python_exit() +{ + LiveTreeRegistry::get().halt_all(); +} + +#if defined(__unix__) || defined(__APPLE__) +// Captured at module init. If getpid() differs from this when a tick is +// attempted, we know we're running in a forked child — and BT.CPP holds +// state (parallel-node threads, atomic flags, signal handlers) that does +// not survive fork. Detect and refuse rather than crash unpredictably. +pid_t g_startup_pid = 0; + +void detect_fork_or_throw() +{ + if(g_startup_pid != 0 && getpid() != g_startup_pid) + { + throw BT::RuntimeError("pybt is not fork-safe — create the tree in the " + "child process"); + } +} +#else +inline void detect_fork_or_throw() +{} +#endif + +// tick_while_running re-implemented to interleave PyErr_CheckSignals +// between ticks. Mirrors Tree::tickWhileRunning but with signal-check. +BT::NodeStatus tick_while_running_with_signals(BT::Tree& self, + std::chrono::milliseconds sleep_dur) +{ + detect_fork_or_throw(); + + BT::NodeStatus status = BT::NodeStatus::IDLE; + + // Drop the GIL for the whole loop. Trampolines reacquire when they + // need to call into Python. Signal checks reacquire briefly per iter. + { + nb::gil_scoped_release no_gil; + + do + { + status = self.tickOnce(); + + if(status == BT::NodeStatus::RUNNING) + { + self.sleep(sleep_dur); + } + + // Signal check — reacquire GIL just long enough. + { + nb::gil_scoped_acquire gil; + if(PyErr_CheckSignals() != 0) + { + // A signal handler raised a Python exception (typically + // KeyboardInterrupt). Stash it so the halt path runs WITHOUT a + // pending exception — Python C-API calls (including the trampoline + // attribute lookups in adapters' onHalted) are undefined behavior + // when an exception is already set, and have been observed to + // std::abort the process. + PyObject *exc_type = nullptr, *exc_value = nullptr, *exc_tb = nullptr; + PyErr_Fetch(&exc_type, &exc_value, &exc_tb); + { + nb::gil_scoped_release release_for_halt; + try + { + self.haltTree(); + } + catch(...) + { + // Swallow halt-time failures — the original signal is what we + // want to surface, not noise from a node's cleanup. + } + } + PyErr_Restore(exc_type, exc_value, exc_tb); + throw nb::python_error(); + } + } + } while(status == BT::NodeStatus::RUNNING); + } + + return status; +} + +} // namespace + +void register_tree(nb::module_& m) +{ + // Halt every live tree at interpreter shutdown. + // + // We register via Python-level `atexit`, not `Py_AtExit`. Order matters: + // * `atexit.register` callbacks run during finalization BEFORE the module + // dict is cleared. We halt while Tree objects (and their daemon + // ticking threads) are still alive. + // * `Py_AtExit` callbacks run AFTER module cleanup — by which point the + // Python Tree wrapper has been dropped, the C++ Tree destructor has + // already run, and a daemon thread mid-`tickOnce` is touching freed + // memory. That ordering produced `terminate called without an active + // exception` (SIGABRT) on shutdown. + // + // The C-level Py_AtExit hook is kept as a safety net + // for the unlikely case the Python halt is bypassed (e.g. _Py_Finalize + // skipped Python atexit due to an early error). + static bool atexit_registered = false; + if(!atexit_registered) + { + nb::module_::import_("atexit").attr("register")( + nb::cpp_function([]() { LiveTreeRegistry::get().halt_all(); })); + Py_AtExit(&on_python_exit); + atexit_registered = true; + } + +#if defined(__unix__) || defined(__APPLE__) + // Capture startup pid for fork-safety detection (see detect_fork_or_throw). + if(g_startup_pid == 0) + { + g_startup_pid = getpid(); + } +#endif + + nb::class_(m, "Tree", + "An instantiated behavior tree. Construct via the " + "factory's `create_tree*` methods. Trees are owned by " + "Python; when garbage-collected, all nodes are halted " + "and destroyed.") + + .def("tick_once", &BT::Tree::tickOnce, + "Tick the root once (or repeatedly within a single call if a node " + "wakes the tree). Releases the GIL while ticking. Returns the " + "resulting status.") + + .def("tick_exactly_once", &BT::Tree::tickExactlyOnce, + "Tick the root exactly once, even if a node calls " + "emitWakeUpSignal(). Returns the resulting status.") + + .def( + "tick_while_running", + [](BT::Tree& self, int sleep_ms) { + return tick_while_running_with_signals(self, + std::chrono::milliseconds(sleep_ms)); + }, + "sleep_ms"_a = 10, + "Tick repeatedly until the tree returns SUCCESS or FAILURE. Sleeps " + "`sleep_ms` between iterations. Releases the GIL between ticks and " + "checks for KeyboardInterrupt every iteration.") + + .def("halt_tree", &BT::Tree::haltTree, "Halt every running node in the tree.") + + .def("root_blackboard", &BT::Tree::rootBlackboard, + "Return the root Blackboard (opaque in Phase 1 — full binding lands " + "in a later phase).") + + .def( + "sleep", + [](BT::Tree& self, int ms) { + return self.sleep(std::chrono::milliseconds(ms)); + }, + "duration_ms"_a, + "Sleep, interruptible by a wake signal. Returns True if a wake " + "signal arrived before the timeout.") + + .def_prop_ro( + "root_node", [](BT::Tree& self) -> BT::TreeNode* { return self.rootNode(); }, + nb::rv_policy::reference_internal, + "The root TreeNode of this tree (or None if empty)."); +} + +} // namespace pybt diff --git a/python/src/_pybt/bind_tree_node.cpp b/python/src/_pybt/bind_tree_node.cpp new file mode 100644 index 000000000..d3e12b923 --- /dev/null +++ b/python/src/_pybt/bind_tree_node.cpp @@ -0,0 +1,349 @@ +// bind_tree_node.cpp — TreeNode binding plus user-subclassable shell +// classes and the adapters that bridge Python to BT.CPP at tree-tick time. +// +// Design (adapter pattern, see Phase 1 Subagent B notes): +// +// * `PySyncActionNode` / `PyStatefulActionNode` are nanobind trampoline +// "shells". They exist so Python can `class Foo(pybt.SyncActionNode):` +// and have a real, instantiable Python base. Their tick / on_* methods +// are placeholders — they should never be invoked at tree-tick time. +// +// * `PythonSyncActionAdapter` / `PythonStatefulActionAdapter` are the +// actual C++ tree nodes. They hold a strong `nb::object` reference to +// the user's Python instance and forward virtual calls into Python. +// They are allocated via standard `new` (`std::make_unique`) so the +// `std::unique_ptr` BT.CPP holds can `delete` them safely. +// +// Why two classes per kind? Combining "Python wrapper trampoline" with +// "tree-owned C++ object" produced an allocator mismatch: nanobind +// allocates trampoline instances as part of the Python wrapper object, +// but `unique_ptr::~unique_ptr` calls plain `delete` — which +// targets a different allocator and triggered `free(): invalid pointer` +// on tree teardown. Splitting the roles fixes the lifetime model. + +#include "json_bridge.hpp" + +#include "behaviortree_cpp/action_node.h" +#include "behaviortree_cpp/json_export.h" +#include "behaviortree_cpp/tree_node.h" + +#include +#include +#include + +namespace nb = nanobind; +using namespace nb::literals; + +namespace pybt +{ + +// -------------------------------------------------------------------------- +// Trampoline shells +// +// These exist solely so Python can subclass them with a stable C++ base. +// The factory does NOT use these as tree nodes; the adapters below do. +// -------------------------------------------------------------------------- + +struct PySyncActionNode : public BT::SyncActionNode +{ + NB_TRAMPOLINE(BT::SyncActionNode, 1); + + PySyncActionNode(const std::string& name, const BT::NodeConfig& config) + : BT::SyncActionNode(name, config) + {} + + // Placeholder. The actual tick goes through PythonSyncActionAdapter. + // If this fires, the factory wiring is broken. + BT::NodeStatus tick() override + { + throw BT::LogicError("PySyncActionNode::tick() invoked directly — should go through " + "PythonSyncActionAdapter. This is a binding bug."); + } +}; + +struct PyStatefulActionNode : public BT::StatefulActionNode +{ + NB_TRAMPOLINE(BT::StatefulActionNode, 3); + + PyStatefulActionNode(const std::string& name, const BT::NodeConfig& config) + : BT::StatefulActionNode(name, config) + {} + + BT::NodeStatus onStart() override + { + throw BT::LogicError("PyStatefulActionNode::onStart() invoked directly — " + "should go through PythonStatefulActionAdapter."); + } + BT::NodeStatus onRunning() override + { + throw BT::LogicError("PyStatefulActionNode::onRunning() invoked directly."); + } + void onHalted() override + { + // No-op default for the shell (never reached at tick time). + } +}; + +// -------------------------------------------------------------------------- +// Adapters +// +// Each adapter is allocated by us via `new` and owned by Tree's +// `unique_ptr`. It holds the user's Python instance and forwards +// virtual calls into Python under `gil_scoped_acquire`. +// -------------------------------------------------------------------------- + +class PythonSyncActionAdapter : public BT::SyncActionNode +{ +public: + PythonSyncActionAdapter(const std::string& name, const BT::NodeConfig& config, + nb::object py_inst) + : BT::SyncActionNode(name, config), py_inst_(std::move(py_inst)) + {} + + ~PythonSyncActionAdapter() override + { + nb::gil_scoped_acquire gil; + py_inst_.reset(); + } + + BT::NodeStatus tick() override + { + nb::gil_scoped_acquire gil; + return nb::cast(py_inst_.attr("tick")()); + } + +private: + nb::object py_inst_; +}; + +class PythonStatefulActionAdapter : public BT::StatefulActionNode +{ +public: + PythonStatefulActionAdapter(const std::string& name, const BT::NodeConfig& config, + nb::object py_inst) + : BT::StatefulActionNode(name, config), py_inst_(std::move(py_inst)) + {} + + ~PythonStatefulActionAdapter() override + { + nb::gil_scoped_acquire gil; + py_inst_.reset(); + } + + BT::NodeStatus onStart() override + { + nb::gil_scoped_acquire gil; + return nb::cast(py_inst_.attr("on_start")()); + } + + BT::NodeStatus onRunning() override + { + nb::gil_scoped_acquire gil; + return nb::cast(py_inst_.attr("on_running")()); + } + + void onHalted() override + { + nb::gil_scoped_acquire gil; + // `on_halted` is optional — user may skip it when they have no cleanup. + if(nb::hasattr(py_inst_, "on_halted")) + { + py_inst_.attr("on_halted")(); + } + } + +private: + nb::object py_inst_; +}; + +// -------------------------------------------------------------------------- +// get_input / set_output bridges +// -------------------------------------------------------------------------- + +static nb::object get_input_impl(BT::TreeNode& self, const std::string& name) +{ + // Case 1: port is remapped to a blackboard entry — read the BT::Any and + // route through JsonExporter for typed conversion. + if(auto locked = self.getLockedPortContent(name)) + { + const BT::Any* any = locked.get(); + if(any && !any->empty()) + { + nlohmann::json j; + if(BT::JsonExporter::get().toJson(*any, j)) + { + return json_to_python(j); + } + // No JSON converter for this type — try a string cast as a last resort. + try + { + return nb::cast(const_cast(any)->cast()); + } + catch(...) + { + throw BT::RuntimeError("get_input('", name, + "'): value has no JSON converter registered. " + "Register one with JsonExporter::addConverter " + "or pass a JSON-native type."); + } + } + } + + // Case 2: port is a raw string literal from XML (e.g. message="hello"). + try + { + auto raw = self.getRawPortValue(name); + return nb::cast(std::string(raw)); + } + catch(const std::exception& e) + { + throw BT::RuntimeError("get_input('", name, "'): ", e.what()); + } +} + +static void set_output_impl(BT::TreeNode& self, const std::string& name, nb::object value) +{ + // Honor BT.CPP's port-declaration contract: fail if the output port was + // never declared. setOutput performs this check internally and + // additionally validates that the declared port type is Any or + // AnyTypeAllowed. + nlohmann::json j = python_to_json(value); + + // Convert JSON to BT::Any. For primitive JSON values we don't need + // JsonExporter — we can build BT::Any directly. For complex/registered + // types, defer to JsonExporter::fromJson which knows about user converters. + BT::Any any; + switch(j.type()) + { + case nlohmann::json::value_t::null: + any = BT::Any(); + break; + case nlohmann::json::value_t::boolean: + any = BT::Any(j.get()); + break; + case nlohmann::json::value_t::number_integer: + any = BT::Any(j.get()); + break; + case nlohmann::json::value_t::number_unsigned: + any = BT::Any(j.get()); + break; + case nlohmann::json::value_t::number_float: + any = BT::Any(j.get()); + break; + case nlohmann::json::value_t::string: + any = BT::Any(j.get()); + break; + default: { + auto entry = BT::JsonExporter::get().fromJson(j); + if(!entry) + { + throw BT::RuntimeError("set_output('", name, + "'): cannot convert value to a BT type: ", entry.error()); + } + any = entry->first; + break; + } + } + + auto result = self.setOutput(name, any); + if(!result) + { + throw BT::RuntimeError("set_output('", name, "'): ", result.error()); + } +} + +// -------------------------------------------------------------------------- +// Binding registration +// -------------------------------------------------------------------------- + +void register_tree_node(nb::module_& m) +{ + // Opaque NodeConfig binding — never constructed or inspected from Python, + // but must be visible so the factory's builder can pass it through + // `py_cls(name, config)` to the user's class's super().__init__. + nb::class_(m, "NodeConfig", + "Opaque per-node configuration handed in by the " + "factory. Pass through to super().__init__; do " + "not construct or inspect."); + + nb::class_(m, "TreeNode", + "Abstract base of every behavior-tree node. Cannot be " + "constructed directly; use SyncActionNode, " + "StatefulActionNode, or build a tree via the factory.") + .def_prop_ro("name", &BT::TreeNode::name, "The instance name assigned in the XML.") + .def_prop_ro("status", &BT::TreeNode::status, "Current NodeStatus.") + .def_prop_ro("uid", &BT::TreeNode::UID, + "Numeric unique identifier assigned by the factory.") + .def_prop_ro("full_path", &BT::TreeNode::fullPath, + "Hierarchical path including all subtrees.") + .def_prop_ro("registration_name", &BT::TreeNode::registrationName, + "The registration ID this node was created from.") + .def("get_input", &get_input_impl, "name"_a, + "Read a typed input port by name. Returns the JSON-converted value, " + "or the raw string for XML literals. Raises BTRuntimeError if the " + "port is missing or unconvertible.") + .def("set_output", &set_output_impl, "name"_a, "value"_a, + "Write a value to a declared output port. Raises BTRuntimeError if " + "the port was not declared."); + + nb::class_(m, "SyncActionNode", + "Synchronous action. " + "Subclass and implement " + "`tick(self)` returning " + "NodeStatus.SUCCESS or " + "NodeStatus.FAILURE. " + "Returning RUNNING is " + "forbidden — use " + "StatefulActionNode " + "instead.") + .def(nb::init(), "name"_a, "config"_a, + "Built by the factory; users rarely call this directly."); + + nb::class_(m, + "StatefulActionN" + "ode", + "Stateful " + "action for " + "asynchronous " + "work. Subclass " + "and implement " + "`on_start`, " + "`on_running`, " + "`on_halted`. " + "The factory " + "calls on_start " + "once, " + "then " + "on_running " + "until the node " + "returns " + "SUCCESS or " + "FAILURE; " + "on_halted " + "runs if the " + "parent halts " + "the node while " + "RUNNING.") + .def(nb::init(), "name"_a, "config"_a, + "Built by the factory; users rarely call this directly."); +} + +// -------------------------------------------------------------------------- +// Adapter factories — called from bind_factory.cpp's NodeBuilder lambda. +// -------------------------------------------------------------------------- + +std::unique_ptr make_sync_action_adapter(const std::string& name, + const BT::NodeConfig& config, + nb::object py_inst) +{ + return std::make_unique(name, config, std::move(py_inst)); +} + +std::unique_ptr make_stateful_action_adapter(const std::string& name, + const BT::NodeConfig& config, + nb::object py_inst) +{ + return std::make_unique(name, config, std::move(py_inst)); +} + +} // namespace pybt diff --git a/python/src/_pybt/exceptions.cpp b/python/src/_pybt/exceptions.cpp new file mode 100644 index 000000000..25d9375d1 --- /dev/null +++ b/python/src/_pybt/exceptions.cpp @@ -0,0 +1,38 @@ +// exceptions.cpp — Python exception hierarchy mirroring BT.CPP exceptions. +// +// Hierarchy: +// pybt.BTError(Exception) +// ├── pybt.BTRuntimeError <- BT::RuntimeError +// │ └── pybt.BTNodeExecutionError <- BT::NodeExecutionError +// └── pybt.BTLogicError <- BT::LogicError +// +// Standard C++ exceptions (std::out_of_range, std::invalid_argument, +// std::runtime_error, etc.) are translated to Python equivalents +// (IndexError, ValueError, RuntimeError) by nanobind's defaults — we do +// not register translators for those here. + +#include "behaviortree_cpp/exceptions.h" + +#include + +namespace nb = nanobind; + +namespace pybt +{ + +void register_exceptions(nb::module_& m) +{ + // Register most-specific exception last so nanobind's LIFO translator + // chain catches subclasses before their bases. + + nb::exception(m, "BTError"); + nb::handle base = m.attr("BTError"); + + nb::exception(m, "BTRuntimeError", base); + nb::exception(m, "BTLogicError", base); + + nb::handle runtime = m.attr("BTRuntimeError"); + nb::exception(m, "BTNodeExecutionError", runtime); +} + +} // namespace pybt diff --git a/python/src/_pybt/json_bridge.hpp b/python/src/_pybt/json_bridge.hpp new file mode 100644 index 000000000..1c61eed6a --- /dev/null +++ b/python/src/_pybt/json_bridge.hpp @@ -0,0 +1,120 @@ +// json_bridge.hpp — convert between nlohmann::json and Python objects. +// +// Used by bind_tree_node.cpp to bridge port get/set across the BT.CPP +// JsonExporter and Python. Header-only; included only by binding TUs. +// +// Coverage: null, bool, int, float, str, list, tuple, dict. +// Unsupported: bytes, binary blobs, NaN (json default is silently null) — +// users hitting these limits should register a custom JsonExporter converter. + +#pragma once + +#include "behaviortree_cpp/contrib/json.hpp" + +#include +#include + +#include +#include + +namespace pybt +{ + +namespace nb = nanobind; +using nlohmann::json; + +inline nb::object json_to_python(const json& j) +{ + switch(j.type()) + { + case json::value_t::null: + return nb::none(); + case json::value_t::boolean: + return nb::cast(j.get()); + case json::value_t::number_integer: + return nb::cast(j.get()); + case json::value_t::number_unsigned: + return nb::cast(j.get()); + case json::value_t::number_float: + return nb::cast(j.get()); + case json::value_t::string: + return nb::cast(j.get()); + case json::value_t::array: { + nb::list result; + for(const auto& el : j) + { + result.append(json_to_python(el)); + } + return result; + } + case json::value_t::object: { + nb::dict result; + for(auto it = j.begin(); it != j.end(); ++it) + { + result[nb::cast(it.key())] = json_to_python(it.value()); + } + return result; + } + case json::value_t::binary: + case json::value_t::discarded: + default: + throw std::runtime_error("json_to_python: unsupported JSON value type"); + } +} + +inline json python_to_json(nb::handle obj) +{ + if(obj.is_none()) + { + return json(nullptr); + } + // bool must be checked before int because bool is a subclass of int. + if(nb::isinstance(obj)) + { + return json(nb::cast(obj)); + } + if(nb::isinstance(obj)) + { + return json(nb::cast(obj)); + } + if(nb::isinstance(obj)) + { + return json(nb::cast(obj)); + } + if(nb::isinstance(obj)) + { + return json(nb::cast(obj)); + } + if(nb::isinstance(obj)) + { + json arr = json::array(); + for(auto item : nb::cast(obj)) + { + arr.push_back(python_to_json(nb::handle(item))); + } + return arr; + } + if(nb::isinstance(obj)) + { + json arr = json::array(); + for(auto item : nb::cast(obj)) + { + arr.push_back(python_to_json(nb::handle(item))); + } + return arr; + } + if(nb::isinstance(obj)) + { + json result = json::object(); + for(auto [k, v] : nb::cast(obj)) + { + result[nb::cast(k)] = python_to_json(nb::handle(v)); + } + return result; + } + throw std::runtime_error("python_to_json: unsupported Python type — " + "register a JsonExporter converter or pass a " + "JSON-native value (None/bool/int/float/str/list/dict)"); +} + +} // namespace pybt diff --git a/python/src/_pybt/module.cpp b/python/src/_pybt/module.cpp new file mode 100644 index 000000000..e149dbac4 --- /dev/null +++ b/python/src/_pybt/module.cpp @@ -0,0 +1,30 @@ +// module.cpp — pybt entry point. Dispatches to each bind_* registration +// function. Order matters: types and exceptions first, then port/node +// machinery, then high-level factory and tree. + +#include + +namespace nb = nanobind; + +namespace pybt +{ +void register_exceptions(nb::module_& m); +void register_basic_types(nb::module_& m); +void register_ports(nb::module_& m); +void register_tree_node(nb::module_& m); +void register_factory(nb::module_& m); +void register_tree(nb::module_& m); +} // namespace pybt + +NB_MODULE(_pybt, m) +{ + m.doc() = "pybt — Python bindings for BehaviorTree.CPP."; + m.attr("__phase__") = "1-foundation"; + + pybt::register_exceptions(m); + pybt::register_basic_types(m); + pybt::register_ports(m); + pybt::register_tree_node(m); + pybt::register_tree(m); + pybt::register_factory(m); +} diff --git a/python/src/pybt/__init__.py b/python/src/pybt/__init__.py new file mode 100644 index 000000000..2842c909f --- /dev/null +++ b/python/src/pybt/__init__.py @@ -0,0 +1,51 @@ +"""pybt — Python bindings for BehaviorTree.CPP.""" + +from importlib.metadata import PackageNotFoundError +from importlib.metadata import version as _pkg_version + +from ._pybt import ( + BehaviorTreeFactory, + BTError, + BTLogicError, + BTNodeExecutionError, + BTRuntimeError, + NodeStatus, + NodeType, + PortDirection, + PortInfo, + StatefulActionNode, + SyncActionNode, + Tree, + TreeNode, + bidirectional_port, + input_port, + output_port, +) +from .nodes import ports, simple_action + +try: + __version__ = _pkg_version("pybt") +except PackageNotFoundError: + __version__ = "0.0.0+unknown" + +__all__ = [ + "BehaviorTreeFactory", + "BTError", + "BTLogicError", + "BTNodeExecutionError", + "BTRuntimeError", + "NodeStatus", + "NodeType", + "PortDirection", + "PortInfo", + "StatefulActionNode", + "SyncActionNode", + "Tree", + "TreeNode", + "__version__", + "bidirectional_port", + "input_port", + "output_port", + "ports", + "simple_action", +] diff --git a/python/src/pybt/exceptions.py b/python/src/pybt/exceptions.py new file mode 100644 index 000000000..3f2c5b3ff --- /dev/null +++ b/python/src/pybt/exceptions.py @@ -0,0 +1,19 @@ +"""pybt exception hierarchy, re-exported from the C++ binding. + +All BT.CPP exceptions translate into one of these types when they cross +the binding boundary. Catch `BTError` to handle any pybt-originated failure. +""" + +from ._pybt import ( + BTError, + BTLogicError, + BTNodeExecutionError, + BTRuntimeError, +) + +__all__ = [ + "BTError", + "BTLogicError", + "BTNodeExecutionError", + "BTRuntimeError", +] diff --git a/python/src/pybt/nodes.py b/python/src/pybt/nodes.py new file mode 100644 index 000000000..c301cdbb9 --- /dev/null +++ b/python/src/pybt/nodes.py @@ -0,0 +1,65 @@ +"""Pythonic helpers for declaring custom nodes and ports. + +These wrap the underlying nanobind bindings (`pybt.BehaviorTreeFactory.register_node_type` +and `register_simple_action`) with class- and function-decorator forms. +""" + +from collections.abc import Callable, Iterable +from typing import Optional + +from ._pybt import BehaviorTreeFactory + + +def ports( + *, + inputs: Optional[Iterable[str]] = None, + outputs: Optional[Iterable[str]] = None, +): + """Declare a custom node's input and output ports. + + Attach the returned decorator to a `SyncActionNode` or `StatefulActionNode` + subclass. The factory's `register_node_type` reads `cls.input_ports` + and `cls.output_ports` set here. + + Example:: + + @pybt.ports(inputs=["target"], outputs=["result"]) + class Approach(pybt.SyncActionNode): + def tick(self): + self.set_output("result", "done") + return pybt.NodeStatus.SUCCESS + """ + inputs_list = list(inputs) if inputs else [] + outputs_list = list(outputs) if outputs else [] + + def decorator(cls): + cls.input_ports = inputs_list + cls.output_ports = outputs_list + return cls + + return decorator + + +def simple_action( + factory: BehaviorTreeFactory, + id: str, + ports: Optional[Iterable[tuple]] = None, +): + """Register a callable on `factory` as a synchronous action node. + + The wrapped function receives a `TreeNode` and must return a `NodeStatus`. + + Example:: + + factory = pybt.BehaviorTreeFactory() + + @pybt.simple_action(factory, "Hello") + def hello(node): + return pybt.NodeStatus.SUCCESS + """ + + def decorator(fn: Callable): + factory.register_simple_action(id, fn, list(ports) if ports else None) + return fn + + return decorator diff --git a/python/src/pybt/py.typed b/python/src/pybt/py.typed new file mode 100644 index 000000000..e69de29bb diff --git a/python/tests/README.md b/python/tests/README.md new file mode 100644 index 000000000..b1dda68c6 --- /dev/null +++ b/python/tests/README.md @@ -0,0 +1,38 @@ +# pybt tests + +## Setup (once) + +```bash +cd python +python3 -m venv .venv +source .venv/bin/activate +pip install -e .[dev] -v +``` + +## Run + +```bash +# Everything in tests/ +pytest + +# Only smoke tests +pytest -m smoke + +# A single test +pytest tests/test_smoke.py::test_sync_action_node + +# Standalone (no pytest, no fixtures, plain Python) +python tests/test_smoke.py +``` + +## What's here + +| File | Covers | +|---|---| +| `test_smoke.py` | Module surface, exception hierarchy, sync/stateful nodes, ports, JSON bridge, fork safety, SIGINT, GIL release, module reload. | +| `test_lifecycle.py` | Subprocess-isolated interpreter-shutdown tests. Marked `slow`. | +| `conftest.py` | Shared fixtures (`fresh_factory`, `simple_xml`, `wrap_in_tree`) and an `assert_tree_returns` helper. | + +## Related + +- Microbenchmarks: see [`../benchmarks/README.md`](../benchmarks/README.md). diff --git a/python/tests/test_examples.py b/python/tests/test_examples.py new file mode 100644 index 000000000..f1a99be30 --- /dev/null +++ b/python/tests/test_examples.py @@ -0,0 +1,33 @@ +"""Runs every example script in a subprocess and asserts exit 0. + +Examples are the user-facing front door — if any of them stops working +end-to-end, this test fails before the regression reaches a user. +""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +import pytest + +pytestmark = pytest.mark.smoke + +EXAMPLES_DIR = Path(__file__).resolve().parent.parent / "examples" +EXAMPLES = sorted(EXAMPLES_DIR.glob("t*.py")) + + +@pytest.mark.parametrize("script", EXAMPLES, ids=lambda p: p.name) +def test_example_runs_clean(script: Path) -> None: + result = subprocess.run( + [sys.executable, str(script)], + capture_output=True, + text=True, + timeout=15, + ) + assert result.returncode == 0, ( + f"{script.name} exited {result.returncode}\n" + f"stdout:\n{result.stdout}\n" + f"stderr:\n{result.stderr}" + ) diff --git a/python/tests/test_lifecycle.py b/python/tests/test_lifecycle.py new file mode 100644 index 000000000..2ba8f2a1c --- /dev/null +++ b/python/tests/test_lifecycle.py @@ -0,0 +1,120 @@ +"""Interpreter-lifecycle tests for pybt. + +These use a subprocess so a crash on shutdown surfaces as a non-zero exit +code rather than killing the test runner. +""" + +import subprocess +import sys +import textwrap + +import pytest + +pytestmark = [pytest.mark.smoke, pytest.mark.slow] + + +def _run_in_subprocess( + script: str, timeout: float = 10.0 +) -> subprocess.CompletedProcess: + return subprocess.run( + [sys.executable, "-c", textwrap.dedent(script)], + capture_output=True, + text=True, + timeout=timeout, + ) + + +def test_clean_thread_shutdown_with_stop_flag(): + """A tree ticking in a background thread exits cleanly via a node-level stop flag.""" + + script = """ + import threading + import time + + import pybt + + stop = threading.Event() + + class StoppableForever(pybt.StatefulActionNode): + def on_start(self): + return pybt.NodeStatus.RUNNING + + def on_running(self): + if stop.is_set(): + return pybt.NodeStatus.SUCCESS + return pybt.NodeStatus.RUNNING + + f = pybt.BehaviorTreeFactory() + f.register_node_type(StoppableForever, "StoppableForever") + t = f.create_tree_from_text( + '' + ) + + def go(): + try: + t.tick_while_running(sleep_ms=10) + except BaseException: + pass + + th = threading.Thread(target=go) # NOT daemon — joined explicitly below + th.start() + time.sleep(0.05) # Let the tick loop enter C++ + stop.set() # Cooperatively ask the node to finish + th.join(timeout=2) + assert not th.is_alive(), "tick thread did not exit within 2s of stop flag" + print("MAIN_DONE", flush=True) + """ + + result = _run_in_subprocess(script) + assert result.returncode == 0, ( + f"interpreter exited with {result.returncode}\n" + f"stdout: {result.stdout}\nstderr: {result.stderr}" + ) + assert ( + "MAIN_DONE" in result.stdout + ), f"main thread didn't reach the end: stdout={result.stdout}" + + +def test_clean_module_load_and_shutdown(): + """Bare `import pybt` exits 0 — guards against atexit-handler regressions.""" + result = _run_in_subprocess( + """ + import pybt + assert pybt.NodeStatus.SUCCESS + """ + ) + assert result.returncode == 0, f"clean import + exit failed: stderr={result.stderr}" + + +def test_no_nanobind_leaks(): + """The standalone smoke runner shuts down with no nanobind leak warnings. + + nanobind prints `nanobind: leaked N instances/types/...` to stderr at + interpreter teardown when any registered C++ object outlives its Python + wrapper. Running the smoke suite as a standalone script (functions get + called and return, so locals release deterministically) should leave the + registry empty by the time nanobind's atexit check fires. + """ + import pathlib + + test_smoke_path = pathlib.Path(__file__).parent / "test_smoke.py" + result = subprocess.run( + [sys.executable, str(test_smoke_path)], + capture_output=True, + text=True, + timeout=120, + ) + assert result.returncode == 0, ( + f"standalone smoke runner failed:\n" + f"stdout: {result.stdout}\nstderr: {result.stderr}" + ) + + combined = result.stdout + result.stderr + leak_lines = [ + line + for line in combined.splitlines() + if "nanobind" in line and "leak" in line.lower() + ] + assert not leak_lines, "nanobind reported leaks at shutdown:\n " + "\n ".join( + leak_lines + ) diff --git a/python/tests/test_smoke.py b/python/tests/test_smoke.py new file mode 100644 index 000000000..c4bd26c9b --- /dev/null +++ b/python/tests/test_smoke.py @@ -0,0 +1,514 @@ +"""Smoke tests for pybt. + +Run any of: + python python/tests/test_smoke.py + pytest python/tests/test_smoke.py -v + pytest -m smoke + pytest python/tests/test_smoke.py::test_sync_action_node +""" + +import multiprocessing +import sys + +import pytest + +import pybt + +# Every test in this file participates in the `-m smoke` selection. +pytestmark = pytest.mark.smoke + +XML_SINGLE = '{}' + + +# --------------------------------------------------------------------------- +# Module surface +# --------------------------------------------------------------------------- + + +def test_import_and_basic_types(): + """Module loads, enum values are distinct, version is non-empty.""" + assert pybt.NodeStatus.SUCCESS != pybt.NodeStatus.FAILURE + assert pybt.NodeStatus.RUNNING != pybt.NodeStatus.IDLE + assert pybt.NodeType.ACTION != pybt.NodeType.CONTROL + assert pybt.PortDirection.INPUT != pybt.PortDirection.OUTPUT + assert pybt.__version__ + + +def test_exception_hierarchy(): + """BTError hierarchy is exposed and BTRuntimeError descends from BTError.""" + assert issubclass(pybt.BTRuntimeError, pybt.BTError) + assert issubclass(pybt.BTLogicError, pybt.BTError) + assert issubclass(pybt.BTNodeExecutionError, pybt.BTRuntimeError) + + +# --------------------------------------------------------------------------- +# Built-in (pure C++) tree +# --------------------------------------------------------------------------- + + +def test_built_in_always_success(): + """No Python nodes — pure C++ AlwaysSuccess ticks to SUCCESS.""" + f = pybt.BehaviorTreeFactory() + t = f.create_tree_from_text(XML_SINGLE.format("")) + assert t.tick_while_running() == pybt.NodeStatus.SUCCESS + + +# --------------------------------------------------------------------------- +# SyncActionNode subclass +# --------------------------------------------------------------------------- + + +def test_sync_action_node(): + """SyncActionNode subclass ticks via the adapter and returns user status.""" + visits = [] + + class Foo(pybt.SyncActionNode): + def tick(self): + visits.append(self.name) + return pybt.NodeStatus.SUCCESS + + f = pybt.BehaviorTreeFactory() + f.register_node_type(Foo, "Foo") + t = f.create_tree_from_text(XML_SINGLE.format("")) + assert t.tick_while_running() == pybt.NodeStatus.SUCCESS + assert visits == ["Foo"] + + +def test_sync_action_failure(): + """SyncActionNode returning FAILURE propagates.""" + + class Nope(pybt.SyncActionNode): + def tick(self): + return pybt.NodeStatus.FAILURE + + f = pybt.BehaviorTreeFactory() + f.register_node_type(Nope, "Nope") + t = f.create_tree_from_text(XML_SINGLE.format("")) + assert t.tick_while_running() == pybt.NodeStatus.FAILURE + + +# --------------------------------------------------------------------------- +# StatefulActionNode lifecycle +# --------------------------------------------------------------------------- + + +def test_stateful_action_lifecycle(): + """on_start once, on_running until SUCCESS, events in correct order.""" + events = [] + + class Counter(pybt.StatefulActionNode): + def __init__(self, name, config): + super().__init__(name, config) + self.n = 0 + + def on_start(self): + events.append("start") + return pybt.NodeStatus.RUNNING + + def on_running(self): + self.n += 1 + events.append(f"run:{self.n}") + return pybt.NodeStatus.SUCCESS if self.n >= 3 else pybt.NodeStatus.RUNNING + + f = pybt.BehaviorTreeFactory() + f.register_node_type(Counter, "Counter") + t = f.create_tree_from_text(XML_SINGLE.format("")) + assert t.tick_while_running(sleep_ms=1) == pybt.NodeStatus.SUCCESS + assert events == ["start", "run:1", "run:2", "run:3"] + + +def test_stateful_on_halted_optional(): + """StatefulActionNode without on_halted defined still works (no-op fallback).""" + + class NoCleanup(pybt.StatefulActionNode): + def on_start(self): + return pybt.NodeStatus.SUCCESS + + def on_running(self): + return pybt.NodeStatus.SUCCESS + + # Intentionally no on_halted. + + f = pybt.BehaviorTreeFactory() + f.register_node_type(NoCleanup, "NoCleanup") + t = f.create_tree_from_text(XML_SINGLE.format("")) + assert t.tick_while_running() == pybt.NodeStatus.SUCCESS + + +# --------------------------------------------------------------------------- +# Simple-action callback +# --------------------------------------------------------------------------- + + +def test_simple_action_callback(): + """register_simple_action wraps a plain Python callable.""" + invoked = [0] + + def cb(node): + invoked[0] += 1 + return pybt.NodeStatus.SUCCESS + + f = pybt.BehaviorTreeFactory() + f.register_simple_action("Cb", cb) + t = f.create_tree_from_text(XML_SINGLE.format("")) + assert t.tick_while_running() == pybt.NodeStatus.SUCCESS + assert invoked[0] == 1 + + +def test_simple_action_decorator(): + """@pybt.simple_action(factory, id) decorator registers the wrapped function.""" + f = pybt.BehaviorTreeFactory() + invoked = [0] + + @pybt.simple_action(f, "Deco") + def deco(node): + invoked[0] += 1 + return pybt.NodeStatus.SUCCESS + + t = f.create_tree_from_text(XML_SINGLE.format("")) + assert t.tick_while_running() == pybt.NodeStatus.SUCCESS + assert invoked[0] == 1 + + +# --------------------------------------------------------------------------- +# Exception translation +# --------------------------------------------------------------------------- + + +def test_python_exception_propagates(): + """RuntimeError raised in Python tick() surfaces as an exception out of tick_while_running.""" + + class Boom(pybt.SyncActionNode): + def tick(self): + raise RuntimeError("kaboom") + + f = pybt.BehaviorTreeFactory() + f.register_node_type(Boom, "Boom") + t = f.create_tree_from_text(XML_SINGLE.format("")) + try: + t.tick_while_running() + except Exception as e: + assert "kaboom" in str(e), f"unexpected message: {e!r}" + return + raise AssertionError("tick_while_running should have raised") + + +# --------------------------------------------------------------------------- +# Ports (JSON bridge) +# --------------------------------------------------------------------------- + + +def test_input_port_xml_string_literal(): + """A string literal declared in XML reaches Python via get_input.""" + received = [] + + @pybt.ports(inputs=["msg"]) + class Echo(pybt.SyncActionNode): + def tick(self): + received.append(self.get_input("msg")) + return pybt.NodeStatus.SUCCESS + + f = pybt.BehaviorTreeFactory() + f.register_node_type(Echo, "Echo") + t = f.create_tree_from_text( + XML_SINGLE.format(''), + ) + t.tick_while_running() + assert received == ["hello"] + + +def test_output_then_input_roundtrip(): + """Writer sets a blackboard entry; reader reads it back via the JSON bridge.""" + seen = [] + + @pybt.ports(outputs=["value"]) + class Writer(pybt.SyncActionNode): + def tick(self): + self.set_output("value", 42) + return pybt.NodeStatus.SUCCESS + + @pybt.ports(inputs=["value"]) + class Reader(pybt.SyncActionNode): + def tick(self): + seen.append(self.get_input("value")) + return pybt.NodeStatus.SUCCESS + + f = pybt.BehaviorTreeFactory() + f.register_node_type(Writer, "Writer") + f.register_node_type(Reader, "Reader") + xml = XML_SINGLE.format( + "" + '' + '' + "" + ) + t = f.create_tree_from_text(xml) + assert t.tick_while_running() == pybt.NodeStatus.SUCCESS + assert seen == [42] + + +def test_set_output_undeclared_port_raises(): + """set_output on a port that wasn't declared raises BTRuntimeError.""" + + class BadWriter(pybt.SyncActionNode): + def tick(self): + self.set_output("never_declared", 1) + return pybt.NodeStatus.SUCCESS + + f = pybt.BehaviorTreeFactory() + f.register_node_type(BadWriter, "BadWriter") + t = f.create_tree_from_text(XML_SINGLE.format("")) + try: + t.tick_while_running() + except Exception as e: + assert "never_declared" in str(e), f"unexpected message: {e!r}" + return + raise AssertionError("set_output of an undeclared port should have raised") + + +# --------------------------------------------------------------------------- +# Registration validation +# --------------------------------------------------------------------------- + + +def test_register_non_action_class_raises(): + """Registering a class that does not derive from SyncActionNode/StatefulActionNode fails clearly. + + The error may surface at registration or at first tree construction (when + the factory's builder tries to cast the Python instance to a C++ TreeNode). + Either is acceptable; what matters is that a Python exception is raised + rather than a segfault. + """ + + class NotANode: + def tick(self): + return pybt.NodeStatus.SUCCESS + + f = pybt.BehaviorTreeFactory() + try: + f.register_node_type(NotANode, "NotANode") + t = f.create_tree_from_text(XML_SINGLE.format("")) + t.tick_while_running() + except Exception: + return + raise AssertionError("registering or ticking a non-action class should have raised") + + +def test_register_duplicate_id_raises(): + """Registering two node types with the same ID raises (does not silently replace).""" + + class A(pybt.SyncActionNode): + def tick(self): + return pybt.NodeStatus.SUCCESS + + class B(pybt.SyncActionNode): + def tick(self): + return pybt.NodeStatus.SUCCESS + + f = pybt.BehaviorTreeFactory() + f.register_node_type(A, "Dup") + try: + f.register_node_type(B, "Dup") + except Exception: + return + raise AssertionError("duplicate-id registration should have raised") + + +def test_create_tree_from_malformed_xml_raises(): + """Malformed XML raises a clear Python exception, not a crash.""" + f = pybt.BehaviorTreeFactory() + try: + f.create_tree_from_text("' + ) + t.tick_while_running() + queue.put(("unexpected_success", None)) + except _pybt.BTRuntimeError as e: + queue.put(("BTRuntimeError", str(e))) + except BaseException as e: + queue.put((type(e).__name__, str(e))) + + +def test_fork_safety_raises_btruntimeerror(): + """A tree.tick_while_running() in a forked child raises BTRuntimeError, not a crash.""" + if sys.platform == "win32": + return # No fork on Windows + # Parent must have imported pybt and at least registered the startup pid. + # The startup pid is captured by module init; we trigger it by touching the module. + _ = pybt.BehaviorTreeFactory() + + ctx = multiprocessing.get_context("fork") + q = ctx.Queue() + p = ctx.Process(target=_fork_child_target, args=(q,)) + p.start() + p.join(timeout=5) + assert p.exitcode is not None, "child process hung" + kind, msg = q.get(timeout=1) + assert kind == "BTRuntimeError", f"expected BTRuntimeError, got {kind}: {msg}" + assert "fork-safe" in msg, f"unexpected message: {msg}" + + +# --------------------------------------------------------------------------- +# SIGINT / Ctrl-C +# --------------------------------------------------------------------------- + + +def test_sigint_interrupts_tick(): + """SIGINT during tick_while_running raises KeyboardInterrupt within ~50ms.""" + import signal + import threading + import time + + class Forever(pybt.StatefulActionNode): + def on_start(self): + return pybt.NodeStatus.RUNNING + + def on_running(self): + return pybt.NodeStatus.RUNNING + + f = pybt.BehaviorTreeFactory() + f.register_node_type(Forever, "Forever") + t = f.create_tree_from_text(XML_SINGLE.format("")) + + # Schedule SIGINT to self after 50ms. signal.raise_signal targets the + # current process; CPython delivers the resulting signal to the main + # thread, where tick_while_running's PyErr_CheckSignals picks it up. + timer = threading.Timer(0.05, lambda: signal.raise_signal(signal.SIGINT)) + timer.start() + + start = time.monotonic() + try: + t.tick_while_running(sleep_ms=5) + except KeyboardInterrupt: + elapsed = time.monotonic() - start + # Be generous: the per-iter signal check + sleep_ms can stack. + assert elapsed < 1.0, f"SIGINT took too long: {elapsed:.3f}s" + return + finally: + timer.cancel() + raise AssertionError("tick_while_running should have raised KeyboardInterrupt") + + +# --------------------------------------------------------------------------- +# GIL release — two threads, two trees, concurrent +# --------------------------------------------------------------------------- + + +def test_two_threads_two_trees_run_concurrently(): + """Two threads ticking two trees in parallel finish in ~one tree's worth of wall time.""" + import threading + import time + + class Burner(pybt.StatefulActionNode): + def __init__(self, name, config): + super().__init__(name, config) + self.iters = 0 + + def on_start(self): + return pybt.NodeStatus.RUNNING + + def on_running(self): + self.iters += 1 + return ( + pybt.NodeStatus.SUCCESS if self.iters >= 8 else pybt.NodeStatus.RUNNING + ) + + def run_tree(): + f = pybt.BehaviorTreeFactory() + f.register_node_type(Burner, "Burner") + t = f.create_tree_from_text(XML_SINGLE.format("")) + t.tick_while_running(sleep_ms=10) + + # Single-thread baseline first (fewer iters to keep test snappy). + one_start = time.monotonic() + run_tree() + one_elapsed = time.monotonic() - one_start + + # Two threads in parallel. + two_start = time.monotonic() + t1 = threading.Thread(target=run_tree) + t2 = threading.Thread(target=run_tree) + t1.start() + t2.start() + t1.join() + t2.join() + two_elapsed = time.monotonic() - two_start + + # If the GIL were held throughout, two_elapsed ≈ 2 * one_elapsed. + # With proper GIL release, two_elapsed ≈ one_elapsed. + # Allow generous headroom (CI is noisy): pass if two-thread is < 1.7× single. + ratio = two_elapsed / max(one_elapsed, 1e-6) + assert ratio < 1.7, ( + f"two threads took {two_elapsed:.3f}s vs single {one_elapsed:.3f}s " + f"(ratio {ratio:.2f}) — GIL probably not released during ticks" + ) + + +# --------------------------------------------------------------------------- +# Module reload +# --------------------------------------------------------------------------- + + +def test_module_reload_does_not_crash(): + """importlib.reload(pybt) either succeeds or raises cleanly — must not segfault.""" + import importlib + + try: + importlib.reload(pybt) + except (ImportError, RuntimeError) as e: + # Some nanobind versions reject reload of extension modules — that's + # acceptable as long as it raises cleanly rather than crashing. + print(f" (reload raised cleanly: {type(e).__name__}: {e})") + # Module must still be functional afterward. + assert pybt.NodeStatus.SUCCESS != pybt.NodeStatus.FAILURE + + +# --------------------------------------------------------------------------- +# Standalone runner +# --------------------------------------------------------------------------- + + +def _collect_tests(): + g = globals() + return [(n, g[n]) for n in sorted(g) if n.startswith("test_") and callable(g[n])] + + +def _run_all(): + tests = _collect_tests() + passed = 0 + failed = [] + for name, fn in tests: + try: + fn() + except BaseException as e: + failed.append((name, e)) + print(f"FAIL {name}: {type(e).__name__}: {e}") + else: + passed += 1 + print(f"PASS {name}") + print() + print( + f"{passed}/{len(tests)} passed", "" if not failed else f"({len(failed)} failed)" + ) + return 0 if not failed else 1 + + +if __name__ == "__main__": + sys.exit(_run_all())