diff --git a/bazel_ros2_rules/ros2/BUILD.bazel b/bazel_ros2_rules/ros2/BUILD.bazel index 354a7bdf..c6697127 100644 --- a/bazel_ros2_rules/ros2/BUILD.bazel +++ b/bazel_ros2_rules/ros2/BUILD.bazel @@ -22,17 +22,6 @@ py_library( deps = ["@bazel_tools//tools/python/runfiles"], ) -py_library( - name = "roslaunch_util.py", - srcs = [ - "tools/roslaunch_util/__init__.py", - "tools/roslaunch_util/roslaunch_util.py", - "tools/roslaunch_util/setup.py", - ], - visibility = ["//visibility:public"], - deps = ["@bazel_tools//tools/python/runfiles"], -) - run_calculate_rosidl_capitalization_tests() py_library( diff --git a/bazel_ros2_rules/ros2/README.md b/bazel_ros2_rules/ros2/README.md index bf71c139..3618483a 100644 --- a/bazel_ros2_rules/ros2/README.md +++ b/bazel_ros2_rules/ros2/README.md @@ -91,6 +91,10 @@ equivalent to the native `py_binary` and `py_test` rules, ensure these binaries run in an environment that is tightly coupled with the underlying ROS 2 workspace install space. +To generate a Python or XML launch target, `ros_launch` is available in the +`ros_py.bzl` file. Note that there are some nuances; pelase see the Launch +Files section below. + To generate and build ROS 2 interfaces, a `rosidl_interfaces_group` rule is available in the `rosidl.bzl` file. This rule generates C++ and Python code for the given ROS 2 interface definition files and builds them using what is @@ -103,6 +107,21 @@ the same file. By default, these naming conventions allow downstream `rosidl_interfaces_group` rules to depend on upstream `rosidl_interface_group` rules. +#### Launch Files + +An example of the `ros_launch` macro can be found under `ros2_example_apps`. +Please note the following limitations: + +- Exposing a Bazel package as a ROS package has not yet been done. Once that is + done, `launch_ros.actions.Node` / `` can be used. +- For Python launch files, it is best to use `Rlocation` (as shown in the example) + so that the launch file can be run via `bazel run`, `bazel test`, and + `./bazel-bin` (directly). +- For XML launch files, we need to (somehow) expose either ROS packages or + `Rlocation`. This needs to be done in a way that can be discovered by + [`Parser.load_launch_extensions`](https://github.com/ros2/launch/blob/698e979382877242621a0d633750fe96ff0c2bca/launch/launch/frontend/parser.py#L72-L87), + which may require care to do so correctly in Bazel. + ### Tools The `rmw_isolation` subpackage provides C++ and Python `isolate_rmw_by_path` @@ -188,9 +207,9 @@ From the above two examples (at present), the following features are in directly, but instead leverage the C-level interfaces, we get into an awkward ground of mutually exclusive memory / resource management paradigms. +- Affordances for `ros2 launch` The other repos, however, have the following that `bazel_ros2_rules` is missing: -- Affordances for `ros2 launch` - Launching from containers (Docker, Apptainer) diff --git a/bazel_ros2_rules/ros2/resources/bazel_ros_env/bazel_ros_env.py b/bazel_ros2_rules/ros2/resources/bazel_ros_env/bazel_ros_env.py index d5c73935..41d1f6ca 100644 --- a/bazel_ros2_rules/ros2/resources/bazel_ros_env/bazel_ros_env.py +++ b/bazel_ros2_rules/ros2/resources/bazel_ros_env/bazel_ros_env.py @@ -64,7 +64,10 @@ def _runfiles(): def Rlocation(path): - return _runfiles().Rlocation(path) + result = _runfiles().Rlocation(path) + if result is None: + raise RuntimeError(f"Unable to find Rlocation({repr(path)})") + return result def make_bazel_runfiles_env(): diff --git a/bazel_ros2_rules/ros2/ros_py.bzl b/bazel_ros2_rules/ros2/ros_py.bzl index 5048298d..c20c1802 100644 --- a/bazel_ros2_rules/ros2/ros_py.bzl +++ b/bazel_ros2_rules/ros2/ros_py.bzl @@ -130,24 +130,100 @@ def ros_py_binary( ) py_binary_rule(name = name, **kwargs) +def _add_deps(existing, new): + deps = list(existing) + for dep in new: + if dep not in deps: + deps.append(dep) + return deps + +def _generate_file_impl(ctx): + out = ctx.actions.declare_file(ctx.label.name) + ctx.actions.write(out, ctx.attr.content, ctx.attr.is_executable) + return [DefaultInfo( + files = depset([out]), + data_runfiles = ctx.runfiles(files = [out]), + )] + +_generate_file = rule( + attrs = { + "content": attr.string(mandatory = True), + "is_executable": attr.bool(default = False), + }, + output_to_genfiles = True, + implementation = _generate_file_impl, +) + +_LAUNCH_PY_TEMPLATE = """ +import os +import sys + +from bazel_ros_env import Rlocation + +assert __name__ == "__main__" +launch_file = Rlocation({launch_respath}) +ros2_bin = Rlocation("ros2/ros2") +args = [ros2_bin, "launch", launch_file] + sys.argv[1:] +os.execv(ros2_bin, args) +""" + +def _make_respath(relpath, workspace_name): + repo = native.repository_name() + if repo == "@": + if workspace_name == None: + fail( + "Please provide `ros_launch(*, workspace_name)` so that " + + "paths can be resolved properly", + ) + repo = workspace_name + pkg = native.package_name() + if pkg != "": + pieces = [repo, pkg, relpath] + else: + pieces = [repo, relpath] + return "/".join(pieces) + def ros_launch( name, - launch_file = None, - node_targets = []): - deps = ["@ros2//:ros2", "@bazel_ros2_rules//ros2:roslaunch_util.py"] - srcs = ["@bazel_ros2_rules//ros2:roslaunch_util.py"] + launch_file, + args = [], + data = [], + deps = [], + visibility = None, + # TODO(eric.cousineau): Remove this once Bazel provides a way to tell + # runfiles.py to use "this repository" in a way that doesn't require + # bespoke information. + workspace_name = None, + **kwargs): + main = "{}_roslaunch_main.py".format(name) + launch_respath = _make_respath(launch_file, workspace_name) - data = [launch_file] - data += node_targets - args = [launch_file] + content = _LAUNCH_PY_TEMPLATE.format( + launch_respath = repr(launch_respath), + ) + _generate_file( + name = main, + content = content, + visibility = ["//visibility:private"], + ) + + deps = _add_deps( + deps, + [ + "@ros2//:ros2", + "@ros2//resources/bazel_ros_env:bazel_ros_env_py", + ], + ) + data = data + [launch_file] ros_py_binary( name = name, - main = "@bazel_ros2_rules//ros2:roslaunch_util.py", + main = main, deps = deps, - srcs = srcs, + srcs = [main], data = data, - args = args, + visibility = visibility, + **kwargs ) def ros_py_test( diff --git a/bazel_ros2_rules/ros2/tools/roslaunch_util/__init__.py b/bazel_ros2_rules/ros2/tools/roslaunch_util/__init__.py deleted file mode 100644 index 12a7db48..00000000 --- a/bazel_ros2_rules/ros2/tools/roslaunch_util/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .roslaunch_util import ExecuteBazelTarget - -__all__ = ['ExecuteBazelTarget'] diff --git a/bazel_ros2_rules/ros2/tools/roslaunch_util/roslaunch_util.py b/bazel_ros2_rules/ros2/tools/roslaunch_util/roslaunch_util.py deleted file mode 100644 index dd02c0a2..00000000 --- a/bazel_ros2_rules/ros2/tools/roslaunch_util/roslaunch_util.py +++ /dev/null @@ -1,52 +0,0 @@ -import subprocess -import sys -import os - -from launch.actions import ExecuteProcess -from launch.frontend import expose_action - -# This util file serves as a wrapper over the cmdline -# way to run launch, using "ros2 launch launch_file.py". -# The ros_launch() bazel rule starts this script, which -# gets run as a ros_py_binary(). This way we get the ros -# environment set up for us for free. - -def find_rel_path(name, path): - for root, _, files in os.walk(path): - if name in files: - return os.path.relpath(os.path.join(root, name)) - -# Launch action to wrap over ExecuteProcess. -@expose_action('execute_bazel_target') -class ExecuteBazelTarget(ExecuteProcess): - def __init__(self,target,**kwargs): - super().__init__(cmd=[find_rel_path(target, os.getcwd())], - **kwargs) - - @classmethod - def parse(cls, entity, parser): - _, kwargs = super().parse(entity, parser, ignore=['cmd']) - kwargs['target'] = entity.get_attr('target') - return cls, kwargs - - def execute(self, context): - return super().execute(context) - -if __name__ == '__main__': - # TODO (Aditya): How do I stop installing this every time ? - # Required to get xml launch working, as we need to register an entry - # point for launch extensions. - os.system("pip install ../bazel_ros2_rules/ros2/tools/roslaunch_util >/dev/null 2>&1") - # Actual launch files should be able to import the custom action now. - env = {**os.environ, 'PYTHONPATH': os.getcwd() + '/external/bazel_ros2_rules/ros2/tools:' - + os.environ['PYTHONPATH']} - - launch_file_name = sys.argv[1] - - roslaunch_cli = "./external/ros2/ros2" - action = "launch" - # TODO : Is there a better way to locate the launch file exactly ? - launch_file = find_rel_path(launch_file_name, os.getcwd()) - - # This basically runs "ros2 launch launch_file" - subprocess.run([roslaunch_cli, action, launch_file], env=env) diff --git a/bazel_ros2_rules/ros2/tools/roslaunch_util/setup.py b/bazel_ros2_rules/ros2/tools/roslaunch_util/setup.py deleted file mode 100644 index 9c0ccfbe..00000000 --- a/bazel_ros2_rules/ros2/tools/roslaunch_util/setup.py +++ /dev/null @@ -1,10 +0,0 @@ -from setuptools import setup - -setup( - name = "roslaunch_util", - entry_points={ - 'launch.frontend.launch_extension': [ - 'roslaunch_util = roslaunch_util', - ], - } -) diff --git a/ros2_example_bazel_installed/ros2_example_apps/BUILD.bazel b/ros2_example_bazel_installed/ros2_example_apps/BUILD.bazel index ed49820d..776aa7cc 100644 --- a/ros2_example_bazel_installed/ros2_example_apps/BUILD.bazel +++ b/ros2_example_bazel_installed/ros2_example_apps/BUILD.bazel @@ -2,8 +2,7 @@ # vi: set ft=python : load("@ros2//:ros_cc.bzl", "ros_cc_binary", "ros_cc_test") -load("@ros2//:ros_py.bzl", "ros_py_binary", "ros_py_test") -load("@ros2//:ros_py.bzl", "ros_launch") +load("@ros2//:ros_py.bzl", "ros_launch", "ros_py_binary", "ros_py_test") load("@ros2//:rosidl.bzl", "rosidl_interfaces_group") load("//tools:cmd_test.bzl", "cmd_test") @@ -206,6 +205,7 @@ ros_py_binary( ros_py_binary( name = "eg_talker", srcs = ["roslaunch_eg_nodes/eg_talker.py"], + data = ["@ros2//:rmw_cyclonedds_cpp_cc"], deps = [ "@ros2//:rclpy_py", "@ros2//:std_msgs_py", @@ -215,29 +215,50 @@ ros_py_binary( ros_cc_binary( name = "eg_listener", srcs = ["roslaunch_eg_nodes/eg_listener.cpp"], + data = ["@ros2//:rmw_cyclonedds_cpp_cc"], deps = [ "@ros2//:rclcpp_cc", "@ros2//:std_msgs_cc", ], ) +# See bazel_ros2_rules/ros2/README.md, Launch Files, for notes on +# features and limitations. + # Uses a python launch file to spawn the talker and listener. +workspace_name = "ros2_example_bazel_installed" + ros_launch( name = "roslaunch_eg_py", - launch_file = "eg_launch.py", - node_targets = [ - ":eg_talker", + data = [ ":eg_listener", + ":eg_talker", ], + launch_file = "eg_launch.py", + workspace_name = workspace_name, ) # Uses an xml launch file to spawn the talker and listener. ros_launch( name = "roslaunch_eg_xml", - launch_file = "eg_launch.xml", - node_targets = [ - ":eg_talker", + data = [ ":eg_listener", + ":eg_talker", + ], + launch_file = "eg_launch.xml", + workspace_name = workspace_name, +) + +ros_py_test( + name = "roslaunch_eg_test", + srcs = ["test/roslaunch_eg_test.py"], + data = [ + ":roslaunch_eg_py", + ":roslaunch_eg_xml", + ], + main = "test/roslaunch_eg_test.py", + deps = [ + "@ros2//resources/bazel_ros_env:bazel_ros_env_py", ], ) diff --git a/ros2_example_bazel_installed/ros2_example_apps/eg_launch.py b/ros2_example_bazel_installed/ros2_example_apps/eg_launch.py index d2ed52b3..cbaff855 100644 --- a/ros2_example_bazel_installed/ros2_example_apps/eg_launch.py +++ b/ros2_example_bazel_installed/ros2_example_apps/eg_launch.py @@ -1,10 +1,18 @@ +from bazel_ros_env import Rlocation from launch import LaunchDescription -from roslaunch_util import ExecuteBazelTarget +from launch.actions import ExecuteProcess + def generate_launch_description(): + # See bazel_ros2_rules/ros2/README.md, Launch Files, for notes on + # features and limitations. + prefix = "ros2_example_bazel_installed/ros2_example_apps" + talker_bin = Rlocation(f"{prefix}/eg_talker") + listener_bin = Rlocation(f"{prefix}/eg_listener") + return LaunchDescription([ # Running a talker written in python. - ExecuteBazelTarget('eg_talker'), + ExecuteProcess(cmd=[talker_bin]), # Running a listener written in cpp. - ExecuteBazelTarget('eg_listener'), + ExecuteProcess(cmd=[listener_bin]), ]) diff --git a/ros2_example_bazel_installed/ros2_example_apps/eg_launch.xml b/ros2_example_bazel_installed/ros2_example_apps/eg_launch.xml index 07f1a926..110b1951 100644 --- a/ros2_example_bazel_installed/ros2_example_apps/eg_launch.xml +++ b/ros2_example_bazel_installed/ros2_example_apps/eg_launch.xml @@ -1,4 +1,8 @@ - - + + + diff --git a/ros2_example_bazel_installed/ros2_example_apps/roslaunch_eg_nodes/eg_listener.cpp b/ros2_example_bazel_installed/ros2_example_apps/roslaunch_eg_nodes/eg_listener.cpp index 733fcac2..be8ee77e 100644 --- a/ros2_example_bazel_installed/ros2_example_apps/roslaunch_eg_nodes/eg_listener.cpp +++ b/ros2_example_bazel_installed/ros2_example_apps/roslaunch_eg_nodes/eg_listener.cpp @@ -1,3 +1,4 @@ +#include #include #include "rclcpp/rclcpp.hpp" @@ -7,25 +8,41 @@ using std::placeholders::_1; class MinimalSubscriber : public rclcpp::Node { public: - MinimalSubscriber() - : Node("minimal_subscriber") + MinimalSubscriber(int max_count = 10) + : Node("minimal_subscriber"), max_count_(max_count) { subscription_ = this->create_subscription( "topic", 10, std::bind(&MinimalSubscriber::topic_callback, this, _1)); } + bool is_done() const { return count_ >= max_count_; } + private: - void topic_callback(const std_msgs::msg::String::SharedPtr msg) const + void topic_callback(const std_msgs::msg::String::SharedPtr msg) { RCLCPP_INFO(this->get_logger(), "I heard: '%s'", msg->data.c_str()); + ++count_; } rclcpp::Subscription::SharedPtr subscription_; + + int max_count_{}; + int count_{}; }; int main(int argc, char * argv[]) { rclcpp::init(argc, argv); - rclcpp::spin(std::make_shared()); - rclcpp::shutdown(); + + // Use explicit `spin_some` so we can manually check for completion. + auto node = std::make_shared(); + rclcpp::executors::SingleThreadedExecutor executor; + executor.add_node(node); + // Use large enough non-zero max duration to ensure we do not block. + const std::chrono::microseconds spin_max_duration{50}; + + while (rclcpp::ok() && !node->is_done()) { + executor.spin_some(spin_max_duration); + } + return 0; } diff --git a/ros2_example_bazel_installed/ros2_example_apps/roslaunch_eg_nodes/eg_talker.py b/ros2_example_bazel_installed/ros2_example_apps/roslaunch_eg_nodes/eg_talker.py index 4b3194bb..57e58e53 100644 --- a/ros2_example_bazel_installed/ros2_example_apps/roslaunch_eg_nodes/eg_talker.py +++ b/ros2_example_bazel_installed/ros2_example_apps/roslaunch_eg_nodes/eg_talker.py @@ -1,4 +1,5 @@ import rclpy +from rclpy.executors import SingleThreadedExecutor from rclpy.node import Node from std_msgs.msg import String @@ -6,34 +7,40 @@ class MinimalPublisher(Node): - def __init__(self): - super().__init__('minimal_publisher') - self.publisher_ = self.create_publisher(String, 'topic', 10) - timer_period = 0.5 # seconds + def __init__(self, *, max_count = 10): + super().__init__("node") + self.publisher = self.create_publisher(String, "topic", 10) + timer_period = 0.1 # seconds self.timer = self.create_timer(timer_period, self.timer_callback) - self.i = 0 + self.count = 0 + self.max_count = max_count def timer_callback(self): msg = String() - msg.data = 'Hello World: %d' % self.i - self.publisher_.publish(msg) - self.get_logger().info('Publishing: "%s"' % msg.data) - self.i += 1 + msg.data = f"Hello World: {self.count}" + self.publisher.publish(msg) + self.get_logger().info(f"Publishing: '{msg.data}'") + self.count += 1 + def is_done(self): + return self.count >= self.max_count -def main(args=None): - rclpy.init(args=args) - minimal_publisher = MinimalPublisher() +def main(): + rclpy.init() - rclpy.spin(minimal_publisher) + node = MinimalPublisher() - # Destroy the node explicitly - # (optional - otherwise it will be done automatically - # when the garbage collector destroys the node object) - minimal_publisher.destroy_node() - rclpy.shutdown() + # Use explicit `spin_once` so we can manually check for completion. + executor = SingleThreadedExecutor() + executor.add_node(node) + while rclpy.ok() and not node.is_done(): + executor.spin_once(timeout_sec=1e-3) + # Avoid odd error: + # cannot use Destroyable because destruction was requested + executor._sigint_gc = None -if __name__ == '__main__': + +if __name__ == "__main__": main() diff --git a/ros2_example_bazel_installed/ros2_example_apps/test/roslaunch_eg_test.py b/ros2_example_bazel_installed/ros2_example_apps/test/roslaunch_eg_test.py new file mode 100644 index 00000000..29cd4166 --- /dev/null +++ b/ros2_example_bazel_installed/ros2_example_apps/test/roslaunch_eg_test.py @@ -0,0 +1,30 @@ +import os +import subprocess +import sys + +from bazel_ros_env import Rlocation, make_unique_ros_isolation_env + + +def test_launch_py(): + launch_bin = Rlocation( + "ros2_example_bazel_installed/ros2_example_apps/roslaunch_eg_py" + ) + subprocess.run([launch_bin], check=True) + + +def test_launch_xml(): + launch_bin = Rlocation( + "ros2_example_bazel_installed/ros2_example_apps/roslaunch_eg_xml" + ) + subprocess.run([launch_bin], check=True) + + +def main(): + os.environ.update(make_unique_ros_isolation_env()) + # For simplicity, run test points directly (rather than via pytest). + test_launch_py() + test_launch_xml() + + +if __name__ == '__main__': + main()