From 3ccc3fd81fd3f364b007c1b01882bbd87cae7080 Mon Sep 17 00:00:00 2001 From: shaur-k Date: Thu, 19 Jun 2025 14:59:19 -0600 Subject: [PATCH 1/2] Squashed commit of the following: commit 47f067c86d0cade6c8c69e7e285fae0f18c0fea0 Author: shaur-k Date: Thu Jun 19 14:53:45 2025 -0600 feat: clean up old PR for new version of BTCPP commit 8c6cface3d3df11735e08f897af877f361c33e92 Author: shaur-k Date: Thu Jun 19 13:16:13 2025 -0600 temp commit 6a6f86e9abfa99d8df29352826da8dccb263841a Merge: 890396c 1b9e0e2 Author: shaur-k Date: Thu Jun 19 12:07:36 2025 -0600 Merge branch 'master' into python-bindings commit 890396c7d962d65f554e7c0ac356aea66484d3e1 Author: Kyle Cesare Date: Tue Sep 5 23:45:06 2023 -0600 Fix setup.py package attributes. commit fb33788047a62f790e587e87df64369d560e63ef Author: Kyle Cesare Date: Tue Sep 5 21:57:19 2023 -0600 Add useful note for ex05 on shared lib location. commit 632eb66dcad8312f95f45ba94904cfaddb5a7c3a Author: Kyle Cesare Date: Tue Sep 5 21:55:52 2023 -0600 Clean up dummy node use in ex05. commit 1a7ac0a960aab547d86952e39852ff4c86c9e1c7 Author: Kyle Cesare Date: Tue Sep 5 21:48:23 2023 -0600 Properly specify __all__ for btpy module. commit b425c91e878989712c986a200a7686d1bf6a6ccb Author: Kyle Cesare Date: Mon Sep 4 19:17:31 2023 -0600 Add docs for `JsonExporter::fromJson`. commit 2584aecc0880aadc6c06adf850c0d43ceb1b45b6 Author: Kyle Cesare Date: Mon Sep 4 19:03:16 2023 -0600 Add default impl of AsyncActionNode#on_halted commit 04f435d7c1df1f30d30084ea32319fbadcff5075 Author: Kyle Cesare Date: Mon Sep 4 19:03:07 2023 -0600 Add `halt_tree` binding and use in demo commit 535ea8801cfd8de54d4299ae98c9a5eaa09493e0 Author: Kyle Cesare Date: Mon Sep 4 18:56:14 2023 -0600 Improve python example README commit 93b58c35318a00514f4a0cf4c0d3174684834a24 Author: Kyle Cesare Date: Mon Sep 4 18:56:08 2023 -0600 Formatting. commit 0ee0a20bc100304325f7a7e837a31fa6759f2600 Author: Kyle Cesare Date: Mon Sep 4 18:55:57 2023 -0600 Fix some string-embedded XML indentation. commit 07864754611f4535e74c7d7be93940d74e10d038 Author: Kyle Cesare Date: Mon Sep 4 18:54:11 2023 -0600 Don't make Py_StatefulActionNode final. commit 4ad738c18c79603e30530a135423578b8f7acf55 Author: Kyle Cesare Date: Sun Sep 3 19:16:29 2023 -0600 Add some docs to Python ex06. commit 84ae12dce804d481d6a00a41c669e4e40f7e7c82 Author: Kyle Cesare Date: Sun Sep 3 19:04:27 2023 -0600 Add some type hints to the Python code commit 46929a8f2b62a66c1dfd72eec9368675c38c3fe3 Author: Kyle Cesare Date: Sun Sep 3 12:31:00 2023 -0600 Move Python-related source files into subdirectory. commit 9d8db3cc5ca56ccbba1e9fe71746f298830c18b5 Author: Kyle Cesare Date: Sun Sep 3 11:57:13 2023 -0600 Clean up Python example XMLs. commit ee7f464357171d7dbb2a57c5c3fa79c5dce15e25 Author: Kyle Cesare Date: Sat Sep 2 11:08:42 2023 -0600 Modify py::type argument to support older pybind commit fdc2232fe1b2fe7faa245bc3de2301ad2153e283 Author: Kyle Cesare Date: Fri Sep 1 22:18:04 2023 -0600 Add pyproject.toml/setup.py for building wheels. commit 2c1b18a33bf096c1e6aa4a3d32739c34341459dd Author: Kyle Cesare Date: Fri Sep 1 21:40:14 2023 -0600 Use docstring as tree node description. commit 1a69d3ae1922be76829de417ef8ee7dc7aca5b80 Author: Kyle Cesare Date: Sat Aug 26 22:47:56 2023 -0600 Clean up Python ex06. commit 0e35ac06a5a84e61ee46690f263653ab0fbdf51b Author: Kyle Cesare Date: Fri Sep 1 19:35:57 2023 -0600 Move some dummy_nodes definitions to cpp file to fix linker error commit 83caef78141e625947e253ecc3ccbdf9a8959bbf Author: Kyle Cesare Date: Fri Sep 1 19:35:46 2023 -0600 Add missing pybind11 dependency to package.xml commit c703efd9d570caad46eac5ed2fc06bc63c39f6d5 Author: Kyle Cesare Date: Sat Aug 19 19:49:01 2023 -0600 Implement coroutine-based Python nodes. commit 44483766436f16effbb91cb6b7559be387b35193 Author: Kyle Cesare Date: Sat Aug 19 19:07:54 2023 -0600 Add pybind11 conan dependency. commit 21d450e880e772c4e3e8a11d865a19d07ce5270d Author: Kyle Cesare Date: Sat Aug 19 18:52:52 2023 -0600 Add `BehaviorTreeFactory.register_from_plugin` binding. commit d1fe0e36027691feaf90c41d533ef86d51115f36 Author: Kyle Cesare Date: Sat Aug 19 18:52:39 2023 -0600 Implement C++ <-> Python type interop via JSON. commit cfa553a32af0f9aa8f9465590efb9f049cfd6826 Author: Kyle Cesare Date: Sat Aug 19 12:52:01 2023 -0600 Disable zero variadic arg warning. commit 7927e67e0dd3c2c07a29d997bea98436277c5cbc Author: Kyle Cesare Date: Sat Aug 19 12:51:44 2023 -0600 Fix onHalted override copy-paste error. commit 712370bac554b665330791720d01869a88f8acd0 Author: Kyle Cesare Date: Sun Aug 13 12:30:01 2023 -0600 Add useful command for ex04. commit dc9e9535a4442bb3c7fadd01924c55cd8f14e07e Author: Kyle Cesare Date: Sun Aug 13 12:28:16 2023 -0600 Fix typo in ex04. commit ed78e8450978abbe5d76044b08b8d46b40285520 Author: Kyle Cesare Date: Sun Aug 13 12:27:34 2023 -0600 Fix NodeStatus enum value ordering. commit 2a22fa8490f3a3c5783efacd6508d2e6c0f01ec4 Author: Kyle Cesare Date: Sun Aug 13 12:05:05 2023 -0600 Add note about Py_getInput return value. commit 37ae114cc6b3aff99ab6f28c4d0dfd6b1c2d1dde Author: Kyle Cesare Date: Sun Aug 13 12:01:52 2023 -0600 Add simple README. commit adf5cef73f2f75cede5a7ca0090297b1b8140ddc Author: Kyle Cesare Date: Sun Aug 13 11:56:52 2023 -0600 Add ROS2 interop example. commit 404a195b52bc0f004c10e3039171c8651a313c1b Author: Kyle Cesare Date: Sun Aug 13 11:56:38 2023 -0600 Add builder args to be passed to node ctors. commit 9493f103a3b844abab2b38ed145bb1106ed1bdc4 Author: Kyle Cesare Date: Sun Aug 13 11:56:23 2023 -0600 Return None if blackboard value doesn't exist. commit 6188c215c2601233316ad0288f13a7e9ac1bd4d6 Author: Kyle Cesare Date: Sun Aug 13 11:10:10 2023 -0600 Ignore pycache. commit b22772ed77e374fafae5123dc0e79fbcfa63cb1d Author: Kyle Cesare Date: Sun Aug 13 10:55:49 2023 -0600 Put generic methods on abstract base class. commit 17da541bfafc82ccc9703fd9d4fedcf6087691cb Author: Kyle Cesare Date: Sun Aug 13 10:54:59 2023 -0600 Clean up port handling. commit 4ff5673a5b7bcf485c572fc4864decbdd6c0425b Author: Kyle Cesare Date: Sun Aug 13 10:54:37 2023 -0600 Export minimal set of identifiers to Python lib. commit 351c33a934df3b60a47ac972b941772891acc410 Author: Kyle Cesare Date: Sun Aug 13 10:54:25 2023 -0600 Use proper PYBIND11_OVERRIDE macros. commit 1f9db33038c0c6f1ed75ef8a09f0d1ffa6c76a2a Author: Kyle Cesare Date: Sun Aug 13 10:30:18 2023 -0600 Eliminate some code duplication. commit f560500011c1c478c665643907324123fffcf82f Author: Kyle Cesare Date: Sun Aug 13 00:22:11 2023 -0600 Add stateful action bindings. commit 9fdcda194adff47eba19dd267f084712d45a0e7c Author: Kyle Cesare Date: Sat Aug 12 23:09:20 2023 -0600 Implement minimal Python bindings to SyncActionNode --- .gitignore | 1 + CMakeLists.txt | 17 ++ btpy/__init__.py | 89 +++++++ conanfile.txt | 1 + .../contrib/pybind11_json.hpp | 226 ++++++++++++++++++ include/behaviortree_cpp/json_export.h | 8 +- include/behaviortree_cpp/python/types.h | 40 ++++ include/behaviortree_cpp/tree_node.h | 25 ++ package.xml | 1 + pyproject.toml | 8 + python_examples/README.md | 3 + python_examples/ex01_sample.py | 46 ++++ python_examples/ex02_generic_data.py | 75 ++++++ python_examples/ex03_stateful_nodes.py | 68 ++++++ python_examples/ex04_ros_interop.py | 87 +++++++ python_examples/ex05_type_interop.py | 68 ++++++ python_examples/ex06_async_nodes.py | 75 ++++++ sample_nodes/dummy_nodes.cpp | 12 + sample_nodes/dummy_nodes.h | 66 +++++ setup.py | 141 +++++++++++ src/python/bindings.cpp | 208 ++++++++++++++++ src/python/types.cpp | 25 ++ 22 files changed, 1288 insertions(+), 2 deletions(-) create mode 100644 btpy/__init__.py create mode 100644 include/behaviortree_cpp/contrib/pybind11_json.hpp create mode 100644 include/behaviortree_cpp/python/types.h create mode 100644 pyproject.toml create mode 100644 python_examples/README.md create mode 100644 python_examples/ex01_sample.py create mode 100644 python_examples/ex02_generic_data.py create mode 100644 python_examples/ex03_stateful_nodes.py create mode 100644 python_examples/ex04_ros_interop.py create mode 100644 python_examples/ex05_type_interop.py create mode 100644 python_examples/ex06_async_nodes.py create mode 100644 setup.py create mode 100644 src/python/bindings.cpp create mode 100644 src/python/types.cpp diff --git a/.gitignore b/.gitignore index cba22ead7..76553216c 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ build* site/* /.vscode/ .vs/ +__pycache__ # clangd cache /.cache/* diff --git a/CMakeLists.txt b/CMakeLists.txt index 54451c6a9..cdd3fe182 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -33,6 +33,7 @@ option(BTCPP_EXAMPLES "Build tutorials and examples" ON) option(BTCPP_UNIT_TESTS "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 "Add Python bindings" ON) option(USE_V3_COMPATIBLE_NAMES "Use some alias to compile more easily old 3.x code" OFF) @@ -142,6 +143,10 @@ if(BTCPP_SQLITE_LOGGING) list(APPEND BT_SOURCE src/loggers/bt_sqlite_logger.cpp ) endif() +if(BTCPP_PYTHON) + list(APPEND BT_SOURCE src/python/types.cpp) +endif() + ###################################################### if (UNIX) @@ -173,6 +178,18 @@ target_link_libraries(${BTCPP_LIBRARY} ${BTCPP_EXTRA_LIBRARIES} ) +if(BTCPP_PYTHON) + find_package(Python COMPONENTS Interpreter Development) + find_package(pybind11 CONFIG) + + pybind11_add_module(btpy_cpp src/python/bindings.cpp) + target_compile_options(btpy_cpp PRIVATE -Wno-gnu-zero-variadic-macro-arguments) + target_link_libraries(btpy_cpp PRIVATE ${BTCPP_LIBRARY}) + + target_link_libraries(${BTCPP_LIBRARY} PUBLIC Python::Python pybind11::pybind11) + target_compile_definitions(${BTCPP_LIBRARY} PUBLIC BTCPP_PYTHON) +endif() + target_include_directories(${BTCPP_LIBRARY} PUBLIC $ diff --git a/btpy/__init__.py b/btpy/__init__.py new file mode 100644 index 000000000..e664741f1 --- /dev/null +++ b/btpy/__init__.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 + +""" +Top-level module of the BehaviorTree.CPP Python bindings. +""" + +# re-export +from btpy_cpp import ( + BehaviorTreeFactory, + NodeStatus, + StatefulActionNode, + SyncActionNode, + Tree, +) + + +def ports(inputs: list[str] = [], outputs: list[str] = []): + """Decorator to specify input and outputs ports for an action node.""" + + def specify_ports(cls): + cls.input_ports = list(inputs) + cls.output_ports = list(outputs) + return cls + + return specify_ports + + +class AsyncActionNode(StatefulActionNode): + """An abstract action node implemented via cooperative multitasking. + + Subclasses must implement the `run()` method as a generator. Optionally, + this method can return a final `NodeStatus` value to indicate its exit + condition. + + Optionally, subclasses can override the `on_halted()` method which is called + when the tree halts. The default implementation does nothing. The `run()` + method will never be called again after a halt. + + Note: + It is the responsibility of the action author to not block the main + behavior tree loop with long-running tasks. `yield` calls should be + placed whenever a pause is appropriate. + """ + + def __init__(self, name, config): + super().__init__(name, config) + + def on_start(self) -> NodeStatus: + self.coroutine = self.run() + return NodeStatus.RUNNING + + def on_running(self) -> NodeStatus: + # The library logic should never allow this to happen, but users can + # still manually call `on_running` without an associated `on_start` + # call. Make sure to print a useful error when this happens. + if self.coroutine is None: + raise "AsyncActionNode run without starting" + + # Resume the coroutine (generator). As long as the generator is not + # exhausted, keep this action in the RUNNING state. + try: + next(self.coroutine) + return NodeStatus.RUNNING + except StopIteration as e: + # If the action returns a status then propagate it upwards. + if e.value is not None: + return e.value + # Otherwise, just assume the action finished successfully. + else: + return NodeStatus.SUCCESS + + def on_halted(self): + # Default action: do nothing + pass + + +# Specify the symbols to be imported with `from btpy import *`, as described in +# [1]. +# +# [1]: https://docs.python.org/3/tutorial/modules.html#importing-from-a-package +__all__ = [ + "ports", + "AsyncActionNode", + "BehaviorTreeFactory", + "NodeStatus", + "StatefulActionNode", + "SyncActionNode", + "Tree", +] diff --git a/conanfile.txt b/conanfile.txt index 7b81d1d6d..bf225e730 100644 --- a/conanfile.txt +++ b/conanfile.txt @@ -2,6 +2,7 @@ gtest/1.14.0 zeromq/4.3.4 sqlite3/3.40.1 +pybind11/2.10.4 [generators] CMakeDeps diff --git a/include/behaviortree_cpp/contrib/pybind11_json.hpp b/include/behaviortree_cpp/contrib/pybind11_json.hpp new file mode 100644 index 000000000..fe76b57e7 --- /dev/null +++ b/include/behaviortree_cpp/contrib/pybind11_json.hpp @@ -0,0 +1,226 @@ +/*************************************************************************** +* Copyright (c) 2019, Martin Renou * +* * +* Distributed under the terms of the BSD 3-Clause License. * +* * +* The full license is in the file LICENSE, distributed with this software. * +****************************************************************************/ + +#ifndef PYBIND11_JSON_HPP +#define PYBIND11_JSON_HPP + +#include +#include + +#include "behaviortree_cpp/contrib/json.hpp" + +#include "pybind11/pybind11.h" + +namespace pyjson +{ + namespace py = pybind11; + namespace nl = nlohmann; + + inline py::object from_json(const nl::json& j) + { + if (j.is_null()) + { + return py::none(); + } + else if (j.is_boolean()) + { + return py::bool_(j.get()); + } + else if (j.is_number_unsigned()) + { + return py::int_(j.get()); + } + else if (j.is_number_integer()) + { + return py::int_(j.get()); + } + else if (j.is_number_float()) + { + return py::float_(j.get()); + } + else if (j.is_string()) + { + return py::str(j.get()); + } + else if (j.is_array()) + { + py::list obj(j.size()); + for (std::size_t i = 0; i < j.size(); i++) + { + obj[i] = from_json(j[i]); + } + return obj; + } + else // Object + { + py::dict obj; + for (nl::json::const_iterator it = j.cbegin(); it != j.cend(); ++it) + { + obj[py::str(it.key())] = from_json(it.value()); + } + return obj; + } + } + + inline nl::json to_json(const py::handle& obj) + { + if (obj.ptr() == nullptr || obj.is_none()) + { + return nullptr; + } + if (py::isinstance(obj)) + { + return obj.cast(); + } + if (py::isinstance(obj)) + { + try + { + nl::json::number_integer_t s = obj.cast(); + if (py::int_(s).equal(obj)) + { + return s; + } + } + catch (...) + { + } + try + { + nl::json::number_unsigned_t u = obj.cast(); + if (py::int_(u).equal(obj)) + { + return u; + } + } + catch (...) + { + } + throw std::runtime_error("to_json received an integer out of range for both nl::json::number_integer_t and nl::json::number_unsigned_t type: " + py::repr(obj).cast()); + } + if (py::isinstance(obj)) + { + return obj.cast(); + } + if (py::isinstance(obj)) + { + py::module base64 = py::module::import("base64"); + return base64.attr("b64encode")(obj).attr("decode")("utf-8").cast(); + } + if (py::isinstance(obj)) + { + return obj.cast(); + } + if (py::isinstance(obj) || py::isinstance(obj)) + { + auto out = nl::json::array(); + for (const py::handle value : obj) + { + out.push_back(to_json(value)); + } + return out; + } + if (py::isinstance(obj)) + { + auto out = nl::json::object(); + for (const py::handle key : obj) + { + out[py::str(key).cast()] = to_json(obj[key]); + } + return out; + } + throw std::runtime_error("to_json not implemented for this type of object: " + py::repr(obj).cast()); + } +} + +// nlohmann_json serializers +namespace nlohmann +{ + namespace py = pybind11; + + #define MAKE_NLJSON_SERIALIZER_DESERIALIZER(T) \ + template <> \ + struct adl_serializer \ + { \ + inline static void to_json(json& j, const T& obj) \ + { \ + j = pyjson::to_json(obj); \ + } \ + \ + inline static T from_json(const json& j) \ + { \ + return pyjson::from_json(j); \ + } \ + } + + #define MAKE_NLJSON_SERIALIZER_ONLY(T) \ + template <> \ + struct adl_serializer \ + { \ + inline static void to_json(json& j, const T& obj) \ + { \ + j = pyjson::to_json(obj); \ + } \ + } + + MAKE_NLJSON_SERIALIZER_DESERIALIZER(py::object); + + MAKE_NLJSON_SERIALIZER_DESERIALIZER(py::bool_); + MAKE_NLJSON_SERIALIZER_DESERIALIZER(py::int_); + MAKE_NLJSON_SERIALIZER_DESERIALIZER(py::float_); + MAKE_NLJSON_SERIALIZER_DESERIALIZER(py::str); + + MAKE_NLJSON_SERIALIZER_DESERIALIZER(py::list); + MAKE_NLJSON_SERIALIZER_DESERIALIZER(py::tuple); + MAKE_NLJSON_SERIALIZER_DESERIALIZER(py::dict); + + MAKE_NLJSON_SERIALIZER_ONLY(py::handle); + MAKE_NLJSON_SERIALIZER_ONLY(py::detail::item_accessor); + MAKE_NLJSON_SERIALIZER_ONLY(py::detail::list_accessor); + MAKE_NLJSON_SERIALIZER_ONLY(py::detail::tuple_accessor); + MAKE_NLJSON_SERIALIZER_ONLY(py::detail::sequence_accessor); + MAKE_NLJSON_SERIALIZER_ONLY(py::detail::str_attr_accessor); + MAKE_NLJSON_SERIALIZER_ONLY(py::detail::obj_attr_accessor); + + #undef MAKE_NLJSON_SERIALIZER + #undef MAKE_NLJSON_SERIALIZER_ONLY +} + +// pybind11 caster +namespace pybind11 +{ + namespace detail + { + template <> struct type_caster + { + public: + PYBIND11_TYPE_CASTER(nlohmann::json, _("json")); + + bool load(handle src, bool) + { + try + { + value = pyjson::to_json(src); + return true; + } + catch (...) + { + return false; + } + } + + static handle cast(nlohmann::json src, return_value_policy /* policy */, handle /* parent */) + { + object obj = pyjson::from_json(src); + return obj.release(); + } + }; + } +} + +#endif diff --git a/include/behaviortree_cpp/json_export.h b/include/behaviortree_cpp/json_export.h index 1d47b0877..87eac031c 100644 --- a/include/behaviortree_cpp/json_export.h +++ b/include/behaviortree_cpp/json_export.h @@ -1,6 +1,5 @@ #pragma once -#include "behaviortree_cpp/basic_types.h" #include "behaviortree_cpp/utils/safe_any.hpp" #include "behaviortree_cpp/basic_types.h" @@ -45,7 +44,6 @@ namespace BT /** * Use RegisterJsonDefinition(); */ - class JsonExporter { public: @@ -80,6 +78,12 @@ class JsonExporter template Expected fromJson(const nlohmann::json& source) const; + template + void fromJsonHelper(const nlohmann::json& src, T& dst) const + { + dst = *fromJson(src); + } + /// Register new JSON converters with addConverter(). /// You should have used first the macro BT_JSON_CONVERTER template diff --git a/include/behaviortree_cpp/python/types.h b/include/behaviortree_cpp/python/types.h new file mode 100644 index 000000000..80ca14332 --- /dev/null +++ b/include/behaviortree_cpp/python/types.h @@ -0,0 +1,40 @@ +#pragma once + +#include + +#include "behaviortree_cpp/basic_types.h" +#include "behaviortree_cpp/json_export.h" +#include "behaviortree_cpp/contrib/json.hpp" +#include "behaviortree_cpp/contrib/pybind11_json.hpp" +#include "behaviortree_cpp/utils/safe_any.hpp" + +namespace BT +{ + +/** + * @brief Generic method to convert Python objects to type T via JSON. + * + * For this function to succeed, the type T must be convertible from JSON via + * the JsonExporter interface. + */ +template +bool fromPythonObject(const pybind11::object& obj, T& dest) +{ + if constexpr(nlohmann::detail::is_getable::value) + { + JsonExporter::get().fromJsonHelper(obj, dest); + return true; + } + + return false; +} + +/** + * @brief Convert a BT::Any to a Python object via JSON. + * + * For this function to succeed, the type stored inside the Any must be + * convertible to JSON via the JsonExporter interface. + */ +bool toPythonObject(const BT::Any& val, pybind11::object& dest); + +} // namespace BT diff --git a/include/behaviortree_cpp/tree_node.h b/include/behaviortree_cpp/tree_node.h index bd77c4c72..de8ec1006 100644 --- a/include/behaviortree_cpp/tree_node.h +++ b/include/behaviortree_cpp/tree_node.h @@ -24,6 +24,13 @@ #include "behaviortree_cpp/utils/wakeup_signal.hpp" #include "behaviortree_cpp/scripting/script_parser.hpp" +#ifdef BTCPP_PYTHON +#include +#include + +#include "behaviortree_cpp/python/types.h" +#endif + #ifdef _MSC_VER #pragma warning(disable : 4127) #endif @@ -538,6 +545,24 @@ inline Expected TreeNode::getInputStamped(const std::string& key, { destination = parseString(any_value.cast()); } +#ifdef BTCPP_PYTHON + // py::object -> C++ + else if(any_value.type() == typeid(pybind11::object)) + { + if(!fromPythonObject(any_value.cast(), destination)) + { + return nonstd::make_unexpected("Cannot convert from Python object"); + } + } + // C++ -> py::object + else if constexpr(std::is_same_v) + { + if(!toPythonObject(any_value, destination)) + { + return nonstd::make_unexpected("Cannot convert to Python object"); + } + } +#endif else { destination = any_value.cast(); diff --git a/package.xml b/package.xml index c4cb75729..3fc580096 100644 --- a/package.xml +++ b/package.xml @@ -23,6 +23,7 @@ libsqlite3-dev libzmq3-dev + pybind11-dev ament_cmake_gtest diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 000000000..b07da1aa8 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,8 @@ +[build-system] +requires = [ + "setuptools>=42", + "wheel", + "ninja", + "cmake>=3.16", +] +build-backend = "setuptools.build_meta" diff --git a/python_examples/README.md b/python_examples/README.md new file mode 100644 index 000000000..0c82171eb --- /dev/null +++ b/python_examples/README.md @@ -0,0 +1,3 @@ +1. Create a Python virtualenv in the root directory: `python3 -m venv venv && source venv/bin/activate` +2. Build and install the BehaviorTree Python package: `pip install -v .` +3. Run an example, e.g. `python3 python_examples/ex01_sample.py` diff --git a/python_examples/ex01_sample.py b/python_examples/ex01_sample.py new file mode 100644 index 000000000..e1967bc4f --- /dev/null +++ b/python_examples/ex01_sample.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 + +""" +Demo adapted from [btcpp_sample](https://github.com/BehaviorTree/btcpp_sample). +""" + +from btpy import BehaviorTreeFactory, SyncActionNode, NodeStatus, ports + + +xml_text = """ + + + + + + + + + + + + +""" + + +@ports(inputs=["message"]) +class SaySomething(SyncActionNode): + def tick(self): + msg = self.get_input("message") + print(msg) + return NodeStatus.SUCCESS + + +@ports(outputs=["text"]) +class ThinkWhatToSay(SyncActionNode): + def tick(self): + self.set_output("text", "The answer is 42") + return NodeStatus.SUCCESS + + +factory = BehaviorTreeFactory() +factory.register(SaySomething) +factory.register(ThinkWhatToSay) + +tree = factory.create_tree_from_text(xml_text) +tree.tick_while_running() diff --git a/python_examples/ex02_generic_data.py b/python_examples/ex02_generic_data.py new file mode 100644 index 000000000..8e6e3f436 --- /dev/null +++ b/python_examples/ex02_generic_data.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 + +""" +Demonstration of passing generic data between nodes. +""" + +import numpy as np +from btpy import BehaviorTreeFactory, SyncActionNode, NodeStatus, ports + + +xml_text = """ + + + + + + + + + + + + +""" + + +@ports(inputs=["position", "theta"], outputs=["out"]) +class Rotate(SyncActionNode): + def tick(self): + # Build a rotation matrix which rotates points by `theta` degrees. + theta = np.deg2rad(self.get_input("theta")) + c, s = np.cos(theta), np.sin(theta) + M = np.array([[c, -s], [s, c]]) + + # Apply the rotation to the input position. + position = self.get_input("position") + rotated = M @ position + + # Set the output. + self.set_output("out", rotated) + + return NodeStatus.SUCCESS + + +@ports(inputs=["position", "offset"], outputs=["out"]) +class Translate(SyncActionNode): + def tick(self): + offset = np.asarray(self.get_input("offset")) + + # Apply the translation to the input position. + position = np.asarray(self.get_input("position")) + translated = position + offset + + # Set the output. + self.set_output("out", translated) + + return NodeStatus.SUCCESS + + +@ports(inputs=["value"]) +class Print(SyncActionNode): + def tick(self): + value = self.get_input("value") + if value is not None: + print(value) + return NodeStatus.SUCCESS + + +factory = BehaviorTreeFactory() +factory.register(Rotate) +factory.register(Translate) +factory.register(Print) + +tree = factory.create_tree_from_text(xml_text) +tree.tick_while_running() diff --git a/python_examples/ex03_stateful_nodes.py b/python_examples/ex03_stateful_nodes.py new file mode 100644 index 000000000..aadca07f0 --- /dev/null +++ b/python_examples/ex03_stateful_nodes.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 + +""" +Demonstration of stateful action nodes. +""" + +import numpy as np +from btpy import ( + BehaviorTreeFactory, + StatefulActionNode, + SyncActionNode, + NodeStatus, + ports, +) + + +xml_text = """ + + + + + + + + + + + +""" + + +@ports(inputs=["x0", "x1"], outputs=["out"]) +class Interpolate(StatefulActionNode): + def on_start(self): + self.t = 0.0 + self.x0 = np.asarray(self.get_input("x0")) + self.x1 = np.asarray(self.get_input("x1")) + return NodeStatus.RUNNING + + def on_running(self): + if self.t < 1.0: + x = (1.0 - self.t) * self.x0 + self.t * self.x1 + self.set_output("out", x) + self.t += 0.1 + return NodeStatus.RUNNING + else: + return NodeStatus.SUCCESS + + def on_halted(self): + pass + + +@ports(inputs=["value"]) +class Print(SyncActionNode): + def tick(self): + value = self.get_input("value") + if value is not None: + print(value) + return NodeStatus.SUCCESS + + +factory = BehaviorTreeFactory() +factory.register(Interpolate) +factory.register(Print) + +tree = factory.create_tree_from_text(xml_text) +tree.tick_while_running() diff --git a/python_examples/ex04_ros_interop.py b/python_examples/ex04_ros_interop.py new file mode 100644 index 000000000..966f60d3f --- /dev/null +++ b/python_examples/ex04_ros_interop.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 + +""" +Demonstrates interop of BehaviorTree.CPP Python bindings and ROS2 via rclpy. + +You can publish the transform expected in the tree below using this command: + + ros2 run tf2_ros static_transform_publisher \ + --frame-id odom --child-frame-id base_link \ + --x 1.0 --y 2.0 +""" + +import rclpy +from rclpy.node import Node +from tf2_ros.buffer import Buffer +from tf2_ros.transform_listener import TransformListener + +from btpy import ( + BehaviorTreeFactory, + StatefulActionNode, + SyncActionNode, + NodeStatus, + ports, +) + + +xml_text = """ + + + + + + + + + + +""" + + +@ports(inputs=["frame_id", "child_frame_id"], outputs=["tf"]) +class GetRosTransform(StatefulActionNode): + def __init__(self, name, config, node): + super().__init__(name, config) + + self.node = node + self.tf_buffer = Buffer() + self.tf_listener = TransformListener(self.tf_buffer, self.node) + + def on_start(self): + return NodeStatus.RUNNING + + def on_running(self): + frame_id = self.get_input("frame_id") + child_frame_id = self.get_input("child_frame_id") + + time = self.node.get_clock().now() + if self.tf_buffer.can_transform(frame_id, child_frame_id, time): + tf = self.tf_buffer.lookup_transform(frame_id, child_frame_id, time) + self.set_output("tf", tf) + + return NodeStatus.RUNNING + + def on_halted(self): + pass + + +@ports(inputs=["value"]) +class Print(SyncActionNode): + def tick(self): + value = self.get_input("value") + if value is not None: + print(value) + return NodeStatus.SUCCESS + + +rclpy.init() +node = Node("ex04_ros_interop") + +factory = BehaviorTreeFactory() +factory.register(GetRosTransform, node) +factory.register(Print) + +tree = factory.create_tree_from_text(xml_text) + +node.create_timer(0.01, lambda: tree.tick_once()) +rclpy.spin(node) diff --git a/python_examples/ex05_type_interop.py b/python_examples/ex05_type_interop.py new file mode 100644 index 000000000..c10bb8b2c --- /dev/null +++ b/python_examples/ex05_type_interop.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 + +""" +Demo of seamless conversion between C++ and Python types. + +NOTE: To run this example, make sure that the path +`sample_nodes/bin/libdummy_nodes_dyn.so` is accessible from the current working +directory. After building the project, this path will exist in your CMake build +root. +""" + +from btpy import BehaviorTreeFactory, SyncActionNode, NodeStatus, ports + + +xml_text = """ + + + + + + + + + + + + + + + + + + + +""" + + +@ports(outputs=["output"]) +class PutVector(SyncActionNode): + def tick(self): + # Schema matching std::unordered_map + # (defined in dummy_nodes.h, input type of PrintMapOfVectors) + self.set_output( + "output", + { + "a": {"x": 0.0, "y": 42.0, "z": 9.0}, + "b": {"x": 1.0, "y": -2.0, "z": 1.0}, + }, + ) + return NodeStatus.SUCCESS + + +@ports(inputs=["value"]) +class Print(SyncActionNode): + def tick(self): + value = self.get_input("value") + if value is not None: + print("Python:", value) + return NodeStatus.SUCCESS + + +factory = BehaviorTreeFactory() +factory.register_from_plugin("sample_nodes/bin/libdummy_nodes_dyn.so") +factory.register(PutVector) +factory.register(Print) + +tree = factory.create_tree_from_text(xml_text) +tree.tick_while_running() diff --git a/python_examples/ex06_async_nodes.py b/python_examples/ex06_async_nodes.py new file mode 100644 index 000000000..cccebcd97 --- /dev/null +++ b/python_examples/ex06_async_nodes.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 + +""" +Demonstration of an asynchronous action node implemented conveniently as a +Python coroutine. This enables simple synchronous code to be written in place of +complex asynchronous state machines. +""" + +import time +import numpy as np +from btpy import ( + AsyncActionNode, + BehaviorTreeFactory, + SyncActionNode, + NodeStatus, + ports, +) + + +xml_text = """ + + + + + + + + + + +""" + + +@ports(inputs=["start", "goal"], outputs=["command"]) +class MyAsyncNode(AsyncActionNode): + def run(self): + start = np.asarray(self.get_input("start")) + goal = np.asarray(self.get_input("goal")) + + # Here we write an imperative-looking loop, but we place a `yield` call + # at each iteration. This causes the coroutine to yield back to the + # caller until the next iteration of the tree, rather than block the + # main thread. + t0 = time.time() + while (t := time.time() - t0) < 1.0: + command = (1.0 - t) * start + t * goal + self.set_output("command", command) + yield + + print("Trajectory finished!") + return NodeStatus.SUCCESS + + def on_halted(self): + print("Trajectory halted!") + + +@ports(inputs=["value"]) +class Print(SyncActionNode): + def tick(self): + value = self.get_input("value") + if value is not None: + print(value) + return NodeStatus.SUCCESS + + +factory = BehaviorTreeFactory() +factory.register(MyAsyncNode) +factory.register(Print) + +tree = factory.create_tree_from_text(xml_text) + +# Run for a bit, then halt early. +for i in range(0, 10): + tree.tick_once() +tree.halt_tree() diff --git a/sample_nodes/dummy_nodes.cpp b/sample_nodes/dummy_nodes.cpp index 1ce2ade8d..8406ff3e2 100644 --- a/sample_nodes/dummy_nodes.cpp +++ b/sample_nodes/dummy_nodes.cpp @@ -72,4 +72,16 @@ BT::NodeStatus SaySomethingSimple(BT::TreeNode& self) return BT::NodeStatus::SUCCESS; } +void to_json(nlohmann::json& j, const Vector3& p) +{ + j = nlohmann::json{ { "x", p.x }, { "y", p.y }, { "z", p.z } }; +} + +void from_json(const nlohmann::json& j, Vector3& p) +{ + j.at("x").get_to(p.x); + j.at("y").get_to(p.y); + j.at("z").get_to(p.z); +} + } // namespace DummyNodes diff --git a/sample_nodes/dummy_nodes.h b/sample_nodes/dummy_nodes.h index 6781eaba8..88049b9cf 100644 --- a/sample_nodes/dummy_nodes.h +++ b/sample_nodes/dummy_nodes.h @@ -3,6 +3,7 @@ #include "behaviortree_cpp/behavior_tree.h" #include "behaviortree_cpp/bt_factory.h" +#include "behaviortree_cpp/json_export.h" namespace DummyNodes { @@ -119,6 +120,71 @@ class SleepNode : public BT::StatefulActionNode std::chrono::system_clock::time_point deadline_; }; +struct Vector3 +{ + float x; + float y; + float z; +}; + +void to_json(nlohmann::json& j, const Vector3& p); + +void from_json(const nlohmann::json& j, Vector3& p); + +class RandomVector : public BT::SyncActionNode +{ +public: + RandomVector(const std::string& name, const BT::NodeConfig& config) + : BT::SyncActionNode(name, config) + {} + + // You must override the virtual function tick() + NodeStatus tick() override + { + setOutput("vector", Vector3{ 1.0, 2.0, 3.0 }); + return BT::NodeStatus::SUCCESS; + } + + // It is mandatory to define this static method. + static BT::PortsList providedPorts() + { + return { BT::OutputPort("vector") }; + } +}; + +class PrintMapOfVectors : public BT::SyncActionNode +{ +public: + PrintMapOfVectors(const std::string& name, const BT::NodeConfig& config) + : BT::SyncActionNode(name, config) + {} + + // You must override the virtual function tick() + NodeStatus tick() override + { + auto input = getInput>("input"); + if(input.has_value()) + { + std::cerr << "{"; + for(const auto& [key, value] : *input) + { + std::cerr << key << ": (" << value.x << ", " << value.y << ", " << value.z + << "), "; + } + std::cerr << "}" << std::endl; + ; + } + + return BT::NodeStatus::SUCCESS; + } + + // It is mandatory to define this static method. + static BT::PortsList providedPorts() + { + return { BT::InputPort>("input") }; + } +}; + inline void RegisterNodes(BT::BehaviorTreeFactory& factory) { static GripperInterface grip_singleton; diff --git a/setup.py b/setup.py new file mode 100644 index 000000000..9e97b01bd --- /dev/null +++ b/setup.py @@ -0,0 +1,141 @@ +import os +import re +import subprocess +import sys +from pathlib import Path + +from setuptools import Extension, setup +from setuptools.command.build_ext import build_ext + +# Convert distutils Windows platform specifiers to CMake -A arguments +PLAT_TO_CMAKE = { + "win32": "Win32", + "win-amd64": "x64", + "win-arm32": "ARM", + "win-arm64": "ARM64", +} + + +# A CMakeExtension needs a sourcedir instead of a file list. +# The name must be the _single_ output extension from the CMake build. +# If you need multiple extensions, see scikit-build. +class CMakeExtension(Extension): + def __init__(self, name: str, sourcedir: str = "") -> None: + super().__init__(name, sources=[]) + self.sourcedir = os.fspath(Path(sourcedir).resolve()) + + +class CMakeBuild(build_ext): + def build_extension(self, ext: CMakeExtension) -> None: + # Must be in this form due to bug in .resolve() only fixed in Python 3.10+ + ext_fullpath = Path.cwd() / self.get_ext_fullpath(ext.name) + extdir = ext_fullpath.parent.resolve() + + # Using this requires trailing slash for auto-detection & inclusion of + # auxiliary "native" libs + + debug = int(os.environ.get("DEBUG", 0)) if self.debug is None else self.debug + cfg = "Debug" if debug else "Release" + + # CMake lets you override the generator - we need to check this. + # Can be set with Conda-Build, for example. + cmake_generator = os.environ.get("CMAKE_GENERATOR", "") + + # Set Python_EXECUTABLE instead if you use PYBIND11_FINDPYTHON + # EXAMPLE_VERSION_INFO shows you how to pass a value into the C++ code + # from Python. + cmake_args = [ + f"-DCMAKE_LIBRARY_OUTPUT_DIRECTORY={extdir}{os.sep}", + f"-DPYTHON_EXECUTABLE={sys.executable}", + f"-DCMAKE_BUILD_TYPE={cfg}", # not used on MSVC, but no harm + # BehaviorTree.CPP specific CMake options + "-DBTCPP_BUILD_TOOLS=OFF", + "-DBTCPP_EXAMPLES=OFF", + "-DBTCPP_UNIT_TESTS=OFF", + ] + build_args = [] + # Adding CMake arguments set as environment variable + # (needed e.g. to build for ARM OSx on conda-forge) + if "CMAKE_ARGS" in os.environ: + cmake_args += [item for item in os.environ["CMAKE_ARGS"].split(" ") if item] + + if self.compiler.compiler_type != "msvc": + # Using Ninja-build since it a) is available as a wheel and b) + # multithreads automatically. MSVC would require all variables be + # exported for Ninja to pick it up, which is a little tricky to do. + # Users can override the generator with CMAKE_GENERATOR in CMake + # 3.15+. + if not cmake_generator or cmake_generator == "Ninja": + try: + import ninja + + ninja_executable_path = Path(ninja.BIN_DIR) / "ninja" + cmake_args += [ + "-GNinja", + f"-DCMAKE_MAKE_PROGRAM:FILEPATH={ninja_executable_path}", + ] + except ImportError: + pass + + else: + # Single config generators are handled "normally" + single_config = any(x in cmake_generator for x in {"NMake", "Ninja"}) + + # CMake allows an arch-in-generator style for backward compatibility + contains_arch = any(x in cmake_generator for x in {"ARM", "Win64"}) + + # Specify the arch if using MSVC generator, but only if it doesn't + # contain a backward-compatibility arch spec already in the + # generator name. + if not single_config and not contains_arch: + cmake_args += ["-A", PLAT_TO_CMAKE[self.plat_name]] + + # Multi-config generators have a different way to specify configs + if not single_config: + cmake_args += [ + f"-DCMAKE_LIBRARY_OUTPUT_DIRECTORY_{cfg.upper()}={extdir}" + ] + build_args += ["--config", cfg] + + if sys.platform.startswith("darwin"): + # Cross-compile support for macOS - respect ARCHFLAGS if set + archs = re.findall(r"-arch (\S+)", os.environ.get("ARCHFLAGS", "")) + if archs: + cmake_args += ["-DCMAKE_OSX_ARCHITECTURES={}".format(";".join(archs))] + + # Set CMAKE_BUILD_PARALLEL_LEVEL to control the parallel build level + # across all generators. + if "CMAKE_BUILD_PARALLEL_LEVEL" not in os.environ: + # self.parallel is a Python 3 only way to set parallel jobs by hand + # using -j in the build_ext call, not supported by pip or PyPA-build. + if hasattr(self, "parallel") and self.parallel: + # CMake 3.12+ only. + build_args += [f"-j{self.parallel}"] + + build_temp = Path(self.build_temp) / ext.name + if not build_temp.exists(): + build_temp.mkdir(parents=True) + + subprocess.run( + ["cmake", ext.sourcedir, *cmake_args], cwd=build_temp, check=True + ) + subprocess.run( + ["cmake", "--build", ".", *build_args], cwd=build_temp, check=True + ) + + +# The information here can also be placed in setup.cfg - better separation of +# logic and declaration, and simpler if you include description/version in a file. +setup( + name="btpy", + version="0.0.1", + author="Davide Faconti", + author_email="davide.faconti@gmail.com", + description="Python bindings to the BehaviorTree.CPP library", + long_description="", + packages=["btpy"], + ext_modules=[CMakeExtension("btcpp")], + cmdclass={"build_ext": CMakeBuild}, + zip_safe=False, + python_requires=">=3.7", +) diff --git a/src/python/bindings.cpp b/src/python/bindings.cpp new file mode 100644 index 000000000..49067fde8 --- /dev/null +++ b/src/python/bindings.cpp @@ -0,0 +1,208 @@ +#include +#include + +#include +#include +#include +#include +#include +#include + +#include "behaviortree_cpp/basic_types.h" +#include "behaviortree_cpp/bt_factory.h" +#include "behaviortree_cpp/action_node.h" +#include "behaviortree_cpp/tree_node.h" + +namespace BT +{ + +namespace py = pybind11; + +class Py_SyncActionNode : public SyncActionNode +{ +public: + Py_SyncActionNode(const std::string& name, const NodeConfig& config) + : SyncActionNode(name, config) + {} + + NodeStatus tick() override + { + PYBIND11_OVERRIDE_PURE_NAME(NodeStatus, Py_SyncActionNode, "tick", tick); + } +}; + +class Py_StatefulActionNode : public StatefulActionNode +{ +public: + Py_StatefulActionNode(const std::string& name, const NodeConfig& config) + : StatefulActionNode(name, config) + {} + + NodeStatus onStart() override + { + PYBIND11_OVERRIDE_PURE_NAME(NodeStatus, Py_StatefulActionNode, "on_start", onStart); + } + + NodeStatus onRunning() override + { + PYBIND11_OVERRIDE_PURE_NAME(NodeStatus, Py_StatefulActionNode, "on_running", + onRunning); + } + + void onHalted() override + { + PYBIND11_OVERRIDE_PURE_NAME(void, Py_StatefulActionNode, "on_halted", onHalted); + } +}; + +py::object Py_getInput(const TreeNode& node, const std::string& name) +{ + py::object obj; + + // The input could not exist on the blackboard, in which case we return Python + // `None` instead of an invalid object. + if(!node.getInput(name, obj).has_value()) + { + return py::none(); + } + return obj; +} + +void Py_setOutput(TreeNode& node, const std::string& name, const py::object& value) +{ + node.setOutput(name, value); +} + +// Add a conversion specialization from string values into general py::objects +// by evaluating as a Python expression. +template <> +inline py::object convertFromString(StringView str) +{ + try + { + // First, try evaluating the string as-is. Maybe it's a number, a list, a + // dict, an object, etc. + return py::eval(str); + } + catch(py::error_already_set& e) + { + // If that fails, then assume it's a string literal with quotation marks + // omitted. + return py::str(str); + } +} + +PortsList extractPortsList(const py::type& type) +{ + PortsList ports; + + const auto input_ports = type.attr("input_ports").cast(); + for(const auto& name : input_ports) + { + ports.insert(InputPort(name.cast())); + } + + const auto output_ports = type.attr("output_ports").cast(); + for(const auto& name : output_ports) + { + ports.insert(OutputPort(name.cast())); + } + + return ports; +} + +NodeBuilder makeTreeNodeBuilderFn(const py::type& type, const py::args& args, + const py::kwargs& kwargs) +{ + return [=](const auto& name, const auto& config) -> auto { + py::object obj; + obj = type(name, config, *args, **kwargs); + + // TODO: Increment the object's reference count or else it + // will be GC'd at the end of this scope. The downside is + // that, unless we can decrement the ref when the unique_ptr + // is destroyed, then the object will live forever. + obj.inc_ref(); + + if(py::isinstance(obj)) + { + return std::unique_ptr(obj.cast()); + } + else + { + throw std::runtime_error("invalid node type of " + name); + } + }; +} + +PYBIND11_MODULE(btpy_cpp, m) +{ + py::class_(m, "BehaviorTreeFactory") + .def(py::init()) + .def("register", + [](BehaviorTreeFactory& factory, const py::object& type, const py::args& args, + const py::kwargs& kwargs) { + const std::string name = type.attr("__name__").cast(); + + TreeNodeManifest manifest; + manifest.type = NodeType::ACTION; + manifest.registration_ID = name; + manifest.ports = extractPortsList(type); + manifest.metadata = KeyValueVector{ + { "description", "" }, + }; + + // Use the type's docstring as the node description, if it exists. + if(const auto doc = type.attr("__doc__"); !doc.is_none()) + { + manifest.metadata = KeyValueVector{ + { "description", doc.cast() }, + }; + } + + factory.registerBuilder(manifest, makeTreeNodeBuilderFn(type, args, kwargs)); + }) + .def("register_from_plugin", &BehaviorTreeFactory::registerFromPlugin) + .def("create_tree_from_text", + [](BehaviorTreeFactory& factory, const std::string& text) -> Tree { + return factory.createTreeFromText(text); + }); + + py::class_(m, "Tree") + .def("tick_once", &Tree::tickOnce) + .def("tick_exactly_once", &Tree::tickExactlyOnce) + .def("tick_while_running", &Tree::tickWhileRunning, + py::arg("sleep_time") = std::chrono::milliseconds(10)) + .def("halt_tree", &Tree::haltTree); + + py::enum_(m, "NodeStatus") + .value("IDLE", NodeStatus::IDLE) + .value("RUNNING", NodeStatus::RUNNING) + .value("SUCCESS", NodeStatus::SUCCESS) + .value("FAILURE", NodeStatus::FAILURE) + .value("SKIPPED", NodeStatus::SKIPPED) + .export_values(); + + py::class_(m, "NodeConfig"); + + // Register the C++ type hierarchy so that we can refer to Python subclasses + // by their superclass ptr types in generic C++ code. + py::class_(m, "_TreeNode") + .def("get_input", &Py_getInput) + .def("set_output", &Py_setOutput); + py::class_(m, "_ActionNodeBase"); + py::class_(m, "_SyncActionNode"); + py::class_(m, "_StatefulActionNode"); + + py::class_(m, "SyncActionNode") + .def(py::init()) + .def("tick", &Py_SyncActionNode::tick); + + py::class_(m, "StatefulActionNode") + .def(py::init()) + .def("on_start", &Py_StatefulActionNode::onStart) + .def("on_running", &Py_StatefulActionNode::onRunning) + .def("on_halted", &Py_StatefulActionNode::onHalted); +} + +} // namespace BT diff --git a/src/python/types.cpp b/src/python/types.cpp new file mode 100644 index 000000000..ddab4a31f --- /dev/null +++ b/src/python/types.cpp @@ -0,0 +1,25 @@ +#include "behaviortree_cpp/python/types.h" + +#include +#include + +#include "behaviortree_cpp/json_export.h" +#include "behaviortree_cpp/contrib/json.hpp" +#include "behaviortree_cpp/contrib/pybind11_json.hpp" + +namespace BT +{ + +bool toPythonObject(const BT::Any& val, pybind11::object& dest) +{ + nlohmann::json json; + if(JsonExporter::get().toJson(val, json)) + { + dest = json; + return true; + } + + return false; +} + +} // namespace BT From 5f3220e7bce3b9cce7eb51b1cdc203e64c9eac9a Mon Sep 17 00:00:00 2001 From: shaur-k Date: Thu, 19 Jun 2025 15:23:05 -0600 Subject: [PATCH 2/2] fix: guards for template specialization of pyobject fromJson() --- CMakeLists.txt | 1 + .../contrib/pybind11_json.hpp | 6 + include/behaviortree_cpp/json_export.h | 23 +-- include/behaviortree_cpp/python/types.h | 10 +- python_examples/ex02_generic_data.py | 4 +- python_examples/ex05_type_interop.py | 2 +- sample_nodes/dummy_nodes.h | 2 + setup.py | 141 ------------------ src/python/types.cpp | 8 +- 9 files changed, 36 insertions(+), 161 deletions(-) delete mode 100644 setup.py diff --git a/CMakeLists.txt b/CMakeLists.txt index 56778523c..a2112b980 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -192,6 +192,7 @@ target_link_libraries(${BTCPP_LIBRARY} if(BTCPP_PYTHON) find_package(Python COMPONENTS Interpreter Development) find_package(pybind11 CONFIG) + message("PYTHON_EXECUTABLE: ${Python_EXECUTABLE}") pybind11_add_module(btpy_cpp src/python/bindings.cpp) target_compile_options(btpy_cpp PRIVATE -Wno-gnu-zero-variadic-macro-arguments) diff --git a/include/behaviortree_cpp/contrib/pybind11_json.hpp b/include/behaviortree_cpp/contrib/pybind11_json.hpp index fe76b57e7..041b930d2 100644 --- a/include/behaviortree_cpp/contrib/pybind11_json.hpp +++ b/include/behaviortree_cpp/contrib/pybind11_json.hpp @@ -9,6 +9,8 @@ #ifndef PYBIND11_JSON_HPP #define PYBIND11_JSON_HPP +#include +#include #include #include @@ -134,6 +136,10 @@ namespace pyjson } return out; } + if (py::isinstance(obj)) + { + return obj.cast(); + } throw std::runtime_error("to_json not implemented for this type of object: " + py::repr(obj).cast()); } } diff --git a/include/behaviortree_cpp/json_export.h b/include/behaviortree_cpp/json_export.h index 646e68498..5061b17ec 100644 --- a/include/behaviortree_cpp/json_export.h +++ b/include/behaviortree_cpp/json_export.h @@ -2,10 +2,15 @@ #include "behaviortree_cpp/utils/safe_any.hpp" #include "behaviortree_cpp/basic_types.h" - // Use the version nlohmann::json embedded in BT.CPP #include "behaviortree_cpp/contrib/json.hpp" +#ifdef BTCPP_PYTHON +#include +#include +#include "behaviortree_cpp/contrib/pybind11_json.hpp" +#endif + namespace BT { @@ -82,13 +87,6 @@ class JsonExporter template Expected fromJson(const nlohmann::json& source) const; - template - void fromJsonHelper(const nlohmann::json& src, T& dst) const - { - dst = *fromJson(src); - } - - /** * @brief Register new JSON converters with addConverter(). * You should used first the macro BT_JSON_CONVERTER. @@ -126,7 +124,14 @@ class JsonExporter std::unordered_map from_json_array_converters_; std::unordered_map type_names_; }; - +#ifdef BTCPP_PYTHON +template <> +inline Expected +JsonExporter::fromJson(const nlohmann::json& source) const +{ + return pyjson::from_json(source); +} +#endif template inline Expected JsonExporter::fromJson(const nlohmann::json& source) const { diff --git a/include/behaviortree_cpp/python/types.h b/include/behaviortree_cpp/python/types.h index 80ca14332..7501cb758 100644 --- a/include/behaviortree_cpp/python/types.h +++ b/include/behaviortree_cpp/python/types.h @@ -2,10 +2,8 @@ #include -#include "behaviortree_cpp/basic_types.h" #include "behaviortree_cpp/json_export.h" -#include "behaviortree_cpp/contrib/json.hpp" -#include "behaviortree_cpp/contrib/pybind11_json.hpp" + #include "behaviortree_cpp/utils/safe_any.hpp" namespace BT @@ -20,12 +18,12 @@ namespace BT template bool fromPythonObject(const pybind11::object& obj, T& dest) { - if constexpr(nlohmann::detail::is_getable::value) + auto dest_maybe = JsonExporter::get().fromJson(obj); + if(dest_maybe.has_value()) { - JsonExporter::get().fromJsonHelper(obj, dest); + dest = dest_maybe.value(); return true; } - return false; } diff --git a/python_examples/ex02_generic_data.py b/python_examples/ex02_generic_data.py index 8e6e3f436..5f13d4648 100644 --- a/python_examples/ex02_generic_data.py +++ b/python_examples/ex02_generic_data.py @@ -7,7 +7,6 @@ import numpy as np from btpy import BehaviorTreeFactory, SyncActionNode, NodeStatus, ports - xml_text = """ @@ -24,7 +23,7 @@ """ -@ports(inputs=["position", "theta"], outputs=["out"]) +@ports(inputs=["position", "theta"], outputs=["out", "position"]) class Rotate(SyncActionNode): def tick(self): # Build a rotation matrix which rotates points by `theta` degrees. @@ -38,6 +37,7 @@ def tick(self): # Set the output. self.set_output("out", rotated) + self.set_output("position", position) return NodeStatus.SUCCESS diff --git a/python_examples/ex05_type_interop.py b/python_examples/ex05_type_interop.py index c10bb8b2c..d8e82e3ee 100644 --- a/python_examples/ex05_type_interop.py +++ b/python_examples/ex05_type_interop.py @@ -60,7 +60,7 @@ def tick(self): factory = BehaviorTreeFactory() -factory.register_from_plugin("sample_nodes/bin/libdummy_nodes_dyn.so") +factory.register_from_plugin("build/sample_nodes/bin/libdummy_nodes_dyn.so") factory.register(PutVector) factory.register(Print) diff --git a/sample_nodes/dummy_nodes.h b/sample_nodes/dummy_nodes.h index 88049b9cf..3703a3f03 100644 --- a/sample_nodes/dummy_nodes.h +++ b/sample_nodes/dummy_nodes.h @@ -198,6 +198,8 @@ inline void RegisterNodes(BT::BehaviorTreeFactory& factory) std::bind(&GripperInterface::close, &grip_singleton)); factory.registerNodeType("ApproachObject"); factory.registerNodeType("SaySomething"); + factory.registerNodeType("RandomVector"); + factory.registerNodeType("PrintMapOfVectors"); } } // namespace DummyNodes diff --git a/setup.py b/setup.py deleted file mode 100644 index 9e97b01bd..000000000 --- a/setup.py +++ /dev/null @@ -1,141 +0,0 @@ -import os -import re -import subprocess -import sys -from pathlib import Path - -from setuptools import Extension, setup -from setuptools.command.build_ext import build_ext - -# Convert distutils Windows platform specifiers to CMake -A arguments -PLAT_TO_CMAKE = { - "win32": "Win32", - "win-amd64": "x64", - "win-arm32": "ARM", - "win-arm64": "ARM64", -} - - -# A CMakeExtension needs a sourcedir instead of a file list. -# The name must be the _single_ output extension from the CMake build. -# If you need multiple extensions, see scikit-build. -class CMakeExtension(Extension): - def __init__(self, name: str, sourcedir: str = "") -> None: - super().__init__(name, sources=[]) - self.sourcedir = os.fspath(Path(sourcedir).resolve()) - - -class CMakeBuild(build_ext): - def build_extension(self, ext: CMakeExtension) -> None: - # Must be in this form due to bug in .resolve() only fixed in Python 3.10+ - ext_fullpath = Path.cwd() / self.get_ext_fullpath(ext.name) - extdir = ext_fullpath.parent.resolve() - - # Using this requires trailing slash for auto-detection & inclusion of - # auxiliary "native" libs - - debug = int(os.environ.get("DEBUG", 0)) if self.debug is None else self.debug - cfg = "Debug" if debug else "Release" - - # CMake lets you override the generator - we need to check this. - # Can be set with Conda-Build, for example. - cmake_generator = os.environ.get("CMAKE_GENERATOR", "") - - # Set Python_EXECUTABLE instead if you use PYBIND11_FINDPYTHON - # EXAMPLE_VERSION_INFO shows you how to pass a value into the C++ code - # from Python. - cmake_args = [ - f"-DCMAKE_LIBRARY_OUTPUT_DIRECTORY={extdir}{os.sep}", - f"-DPYTHON_EXECUTABLE={sys.executable}", - f"-DCMAKE_BUILD_TYPE={cfg}", # not used on MSVC, but no harm - # BehaviorTree.CPP specific CMake options - "-DBTCPP_BUILD_TOOLS=OFF", - "-DBTCPP_EXAMPLES=OFF", - "-DBTCPP_UNIT_TESTS=OFF", - ] - build_args = [] - # Adding CMake arguments set as environment variable - # (needed e.g. to build for ARM OSx on conda-forge) - if "CMAKE_ARGS" in os.environ: - cmake_args += [item for item in os.environ["CMAKE_ARGS"].split(" ") if item] - - if self.compiler.compiler_type != "msvc": - # Using Ninja-build since it a) is available as a wheel and b) - # multithreads automatically. MSVC would require all variables be - # exported for Ninja to pick it up, which is a little tricky to do. - # Users can override the generator with CMAKE_GENERATOR in CMake - # 3.15+. - if not cmake_generator or cmake_generator == "Ninja": - try: - import ninja - - ninja_executable_path = Path(ninja.BIN_DIR) / "ninja" - cmake_args += [ - "-GNinja", - f"-DCMAKE_MAKE_PROGRAM:FILEPATH={ninja_executable_path}", - ] - except ImportError: - pass - - else: - # Single config generators are handled "normally" - single_config = any(x in cmake_generator for x in {"NMake", "Ninja"}) - - # CMake allows an arch-in-generator style for backward compatibility - contains_arch = any(x in cmake_generator for x in {"ARM", "Win64"}) - - # Specify the arch if using MSVC generator, but only if it doesn't - # contain a backward-compatibility arch spec already in the - # generator name. - if not single_config and not contains_arch: - cmake_args += ["-A", PLAT_TO_CMAKE[self.plat_name]] - - # Multi-config generators have a different way to specify configs - if not single_config: - cmake_args += [ - f"-DCMAKE_LIBRARY_OUTPUT_DIRECTORY_{cfg.upper()}={extdir}" - ] - build_args += ["--config", cfg] - - if sys.platform.startswith("darwin"): - # Cross-compile support for macOS - respect ARCHFLAGS if set - archs = re.findall(r"-arch (\S+)", os.environ.get("ARCHFLAGS", "")) - if archs: - cmake_args += ["-DCMAKE_OSX_ARCHITECTURES={}".format(";".join(archs))] - - # Set CMAKE_BUILD_PARALLEL_LEVEL to control the parallel build level - # across all generators. - if "CMAKE_BUILD_PARALLEL_LEVEL" not in os.environ: - # self.parallel is a Python 3 only way to set parallel jobs by hand - # using -j in the build_ext call, not supported by pip or PyPA-build. - if hasattr(self, "parallel") and self.parallel: - # CMake 3.12+ only. - build_args += [f"-j{self.parallel}"] - - build_temp = Path(self.build_temp) / ext.name - if not build_temp.exists(): - build_temp.mkdir(parents=True) - - subprocess.run( - ["cmake", ext.sourcedir, *cmake_args], cwd=build_temp, check=True - ) - subprocess.run( - ["cmake", "--build", ".", *build_args], cwd=build_temp, check=True - ) - - -# The information here can also be placed in setup.cfg - better separation of -# logic and declaration, and simpler if you include description/version in a file. -setup( - name="btpy", - version="0.0.1", - author="Davide Faconti", - author_email="davide.faconti@gmail.com", - description="Python bindings to the BehaviorTree.CPP library", - long_description="", - packages=["btpy"], - ext_modules=[CMakeExtension("btcpp")], - cmdclass={"build_ext": CMakeBuild}, - zip_safe=False, - python_requires=">=3.7", -) diff --git a/src/python/types.cpp b/src/python/types.cpp index ddab4a31f..83b0889dd 100644 --- a/src/python/types.cpp +++ b/src/python/types.cpp @@ -5,12 +5,16 @@ #include "behaviortree_cpp/json_export.h" #include "behaviortree_cpp/contrib/json.hpp" -#include "behaviortree_cpp/contrib/pybind11_json.hpp" namespace BT { -bool toPythonObject(const BT::Any& val, pybind11::object& dest) +#if defined(_WIN32) +__declspec(dllexport) +#else +__attribute__((visibility("default"))) +#endif + bool toPythonObject(const BT::Any& val, pybind11::object& dest) { nlohmann::json json; if(JsonExporter::get().toJson(val, json))