From 499a0021f2712f655477f31342a4496eff157c1a Mon Sep 17 00:00:00 2001 From: Anmol Kathail Date: Sun, 17 May 2026 18:10:08 -0500 Subject: [PATCH 1/8] initial setup with nanobind --- .gitignore | 2 + CMakeLists.txt | 5 +++ python/.gitignore | 23 ++++++++++ python/CMakeLists.txt | 27 ++++++++++++ python/README.md | 23 ++++++++++ python/pyproject.toml | 84 +++++++++++++++++++++++++++++++++++++ python/src/_pybt/module.cpp | 9 ++++ python/src/pybt/__init__.py | 11 +++++ python/src/pybt/py.typed | 0 9 files changed, 184 insertions(+) create mode 100644 python/.gitignore create mode 100644 python/CMakeLists.txt create mode 100644 python/README.md create mode 100644 python/pyproject.toml create mode 100644 python/src/_pybt/module.cpp create mode 100644 python/src/pybt/__init__.py create mode 100644 python/src/pybt/py.typed 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/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..ff9145d26 --- /dev/null +++ b/python/.gitignore @@ -0,0 +1,23 @@ +# scikit-build-core / setuptools build artifacts +_skbuild/ +build/ +dist/ +wheelhouse/ +*.egg-info/ + +# Python caches +__pycache__/ +*.py[cod] +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ + +# 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/ \ No newline at end of file diff --git a/python/CMakeLists.txt b/python/CMakeLists.txt new file mode 100644 index 000000000..ce2d586ba --- /dev/null +++ b/python/CMakeLists.txt @@ -0,0 +1,27 @@ +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 +) + +target_link_libraries(_pybt PRIVATE ${BTCPP_LIBRARY}) + +target_compile_features(_pybt PRIVATE cxx_std_17) + +# 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) diff --git a/python/README.md b/python/README.md new file mode 100644 index 000000000..ebba5f433 --- /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 +python -m venv .venv # Only needs to be done once. +source .venv/bin/activate +pip install -e .[dev] +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/pyproject.toml b/python/pyproject.toml new file mode 100644 index 000000000..9eedce378 --- /dev/null +++ b/python/pyproject.toml @@ -0,0 +1,84 @@ +[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" diff --git a/python/src/_pybt/module.cpp b/python/src/_pybt/module.cpp new file mode 100644 index 000000000..c6c06ac85 --- /dev/null +++ b/python/src/_pybt/module.cpp @@ -0,0 +1,9 @@ +#include + +namespace nb = nanobind; + +NB_MODULE(_pybt, m) +{ + m.doc() = "pybt — Python bindings for BehaviorTree.CPP."; + m.attr("__phase__") = "1-foundation"; +} diff --git a/python/src/pybt/__init__.py b/python/src/pybt/__init__.py new file mode 100644 index 000000000..c2a246c7b --- /dev/null +++ b/python/src/pybt/__init__.py @@ -0,0 +1,11 @@ +"""pybt — Python bindings for BehaviorTree.CPP.""" + +from importlib.metadata import PackageNotFoundError +from importlib.metadata import version as _pkg_version + +from ._pybt import * # noqa: F401, F403 + +try: + __version__ = _pkg_version("pybt") +except PackageNotFoundError: + __version__ = "0.0.0+unknown" diff --git a/python/src/pybt/py.typed b/python/src/pybt/py.typed new file mode 100644 index 000000000..e69de29bb From 456a120d0b3dae76247d7bf8b03369f4f4a098ea Mon Sep 17 00:00:00 2001 From: Anmol Kathail Date: Sun, 17 May 2026 19:14:29 -0500 Subject: [PATCH 2/8] simple example to test py binding --- python/CMakeLists.txt | 18 ++ python/README.md | 2 +- python/src/_pybt/bind_basic_types.cpp | 43 ++++ python/src/_pybt/bind_factory.cpp | 188 ++++++++++++++++++ python/src/_pybt/bind_ports.cpp | 76 +++++++ python/src/_pybt/bind_tree.cpp | 185 +++++++++++++++++ python/src/_pybt/bind_tree_node.cpp | 275 ++++++++++++++++++++++++++ python/src/_pybt/exceptions.cpp | 37 ++++ python/src/_pybt/json_bridge.hpp | 119 +++++++++++ python/src/_pybt/module.cpp | 20 ++ python/src/pybt/__init__.py | 42 +++- python/src/pybt/exceptions.py | 19 ++ python/src/pybt/nodes.py | 65 ++++++ 13 files changed, 1087 insertions(+), 2 deletions(-) create mode 100644 python/src/_pybt/bind_basic_types.cpp create mode 100644 python/src/_pybt/bind_factory.cpp create mode 100644 python/src/_pybt/bind_ports.cpp create mode 100644 python/src/_pybt/bind_tree.cpp create mode 100644 python/src/_pybt/bind_tree_node.cpp create mode 100644 python/src/_pybt/exceptions.cpp create mode 100644 python/src/_pybt/json_bridge.hpp create mode 100644 python/src/pybt/exceptions.py create mode 100644 python/src/pybt/nodes.py diff --git a/python/CMakeLists.txt b/python/CMakeLists.txt index ce2d586ba..107983914 100644 --- a/python/CMakeLists.txt +++ b/python/CMakeLists.txt @@ -16,12 +16,30 @@ 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) diff --git a/python/README.md b/python/README.md index ebba5f433..e4c5d6cd5 100644 --- a/python/README.md +++ b/python/README.md @@ -8,7 +8,7 @@ Python bindings for [BehaviorTree.CPP](https://github.com/BehaviorTree/BehaviorT cd python python -m venv .venv # Only needs to be done once. source .venv/bin/activate -pip install -e .[dev] +pip install -e .[dev] -v python -c "import pybt; print(pybt.__version__, pybt._pybt.__phase__)" ``` diff --git a/python/src/_pybt/bind_basic_types.cpp b/python/src/_pybt/bind_basic_types.cpp new file mode 100644 index 000000000..d4bd55eb3 --- /dev/null +++ b/python/src/_pybt/bind_basic_types.cpp @@ -0,0 +1,43 @@ +// bind_basic_types.cpp — enum bindings: NodeStatus, NodeType, PortDirection. + +#include + +#include "behaviortree_cpp/basic_types.h" + +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..3b115761d --- /dev/null +++ b/python/src/_pybt/bind_factory.cpp @@ -0,0 +1,188 @@ +// bind_factory.cpp — BehaviorTreeFactory binding. + +#include +#include +#include +#include +#include + +#include +#include +#include + +#include "behaviortree_cpp/basic_types.h" +#include "behaviortree_cpp/bt_factory.h" +#include "behaviortree_cpp/tree_node.h" + +namespace nb = nanobind; +using namespace nb::literals; + +namespace pybt { + +// Defined in bind_tree_node.cpp — keeps the Python wrapper alive as long +// as the corresponding C++ TreeNode lives. +void park_python_instance(BT::TreeNode* node, nb::object instance); + +// 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, {} }; + + BT::NodeBuilder builder = + [py_cls](const std::string& name, + const BT::NodeConfig& config) -> std::unique_ptr { + nb::gil_scoped_acquire gil; + nb::object py_inst = py_cls(name, config); + BT::TreeNode* raw = nb::cast(py_inst); + park_python_instance(raw, std::move(py_inst)); + return std::unique_ptr(raw); + }; + + 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..67d90169e --- /dev/null +++ b/python/src/_pybt/bind_ports.cpp @@ -0,0 +1,76 @@ +// 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 +#include +#include + +#include "behaviortree_cpp/basic_types.h" + +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..6564829e1 --- /dev/null +++ b/python/src/_pybt/bind_tree.cpp @@ -0,0 +1,185 @@ +// 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 + +#include +#include + +#include "behaviortree_cpp/bt_factory.h" + +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(); +} + +// 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) +{ + 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) + { + // GIL acquired and an exception is set (e.g. KeyboardInterrupt). + // Halt the tree before throwing so background work stops cleanly. + { + nb::gil_scoped_release release_for_halt; + self.haltTree(); + } + throw nb::python_error(); + } + } + } while(status == BT::NodeStatus::RUNNING); + } + + return status; +} + +} // namespace + +void register_tree(nb::module_& m) +{ + // Register atexit halt — once per interpreter. + static bool atexit_registered = false; + if(!atexit_registered) + { + Py_AtExit(&on_python_exit); + atexit_registered = true; + } + + 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..342b09b46 --- /dev/null +++ b/python/src/_pybt/bind_tree_node.cpp @@ -0,0 +1,275 @@ +// bind_tree_node.cpp — TreeNode binding plus trampolines for the two +// user-subclassable kinds of action node. +// +// Trampoline pattern: each trampoline class inherits the BT.CPP type, +// declares NB_TRAMPOLINE for the methods Python may override, and forwards +// the virtual calls back to Python under nb::gil_scoped_acquire (the tick +// loop drops the GIL by default; trampolines reacquire to call into Python). +// +// Lifetime: Python users construct trampoline subclasses; the factory's +// NodeBuilder lambda calls the Python class with (name, config) to produce +// a fresh instance per tree. The Python instance is parked in +// PythonInstanceRegistry so it outlives the unique_ptr handed to the +// C++ tree; the trampoline destructor evicts itself from the registry. + +#include +#include + +#include +#include +#include + +#include "behaviortree_cpp/action_node.h" +#include "behaviortree_cpp/json_export.h" +#include "behaviortree_cpp/tree_node.h" + +#include "json_bridge.hpp" + +namespace nb = nanobind; +using namespace nb::literals; + +namespace pybt { + +// -------------------------------------------------------------------------- +// Python instance registry +// +// Keeps the Python wrapper alive as long as the C++ trampoline lives. +// Without this, the user's Python instance can be garbage-collected the +// moment the factory builder returns — then the trampoline's NB_OVERRIDE_PURE +// would fail to find a Python tick() method. +// -------------------------------------------------------------------------- + +class PythonInstanceRegistry +{ +public: + static PythonInstanceRegistry& get() + { + static PythonInstanceRegistry r; + return r; + } + + void store(BT::TreeNode* node, nb::object instance) + { + std::lock_guard lock(mu_); + map_[node] = std::move(instance); + } + + void remove(BT::TreeNode* node) + { + // Drop the nb::object with the GIL held (its destructor decrefs). + nb::gil_scoped_acquire gil; + std::lock_guard lock(mu_); + map_.erase(node); + } + +private: + std::mutex mu_; + std::unordered_map map_; +}; + +// -------------------------------------------------------------------------- +// Trampolines +// -------------------------------------------------------------------------- + +struct PySyncActionNode : public BT::SyncActionNode +{ + NB_TRAMPOLINE(BT::SyncActionNode, 1); + + PySyncActionNode(const std::string& name, const BT::NodeConfig& config) + : BT::SyncActionNode(name, config) + {} + + ~PySyncActionNode() override + { + PythonInstanceRegistry::get().remove(this); + } + + BT::NodeStatus tick() override + { + nb::gil_scoped_acquire gil; + NB_OVERRIDE_PURE(tick); + } +}; + +struct PyStatefulActionNode : public BT::StatefulActionNode +{ + NB_TRAMPOLINE(BT::StatefulActionNode, 3); + + PyStatefulActionNode(const std::string& name, const BT::NodeConfig& config) + : BT::StatefulActionNode(name, config) + {} + + ~PyStatefulActionNode() override + { + PythonInstanceRegistry::get().remove(this); + } + + BT::NodeStatus onStart() override + { + nb::gil_scoped_acquire gil; + NB_OVERRIDE_PURE_NAME("on_start", onStart); + } + + BT::NodeStatus onRunning() override + { + nb::gil_scoped_acquire gil; + NB_OVERRIDE_PURE_NAME("on_running", onRunning); + } + + void onHalted() override + { + nb::gil_scoped_acquire gil; + NB_OVERRIDE_PURE_NAME("on_halted", onHalted); + } +}; + +// -------------------------------------------------------------------------- +// 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) +{ + 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, "StatefulActionNode", + "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."); +} + +// Exposed for bind_factory.cpp: parks `instance` in the registry keyed by `node`. +void park_python_instance(BT::TreeNode* node, nb::object instance) +{ + PythonInstanceRegistry::get().store(node, std::move(instance)); +} + +} // namespace pybt diff --git a/python/src/_pybt/exceptions.cpp b/python/src/_pybt/exceptions.cpp new file mode 100644 index 000000000..c8f0631e8 --- /dev/null +++ b/python/src/_pybt/exceptions.cpp @@ -0,0 +1,37 @@ +// 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 + +#include "behaviortree_cpp/exceptions.h" + +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..c48ca0433 --- /dev/null +++ b/python/src/_pybt/json_bridge.hpp @@ -0,0 +1,119 @@ +// 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 +#include + +#include +#include + +#include "behaviortree_cpp/contrib/json.hpp" + +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 index c6c06ac85..01f462a37 100644 --- a/python/src/_pybt/module.cpp +++ b/python/src/_pybt/module.cpp @@ -1,9 +1,29 @@ +// 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 index c2a246c7b..2842c909f 100644 --- a/python/src/pybt/__init__.py +++ b/python/src/pybt/__init__.py @@ -3,9 +3,49 @@ from importlib.metadata import PackageNotFoundError from importlib.metadata import version as _pkg_version -from ._pybt import * # noqa: F401, F403 +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 From 44d95f7840d2658fbb67b1f9f35fe9a3fc0f703e Mon Sep 17 00:00:00 2001 From: Anmol Kathail Date: Sun, 17 May 2026 22:00:29 -0500 Subject: [PATCH 3/8] fi failures --- python/README.md | 2 +- python/src/_pybt/bind_factory.cpp | 35 ++++-- python/src/_pybt/bind_tree.cpp | 34 ++++++ python/src/_pybt/bind_tree_node.cpp | 173 ++++++++++++++++++---------- 4 files changed, 176 insertions(+), 68 deletions(-) diff --git a/python/README.md b/python/README.md index e4c5d6cd5..78e0cdc1a 100644 --- a/python/README.md +++ b/python/README.md @@ -6,7 +6,7 @@ Python bindings for [BehaviorTree.CPP](https://github.com/BehaviorTree/BehaviorT ```bash cd python -python -m venv .venv # Only needs to be done once. +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__)" diff --git a/python/src/_pybt/bind_factory.cpp b/python/src/_pybt/bind_factory.cpp index 3b115761d..68ca27edb 100644 --- a/python/src/_pybt/bind_factory.cpp +++ b/python/src/_pybt/bind_factory.cpp @@ -10,6 +10,7 @@ #include #include +#include "behaviortree_cpp/action_node.h" #include "behaviortree_cpp/basic_types.h" #include "behaviortree_cpp/bt_factory.h" #include "behaviortree_cpp/tree_node.h" @@ -19,9 +20,15 @@ using namespace nb::literals; namespace pybt { -// Defined in bind_tree_node.cpp — keeps the Python wrapper alive as long -// as the corresponding C++ TreeNode lives. -void park_python_instance(BT::TreeNode* node, nb::object instance); +// 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); @@ -87,14 +94,26 @@ void register_node_type_impl(BT::BehaviorTreeFactory& self, nb::object py_cls, // 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](const std::string& name, - const BT::NodeConfig& config) -> std::unique_ptr { + [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); - BT::TreeNode* raw = nb::cast(py_inst); - park_python_instance(raw, std::move(py_inst)); - return std::unique_ptr(raw); + 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); diff --git a/python/src/_pybt/bind_tree.cpp b/python/src/_pybt/bind_tree.cpp index 6564829e1..0cac6095a 100644 --- a/python/src/_pybt/bind_tree.cpp +++ b/python/src/_pybt/bind_tree.cpp @@ -14,6 +14,11 @@ #include #include +#if defined(__unix__) || defined(__APPLE__) +#include +#include +#endif + #include #include @@ -81,11 +86,32 @@ 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 @@ -134,6 +160,14 @@ void register_tree(nb::module_& m) 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 " diff --git a/python/src/_pybt/bind_tree_node.cpp b/python/src/_pybt/bind_tree_node.cpp index 342b09b46..a198c8965 100644 --- a/python/src/_pybt/bind_tree_node.cpp +++ b/python/src/_pybt/bind_tree_node.cpp @@ -1,19 +1,25 @@ -// bind_tree_node.cpp — TreeNode binding plus trampolines for the two -// user-subclassable kinds of action node. +// bind_tree_node.cpp — TreeNode binding plus user-subclassable shell +// classes and the adapters that bridge Python to BT.CPP at tree-tick time. // -// Trampoline pattern: each trampoline class inherits the BT.CPP type, -// declares NB_TRAMPOLINE for the methods Python may override, and forwards -// the virtual calls back to Python under nb::gil_scoped_acquire (the tick -// loop drops the GIL by default; trampolines reacquire to call into Python). +// Design (adapter pattern, see Phase 1 Subagent B notes): // -// Lifetime: Python users construct trampoline subclasses; the factory's -// NodeBuilder lambda calls the Python class with (name, config) to produce -// a fresh instance per tree. The Python instance is parked in -// PythonInstanceRegistry so it outlives the unique_ptr handed to the -// C++ tree; the trampoline destructor evicts itself from the registry. - -#include -#include +// * `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 #include @@ -31,96 +37,123 @@ using namespace nb::literals; namespace pybt { // -------------------------------------------------------------------------- -// Python instance registry +// Trampoline shells // -// Keeps the Python wrapper alive as long as the C++ trampoline lives. -// Without this, the user's Python instance can be garbage-collected the -// moment the factory builder returns — then the trampoline's NB_OVERRIDE_PURE -// would fail to find a Python tick() method. +// 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. // -------------------------------------------------------------------------- -class PythonInstanceRegistry +struct PySyncActionNode : public BT::SyncActionNode { -public: - static PythonInstanceRegistry& get() + 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 { - static PythonInstanceRegistry r; - return r; + throw BT::LogicError( + "PySyncActionNode::tick() invoked directly — should go through " + "PythonSyncActionAdapter. This is a binding bug."); } +}; - void store(BT::TreeNode* node, nb::object instance) +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 { - std::lock_guard lock(mu_); - map_[node] = std::move(instance); + throw BT::LogicError("PyStatefulActionNode::onStart() invoked directly — " + "should go through PythonStatefulActionAdapter."); } - - void remove(BT::TreeNode* node) + BT::NodeStatus onRunning() override { - // Drop the nb::object with the GIL held (its destructor decrefs). - nb::gil_scoped_acquire gil; - std::lock_guard lock(mu_); - map_.erase(node); + throw BT::LogicError("PyStatefulActionNode::onRunning() invoked directly."); + } + void onHalted() override + { + // No-op default for the shell (never reached at tick time). } - -private: - std::mutex mu_; - std::unordered_map map_; }; // -------------------------------------------------------------------------- -// Trampolines +// 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`. // -------------------------------------------------------------------------- -struct PySyncActionNode : public BT::SyncActionNode +class PythonSyncActionAdapter : public BT::SyncActionNode { - NB_TRAMPOLINE(BT::SyncActionNode, 1); - - PySyncActionNode(const std::string& name, const BT::NodeConfig& config) - : BT::SyncActionNode(name, config) +public: + PythonSyncActionAdapter(const std::string& name, const BT::NodeConfig& config, + nb::object py_inst) + : BT::SyncActionNode(name, config), py_inst_(std::move(py_inst)) {} - ~PySyncActionNode() override + ~PythonSyncActionAdapter() override { - PythonInstanceRegistry::get().remove(this); + nb::gil_scoped_acquire gil; + py_inst_.reset(); } BT::NodeStatus tick() override { nb::gil_scoped_acquire gil; - NB_OVERRIDE_PURE(tick); + return nb::cast(py_inst_.attr("tick")()); } + +private: + nb::object py_inst_; }; -struct PyStatefulActionNode : public BT::StatefulActionNode +class PythonStatefulActionAdapter : public BT::StatefulActionNode { - NB_TRAMPOLINE(BT::StatefulActionNode, 3); - - PyStatefulActionNode(const std::string& name, const BT::NodeConfig& config) - : BT::StatefulActionNode(name, config) +public: + PythonStatefulActionAdapter(const std::string& name, + const BT::NodeConfig& config, nb::object py_inst) + : BT::StatefulActionNode(name, config), py_inst_(std::move(py_inst)) {} - ~PyStatefulActionNode() override + ~PythonStatefulActionAdapter() override { - PythonInstanceRegistry::get().remove(this); + nb::gil_scoped_acquire gil; + py_inst_.reset(); } BT::NodeStatus onStart() override { nb::gil_scoped_acquire gil; - NB_OVERRIDE_PURE_NAME("on_start", onStart); + return nb::cast(py_inst_.attr("on_start")()); } BT::NodeStatus onRunning() override { nb::gil_scoped_acquire gil; - NB_OVERRIDE_PURE_NAME("on_running", onRunning); + return nb::cast(py_inst_.attr("on_running")()); } void onHalted() override { nb::gil_scoped_acquire gil; - NB_OVERRIDE_PURE_NAME("on_halted", onHalted); + // `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_; }; // -------------------------------------------------------------------------- @@ -226,6 +259,14 @@ static void set_output_impl(BT::TreeNode& self, const std::string& name, nb::obj 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, " @@ -266,10 +307,24 @@ void register_tree_node(nb::module_& m) "config"_a, "Built by the factory; users rarely call this directly."); } -// Exposed for bind_factory.cpp: parks `instance` in the registry keyed by `node`. -void park_python_instance(BT::TreeNode* node, nb::object instance) +// -------------------------------------------------------------------------- +// 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) { - PythonInstanceRegistry::get().store(node, std::move(instance)); + return std::make_unique(name, config, + std::move(py_inst)); } } // namespace pybt From 4dfb09e655597e40c770fcd9a0131d7031288e2a Mon Sep 17 00:00:00 2001 From: Anmol Kathail Date: Sun, 17 May 2026 22:00:42 -0500 Subject: [PATCH 4/8] add tests --- python/tests/README.md | 10 ++ python/tests/test_smoke.py | 317 +++++++++++++++++++++++++++++++++++++ 2 files changed, 327 insertions(+) create mode 100644 python/tests/README.md create mode 100644 python/tests/test_smoke.py diff --git a/python/tests/README.md b/python/tests/README.md new file mode 100644 index 000000000..dcad7d474 --- /dev/null +++ b/python/tests/README.md @@ -0,0 +1,10 @@ +To test: + +```bash +cd python +python3 -m venv .venv # Only needs to be done once. +source .venv/bin/activate +pip install -e .[dev] -v +python tests/test_smoke.py + +``` \ No newline at end of file diff --git a/python/tests/test_smoke.py b/python/tests/test_smoke.py new file mode 100644 index 000000000..f8b1c4d3d --- /dev/null +++ b/python/tests/test_smoke.py @@ -0,0 +1,317 @@ +"""Smoke tests for pybt. + +Run any of: + python python/tests/test_smoke.py + pytest python/tests/test_smoke.py -v + pytest python/tests/test_smoke.py::test_sync_action_node +""" + +import multiprocessing +import sys + +import pybt + +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 + + +# --------------------------------------------------------------------------- +# 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") + + +# --------------------------------------------------------------------------- +# Fork safety +# --------------------------------------------------------------------------- + + +def _fork_child_target(queue): + """Run inside a forked child — should fail with BTRuntimeError.""" + import pybt as _pybt + + try: + f = _pybt.BehaviorTreeFactory() + t = 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}" + + +# --------------------------------------------------------------------------- +# 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()) From a5637efa54df93671d1acfa00d8a9af01ec0133e Mon Sep 17 00:00:00 2001 From: Anmol Kathail Date: Sun, 17 May 2026 22:31:23 -0500 Subject: [PATCH 5/8] add pytest support, and initial benchmarks --- python/.gitignore | 3 + python/benchmarks/README.md | 31 ++++++++ python/benchmarks/baseline.json | 11 +++ python/benchmarks/bench.py | 83 ++++++++++++++++++++++ python/benchmarks/conftest.py | 18 +++++ python/pyproject.toml | 15 ++++ python/src/_pybt/bind_tree.cpp | 40 +++++++++-- python/tests/README.md | 48 ++++++++++--- python/tests/conftest.py | 43 ++++++++++++ python/tests/test_lifecycle.py | 87 +++++++++++++++++++++++ python/tests/test_smoke.py | 121 +++++++++++++++++++++++++++++++- 11 files changed, 485 insertions(+), 15 deletions(-) create mode 100644 python/benchmarks/README.md create mode 100644 python/benchmarks/baseline.json create mode 100644 python/benchmarks/bench.py create mode 100644 python/benchmarks/conftest.py create mode 100644 python/tests/conftest.py create mode 100644 python/tests/test_lifecycle.py diff --git a/python/.gitignore b/python/.gitignore index ff9145d26..39f76dc70 100644 --- a/python/.gitignore +++ b/python/.gitignore @@ -12,6 +12,9 @@ __pycache__/ .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 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.py b/python/benchmarks/bench.py new file mode 100644 index 000000000..081979929 --- /dev/null +++ b/python/benchmarks/bench.py @@ -0,0 +1,83 @@ +"""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/pyproject.toml b/python/pyproject.toml index 9eedce378..d2321b62a 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -82,3 +82,18 @@ 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: Phase 1 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_tree.cpp b/python/src/_pybt/bind_tree.cpp index 0cac6095a..473fa4f53 100644 --- a/python/src/_pybt/bind_tree.cpp +++ b/python/src/_pybt/bind_tree.cpp @@ -133,12 +133,27 @@ tick_while_running_with_signals(BT::Tree& self, std::chrono::milliseconds sleep_ nb::gil_scoped_acquire gil; if(PyErr_CheckSignals() != 0) { - // GIL acquired and an exception is set (e.g. KeyboardInterrupt). - // Halt the tree before throwing so background work stops cleanly. + // 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; - self.haltTree(); + 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(); } } @@ -152,10 +167,27 @@ tick_while_running_with_signals(BT::Tree& self, std::chrono::milliseconds sleep_ void register_tree(nb::module_& m) { - // Register atexit halt — once per interpreter. + // 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; } diff --git a/python/tests/README.md b/python/tests/README.md index dcad7d474..b1dda68c6 100644 --- a/python/tests/README.md +++ b/python/tests/README.md @@ -1,10 +1,38 @@ -To test: - -```bash -cd python -python3 -m venv .venv # Only needs to be done once. -source .venv/bin/activate -pip install -e .[dev] -v -python tests/test_smoke.py - -``` \ No newline at end of file +# 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/conftest.py b/python/tests/conftest.py new file mode 100644 index 000000000..f1ce5c800 --- /dev/null +++ b/python/tests/conftest.py @@ -0,0 +1,43 @@ +"""Shared fixtures and helpers for the pybt test suite.""" + +import pytest + +import pybt + + +@pytest.fixture +def fresh_factory(): + """A brand-new, empty BehaviorTreeFactory. Use per-test to avoid registration leakage.""" + return pybt.BehaviorTreeFactory() + + +@pytest.fixture +def simple_xml(): + """Trivial XML: one built-in AlwaysSuccess node inside a BehaviorTree.""" + return ( + '' + '' + "" + ) + + +@pytest.fixture +def wrap_in_tree(): + """Return a helper that wraps an inner XML snippet in a single BehaviorTree.""" + + def _wrap(inner: str) -> str: + return ( + '' + f"{inner}" + "" + ) + + return _wrap + + +def assert_tree_returns(tree, expected_status, sleep_ms: int = 10): + """Tick the tree until it stops and assert it returned the expected status.""" + actual = tree.tick_while_running(sleep_ms=sleep_ms) + assert actual == expected_status, ( + f"expected {expected_status}, got {actual}" + ) diff --git a/python/tests/test_lifecycle.py b/python/tests/test_lifecycle.py new file mode 100644 index 000000000..0065709a0 --- /dev/null +++ b/python/tests/test_lifecycle.py @@ -0,0 +1,87 @@ +"""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_atexit_halts_running_tree(): + """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}" + ) diff --git a/python/tests/test_smoke.py b/python/tests/test_smoke.py index f8b1c4d3d..8ced8a6c6 100644 --- a/python/tests/test_smoke.py +++ b/python/tests/test_smoke.py @@ -3,14 +3,20 @@ 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 = '{}' @@ -286,9 +292,122 @@ def test_fork_safety_raises_btruntimeerror(): # --------------------------------------------------------------------------- -# Standalone runner +# 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() From 26e5aa33874ec7ee050711980e8f3ebf00095ac3 Mon Sep 17 00:00:00 2001 From: Anmol Kathail Date: Sun, 17 May 2026 22:45:35 -0500 Subject: [PATCH 6/8] re-tinker with tests --- python/benchmarks/{bench.py => bench_pybt.py} | 0 python/tests/conftest.py | 43 ----------- python/tests/test_lifecycle.py | 36 ++++++++- python/tests/test_smoke.py | 75 +++++++++++++++++++ 4 files changed, 110 insertions(+), 44 deletions(-) rename python/benchmarks/{bench.py => bench_pybt.py} (100%) delete mode 100644 python/tests/conftest.py diff --git a/python/benchmarks/bench.py b/python/benchmarks/bench_pybt.py similarity index 100% rename from python/benchmarks/bench.py rename to python/benchmarks/bench_pybt.py diff --git a/python/tests/conftest.py b/python/tests/conftest.py deleted file mode 100644 index f1ce5c800..000000000 --- a/python/tests/conftest.py +++ /dev/null @@ -1,43 +0,0 @@ -"""Shared fixtures and helpers for the pybt test suite.""" - -import pytest - -import pybt - - -@pytest.fixture -def fresh_factory(): - """A brand-new, empty BehaviorTreeFactory. Use per-test to avoid registration leakage.""" - return pybt.BehaviorTreeFactory() - - -@pytest.fixture -def simple_xml(): - """Trivial XML: one built-in AlwaysSuccess node inside a BehaviorTree.""" - return ( - '' - '' - "" - ) - - -@pytest.fixture -def wrap_in_tree(): - """Return a helper that wraps an inner XML snippet in a single BehaviorTree.""" - - def _wrap(inner: str) -> str: - return ( - '' - f"{inner}" - "" - ) - - return _wrap - - -def assert_tree_returns(tree, expected_status, sleep_ms: int = 10): - """Tick the tree until it stops and assert it returned the expected status.""" - actual = tree.tick_while_running(sleep_ms=sleep_ms) - assert actual == expected_status, ( - f"expected {expected_status}, got {actual}" - ) diff --git a/python/tests/test_lifecycle.py b/python/tests/test_lifecycle.py index 0065709a0..467fecabb 100644 --- a/python/tests/test_lifecycle.py +++ b/python/tests/test_lifecycle.py @@ -22,7 +22,7 @@ def _run_in_subprocess(script: str, timeout: float = 10.0) -> subprocess.Complet ) -def test_atexit_halts_running_tree(): +def test_clean_thread_shutdown_with_stop_flag(): """A tree ticking in a background thread exits cleanly via a node-level stop flag. """ @@ -85,3 +85,37 @@ def test_clean_module_load_and_shutdown(): 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 index 8ced8a6c6..9195da66a 100644 --- a/python/tests/test_smoke.py +++ b/python/tests/test_smoke.py @@ -154,6 +154,21 @@ def cb(node): 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 # --------------------------------------------------------------------------- @@ -250,6 +265,66 @@ def tick(self): 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(" Date: Sun, 17 May 2026 23:15:11 -0500 Subject: [PATCH 7/8] add CI steps, docs stub, pre-commit hook, and other automation --- .github/workflows/python_test.yml | 150 ++++++++++++++++++++++++++ .pre-commit-config.yaml | 27 +++++ python/.gitignore | 2 +- python/.readthedocs.yaml | 22 ++++ python/CMakeLists.txt | 22 +++- python/CONTRIBUTING.md | 64 +++++++++++ python/benchmarks/bench_pybt.py | 1 + python/docs/check_no_cpp_refs.py | 15 +++ python/docs/conf.py | 8 ++ python/docs/index.md | 5 + python/pyproject.toml | 2 +- python/src/_pybt/bind_basic_types.cpp | 7 +- python/src/_pybt/bind_factory.cpp | 65 ++++++----- python/src/_pybt/bind_ports.cpp | 7 +- python/src/_pybt/bind_tree.cpp | 47 ++++---- python/src/_pybt/bind_tree_node.cpp | 103 ++++++++++-------- python/src/_pybt/exceptions.cpp | 7 +- python/src/_pybt/json_bridge.hpp | 9 +- python/src/_pybt/module.cpp | 3 +- python/tests/test_lifecycle.py | 23 ++-- python/tests/test_smoke.py | 21 ++-- 21 files changed, 471 insertions(+), 139 deletions(-) create mode 100644 .github/workflows/python_test.yml create mode 100644 python/.readthedocs.yaml create mode 100644 python/CONTRIBUTING.md create mode 100644 python/docs/check_no_cpp_refs.py create mode 100644 python/docs/conf.py create mode 100644 python/docs/index.md 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/.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/python/.gitignore b/python/.gitignore index 39f76dc70..575e28bfc 100644 --- a/python/.gitignore +++ b/python/.gitignore @@ -23,4 +23,4 @@ src/pybt/_pybt*.dylib # Editable-install redirect files written by scikit-build-core src/pybt/*.pth -.venv/ \ No newline at end of file +.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 index 107983914..a22bbce4a 100644 --- a/python/CMakeLists.txt +++ b/python/CMakeLists.txt @@ -2,8 +2,8 @@ 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. +# 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 @@ -43,3 +43,21 @@ 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..bf60c7c3e --- /dev/null +++ b/python/CONTRIBUTING.md @@ -0,0 +1,64 @@ +# 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). + +## 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 | +| `benchmarks/` | pytest-benchmark microbenchmarks | +| `docs/` | Sphinx site (stub in Phase 1) | +| `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). +- Docs: per the Documentation Standards in the project plan — standalone, brief, no C++ references outside this file and `README.md`. diff --git a/python/benchmarks/bench_pybt.py b/python/benchmarks/bench_pybt.py index 081979929..103416c69 100644 --- a/python/benchmarks/bench_pybt.py +++ b/python/benchmarks/bench_pybt.py @@ -40,6 +40,7 @@ def one_tick(): benchmark(one_tick) + # --------------------------------------------------------------------------- # 2. Port get/set latency # --------------------------------------------------------------------------- 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/pyproject.toml b/python/pyproject.toml index d2321b62a..f7e5f1b65 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -93,7 +93,7 @@ testpaths = ["tests"] python_files = ["test_*.py", "bench_*.py"] python_functions = ["test_*", "bench_*"] markers = [ - "smoke: Phase 1 smoke tests — must always pass to consider pybt healthy.", + "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 index d4bd55eb3..c1eb92524 100644 --- a/python/src/_pybt/bind_basic_types.cpp +++ b/python/src/_pybt/bind_basic_types.cpp @@ -1,12 +1,13 @@ // bind_basic_types.cpp — enum bindings: NodeStatus, NodeType, PortDirection. -#include - #include "behaviortree_cpp/basic_types.h" +#include + namespace nb = nanobind; -namespace pybt { +namespace pybt +{ void register_basic_types(nb::module_& m) { diff --git a/python/src/_pybt/bind_factory.cpp b/python/src/_pybt/bind_factory.cpp index 68ca27edb..312c96e9a 100644 --- a/python/src/_pybt/bind_factory.cpp +++ b/python/src/_pybt/bind_factory.cpp @@ -1,5 +1,10 @@ // 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 @@ -10,25 +15,21 @@ #include #include -#include "behaviortree_cpp/action_node.h" -#include "behaviortree_cpp/basic_types.h" -#include "behaviortree_cpp/bt_factory.h" -#include "behaviortree_cpp/tree_node.h" - namespace nb = nanobind; using namespace nb::literals; -namespace pybt { +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); +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); @@ -41,7 +42,8 @@ static std::shared_ptr wrap_and_track(BT::Tree&& tree) return sp; } -namespace { +namespace +{ // Build a PortsList from one of: // 1. Explicit list of (name, PortInfo) tuples (e.g. [pybt.input_port("x")]) @@ -68,8 +70,7 @@ BT::PortsList resolve_ports(nb::handle py_cls, nb::object ports_obj) for(nb::handle name : py_cls.attr("input_ports")) { std::string n = nb::cast(name); - ports.insert( - BT::CreatePort(BT::PortDirection::INPUT, n)); + ports.insert(BT::CreatePort(BT::PortDirection::INPUT, n)); } } if(nb::hasattr(py_cls, "output_ports")) @@ -77,8 +78,7 @@ BT::PortsList resolve_ports(nb::handle py_cls, nb::object ports_obj) for(nb::handle name : py_cls.attr("output_ports")) { std::string n = nb::cast(name); - ports.insert( - BT::CreatePort(BT::PortDirection::OUTPUT, n)); + ports.insert(BT::CreatePort(BT::PortDirection::OUTPUT, n)); } } @@ -98,12 +98,12 @@ void register_node_type_impl(BT::BehaviorTreeFactory& self, nb::object py_cls, // 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; + 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 { + [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 @@ -119,9 +119,8 @@ void register_node_type_impl(BT::BehaviorTreeFactory& self, nb::object py_cls, self.registerBuilder(manifest, builder); } -void register_simple_action_impl(BT::BehaviorTreeFactory& self, - const std::string& id, nb::object callable, - nb::object ports_obj) +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); @@ -139,9 +138,9 @@ void register_simple_action_impl(BT::BehaviorTreeFactory& self, void register_factory(nb::module_& m) { - nb::class_( - m, "BehaviorTreeFactory", - "Registers node types and builds Tree instances from XML.") + 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, @@ -166,11 +165,9 @@ void register_factory(nb::module_& m) [](BT::BehaviorTreeFactory& self, const std::string& path) { self.registerBehaviorTreeFromFile(std::filesystem::path(path)); }, - "path"_a, - "Pre-register the definitions from a file on disk.") + "path"_a, "Pre-register the definitions from a file on disk.") - .def("registered_behavior_trees", - &BT::BehaviorTreeFactory::registeredBehaviorTrees, + .def("registered_behavior_trees", &BT::BehaviorTreeFactory::registeredBehaviorTrees, "Names of every behavior tree currently registered with the factory.") .def("clear_registered_behavior_trees", @@ -190,8 +187,7 @@ void register_factory(nb::module_& m) .def( "create_tree_from_file", [](BT::BehaviorTreeFactory& self, const std::string& path) { - return wrap_and_track( - self.createTreeFromFile(std::filesystem::path(path))); + return wrap_and_track(self.createTreeFromFile(std::filesystem::path(path))); }, "path"_a, "Read XML from a file and instantiate the resulting Tree.") @@ -200,8 +196,7 @@ void register_factory(nb::module_& m) [](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."); + "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 index 67d90169e..d90d46a6c 100644 --- a/python/src/_pybt/bind_ports.cpp +++ b/python/src/_pybt/bind_ports.cpp @@ -4,16 +4,17 @@ // 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 -#include "behaviortree_cpp/basic_types.h" - namespace nb = nanobind; using namespace nb::literals; -namespace pybt { +namespace pybt +{ void register_ports(nb::module_& m) { diff --git a/python/src/_pybt/bind_tree.cpp b/python/src/_pybt/bind_tree.cpp index 473fa4f53..127bcc1d7 100644 --- a/python/src/_pybt/bind_tree.cpp +++ b/python/src/_pybt/bind_tree.cpp @@ -19,15 +19,16 @@ #include #endif +#include "behaviortree_cpp/bt_factory.h" + #include #include -#include "behaviortree_cpp/bt_factory.h" - namespace nb = nanobind; using namespace nb::literals; -namespace pybt { +namespace pybt +{ // -------------------------------------------------------------------------- // Live-tree tracking + atexit halt @@ -79,7 +80,8 @@ void register_live_tree(std::shared_ptr tree) LiveTreeRegistry::get().add(tree); } -namespace { +namespace +{ void on_python_exit() { @@ -102,13 +104,14 @@ void detect_fork_or_throw() } } #else -inline void detect_fork_or_throw() {} +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) +BT::NodeStatus tick_while_running_with_signals(BT::Tree& self, + std::chrono::milliseconds sleep_dur) { detect_fork_or_throw(); @@ -185,9 +188,8 @@ void register_tree(nb::module_& m) static bool atexit_registered = false; if(!atexit_registered) { - nb::module_::import_("atexit").attr("register")(nb::cpp_function([]() { - LiveTreeRegistry::get().halt_all(); - })); + nb::module_::import_("atexit").attr("register")( + nb::cpp_function([]() { LiveTreeRegistry::get().halt_all(); })); Py_AtExit(&on_python_exit); atexit_registered = true; } @@ -218,32 +220,31 @@ void register_tree(nb::module_& m) .def( "tick_while_running", [](BT::Tree& self, int sleep_ms) { - return tick_while_running_with_signals( - self, std::chrono::milliseconds(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("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( + "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(); }, + "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)."); } diff --git a/python/src/_pybt/bind_tree_node.cpp b/python/src/_pybt/bind_tree_node.cpp index a198c8965..d3e12b923 100644 --- a/python/src/_pybt/bind_tree_node.cpp +++ b/python/src/_pybt/bind_tree_node.cpp @@ -21,20 +21,21 @@ // targets a different allocator and triggered `free(): invalid pointer` // on tree teardown. Splitting the roles fixes the lifetime model. -#include -#include -#include +#include "json_bridge.hpp" #include "behaviortree_cpp/action_node.h" #include "behaviortree_cpp/json_export.h" #include "behaviortree_cpp/tree_node.h" -#include "json_bridge.hpp" +#include +#include +#include namespace nb = nanobind; using namespace nb::literals; -namespace pybt { +namespace pybt +{ // -------------------------------------------------------------------------- // Trampoline shells @@ -55,9 +56,8 @@ struct PySyncActionNode : public BT::SyncActionNode // 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."); + throw BT::LogicError("PySyncActionNode::tick() invoked directly — should go through " + "PythonSyncActionAdapter. This is a binding bug."); } }; @@ -119,8 +119,8 @@ class PythonSyncActionAdapter : public BT::SyncActionNode class PythonStatefulActionAdapter : public BT::StatefulActionNode { public: - PythonStatefulActionAdapter(const std::string& name, - const BT::NodeConfig& config, nb::object py_inst) + PythonStatefulActionAdapter(const std::string& name, const BT::NodeConfig& config, + nb::object py_inst) : BT::StatefulActionNode(name, config), py_inst_(std::move(py_inst)) {} @@ -238,8 +238,7 @@ static void set_output_impl(BT::TreeNode& self, const std::string& name, nb::obj if(!entry) { throw BT::RuntimeError("set_output('", name, - "'): cannot convert value to a BT type: ", - entry.error()); + "'): cannot convert value to a BT type: ", entry.error()); } any = entry->first; break; @@ -271,10 +270,8 @@ void register_tree_node(nb::module_& m) "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("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, @@ -289,42 +286,64 @@ void register_tree_node(nb::module_& m) "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, "StatefulActionNode", - "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."); + 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) +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)); + 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) +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)); + 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 index c8f0631e8..25d9375d1 100644 --- a/python/src/_pybt/exceptions.cpp +++ b/python/src/_pybt/exceptions.cpp @@ -11,13 +11,14 @@ // (IndexError, ValueError, RuntimeError) by nanobind's defaults — we do // not register translators for those here. -#include - #include "behaviortree_cpp/exceptions.h" +#include + namespace nb = nanobind; -namespace pybt { +namespace pybt +{ void register_exceptions(nb::module_& m) { diff --git a/python/src/_pybt/json_bridge.hpp b/python/src/_pybt/json_bridge.hpp index c48ca0433..1c61eed6a 100644 --- a/python/src/_pybt/json_bridge.hpp +++ b/python/src/_pybt/json_bridge.hpp @@ -9,15 +9,16 @@ #pragma once -#include -#include +#include "behaviortree_cpp/contrib/json.hpp" #include #include -#include "behaviortree_cpp/contrib/json.hpp" +#include +#include -namespace pybt { +namespace pybt +{ namespace nb = nanobind; using nlohmann::json; diff --git a/python/src/_pybt/module.cpp b/python/src/_pybt/module.cpp index 01f462a37..e149dbac4 100644 --- a/python/src/_pybt/module.cpp +++ b/python/src/_pybt/module.cpp @@ -6,7 +6,8 @@ namespace nb = nanobind; -namespace pybt { +namespace pybt +{ void register_exceptions(nb::module_& m); void register_basic_types(nb::module_& m); void register_ports(nb::module_& m); diff --git a/python/tests/test_lifecycle.py b/python/tests/test_lifecycle.py index 467fecabb..2ba8f2a1c 100644 --- a/python/tests/test_lifecycle.py +++ b/python/tests/test_lifecycle.py @@ -13,7 +13,9 @@ pytestmark = [pytest.mark.smoke, pytest.mark.slow] -def _run_in_subprocess(script: str, timeout: float = 10.0) -> subprocess.CompletedProcess: +def _run_in_subprocess( + script: str, timeout: float = 10.0 +) -> subprocess.CompletedProcess: return subprocess.run( [sys.executable, "-c", textwrap.dedent(script)], capture_output=True, @@ -23,9 +25,8 @@ def _run_in_subprocess(script: str, timeout: float = 10.0) -> subprocess.Complet def test_clean_thread_shutdown_with_stop_flag(): - """A tree ticking in a background thread exits cleanly via a node-level stop flag. - """ - + """A tree ticking in a background thread exits cleanly via a node-level stop flag.""" + script = """ import threading import time @@ -69,9 +70,9 @@ def go(): 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}" - ) + assert ( + "MAIN_DONE" in result.stdout + ), f"main thread didn't reach the end: stdout={result.stdout}" def test_clean_module_load_and_shutdown(): @@ -82,9 +83,7 @@ def test_clean_module_load_and_shutdown(): assert pybt.NodeStatus.SUCCESS """ ) - assert result.returncode == 0, ( - f"clean import + exit failed: stderr={result.stderr}" - ) + assert result.returncode == 0, f"clean import + exit failed: stderr={result.stderr}" def test_no_nanobind_leaks(): @@ -116,6 +115,6 @@ def test_no_nanobind_leaks(): 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) + 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 index 9195da66a..c4bd26c9b 100644 --- a/python/tests/test_smoke.py +++ b/python/tests/test_smoke.py @@ -126,6 +126,7 @@ def on_start(self): def on_running(self): return pybt.NodeStatus.SUCCESS + # Intentionally no on_halted. f = pybt.BehaviorTreeFactory() @@ -236,10 +237,10 @@ def tick(self): 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 @@ -290,9 +291,7 @@ def tick(self): t.tick_while_running() except Exception: return - raise AssertionError( - "registering or ticking a non-action class should have raised" - ) + raise AssertionError("registering or ticking a non-action class should have raised") def test_register_duplicate_id_raises(): @@ -370,6 +369,7 @@ def test_fork_safety_raises_btruntimeerror(): # SIGINT / Ctrl-C # --------------------------------------------------------------------------- + def test_sigint_interrupts_tick(): """SIGINT during tick_while_running raises KeyboardInterrupt within ~50ms.""" import signal @@ -410,6 +410,7 @@ def on_running(self): # 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 @@ -426,9 +427,7 @@ def on_start(self): def on_running(self): self.iters += 1 return ( - pybt.NodeStatus.SUCCESS - if self.iters >= 8 - else pybt.NodeStatus.RUNNING + pybt.NodeStatus.SUCCESS if self.iters >= 8 else pybt.NodeStatus.RUNNING ) def run_tree(): @@ -466,6 +465,7 @@ def run_tree(): # Module reload # --------------------------------------------------------------------------- + def test_module_reload_does_not_crash(): """importlib.reload(pybt) either succeeds or raises cleanly — must not segfault.""" import importlib @@ -484,6 +484,7 @@ def test_module_reload_does_not_crash(): # Standalone runner # --------------------------------------------------------------------------- + def _collect_tests(): g = globals() return [(n, g[n]) for n in sorted(g) if n.startswith("test_") and callable(g[n])] @@ -503,7 +504,9 @@ def _run_all(): passed += 1 print(f"PASS {name}") print() - print(f"{passed}/{len(tests)} passed", "" if not failed else f"({len(failed)} failed)") + print( + f"{passed}/{len(tests)} passed", "" if not failed else f"({len(failed)} failed)" + ) return 0 if not failed else 1 From 36977762a6a8a6643d810e5d25f9a741f9929e2b Mon Sep 17 00:00:00 2001 From: Anmol Kathail Date: Mon, 18 May 2026 20:33:40 -0500 Subject: [PATCH 8/8] add examples --- python/CONTRIBUTING.md | 16 ++++- python/examples/t01_build_your_first_tree.py | 56 ++++++++++++++++++ python/examples/t02_basic_ports.py | 57 ++++++++++++++++++ python/examples/t03_passing_data.py | 62 ++++++++++++++++++++ python/tests/test_examples.py | 33 +++++++++++ 5 files changed, 221 insertions(+), 3 deletions(-) create mode 100644 python/examples/t01_build_your_first_tree.py create mode 100644 python/examples/t02_basic_ports.py create mode 100644 python/examples/t03_passing_data.py create mode 100644 python/tests/test_examples.py diff --git a/python/CONTRIBUTING.md b/python/CONTRIBUTING.md index bf60c7c3e..111688de7 100644 --- a/python/CONTRIBUTING.md +++ b/python/CONTRIBUTING.md @@ -30,6 +30,16 @@ 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: @@ -51,9 +61,10 @@ This runs `ruff`, `mypy`, the no-C++-refs check, and the project's standard hook |---|---| | `src/pybt/` | Python package (the user-facing surface) | | `src/_pybt/` | C++ binding code (nanobind) | -| `tests/` | pytest suite — smoke + lifecycle | +| `tests/` | pytest suite — smoke + lifecycle + example runner | +| `examples/` | Runnable tutorial scripts (t01..t03 so far) | | `benchmarks/` | pytest-benchmark microbenchmarks | -| `docs/` | Sphinx site (stub in Phase 1) | +| `docs/` | Sphinx site (stub for now) | | `pyproject.toml` | Build config (scikit-build-core, nanobind, pytest) | | `CMakeLists.txt` | nanobind extension + CTest regression guards | @@ -61,4 +72,3 @@ This runs `ruff`, `mypy`, the no-C++-refs check, and the project's standard hook - Python: `ruff` for lint and format, `mypy` for types. - C++: project root `.clang-format` (Google C++ with 2-space indent, 90-char line limit). -- Docs: per the Documentation Standards in the project plan — standalone, brief, no C++ references outside this file and `README.md`. 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/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}" + )