diff --git a/launch_ros/launch_ros/actions/load_composable_nodes.py b/launch_ros/launch_ros/actions/load_composable_nodes.py index 8b52585d9..0fc5e8c79 100644 --- a/launch_ros/launch_ros/actions/load_composable_nodes.py +++ b/launch_ros/launch_ros/actions/load_composable_nodes.py @@ -279,6 +279,54 @@ def execute( context: LaunchContext ) -> Optional[List[Action]]: """Execute the action.""" + # Dry run mode: log what would be loaded and return without actually loading + if context.dry_run: + self.__logger.info("[DRY RUN] Would load composable nodes into container") + for node_description in self.__composable_node_descriptions: + # Use get_composable_node_load_request to get full parameter details + try: + request = get_composable_node_load_request(node_description, context) + except Exception as e: + self.__logger.error(f"[DRY RUN] Error getting load request: {e}") + continue + + pkg = request.package_name + plugin = request.plugin_name + node_name = request.node_name + node_namespace = request.node_namespace or '' + + self.__logger.info( + f"[DRY RUN] - Node: package='{pkg}', " + f"plugin='{plugin}', name='{node_name}', namespace='{node_namespace}'" + ) + + # Log remappings + if request.remap_rules: + self.__logger.info(f"[DRY RUN] Remappings: {', '.join(request.remap_rules)}") + + # Log and check parameter files using the helper function + param_file_paths = get_composable_node_param_file_paths(node_description, context) + if param_file_paths: + for param_file_path in param_file_paths: + if param_file_path.startswith('/tmp/'): + self.__logger.info(f"[DRY RUN] Param file (tmp): {param_file_path}") + else: + # Check integrity + import os + if os.path.exists(param_file_path): + self.__logger.info(f"[DRY RUN] Param file OK: {param_file_path}") + else: + self.__logger.error(f"[DRY RUN] Param file NOT FOUND: {param_file_path}") + elif request.parameters: + # Only inline params, no files + self.__logger.info(f"[DRY RUN] Inline params: {len(request.parameters)} parameter(s)") + + # Log extra arguments + if request.extra_arguments: + self.__logger.info(f"[DRY RUN] Extra arguments: {len(request.extra_arguments)} argument(s)") + + return None + # resolve target container node name if is_a_subclass(self.__target_container, ComposableNodeContainer): @@ -313,6 +361,37 @@ def execute( ) +def get_composable_node_param_file_paths( + composable_node_description: ComposableNode, + context: LaunchContext +): + """Get parameter file paths from a composable node description (for dry-run logging).""" + param_file_paths = [] + + params_container = context.launch_configurations.get('global_params', None) + if params_container is not None: + for param in params_container: + if not isinstance(param, tuple): + # It's a parameter file path + param_file_path = Path(param).resolve() + param_file_paths.append(str(param_file_path)) + + # Extract file paths from node parameters + if composable_node_description.parameters is not None: + for param in composable_node_description.parameters: + if isinstance(param, ParameterFile): + # Get the param_file property (may be a Path or require evaluation) + try: + param_path = param.param_file + if hasattr(param_path, 'resolve'): + param_path = param_path.resolve() + param_file_paths.append(str(param_path)) + except Exception: + pass # Skip if we can't get the path + + return param_file_paths + + def get_composable_node_load_request( composable_node_description: ComposableNode, context: LaunchContext diff --git a/launch_ros/launch_ros/actions/node.py b/launch_ros/launch_ros/actions/node.py index fd684ce21..44e82d5d6 100644 --- a/launch_ros/launch_ros/actions/node.py +++ b/launch_ros/launch_ros/actions/node.py @@ -488,6 +488,23 @@ def execute(self, context: LaunchContext) -> Optional[List[Action]]: Delegated to :meth:`launch.actions.ExecuteProcess.execute`. """ self._perform_substitutions(context) + + # Dry run mode: log what would be executed and return without spawning + if context.dry_run: + node_logger = launch.logging.get_logger('Node') + # Resolve substitutions for logging - need to normalize to list of substitutions first + package_name = perform_substitutions( + context, normalize_to_list_of_substitutions(self.__package)) if self.__package else 'None' + executable_name = perform_substitutions( + context, normalize_to_list_of_substitutions(self.__node_executable)) if self.__node_executable else 'None' + node_name = self.__final_node_name if self.__final_node_name else self.__node_name + node_namespace = self.__expanded_node_namespace + node_logger.info( + f"[DRY RUN] Would launch ROS node: package='{package_name}', " + f"executable='{executable_name}', name='{node_name}', " + f"namespace='{node_namespace}'" + ) + # Prepare the ros_specific_arguments list and add it to the context so that the # LocalSubstitution placeholders added to the the cmd can be expanded using the contents. ros_specific_arguments: Dict[str, Union[str, List[str]]] = {} diff --git a/ros2launch/ros2launch/api/api.py b/ros2launch/ros2launch/api/api.py index 3bdb5bd1b..ddd39e696 100644 --- a/ros2launch/ros2launch/api/api.py +++ b/ros2launch/ros2launch/api/api.py @@ -145,9 +145,14 @@ def launch_a_launch_file( noninteractive=False, args=None, option_extensions={}, - debug=False + debug=False, + dry_run=False ): - """Launch a given launch file (by path) and pass it the given launch file arguments.""" + """Launch a given launch file (by path) and pass it the given launch file arguments. + + :param: dry_run if True, the launch system will process events but skip actual + process spawning, logging what actions would be executed instead + """ for name in sorted(option_extensions.keys()): option_extensions[name].prestart(args) @@ -162,7 +167,8 @@ def launch_a_launch_file( launch_service = launch.LaunchService( argv=launch_file_arguments, noninteractive=noninteractive, - debug=debug) + debug=debug, + dry_run=dry_run) parsed_launch_arguments = parse_launch_arguments(launch_file_arguments) # Include the user provided launch file using IncludeLaunchDescription so that the