From 2006eaea094ba19afe45fe7355bcb2084a053c1b Mon Sep 17 00:00:00 2001 From: cc Date: Sat, 14 Feb 2026 02:38:05 -0500 Subject: [PATCH 1/3] feat: node param override --- README.md | 477 +++++++++++++++++++-- better_launch/declarative.py | 13 +- better_launch/launcher.py | 32 +- better_launch/utils/better_logging.py | 37 +- better_launch/utils/click.py | 116 ++++- better_launch/utils/settings.py | 7 +- better_launch/wrapper.py | 68 +-- bin/bl | 24 +- docs/about/features.md | 1 - docs/installation/installation.md | 2 +- docs/paper.md | 2 +- docs/paper.pdf | Bin 238363 -> 238302 bytes examples/13_node_param_overrides.launch.py | 33 ++ examples/scripts/timed_talker.py | 38 ++ tests/test_click_utils.py | 155 +++++++ 15 files changed, 887 insertions(+), 118 deletions(-) create mode 100644 examples/13_node_param_overrides.launch.py create mode 100755 examples/scripts/timed_talker.py create mode 100644 tests/test_click_utils.py diff --git a/README.md b/README.md index 21b6e77..27f0ab9 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,26 @@ ![Logo](docs/assets/images/logo_text.png) + +[About](#about) | [Why?](#why-not-improve-the-existing-ros2-launch) | [Features](#okay-what-can-i-do-with-it) | [Usage](#how-do-i-use-it) | [TUI](#the-tui) | [Differences](#what-are-the-differences) | [Performance](#performance) | [Installation](#installation) | [ROS2](#whats-so-bad-about-ros2-launch) | [Contributors](#contributors) + + > [!TIP] > Just looking for the [documentation](https://dfki-ric.github.io/better_launch/)? -> We also have various [examples](examples/)! +> We also have multiple [examples](examples/)! ---- # 🧭 About -Let's face it: ROS2 has been a severe downgrade in terms of usability compared to ROS1. While there are many considerable improvements, the current launch system is borderline unusable. +Let's face it: ROS2 has been a severe downgrade in terms of usability compared to ROS1. While there are many considerable improvements, the current launch system is borderline unusable. I've listed my personal gripes below, but if you're here you likely feel the same. This is why I wrote ***better_launch***. -*better_launch* is what I wish ROS2 launch would be: intuitive to use, simple to understand, easy to remember. This is why *better_launch* is **not** yet another abstraction layer over ROS2 launch; it is a **full replacement** with no required dependencies on the existing launch system. - -Instead of dozens of imports and class instances for even the most basic tasks, your launchfiles could look as simple and beautiful as this: +Instead of dozens of imports and class instances for even the most basic tasks, your launch files could look as simple and beautiful as this: ```python from better_launch import BetterLaunch, launch_this -@launch_this +@launch_this(ui=True) def my_main(enable_x: bool = True): - """This is how nice your launchfiles could be! + """ + This is how nice your launch files could be! """ bl = BetterLaunch() @@ -29,36 +31,240 @@ def my_main(enable_x: bool = True): "example_publisher", ) - # Include other launchfiles, even regular ROS2 launchfiles! + # Include other launch files, even regular ROS2 launch files! bl.include("better_launch", "ros2_turtlesim.launch.py") ``` ```bash -# You can use `ros2 launch`, too, but `bl` is better :) $> bl my_package my_launch_file.py --enable_x True ``` *Do I have your attention? Read on to learn more!* ---- -# 🧞‍♀️ Everything you need to know -- [The What and Why](https://dfki-ric.github.io/better_launch/about/why/) -- [Differences to ROS2](https://dfki-ric.github.io/better_launch/about/differences/) -- [Installation](https://dfki-ric.github.io/better_launch/installation/) -- [HowTo](https://dfki-ric.github.io/better_launch/howto/python/) -- [Examples](examples/) +# 🤔 Why not improve the existing ROS2 launch? +Because I think it is beyond redemption and no amount of refactoring and REPs (ROS enhancement proposals) will turn the sails. Tools like the highly rated [simple_launch](https://github.com/oKermorgant/simple_launch) or [launch-generator](https://github.com/Tacha-S/launch_generator/) exist, but still use ROS2 launch under the hood and so inherit much of its clunkiness. Rather than fixing an inherently broken solution, I decided to make a RAP - a ROS abandonment proposal :) ---- +Essentially, *better_launch* is what I wish ROS2 launch would be: intuitive to use, simple to understand, easy to remember. This is why *better_launch* is **not** yet anothe``r abstraction layer over ROS2 launch; it is a **full replacement** with no required dependencies on the existing launch system. + + +# 🧩 Okay, what can I do with it? +Everything you would expect and a little more! The `BetterLaunch` instance allows you to +- create *subscribers*, *publishers*, *services*, *service clients*, *action servers* and *action clients* on the fly +- start and stop *nodes* +- start and stop *lifecycle nodes* and manage their lifecycle stage +- start and stop *composers* and load *components* into them +- organize your nodes in *groups* +- define hasslefree *topic remaps* for nodes and groups +- *pass any arguments* from the command line without having to declare them +- easily *load parameters* from yaml files +- *locate files* based on filenames and package names +- use *string substitutions* to resolve e.g. paths +- include other *better_launch launch files* +- include other *ROS2 launch files* +- let regular ROS2 launch files *include your better_launch launch files* +- configure *logging* just as you would in ROS2, yet have much more readable output +- manage your node using a nice [terminal UI](#the-tui) reminiscent of [rosmon](https://github.com/xqms/rosmon) + +For a quick comparison, bravely unfold the sections below: +
+ ROS2 + +```python +# Taken from https://docs.ros.org/en/jazzy/Tutorials/Intermediate/Launch/Using-Substitutions.html +from launch_ros.actions import Node + +from launch import LaunchDescription +from launch.actions import DeclareLaunchArgument, ExecuteProcess, TimerAction +from launch.conditions import IfCondition +from launch.substitutions import LaunchConfiguration, PythonExpression + + +def generate_launch_description(): + turtlesim_ns = LaunchConfiguration('turtlesim_ns') + use_provided_red = LaunchConfiguration('use_provided_red') + new_background_r = LaunchConfiguration('new_background_r') + + turtlesim_ns_launch_arg = DeclareLaunchArgument( + 'turtlesim_ns', + default_value='turtlesim1' + ) + use_provided_red_launch_arg = DeclareLaunchArgument( + 'use_provided_red', + default_value='False' + ) + new_background_r_launch_arg = DeclareLaunchArgument( + 'new_background_r', + default_value='200' + ) + + turtlesim_node = Node( + package='turtlesim', + namespace=turtlesim_ns, + executable='turtlesim_node', + name='sim' + ) + spawn_turtle = ExecuteProcess( + cmd=[[ + 'ros2 service call ', + turtlesim_ns, + '/spawn ', + 'turtlesim/srv/Spawn ', + '"{x: 2, y: 2, theta: 0.2}"' + ]], + shell=True + ) + change_background_r = ExecuteProcess( + cmd=[[ + 'ros2 param set ', + turtlesim_ns, + '/sim background_r ', + '120' + ]], + shell=True + ) + change_background_r_conditioned = ExecuteProcess( + condition=IfCondition( + PythonExpression([ + new_background_r, + ' == 200', + ' and ', + use_provided_red + ]) + ), + cmd=[[ + 'ros2 param set ', + turtlesim_ns, + '/sim background_r ', + new_background_r + ]], + shell=True + ) + + return LaunchDescription([ + turtlesim_ns_launch_arg, + use_provided_red_launch_arg, + new_background_r_launch_arg, + turtlesim_node, + spawn_turtle, + change_background_r, + TimerAction( + period=2.0, + actions=[change_background_r_conditioned], + ) + ]) +``` +
+ +
+ better_launch (python) + +```python +from better_launch import BetterLaunch, launch_this +from rclpy import Timer + +@launch_this +def my_start( + # Launch arguments in function signature + turtlesim_ns: str = "turtlesim1", + use_provided_red: bool = False, + new_background_r: int = 200, +): + bl = BetterLaunch() + + # Pythonic AF + with bl.group(turtlesim_ns): + turtle_node = bl.node( + package="turtlesim", + executable="turtlesim_node", + name="sim", + # Pass parameters directly + params={"background_r": 120} + ) + + # Convenient API for common tasks + bl.call_service( + topic=f"/{turtlesim_ns}/spawn", + service_type="turtlesim/srv/Spawn", + # No weird types like passing dicts as strings + request_args={"x": 2.0, "y": 2.0, "theta": 0.2}, + ) + + if new_background_r == 200 and use_provided_red: + turtle_node.is_ros2_connected(timeout=None) + turtle_node.set_live_params({"background_r": new_background_r}) +``` +
-# ⛲ Highlights +
+```toml +bl_eval_mode = "full" + +turtlesim_ns = "turtlesim1" +use_provided_red = False +new_background_r = 200 + +[turtle_group] +func = "group" +namespace = "${turtlesim_ns}" + +[turtle_group.children.turtle_node] +func = "node" +package="turtlesim" +executable="turtlesim_node" +name="turtle_node" +params={"background_r": 120} + +[spawn_turtle] +func = "call_service" +topic = "/${turtlesim_ns}/spawn" +service_type = "turtlesim/srv/Spawn" +request_args = {"x": 2.0, "y": 2.0, "theta": 0.2} + +# TOML files are less powerful than python files, but you can still do a lot +[update_params] +if = "${eval new_background_r == 200 and use_provided_red}" +func = "call_service" +topic = "/${turtlesim_ns}/turtle_node/set_parameters" +service_type = "rcl_interfaces/srv/SetParameters" +request_args = { + "parameters": { + "name": "background_r", + "value": { + type: 2, + integer_value: "${new_background_r}" + } + } +} +``` +
+ + +# 🛠️ How do I use it? +The best way to get to know *better_launch* is to explore the included [examples](examples/). Unlike ROS2, all examples and functions come with proper [documentation](https://dfki-ric.github.io/better_launch/). If anything is left unclear, feel free to contact me. -## 📟 The TUI +You will mainly interact with *better_launch* through the following classes and modules: +- [@launch_this](better_launch/wrapper.py): decorator to create a launch file from a function. +- [BetterLaunch](better_launch/launcher.py): to create and start nodes, include other launch files, find and load parameters, etc. +- [convenience.py](better_launch/convenience.py): convenience functions to start rviz, robot state publishers, read urdf/xacro files, and more. +- [gazebo.py](better_launch/gazebo.py): functions and helpers for starting and populating gazebo simulations as well as bridging topics. + +Note that you are not forced to choose between *better_launch* and the ROS2 launch system. In fact, *better_launch* launch files can be run via `ros2 launch` and even be included from ROS2 launch files! However, this means running two launch systems on top of each other, so there is some overhead. The auto completion of `ros2 launch` is also slow as hell, cluttering the terminal with useless command line options yet is unable to discover the arguments you have declared inside your launch files. For these reasons, *better_launch* comes with the `bl` script, which fixes all of the above and then some. Once you have sourced your workspace you can use it as follows: + +```bash +# Try for autocomplete and check the example launch file for details! +bl better_launch 05_launch_arguments.py --help +``` -> [!NOTE] -> [Using the TUI](https://dfki-ric.github.io/better_launch/howto/tui/) +*better_launch* also reacts to the following environment variables: +- `BL_UI_OVERRIDE` (*enable|disable*): enables or disables the UI for all launch files. Superseded by the `--bl_ui_override` argument. +- `BL_COLORMODE_OVERRIDE` (*default|severity|source|none|rainbow*): overrides the colormode for all launch files. Superseded by the `--bl_colormode_override` argument. +- `BL_SCREEN_LOG_FORMAT_OVERRIDE`: overrides the format for messages logged to the terminal. Check the [PrettyLogFormatter](better_launch/utils/better_logging.py) for valid syntax. +- `BL_FILE_LOG_FORMAT_OVERRIDE`: overrides the format for messages logged to log files. Check the [PrettyLogFormatter](better_launch/utils/better_logging.py) for valid syntax. -*better_launch* comes with an optional, unobstrusive TUI (terminal user interface) based on [prompt_toolkit](https://github.com/prompt-toolkit/python-prompt-toolkit), which will hover below the log output. + +# 📟 The TUI +*better_launch* comes with a sneaky, unobstrusive TUI (terminal user interface) based on [prompt_toolkit](https://github.com/prompt-toolkit/python-prompt-toolkit), which will hover below the log output. You can start it by either passing `ui=True` to the `launch_this` wrapper, or by adding `--bl_ui_override=enable` on the command line. Use *\* to switch between menu items. ![TUI](docs/assets/images/tui.png) @@ -74,37 +280,220 @@ See the single line of shortcuts at the bottom? That's the TUI, and it will neve bl better_launch 02_ui.launch.py ``` -## ⛱️ TOML launchfiles +The TUI is also able to manage nodes started from different shells and processes, even if they have been started by ROS2 or other means. To do so, pass the `manage_foreign_nodes` flag to the wrapper or command line. Be aware though that this will not capture their output - to get their output you will have to use the *takeover* action from the TUI, which will restart the node process with the original arguments. -> [!NOTE] -> [Specification](https://dfki-ric.github.io/better_launch/howto/toml/) +> Foreign node processes are identified by having one of the following parameters in their arguments: `__ns`, `__name`, `__node`, `--ros-args`. This is always true for nodes started from launch files, but fails when they were started via `ros2 run` or other means. As far as I'm aware, there is no better way right now. -For those with aversions against using a turing-complete programming language to specify system startup - fear not! *better_launch* introduces a new launchfile format based on [TOML](https://toml.io/). -```toml -enable = true - -[a_simple_cube] -if = "${enable}" -func = "find" -package = "better_launch" -filename = "cube.sdf" - -[print_me_baby] -func = "log" -severity = "info" -message = "Found cube at ${a_simple_cube}" +# ⚖️ What are the differences? +Because *better_launch* does not use the ROS2 launch system, some aspects work differently from what you may be used to. + + +## Action immediacy +In ROS2 launch, launch files create tasks that are then passed to an asynchronous event loop. This is the reason why e.g. checking for launch parameter values is so incredibly weird - they simply don't exist yet by the time you define the actions. In *better_launch* however, all actions are taken immediately: if you create a node, its process is started right away; if you include another *better_launch* launch file, its contents will be handled before the function returns. + +The only exception to this is adding ROS2 launch actions, e.g. including regular ROS2 launch files. Since these still rely on the ROS2 launch system, they need to be turned into asynchronous tasks and passed to the event loop. Usually a ROS2 `LaunchService` sub-process is started the first time a ROS2 action is passed to *better_launch*. From then on this process will handle all ROS2 actions asynchronously in the background. + +> While the output of the ROS2 launch service process (and its nodes) is captured and formatted by *better_launch* just like for all other nodes, these will usually appear and behave as one single `launch_service` unit in the TUI (unless `manage_foreign_nodes` is true, see above). + + +## Lifecycle nodes +Lifecycle nodes differ from regular nodes in that they don't become fully active after their process starts. Instead you have to call one of their lifecycle management services, usually via additional code in your launch file or the `ros2 lifecycle` CLI. However, in the end they are still just nodes. + +*better_launch* makes no distinction between regular and lifecycle nodes. Instead, all "lifecyclable" objects (e.g. nodes and components) provide a `LifecycleManager` object via their `lifecycle` member. This will be `None` if the object has not been identified (yet) as a lifecycle-thing - otherwise you can use it to manage the object's lifecycle. Additionally, all objects that turn out to be lifecyclable will transition to their *ACTIVE* state by default, unless you pass a different target state on instantiation. + + +## Type checking +When passing arguments to a node in ROS2, in the end everything is passed as stringified command line arguments. So why bother with overly strict type checking? Why do I have to turn half the parameters into strings myself? *better_launch* does not impose a flawed type sytem on you and will happily accept `int`, `string`, `float`, etc. where appropriate. In addition, sensible and *unsurprising* types have been chosen for all arguments you may provide (e.g. remaps are defined as a `dict[str, str]`, floats are happy to accept ints, launch arguments are not required to be strings, etc.). + + +## Declaring launch arguments +Simply put: you don't. *better_launch* will check the signature of your launch function and turn all arguments into launch arguments. For example, if your launch function has an `enable_x` argument, you will be able to pass it with `--enable_x` from the command line. Under the hood *better_launch* is using [click](https://click.palletsprojects.com/), so every launch file you write comes with proper CLI support. + +> Tip: try adding a docstring to your launch function and call your launch file with `--help`! + + +## Parameter files +You do **not** have to put `ros__parameters` in your configs anymore when using `BetterLaunch.load_params`. Hooray! You still can do so of course if you feel slightly masochistic. In fact, *better_launch* supports the full param syntax for mapping params to nodes, including namespace wildcards. See the `load_params` documentation for details. + + +## Logging +Just like ROS2 launch, *better_launch* takes care of managing loggers and redirecting everything where it belongs (in fact that part is largely copied from ROS2 launch). However, I did away with the in my opinion not very useful separation between a node's `stdout` and `stderr`, since nodes apparently write their log output to `stderr` by default. + +I also added a reformatting layer so that colors and nicer screen output are possible. The format can be customized by passing your own logging format strings to `launch_this`. Alternatively, you may set the `OVERRIDE_SCREEN_LOG_FORMAT` and `OVERRIDE_FILE_LOG_FORMAT` environment variables. + + +## Abandoned processes +ROS2 launch has a bad reputation of leaving stale and abandoned processes behind after terminating. In my testing so far this has never been an issue with *better_launch* yet - except when you hard kill (-9) its process. + + +# 💯 Performance +I am not an expert on profiling code. Even though *better_launch* uses synchronous calls (or classic threads if necessary), and does some additional work to reformat output from nodes, it was able to achieve similar performance to `ros2 launch`. The scripts and results from the benchmarks can be found under [docs/benchmarks](docs/benchmarks/). This section will only show the most relevant parts. + +> `bl` is just a script to locate the launch file and then run it, so I decided to not use `bl` for these benchmarks and instead run the launch file directly; otherwise the resources used by the launch file will not be visible to most profilers. + +
+ memray + +[memray](https://github.com/bloomberg/memray) reports that *better_launch* uses about 30% less memory than `ros2 launch`. + +| | better_launch | ros2 launch | +| ----------------- | ------------- | ----------- | +| allocations | 48196 | 60943 | +| peak memory usage | 6.6 MiB | 9.7 MiB | +| details | [link](docs/benchmarks/results/memray/memray-flamegraph-bl.html) | [link](docs/benchmarks/results/memray/memray-flamegraph-ros2.html) | + +
+ +
+ psutil + +[psutil](https://psutil.readthedocs.io/en/latest/index.html#psutil.Process.memory_full_info) shows that *better_launch* uses more CPU in the beginning and more memory in total compared to `ros2 launch`. The memory reported is the unique set size (see the previous link). I'm not sure how these results relate to the memray statistics above. + +![](docs/benchmarks/results/psutil/cpu_usage.png) + +![](docs/benchmarks/results/psutil/memory_usage.png) + +
+ +
+ py-spy + +I use [py-spy](https://github.com/benfred/py-spy) to see where *better_launch* is using resources that can still be optimized. The speedscope files can be visualized on [speedscope.app](https://www.speedscope.app/). + +![](docs/benchmarks/results/pyspy/bl.svg) + +![](docs/benchmarks/results/pyspy/ros2.svg) + +
+ + +# 📥 Installation +I'm working on getting a .deb package up and running. Until then you may follow the steps below! + +*better_launch* is a regular ROS2 package, which means you can install it in your workspace and then use it in all launch files within that workspace. + +ROS2 is slowly [moving towards pixi](https://docs.ros.org/en/kilted/Installation/Windows-Install-Binary.html) as the main python3 environment, but I have not tested it yet. However, by now all the dependencies have been added into rosdep, so the following should get you up and running: + +```bash +# Install dependencies +sudo apt update +rosdep update +rosdep install --from-paths src --ignore-src -y ``` -Under the hood, TOML launchfiles result in calls to the `BetterLaunch` singleton, but offer a more focused and constrained feature set. If you are still missing ROS1 XML launchfiles (and substitutions like `${arg my_arg}`!), these are for you! + +
+ Python venv + +If you prefer a python virtual environment instead, here is a setup that works for us: + +```bash +# Install some prerequisites +sudo apt install python3-pip python3-venv + +# Create a virtual environment for your workspace +cd your/ros2/workspace/ +mkdir venv +python3 -m venv ./venv --system-site-packages --symlinks +touch venv/COLCON_IGNORE + +# Activate the venv +source ./venv/bin/activate + +# Activate your ROS2 workspace +source ./install/setup.bash + +# Install the dependencies into your venv +pip install -r path/to/better_launch/requirements.txt +``` +
--- +No matter which path you choose, once all the dependencies are installed you should build *better_launch* / your workspace. + +```bash +# Get better_launch into your workspace src folder +cd /src +git clone https://github.com/dfki-ric/better_launch.git +``` + +```bash +# Build the better_launch package +cd +colcon build --packages-up-to better_launch +source install/setup.bash +``` + +```bash +# Verify installation +bl --help +``` + + +# 📢 What's so bad about ROS2 launch? +Here is a "simple" launch file from the [official documentation](https://docs.ros.org/en/jazzy/Tutorials/Intermediate/Launch/Using-Substitutions.html) that does nothing but include another launch file: + +```python +from launch_ros.substitutions import FindPackageShare + +from launch import LaunchDescription +from launch.actions import IncludeLaunchDescription +from launch.launch_description_sources import PythonLaunchDescriptionSource +from launch.substitutions import PathJoinSubstitution, TextSubstitution + + +def generate_launch_description(): + colors = { + 'background_r': '200' + } + + return LaunchDescription([ + IncludeLaunchDescription( + PythonLaunchDescriptionSource([ + PathJoinSubstitution([ + FindPackageShare('launch_tutorial'), + 'launch', + 'example_substitutions_launch.py' + ]) + ]), + launch_arguments={ + 'turtlesim_ns': 'turtlesim2', + 'use_provided_red': 'True', + 'new_background_r': TextSubstitution(text=str(colors['background_r'])) + }.items() + ) + ]) +``` + +I think we can agree that this is not exactly elegant - including another launch file should be doable within a single line, not 10 plus 5 imports. Other terrible decisions within ROS2 launch include, but are not limited to: +- a weird fetish for unintuitive import statements (see above) +- unneccesarily strict type checking (why use python if I have to verify everything?) +- nonsensical argument types (e.g. remaps are a *list of tuples* instead of simply a *dict*) +- using asyncio may be slightly faster, but prevents normal variable interactions (ever wondered why you always see these weird `Condition` classes instead of a simple `if my_arg:`?) +- horrendous API for starting lifecycle nodes (also, why the hell are there two completely separate base interfaces?) +- the list goes on... + +For comparison, here is what the above launch file will look like in *better_launch*: + +```python +from better_launch import BetterLaunch, launch_this + +@launch_this +def main(turtlesim_ns = "turtlesim2", use_provided_red = True, new_background_r = 200): + bl = BetterLaunch() + + bl.include( + "launch_tutorial", + "example_substitutions.launch.py", + pass_all_args=True, # or pass as keyword arguments + ) +``` -# 🌱 Contributions +Overall, ROS2 launch seems like a system architect's wet fever dream, and I don't enjoy it. -> [!IMPORTANT] -> Please [see this document](CONTRIBUTING.md) if you're planning to make PR! +# 🌱 Contributors *Author:* [Nikolas Dahn](https://github.com/ndahn/) *Testing & Feedback:* diff --git a/better_launch/declarative.py b/better_launch/declarative.py index 5e4b79c..11d286e 100644 --- a/better_launch/declarative.py +++ b/better_launch/declarative.py @@ -111,7 +111,10 @@ def exec_request(key: str, req: dict) -> Any: with res: if isinstance(children, list): - children = {f"{key}.children.{idx}": child for idx, child in enumerate(children)} + children = { + f"{key}.children.{idx}": child + for idx, child in enumerate(children) + } if not isinstance(children, dict): raise ValueError( @@ -174,6 +177,7 @@ def _get_toml_args(toml: dict) -> list[DeclaredArg]: def launch_toml( path: str, launch_args: dict[str, str] = None, + extra_args: list[str] = None, *, # These should largely mirror the launch_this decorator ui: bool = None, @@ -204,7 +208,7 @@ def launch_toml( executable = "my-node" name = "${name}" - Substitutions are also possible and use a similar syntax as in ROS1 (as shown for the `name` launch argument above). `if` and `unless` conditions can be added as well. + Substitutions are also possible and use a similar syntax as in ROS1 (as shown for the `name` launch argument above). `if` and `unless` conditions can be added as well. All parameters below can be set through the launch file by declaring them on the global scope with a `bl_` prefix (i.e. `ui` becomes `bl_ui`). @@ -310,6 +314,11 @@ def launch_toml( if arg is not None: argv.extend([f"--{key}", str(arg)]) + if extra_args: + if argv is None: + argv = [] + argv.extend(extra_args) + def launch_func(*args, **kwargs): _execute_toml(toml, eval_mode=eval_mode, **kwargs) diff --git a/better_launch/launcher.py b/better_launch/launcher.py index 6872af1..22f04f0 100644 --- a/better_launch/launcher.py +++ b/better_launch/launcher.py @@ -117,6 +117,7 @@ class BetterLaunch(metaclass=BetterLaunchMeta): _launchfile: str = None _launch_func_args: dict[str, Any] = {} + _node_param_overrides: dict[str, dict[str, Any]] = {} def __init__( self, @@ -224,6 +225,9 @@ def hello(self) -> None: # We don't want to log this print(msg) + def set_node_param_overrides(self, overrides: dict[str, dict[str, Any]]) -> None: + BetterLaunch._node_param_overrides = overrides or {} + def spin(self, exit_with_last_node: bool = True) -> None: """Join the BetterLaunch thread until it terminates. You do **not** need to call this if you're using the [launch_this][] wrapper or the TUI. @@ -805,10 +809,7 @@ def load_params( val = val.get("ros__parameters", val) # Global parameters should always be included - if ( - not path - or fnmatch(path, qualifier) - ): + if not path or fnmatch(path, qualifier): for param_name, param_val in val.items(): final_params[param_name] = param_val @@ -1464,7 +1465,9 @@ def node( if name is None: name = f"{package}_{executable}" if not anonymous: - self.logger.warning(f"Name of node {package}/{executable} not set, will use anonymous name") + self.logger.warning( + f"Name of node {package}/{executable} not set, will use anonymous name" + ) anonymous = True if anonymous: @@ -1476,13 +1479,26 @@ def node( group = self.group_tip namespace = group.assemble_namespace() + # Merge CLI overrides (CLI takes precedence) + resolved_params: dict[str, Any] + if isinstance(params, str): + resolved_params = self.load_params(configfile=params) + elif params is None: + resolved_params = {} + else: + resolved_params = params + + cli_overrides = self._node_param_overrides.get(name, {}) + if cli_overrides: + resolved_params = {**resolved_params, **cli_overrides} + node = Node( package, executable, name, namespace, remaps=remaps, - params=params, + params=resolved_params, cmd_args=cmd_args, env=env, isolate_env=isolate_env, @@ -1737,7 +1753,9 @@ def component( if not name: name = f"{package}_{plugin.replace('::', '_')}" if not anonymous: - self.logger.warning(f"Name of {package}::{plugin} not set, will use anonymous name") + self.logger.warning( + f"Name of {package}::{plugin} not set, will use anonymous name" + ) anonymous = True if anonymous: diff --git a/better_launch/utils/better_logging.py b/better_launch/utils/better_logging.py index 831e851..79e7473 100644 --- a/better_launch/utils/better_logging.py +++ b/better_launch/utils/better_logging.py @@ -13,7 +13,7 @@ # Log format string for ROS so that we can identify and reformat its log messages. ROSLOG_PATTERN_ROS = "%%{severity}%%{time}%%{message}" -# Regular expression matching ROSLOG_PATTERN_ROS. The named groups will be matched to +# Regular expression matching ROSLOG_PATTERN_ROS. The named groups will be matched to # logging.LogRecord attributes via their group names. ROSLOG_PATTERN_BL = r"%%(?P\w+)%%(?P[\d.]+)%%(?P[\s\S]*)" @@ -221,15 +221,11 @@ def format(self, record: logging.LogRecord) -> str: record.levelno ) - msg = record.getMessage() - if self.max_message_length > 0 and len(msg) > self.max_message_length: - msg = msg[: self.max_message_length] + "..." - record.msg = msg - # The message has already been formatted with its arguments above. - # Clearing record.args prevents the next formatter from attempting - # a second '%' substitution on the truncated text, which could crash - # if any format placeholders were removed during truncation. - record.args = None + if self.max_message_length > 0: + msg = record.getMessage() + if msg > self.max_message_length: + record.msg = msg[: self.max_message_length] + "…" + return super().format(record) @@ -302,26 +298,25 @@ def configure_logger( screen_formatter: logging.Formatter = None, file_formatter: logging.Formatter = None, ) -> None: - """Initialize the logging framework. - """ + """Initialize the logging framework.""" # TODO proper docstring if output: - if isinstance(output, Iterable) and not isinstance(output, str): - output = [output] + if isinstance(output, str) or not isinstance(output, Iterable): + sinks = [output] + else: + sinks = list(output) - for idx, sink in output: - if isinstance(sink, str): - output[idx] = LogSink[output.upper()] - - output = set(output) + output_sinks = set( + LogSink[sink.upper()] if isinstance(sink, str) else sink for sink in sinks + ) else: - output = {LogSink.SCREEN} + output_sinks = {LogSink.SCREEN} config = Settings() screen_filter = LevelFilter(config.screen_log_level) file_filter = LevelFilter(config.file_log_level) - for sink in output: + for sink in output_sinks: if sink == LogSink.SCREEN: screen_handler = roslog.launch_config.get_screen_handler() if screen_handler not in logger.handlers: diff --git a/better_launch/utils/click.py b/better_launch/utils/click.py index 7277d51..9331372 100644 --- a/better_launch/utils/click.py +++ b/better_launch/utils/click.py @@ -1,4 +1,5 @@ from typing import Any, Type, Iterable, Callable +import json from dataclasses import dataclass import click @@ -53,20 +54,46 @@ def get_click_bl_options(expose: bool = False) -> list[click.Option]: list[click.Option] _description_ """ + def update_value(ctx: click.Context, param: click.Parameter, value: Any): key = param.name[3:].replace("-", "_") - _update_settings(**{key: value}) + + if value is not None: + _update_settings(**{key: value}) + + if key == "node_param_override" and value: + ctx.command.allow_extra_args = True + ctx.allow_extra_args = True + + def _bool_param_type(value: str) -> bool: + """Convert string to boolean for Click options.""" + if isinstance(value, bool): + return value + val_lower = value.lower() + if val_lower in ("true", "1", "yes", "on"): + return True + if val_lower in ("false", "0", "no", "off"): + return False + raise click.BadParameter(f"Invalid boolean value: {value}") # XXX always keep these synchronized with our Settings class options = [ click.Option( ["--bl-ui"], - type=bool, + type=_bool_param_type, default=None, help="Enforce or prevent starting the TUI", expose_value=expose, # not passed to our run method callback=update_value, ), + click.Option( + ["--bl-node-param-override"], + type=_bool_param_type, + default=None, + help="Allow overriding node parameters from the command line", + expose_value=expose, + callback=update_value, + ), click.Option( ["--bl-colormode"], type=click.types.Choice([c.name for c in Colormode], case_sensitive=False), @@ -138,11 +165,88 @@ def get_click_launch_command( allow_kwargs: bool = False, ) -> click.Command: click_cmd = click.Command( - cmd_name, callback=launch_func, params=options, help=cmd_help + cmd_name, + callback=launch_func, + params=options, + help=cmd_help, + context_settings={"ignore_unknown_options": True}, ) - if allow_kwargs: - click_cmd.allow_extra_args = True - click_cmd.ignore_unknown_options = True + click_cmd.allow_extra_args = allow_kwargs + click_cmd.ignore_unknown_options = True return click_cmd + + +def args_to_dict(args: list[str]) -> dict[str, Any]: + """Convert a list of CLI arguments to a dictionary. + + Parameters + ---------- + args : list[str] + List of arguments, e.g. ["--foo", "bar", "--baz", "1.0"] + + Returns + ------- + dict[str, Any] + Dictionary of arguments, e.g. {"foo": "bar", "baz": 1.0} + + Raises + ------ + ValueError + If an argument does not start with "-" or if a value is missing. + """ + result = {} + it = iter(args) + for arg in it: + if not arg.startswith("-"): + raise ValueError(f"Argument '{arg}' does not start with '-'") + + key = arg.lstrip("-") + try: + value = next(it) + except StopIteration: + raise ValueError(f"Missing value for argument '{arg}'") + + try: + value = json.loads(value) + except (json.JSONDecodeError, TypeError): + pass + + result[key] = value + + return result + + +def parse_node_params( + kwargs: dict[str, Any], +) -> tuple[dict[str, Any], dict[str, dict[str, Any]]]: + """Separate node parameters from other keyword arguments. + + Node parameters are identified by containing a dot in their key. The part + before the first dot is considered the node name, the part after the dot + is the parameter name. + + Parameters + ---------- + kwargs : dict[str, Any] + Dictionary of keyword arguments. + + Returns + ------- + tuple[dict[str, Any], dict[str, dict[str, Any]]] + Remaining keyword arguments and a dictionary of node parameters. + """ + remaining_kwargs = {} + node_params = {} + + for key, value in kwargs.items(): + if "." in key: + node_name, param_name = key.split(".", 1) + if node_name not in node_params: + node_params[node_name] = {} + node_params[node_name][param_name] = value + else: + remaining_kwargs[key] = value + + return remaining_kwargs, node_params diff --git a/better_launch/utils/settings.py b/better_launch/utils/settings.py index 28abfd0..25128cd 100644 --- a/better_launch/utils/settings.py +++ b/better_launch/utils/settings.py @@ -46,6 +46,7 @@ def severity_to_loglevel(severity: str) -> int: @dataclass(frozen=True) class _Settings: ui: bool = False + node_param_override: bool = False colormode: Colormode = Colormode.DEFAULT print_limit: int = 0 screen_log_level: int = logging.INFO @@ -139,15 +140,15 @@ def get_env_variables(self) -> dict[str, Any]: return vars def as_dict(self) -> dict[str, Any]: - """Returns the settings as a dict. - """ + """Returns the settings as a dict.""" # A bit more comfortable than having to import dataclasses.asdict each time return asdict(self) + def _update_settings(**overrides) -> None: """Replace the _SETTINGS object with a new instance with updated values. Only non-None values are applied. - This should only be called right after the launch process has started. + This should only be called right after the launch process has started. Parameters ---------- diff --git a/better_launch/wrapper.py b/better_launch/wrapper.py index 48175c7..4c7f2ea 100644 --- a/better_launch/wrapper.py +++ b/better_launch/wrapper.py @@ -15,11 +15,19 @@ _bl_singleton_instance, _bl_include_args, ) -from better_launch.utils.settings import Colormode, Settings, _update_settings, default_screen_format, default_file_format +from better_launch.utils.settings import ( + Colormode, + Settings, + _update_settings, + default_screen_format, + default_file_format, +) from better_launch.utils.better_logging import init_logging from better_launch.utils.introspection import find_calling_frame from better_launch.utils.click import ( DeclaredArg, + parse_node_params, + args_to_dict, get_click_options, get_click_bl_options, get_click_launch_command, @@ -96,7 +104,7 @@ def launch_this( def decoration_helper(func): sig = inspect.signature(func) declared_args = _get_declared_args(sig, func.__doc__) - + func_doc = doc.parse(func.__doc__) argspec = inspect.getfullargspec(func) @@ -144,7 +152,9 @@ def sigterm_handler(sig, frame): signal.signal(signal.SIGQUIT, sigterm_handler) -def _get_declared_args(signature: inspect.Signature, docstring: str = None) -> list[DeclaredArg]: +def _get_declared_args( + signature: inspect.Signature, docstring: str = None +) -> list[DeclaredArg]: # Extract more fine-grained information from the docstring param_docstrings = {} if docstring: @@ -157,7 +167,7 @@ def _get_declared_args(signature: inspect.Signature, docstring: str = None) -> l for param in signature.parameters.values(): ptype = None default = DeclaredArg._undefined - + if param.annotation is not param.empty: ptype = param.annotation if ptype and isinstance(ptype, str): @@ -213,7 +223,11 @@ def _exec_launch_func( include_args: dict = glob[_bl_include_args] bl.logger.info(f"Including launch file: {includefile} (args={include_args})") - call_kw = {a.name: a.default for a in declared_args if a.default != DeclaredArg._undefined} + call_kw = { + a.name: a.default + for a in declared_args + if a.default != DeclaredArg._undefined + } for key, val in include_args.items(): if allow_kwargs or key in call_kw: @@ -222,7 +236,7 @@ def _exec_launch_func( launch_func(**call_kw) return - + # Get the filename of the original launchfile # At this point we know that we are the main launch file if launchfile: @@ -271,33 +285,30 @@ def _exec_launch_func( @click.pass_context def run(ctx: click.Context, *args, **kwargs): - init_logging(roslog.launch_config) + import sys - if allow_kwargs: - # If the launch func defines a **kwarg we can pass all extra arguments to it, with - # the caveat that these extra args need to be defined as `-[-] val` tuples. - assert ( - len(ctx.args) % 2 == 0 - ), f"extra arguments need to be '-- ' tuples ({ctx.args})" + init_logging(roslog.launch_config) - for i in range(0, len(ctx.args), 2): - key = ctx.args[i] - if not key.startswith("-"): - raise ValueError("Extra argument keys must start with a dash") + extra_args_dict = args_to_dict(ctx.args) + remaining_kwargs, node_overrides = parse_node_params(extra_args_dict) - val = ctx.args[i + 1] - try: - val = literal_eval(val) - except Exception: - # Keep val as a string - pass + if Settings().node_param_override and node_overrides: + BetterLaunch._node_param_overrides = node_overrides - kwargs[key.strip("-")] = val + # Handle remaining kwargs if allow_kwargs is enabled + if allow_kwargs and remaining_kwargs: + for key, value in remaining_kwargs.items(): + if isinstance(value, str): + try: + value = literal_eval(value) + except Exception: + pass + kwargs[key] = value # By default BetterLaunch has access to all arguments from its launch function BetterLaunch._launch_func_args = dict(kwargs) - # Wrap the launch function so we can do some preparation and cleanup tasks. + # Wrap the launch function so we can do some preparation and cleanup tasks. def launch_func_wrapper(): try: # Execute the launch function! @@ -339,9 +350,8 @@ def launch_func_wrapper(): allow_kwargs=allow_kwargs, ) - if allow_kwargs: + if allow_kwargs or Settings().node_param_override: click_cmd.allow_extra_args = True - click_cmd.ignore_unknown_options = True try: click_cmd.main(_argv) @@ -350,7 +360,9 @@ def launch_func_wrapper(): raise -def _expose_ros2_launch_function(launch_func: Callable, declared_args: list[DeclaredArg]): +def _expose_ros2_launch_function( + launch_func: Callable, declared_args: list[DeclaredArg] +): """Helper function that exposes a function decorated by launch_this so that it can be included by a regular ROS2 launch file. We achieve this by generating a `generate_launch_description` function and adding it to the module globals where the launch function is defined. Parameters diff --git a/bin/bl b/bin/bl index 8400e4d..b2f29b3 100755 --- a/bin/bl +++ b/bin/bl @@ -146,11 +146,25 @@ class BetterLaunchPython(LaunchFile): flush=True, ) + # Filter out bl_* settings - these are BetterLaunch options, not launch args + launch_args = {k: v for k, v in kwargs.items() if not k.startswith("bl_")} + + # Pass current bl_* settings as CLI args to child process + from better_launch.utils.settings import Settings + + current_settings = Settings() + # First argument becomes argv[0], so should be the program name args = ["python3", self.filepath] - for key, arg in kwargs.items(): + for key, arg in launch_args.items(): if arg is not None: - args.extend([f"--{key}", arg]) + args.extend([f"--{key}", str(arg)]) + + if current_settings.node_param_override: + args.extend(["--bl-node-param-override", "true"]) + + # Pass through extra args (e.g., --node.param value for node overrides) + args.extend(ctx.args) # This does NOT return and will replace our executable with the new executable in the same process os.execvp("python3", [str(a) for a in args]) @@ -190,11 +204,13 @@ class BetterLaunchToml(LaunchFile): from better_launch.declarative import launch_toml + # Filter out bl_* settings - these are BetterLaunch options, not launch args + launch_args = {k: v for k, v in kwargs.items() if not k.startswith("bl_")} + # Since the toml files are not executable, we need something else to run them. We # could create another script with a main function, but I don't think there's much # of a point to this. - # TODO pass overrides? - launch_toml(self.filepath, launch_args=kwargs) + launch_toml(self.filepath, launch_args=launch_args, extra_args=ctx.args) class Ros2LaunchFile(LaunchFile): diff --git a/docs/about/features.md b/docs/about/features.md index cef2180..fa033e4 100644 --- a/docs/about/features.md +++ b/docs/about/features.md @@ -10,7 +10,6 @@ Everything! *better_launch* can do everything ROS2 launch can and some more. It - *remap topics* using simple dicts - launch arguments *passed directly* to your function - easily *locate files* and load configs -- manage *nodes from other launch files* - manage your node using a nice [terminal UI](../howto/tui.md) reminiscent of [rosmon](https://github.com/xqms/rosmon) ![TUI](../assets/images/tui_small1.png) diff --git a/docs/installation/installation.md b/docs/installation/installation.md index 4f1b474..0d64ecf 100644 --- a/docs/installation/installation.md +++ b/docs/installation/installation.md @@ -60,4 +60,4 @@ bl --help ??? example "The devel branch" - If you are the experimental type, *better_launch* also has a `devel` branch where I merge new features for testing. I try to keep it functional (since I'm using it myself), although there might be the occasional hiccup. + If you are the experimental type, *better_launch* also has a `devel` branch where I merge new features for testing. It is in general functional, although there might be the occasional hiccup. diff --git a/docs/paper.md b/docs/paper.md index b17aba3..19baa7e 100644 --- a/docs/paper.md +++ b/docs/paper.md @@ -51,7 +51,7 @@ In order to actually resolve these issues I have written *better_launch*, a comp ![Screenshot of the TUI](../media/tui.png){height="60%"} -We consider *better_launch* mature enough for general use in research applications, with performance similar or better than `ros2 launch` (see [documentation](https://dfki-ric.github.io/better_launch/)) [@py-spy; @memray; @psutil]. It is under active development and can be downloaded for free from https://github.com/dfki-ric/better_launch. We hope that *better_launch* will advance the state of ROS2 in a meaningful way. +We consider *better_launch* mature enough for general use in research applications, with performance similar or better than `ros2 launch` (benchmarks in repo) [@py-spy; @memray; @psutil]. It is under active development and can be downloaded for free from https://github.com/dfki-ric/better_launch. We hope that *better_launch* will advance the state of ROS2 in a meaningful way. \newpage diff --git a/docs/paper.pdf b/docs/paper.pdf index d9c920c6d2bda6a7e4e0dd0dc5c8f46536e3f98a..f434d20e12c16713a947ddc015fb5b8b6bcde615 100644 GIT binary patch delta 5192 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 T4jD1>cpMhXC@QLDq|Nw$ACN{G delta 5244 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. syntax. + +The node in this example declares 'period' parameter with declare_parameter(), so it can be +overridden via -p period:= when launching. + +Example usage: +-------------- +# Default period is 1.0s +bl better_launch 13_node_param_overrides.launch.py --bl-node-param-override true + +# Override period to 0.2s (fast publishing) +bl better_launch 13_node_param_overrides.launch.py --bl-node-param-override true --my_timer.period 0.2 +""" + +from better_launch import BetterLaunch, launch_this +import os + + +@launch_this +def node_param_overrides(): + bl = BetterLaunch() + script_dir = os.path.dirname(os.path.abspath(__file__)) + bl.node( + package=".", + executable=os.path.join(script_dir, "scripts", "timed_talker.py"), + name="my_timer", + ) diff --git a/examples/scripts/timed_talker.py b/examples/scripts/timed_talker.py new file mode 100755 index 0000000..00883b2 --- /dev/null +++ b/examples/scripts/timed_talker.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +"""Simple timed talker that outputs at a configurable rate.""" + +import rclpy +from rclpy.node import Node +from std_msgs.msg import String + + +class TimedTalker(Node): + def __init__(self): + super().__init__("my_timer") + self.declare_parameter("period", 1.0) + self._period = self.get_parameter("period").value + self.get_logger().info(f"Publishing with period={self._period}s") + self._pub = self.create_publisher(String, "chatter", 10) + self._timer = self.create_timer(self._period, self._callback) + self._count = 0 + + def _callback(self): + msg = String() + msg.data = f"Hello World: {self._count}" + self.get_logger().info(f'Publishing: "{msg.data}"') + self._pub.publish(msg) + self._count += 1 + + +def main(args=None): + rclpy.init(args=args) + try: + rclpy.spin(TimedTalker()) + except KeyboardInterrupt: + pass + finally: + rclpy.shutdown() + + +if __name__ == "__main__": + main() diff --git a/tests/test_click_utils.py b/tests/test_click_utils.py new file mode 100644 index 0000000..6a03d0b --- /dev/null +++ b/tests/test_click_utils.py @@ -0,0 +1,155 @@ +import sys +from types import ModuleType +from unittest.mock import MagicMock + + +# Mock rclpy and other ROS2 dependencies before importing better_launch +def mock_package(name): + m = ModuleType(name) + m.__path__ = [] + sys.modules[name] = m + return m + + +mock_rclpy = mock_package("rclpy") +mock_rclpy.Parameter = MagicMock() +sys.modules["rclpy.node"] = MagicMock() +sys.modules["rclpy.qos"] = MagicMock() +sys.modules["rclpy.action"] = MagicMock() +sys.modules["rclpy.parameter"] = MagicMock() +sys.modules["rclpy.executors"] = MagicMock() +sys.modules["rclpy.logging"] = MagicMock() +sys.modules["rclpy.signals"] = MagicMock() +sys.modules["rclpy.context"] = MagicMock() + +mock_package("ament_index_python") +sys.modules["ament_index_python.packages"] = MagicMock() + +mock_package("launch") +sys.modules["launch.actions"] = MagicMock() +sys.modules["launch.launch_description_sources"] = MagicMock() +sys.modules["launch.substitutions"] = MagicMock() +sys.modules["launch.conditions"] = MagicMock() +sys.modules["launch.event_handlers"] = MagicMock() +sys.modules["launch.events"] = MagicMock() + +mock_package("launch_ros") +sys.modules["launch_ros.actions"] = MagicMock() +sys.modules["launch_ros.substitutions"] = MagicMock() + +mock_package("lifecycle_msgs") +sys.modules["lifecycle_msgs.msg"] = MagicMock() +sys.modules["lifecycle_msgs.srv"] = MagicMock() + +mock_package("rcl_interfaces") +sys.modules["rcl_interfaces.msg"] = MagicMock() +sys.modules["rcl_interfaces.srv"] = MagicMock() + +mock_package("std_msgs") +sys.modules["std_msgs.msg"] = MagicMock() + +mock_package("std_srvs") +sys.modules["std_srvs.srv"] = MagicMock() + +mock_package("ros2param") +sys.modules["ros2param.api"] = MagicMock() + +mock_package("osrf_pycommon") +sys.modules["osrf_pycommon.process_utils"] = MagicMock() + +mock_click = mock_package("click") +mock_click.Option = MagicMock() +mock_click.Context = MagicMock() +mock_click.Parameter = MagicMock() +mock_click.Command = MagicMock() +mock_click.types = MagicMock() +mock_click.pass_context = lambda x: x + +mock_doc = mock_package("docstring_parser") +mock_doc.parse = MagicMock() + +import pytest +from better_launch.utils.click import parse_node_params, args_to_dict + + +def test_parse_node_params(): + # empty kwargs + assert parse_node_params({}) == ({}, {}) + + # no node params + kwargs = {"foo": "bar", "baz": 1} + assert parse_node_params(kwargs) == (kwargs, {}) + + # single node param + kwargs = {"node1.param1": "val1", "foo": "bar"} + assert parse_node_params(kwargs) == ({"foo": "bar"}, {"node1": {"param1": "val1"}}) + + # multiple params same node + kwargs = {"node1.param1": "val1", "node1.param2": 2} + assert parse_node_params(kwargs) == ({}, {"node1": {"param1": "val1", "param2": 2}}) + + # multiple nodes + kwargs = {"node1.p1": 1, "node2.p2": 2} + assert parse_node_params(kwargs) == ({}, {"node1": {"p1": 1}, "node2": {"p2": 2}}) + + # mixed kwargs + kwargs = {"node1.p1": 1, "foo": "bar", "node2.p2": 2, "baz": 3} + assert parse_node_params(kwargs) == ( + {"foo": "bar", "baz": 3}, + {"node1": {"p1": 1}, "node2": {"p2": 2}}, + ) + + # various value types + kwargs = { + "n.s": "string", + "n.f": 1.5, + "n.b": True, + "n.l": [1, 2, 3], + "n.d": {"a": 1}, + } + assert parse_node_params(kwargs) == ( + {}, + {"n": {"s": "string", "f": 1.5, "b": True, "l": [1, 2, 3], "d": {"a": 1}}}, + ) + + # nested param names + kwargs = {"node1.param.subparam": "val"} + assert parse_node_params(kwargs) == ({}, {"node1": {"param.subparam": "val"}}) + + +def test_args_to_dict(): + # basic parsing + args = ["--foo", "bar", "--baz", "1.0"] + assert args_to_dict(args) == {"foo": "bar", "baz": 1.0} + + # JSON type inference + args = [ + "--s", + "str", + "--i", + "1", + "--f", + "1.5", + "--b", + "true", + "--l", + "[1, 2]", + "--d", + '{"a": 1}', + ] + assert args_to_dict(args) == { + "s": "str", + "i": 1, + "f": 1.5, + "b": True, + "l": [1, 2], + "d": {"a": 1}, + } + + # missing value error + with pytest.raises(ValueError, match="Missing value for argument '--foo'"): + args_to_dict(["--foo"]) + + # no-dash-prefix error + with pytest.raises(ValueError, match="Argument 'foo' does not start with '-'"): + args_to_dict(["foo", "bar"]) From 7c854a457cd031b498361ed40976c4dce398b084 Mon Sep 17 00:00:00 2001 From: cc Date: Sat, 14 Feb 2026 02:49:16 -0500 Subject: [PATCH 2/3] doc: revert doc change --- README.md | 477 +++--------------------------- docs/about/features.md | 1 + docs/installation/installation.md | 2 +- docs/paper.md | 2 +- docs/paper.pdf | Bin 238302 -> 238363 bytes 5 files changed, 47 insertions(+), 435 deletions(-) diff --git a/README.md b/README.md index 27f0ab9..21b6e77 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,24 @@ ![Logo](docs/assets/images/logo_text.png) - -[About](#about) | [Why?](#why-not-improve-the-existing-ros2-launch) | [Features](#okay-what-can-i-do-with-it) | [Usage](#how-do-i-use-it) | [TUI](#the-tui) | [Differences](#what-are-the-differences) | [Performance](#performance) | [Installation](#installation) | [ROS2](#whats-so-bad-about-ros2-launch) | [Contributors](#contributors) - - > [!TIP] > Just looking for the [documentation](https://dfki-ric.github.io/better_launch/)? -> We also have multiple [examples](examples/)! +> We also have various [examples](examples/)! +--- # 🧭 About -Let's face it: ROS2 has been a severe downgrade in terms of usability compared to ROS1. While there are many considerable improvements, the current launch system is borderline unusable. I've listed my personal gripes below, but if you're here you likely feel the same. This is why I wrote ***better_launch***. +Let's face it: ROS2 has been a severe downgrade in terms of usability compared to ROS1. While there are many considerable improvements, the current launch system is borderline unusable. -Instead of dozens of imports and class instances for even the most basic tasks, your launch files could look as simple and beautiful as this: +*better_launch* is what I wish ROS2 launch would be: intuitive to use, simple to understand, easy to remember. This is why *better_launch* is **not** yet another abstraction layer over ROS2 launch; it is a **full replacement** with no required dependencies on the existing launch system. + +Instead of dozens of imports and class instances for even the most basic tasks, your launchfiles could look as simple and beautiful as this: ```python from better_launch import BetterLaunch, launch_this -@launch_this(ui=True) +@launch_this def my_main(enable_x: bool = True): - """ - This is how nice your launch files could be! + """This is how nice your launchfiles could be! """ bl = BetterLaunch() @@ -31,240 +29,36 @@ def my_main(enable_x: bool = True): "example_publisher", ) - # Include other launch files, even regular ROS2 launch files! + # Include other launchfiles, even regular ROS2 launchfiles! bl.include("better_launch", "ros2_turtlesim.launch.py") ``` ```bash +# You can use `ros2 launch`, too, but `bl` is better :) $> bl my_package my_launch_file.py --enable_x True ``` *Do I have your attention? Read on to learn more!* +--- -# 🤔 Why not improve the existing ROS2 launch? -Because I think it is beyond redemption and no amount of refactoring and REPs (ROS enhancement proposals) will turn the sails. Tools like the highly rated [simple_launch](https://github.com/oKermorgant/simple_launch) or [launch-generator](https://github.com/Tacha-S/launch_generator/) exist, but still use ROS2 launch under the hood and so inherit much of its clunkiness. Rather than fixing an inherently broken solution, I decided to make a RAP - a ROS abandonment proposal :) - -Essentially, *better_launch* is what I wish ROS2 launch would be: intuitive to use, simple to understand, easy to remember. This is why *better_launch* is **not** yet anothe``r abstraction layer over ROS2 launch; it is a **full replacement** with no required dependencies on the existing launch system. - - -# 🧩 Okay, what can I do with it? -Everything you would expect and a little more! The `BetterLaunch` instance allows you to -- create *subscribers*, *publishers*, *services*, *service clients*, *action servers* and *action clients* on the fly -- start and stop *nodes* -- start and stop *lifecycle nodes* and manage their lifecycle stage -- start and stop *composers* and load *components* into them -- organize your nodes in *groups* -- define hasslefree *topic remaps* for nodes and groups -- *pass any arguments* from the command line without having to declare them -- easily *load parameters* from yaml files -- *locate files* based on filenames and package names -- use *string substitutions* to resolve e.g. paths -- include other *better_launch launch files* -- include other *ROS2 launch files* -- let regular ROS2 launch files *include your better_launch launch files* -- configure *logging* just as you would in ROS2, yet have much more readable output -- manage your node using a nice [terminal UI](#the-tui) reminiscent of [rosmon](https://github.com/xqms/rosmon) - -For a quick comparison, bravely unfold the sections below: -
- ROS2 - -```python -# Taken from https://docs.ros.org/en/jazzy/Tutorials/Intermediate/Launch/Using-Substitutions.html -from launch_ros.actions import Node - -from launch import LaunchDescription -from launch.actions import DeclareLaunchArgument, ExecuteProcess, TimerAction -from launch.conditions import IfCondition -from launch.substitutions import LaunchConfiguration, PythonExpression - - -def generate_launch_description(): - turtlesim_ns = LaunchConfiguration('turtlesim_ns') - use_provided_red = LaunchConfiguration('use_provided_red') - new_background_r = LaunchConfiguration('new_background_r') - - turtlesim_ns_launch_arg = DeclareLaunchArgument( - 'turtlesim_ns', - default_value='turtlesim1' - ) - use_provided_red_launch_arg = DeclareLaunchArgument( - 'use_provided_red', - default_value='False' - ) - new_background_r_launch_arg = DeclareLaunchArgument( - 'new_background_r', - default_value='200' - ) - - turtlesim_node = Node( - package='turtlesim', - namespace=turtlesim_ns, - executable='turtlesim_node', - name='sim' - ) - spawn_turtle = ExecuteProcess( - cmd=[[ - 'ros2 service call ', - turtlesim_ns, - '/spawn ', - 'turtlesim/srv/Spawn ', - '"{x: 2, y: 2, theta: 0.2}"' - ]], - shell=True - ) - change_background_r = ExecuteProcess( - cmd=[[ - 'ros2 param set ', - turtlesim_ns, - '/sim background_r ', - '120' - ]], - shell=True - ) - change_background_r_conditioned = ExecuteProcess( - condition=IfCondition( - PythonExpression([ - new_background_r, - ' == 200', - ' and ', - use_provided_red - ]) - ), - cmd=[[ - 'ros2 param set ', - turtlesim_ns, - '/sim background_r ', - new_background_r - ]], - shell=True - ) - - return LaunchDescription([ - turtlesim_ns_launch_arg, - use_provided_red_launch_arg, - new_background_r_launch_arg, - turtlesim_node, - spawn_turtle, - change_background_r, - TimerAction( - period=2.0, - actions=[change_background_r_conditioned], - ) - ]) -``` -
- -
- better_launch (python) - -```python -from better_launch import BetterLaunch, launch_this -from rclpy import Timer - -@launch_this -def my_start( - # Launch arguments in function signature - turtlesim_ns: str = "turtlesim1", - use_provided_red: bool = False, - new_background_r: int = 200, -): - bl = BetterLaunch() - - # Pythonic AF - with bl.group(turtlesim_ns): - turtle_node = bl.node( - package="turtlesim", - executable="turtlesim_node", - name="sim", - # Pass parameters directly - params={"background_r": 120} - ) - - # Convenient API for common tasks - bl.call_service( - topic=f"/{turtlesim_ns}/spawn", - service_type="turtlesim/srv/Spawn", - # No weird types like passing dicts as strings - request_args={"x": 2.0, "y": 2.0, "theta": 0.2}, - ) - - if new_background_r == 200 and use_provided_red: - turtle_node.is_ros2_connected(timeout=None) - turtle_node.set_live_params({"background_r": new_background_r}) -``` -
- -
-```toml -bl_eval_mode = "full" - -turtlesim_ns = "turtlesim1" -use_provided_red = False -new_background_r = 200 - -[turtle_group] -func = "group" -namespace = "${turtlesim_ns}" - -[turtle_group.children.turtle_node] -func = "node" -package="turtlesim" -executable="turtlesim_node" -name="turtle_node" -params={"background_r": 120} - -[spawn_turtle] -func = "call_service" -topic = "/${turtlesim_ns}/spawn" -service_type = "turtlesim/srv/Spawn" -request_args = {"x": 2.0, "y": 2.0, "theta": 0.2} - -# TOML files are less powerful than python files, but you can still do a lot -[update_params] -if = "${eval new_background_r == 200 and use_provided_red}" -func = "call_service" -topic = "/${turtlesim_ns}/turtle_node/set_parameters" -service_type = "rcl_interfaces/srv/SetParameters" -request_args = { - "parameters": { - "name": "background_r", - "value": { - type: 2, - integer_value: "${new_background_r}" - } - } -} -``` -
- - -# 🛠️ How do I use it? -The best way to get to know *better_launch* is to explore the included [examples](examples/). Unlike ROS2, all examples and functions come with proper [documentation](https://dfki-ric.github.io/better_launch/). If anything is left unclear, feel free to contact me. +# 🧞‍♀️ Everything you need to know +- [The What and Why](https://dfki-ric.github.io/better_launch/about/why/) +- [Differences to ROS2](https://dfki-ric.github.io/better_launch/about/differences/) +- [Installation](https://dfki-ric.github.io/better_launch/installation/) +- [HowTo](https://dfki-ric.github.io/better_launch/howto/python/) +- [Examples](examples/) -You will mainly interact with *better_launch* through the following classes and modules: -- [@launch_this](better_launch/wrapper.py): decorator to create a launch file from a function. -- [BetterLaunch](better_launch/launcher.py): to create and start nodes, include other launch files, find and load parameters, etc. -- [convenience.py](better_launch/convenience.py): convenience functions to start rviz, robot state publishers, read urdf/xacro files, and more. -- [gazebo.py](better_launch/gazebo.py): functions and helpers for starting and populating gazebo simulations as well as bridging topics. +--- -Note that you are not forced to choose between *better_launch* and the ROS2 launch system. In fact, *better_launch* launch files can be run via `ros2 launch` and even be included from ROS2 launch files! However, this means running two launch systems on top of each other, so there is some overhead. The auto completion of `ros2 launch` is also slow as hell, cluttering the terminal with useless command line options yet is unable to discover the arguments you have declared inside your launch files. For these reasons, *better_launch* comes with the `bl` script, which fixes all of the above and then some. Once you have sourced your workspace you can use it as follows: +# ⛲ Highlights -```bash -# Try for autocomplete and check the example launch file for details! -bl better_launch 05_launch_arguments.py --help -``` +## 📟 The TUI -*better_launch* also reacts to the following environment variables: -- `BL_UI_OVERRIDE` (*enable|disable*): enables or disables the UI for all launch files. Superseded by the `--bl_ui_override` argument. -- `BL_COLORMODE_OVERRIDE` (*default|severity|source|none|rainbow*): overrides the colormode for all launch files. Superseded by the `--bl_colormode_override` argument. -- `BL_SCREEN_LOG_FORMAT_OVERRIDE`: overrides the format for messages logged to the terminal. Check the [PrettyLogFormatter](better_launch/utils/better_logging.py) for valid syntax. -- `BL_FILE_LOG_FORMAT_OVERRIDE`: overrides the format for messages logged to log files. Check the [PrettyLogFormatter](better_launch/utils/better_logging.py) for valid syntax. +> [!NOTE] +> [Using the TUI](https://dfki-ric.github.io/better_launch/howto/tui/) - -# 📟 The TUI -*better_launch* comes with a sneaky, unobstrusive TUI (terminal user interface) based on [prompt_toolkit](https://github.com/prompt-toolkit/python-prompt-toolkit), which will hover below the log output. You can start it by either passing `ui=True` to the `launch_this` wrapper, or by adding `--bl_ui_override=enable` on the command line. Use *\* to switch between menu items. +*better_launch* comes with an optional, unobstrusive TUI (terminal user interface) based on [prompt_toolkit](https://github.com/prompt-toolkit/python-prompt-toolkit), which will hover below the log output. ![TUI](docs/assets/images/tui.png) @@ -280,220 +74,37 @@ See the single line of shortcuts at the bottom? That's the TUI, and it will neve bl better_launch 02_ui.launch.py ``` -The TUI is also able to manage nodes started from different shells and processes, even if they have been started by ROS2 or other means. To do so, pass the `manage_foreign_nodes` flag to the wrapper or command line. Be aware though that this will not capture their output - to get their output you will have to use the *takeover* action from the TUI, which will restart the node process with the original arguments. - -> Foreign node processes are identified by having one of the following parameters in their arguments: `__ns`, `__name`, `__node`, `--ros-args`. This is always true for nodes started from launch files, but fails when they were started via `ros2 run` or other means. As far as I'm aware, there is no better way right now. - - -# ⚖️ What are the differences? -Because *better_launch* does not use the ROS2 launch system, some aspects work differently from what you may be used to. - - -## Action immediacy -In ROS2 launch, launch files create tasks that are then passed to an asynchronous event loop. This is the reason why e.g. checking for launch parameter values is so incredibly weird - they simply don't exist yet by the time you define the actions. In *better_launch* however, all actions are taken immediately: if you create a node, its process is started right away; if you include another *better_launch* launch file, its contents will be handled before the function returns. - -The only exception to this is adding ROS2 launch actions, e.g. including regular ROS2 launch files. Since these still rely on the ROS2 launch system, they need to be turned into asynchronous tasks and passed to the event loop. Usually a ROS2 `LaunchService` sub-process is started the first time a ROS2 action is passed to *better_launch*. From then on this process will handle all ROS2 actions asynchronously in the background. - -> While the output of the ROS2 launch service process (and its nodes) is captured and formatted by *better_launch* just like for all other nodes, these will usually appear and behave as one single `launch_service` unit in the TUI (unless `manage_foreign_nodes` is true, see above). - - -## Lifecycle nodes -Lifecycle nodes differ from regular nodes in that they don't become fully active after their process starts. Instead you have to call one of their lifecycle management services, usually via additional code in your launch file or the `ros2 lifecycle` CLI. However, in the end they are still just nodes. - -*better_launch* makes no distinction between regular and lifecycle nodes. Instead, all "lifecyclable" objects (e.g. nodes and components) provide a `LifecycleManager` object via their `lifecycle` member. This will be `None` if the object has not been identified (yet) as a lifecycle-thing - otherwise you can use it to manage the object's lifecycle. Additionally, all objects that turn out to be lifecyclable will transition to their *ACTIVE* state by default, unless you pass a different target state on instantiation. - - -## Type checking -When passing arguments to a node in ROS2, in the end everything is passed as stringified command line arguments. So why bother with overly strict type checking? Why do I have to turn half the parameters into strings myself? *better_launch* does not impose a flawed type sytem on you and will happily accept `int`, `string`, `float`, etc. where appropriate. In addition, sensible and *unsurprising* types have been chosen for all arguments you may provide (e.g. remaps are defined as a `dict[str, str]`, floats are happy to accept ints, launch arguments are not required to be strings, etc.). - - -## Declaring launch arguments -Simply put: you don't. *better_launch* will check the signature of your launch function and turn all arguments into launch arguments. For example, if your launch function has an `enable_x` argument, you will be able to pass it with `--enable_x` from the command line. Under the hood *better_launch* is using [click](https://click.palletsprojects.com/), so every launch file you write comes with proper CLI support. - -> Tip: try adding a docstring to your launch function and call your launch file with `--help`! - - -## Parameter files -You do **not** have to put `ros__parameters` in your configs anymore when using `BetterLaunch.load_params`. Hooray! You still can do so of course if you feel slightly masochistic. In fact, *better_launch* supports the full param syntax for mapping params to nodes, including namespace wildcards. See the `load_params` documentation for details. - - -## Logging -Just like ROS2 launch, *better_launch* takes care of managing loggers and redirecting everything where it belongs (in fact that part is largely copied from ROS2 launch). However, I did away with the in my opinion not very useful separation between a node's `stdout` and `stderr`, since nodes apparently write their log output to `stderr` by default. - -I also added a reformatting layer so that colors and nicer screen output are possible. The format can be customized by passing your own logging format strings to `launch_this`. Alternatively, you may set the `OVERRIDE_SCREEN_LOG_FORMAT` and `OVERRIDE_FILE_LOG_FORMAT` environment variables. +## ⛱️ TOML launchfiles +> [!NOTE] +> [Specification](https://dfki-ric.github.io/better_launch/howto/toml/) -## Abandoned processes -ROS2 launch has a bad reputation of leaving stale and abandoned processes behind after terminating. In my testing so far this has never been an issue with *better_launch* yet - except when you hard kill (-9) its process. +For those with aversions against using a turing-complete programming language to specify system startup - fear not! *better_launch* introduces a new launchfile format based on [TOML](https://toml.io/). - -# 💯 Performance -I am not an expert on profiling code. Even though *better_launch* uses synchronous calls (or classic threads if necessary), and does some additional work to reformat output from nodes, it was able to achieve similar performance to `ros2 launch`. The scripts and results from the benchmarks can be found under [docs/benchmarks](docs/benchmarks/). This section will only show the most relevant parts. - -> `bl` is just a script to locate the launch file and then run it, so I decided to not use `bl` for these benchmarks and instead run the launch file directly; otherwise the resources used by the launch file will not be visible to most profilers. - -
- memray - -[memray](https://github.com/bloomberg/memray) reports that *better_launch* uses about 30% less memory than `ros2 launch`. - -| | better_launch | ros2 launch | -| ----------------- | ------------- | ----------- | -| allocations | 48196 | 60943 | -| peak memory usage | 6.6 MiB | 9.7 MiB | -| details | [link](docs/benchmarks/results/memray/memray-flamegraph-bl.html) | [link](docs/benchmarks/results/memray/memray-flamegraph-ros2.html) | - -
- -
- psutil - -[psutil](https://psutil.readthedocs.io/en/latest/index.html#psutil.Process.memory_full_info) shows that *better_launch* uses more CPU in the beginning and more memory in total compared to `ros2 launch`. The memory reported is the unique set size (see the previous link). I'm not sure how these results relate to the memray statistics above. - -![](docs/benchmarks/results/psutil/cpu_usage.png) - -![](docs/benchmarks/results/psutil/memory_usage.png) - -
- -
- py-spy - -I use [py-spy](https://github.com/benfred/py-spy) to see where *better_launch* is using resources that can still be optimized. The speedscope files can be visualized on [speedscope.app](https://www.speedscope.app/). - -![](docs/benchmarks/results/pyspy/bl.svg) - -![](docs/benchmarks/results/pyspy/ros2.svg) - -
- - -# 📥 Installation -I'm working on getting a .deb package up and running. Until then you may follow the steps below! - -*better_launch* is a regular ROS2 package, which means you can install it in your workspace and then use it in all launch files within that workspace. - -ROS2 is slowly [moving towards pixi](https://docs.ros.org/en/kilted/Installation/Windows-Install-Binary.html) as the main python3 environment, but I have not tested it yet. However, by now all the dependencies have been added into rosdep, so the following should get you up and running: - -```bash -# Install dependencies -sudo apt update -rosdep update -rosdep install --from-paths src --ignore-src -y +```toml +enable = true + +[a_simple_cube] +if = "${enable}" +func = "find" +package = "better_launch" +filename = "cube.sdf" + +[print_me_baby] +func = "log" +severity = "info" +message = "Found cube at ${a_simple_cube}" ``` - -
- Python venv - -If you prefer a python virtual environment instead, here is a setup that works for us: - -```bash -# Install some prerequisites -sudo apt install python3-pip python3-venv - -# Create a virtual environment for your workspace -cd your/ros2/workspace/ -mkdir venv -python3 -m venv ./venv --system-site-packages --symlinks -touch venv/COLCON_IGNORE - -# Activate the venv -source ./venv/bin/activate - -# Activate your ROS2 workspace -source ./install/setup.bash - -# Install the dependencies into your venv -pip install -r path/to/better_launch/requirements.txt -``` -
+Under the hood, TOML launchfiles result in calls to the `BetterLaunch` singleton, but offer a more focused and constrained feature set. If you are still missing ROS1 XML launchfiles (and substitutions like `${arg my_arg}`!), these are for you! --- -No matter which path you choose, once all the dependencies are installed you should build *better_launch* / your workspace. - -```bash -# Get better_launch into your workspace src folder -cd /src -git clone https://github.com/dfki-ric/better_launch.git -``` - -```bash -# Build the better_launch package -cd -colcon build --packages-up-to better_launch -source install/setup.bash -``` - -```bash -# Verify installation -bl --help -``` - - -# 📢 What's so bad about ROS2 launch? -Here is a "simple" launch file from the [official documentation](https://docs.ros.org/en/jazzy/Tutorials/Intermediate/Launch/Using-Substitutions.html) that does nothing but include another launch file: - -```python -from launch_ros.substitutions import FindPackageShare - -from launch import LaunchDescription -from launch.actions import IncludeLaunchDescription -from launch.launch_description_sources import PythonLaunchDescriptionSource -from launch.substitutions import PathJoinSubstitution, TextSubstitution - - -def generate_launch_description(): - colors = { - 'background_r': '200' - } - - return LaunchDescription([ - IncludeLaunchDescription( - PythonLaunchDescriptionSource([ - PathJoinSubstitution([ - FindPackageShare('launch_tutorial'), - 'launch', - 'example_substitutions_launch.py' - ]) - ]), - launch_arguments={ - 'turtlesim_ns': 'turtlesim2', - 'use_provided_red': 'True', - 'new_background_r': TextSubstitution(text=str(colors['background_r'])) - }.items() - ) - ]) -``` - -I think we can agree that this is not exactly elegant - including another launch file should be doable within a single line, not 10 plus 5 imports. Other terrible decisions within ROS2 launch include, but are not limited to: -- a weird fetish for unintuitive import statements (see above) -- unneccesarily strict type checking (why use python if I have to verify everything?) -- nonsensical argument types (e.g. remaps are a *list of tuples* instead of simply a *dict*) -- using asyncio may be slightly faster, but prevents normal variable interactions (ever wondered why you always see these weird `Condition` classes instead of a simple `if my_arg:`?) -- horrendous API for starting lifecycle nodes (also, why the hell are there two completely separate base interfaces?) -- the list goes on... - -For comparison, here is what the above launch file will look like in *better_launch*: - -```python -from better_launch import BetterLaunch, launch_this - -@launch_this -def main(turtlesim_ns = "turtlesim2", use_provided_red = True, new_background_r = 200): - bl = BetterLaunch() - - bl.include( - "launch_tutorial", - "example_substitutions.launch.py", - pass_all_args=True, # or pass as keyword arguments - ) -``` -Overall, ROS2 launch seems like a system architect's wet fever dream, and I don't enjoy it. +# 🌱 Contributions +> [!IMPORTANT] +> Please [see this document](CONTRIBUTING.md) if you're planning to make PR! -# 🌱 Contributors *Author:* [Nikolas Dahn](https://github.com/ndahn/) *Testing & Feedback:* diff --git a/docs/about/features.md b/docs/about/features.md index fa033e4..cef2180 100644 --- a/docs/about/features.md +++ b/docs/about/features.md @@ -10,6 +10,7 @@ Everything! *better_launch* can do everything ROS2 launch can and some more. It - *remap topics* using simple dicts - launch arguments *passed directly* to your function - easily *locate files* and load configs +- manage *nodes from other launch files* - manage your node using a nice [terminal UI](../howto/tui.md) reminiscent of [rosmon](https://github.com/xqms/rosmon) ![TUI](../assets/images/tui_small1.png) diff --git a/docs/installation/installation.md b/docs/installation/installation.md index 0d64ecf..4f1b474 100644 --- a/docs/installation/installation.md +++ b/docs/installation/installation.md @@ -60,4 +60,4 @@ bl --help ??? example "The devel branch" - If you are the experimental type, *better_launch* also has a `devel` branch where I merge new features for testing. It is in general functional, although there might be the occasional hiccup. + If you are the experimental type, *better_launch* also has a `devel` branch where I merge new features for testing. I try to keep it functional (since I'm using it myself), although there might be the occasional hiccup. diff --git a/docs/paper.md b/docs/paper.md index 19baa7e..b17aba3 100644 --- a/docs/paper.md +++ b/docs/paper.md @@ -51,7 +51,7 @@ In order to actually resolve these issues I have written *better_launch*, a comp ![Screenshot of the TUI](../media/tui.png){height="60%"} -We consider *better_launch* mature enough for general use in research applications, with performance similar or better than `ros2 launch` (benchmarks in repo) [@py-spy; @memray; @psutil]. It is under active development and can be downloaded for free from https://github.com/dfki-ric/better_launch. We hope that *better_launch* will advance the state of ROS2 in a meaningful way. +We consider *better_launch* mature enough for general use in research applications, with performance similar or better than `ros2 launch` (see [documentation](https://dfki-ric.github.io/better_launch/)) [@py-spy; @memray; @psutil]. It is under active development and can be downloaded for free from https://github.com/dfki-ric/better_launch. We hope that *better_launch* will advance the state of ROS2 in a meaningful way. \newpage diff --git a/docs/paper.pdf b/docs/paper.pdf index f434d20e12c16713a947ddc015fb5b8b6bcde615..d9c920c6d2bda6a7e4e0dd0dc5c8f46536e3f98a 100644 GIT binary patch delta 5244 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 T4jD1>cpMhXC@QLDq|Nw$ACN{G From bfede6c9fc35b1782bc8a6ff1ae5d40fc22cddbd Mon Sep 17 00:00:00 2001 From: cc Date: Tue, 24 Feb 2026 13:23:25 -0500 Subject: [PATCH 3/3] fix: match dev --- better_launch/launcher.py | 21 +++++++-------------- 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/better_launch/launcher.py b/better_launch/launcher.py index aa0da2e..645efb4 100644 --- a/better_launch/launcher.py +++ b/better_launch/launcher.py @@ -778,8 +778,8 @@ def load_params( merge_pure_wildcards : bool, optional If True, wildcard parameters from the root level (/**) will be merged into the returned dict. That is, any new keys therein will be moved to the root level and any keys to already existing dicts will be merged. conflict_resolution : Literal["reject", "accept"], optional - What to do when assembling the final params dict and there are keys that are already defined (e.g. due to wildcards). - - "reject": keep the already existing keys, i.e. the version with the more specific path. + What to do when assembling the final params dict and there are keys that are already defined (e.g. due to wildcards). + - "reject": keep the already existing keys, i.e. the version with the more specific path. - "accept": apply the value from the wildcard dict, replacing the one with the more specific path. strip_ros_path_separators : bool, optional If True, any mentions of ros__parameters will be removed. @@ -829,19 +829,12 @@ def gather_params(current: dict[str, Any], path: str = "") -> None: for key, value in current.items(): current_path = f"{path}/{key}" if path else key - if isinstance(value, dict): - if not path or fnmatch(current_path, qualifier): - selected = ( - remove_ros_params(value) - if strip_ros_path_separators - else value - ) - for param_name, param_val in selected.items(): - final_params[param_name] = param_val - else: - gather_params(value, current_path) - elif fnmatch(current_path, qualifier): + if fnmatch(current_path, qualifier): + if strip_ros_path_separators: + value = remove_ros_params(value) final_params[key] = value + elif isinstance(value, dict): + gather_params(value, current_path) # Merge nested dicts def recursive_merge(