Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions launch_ros/launch_ros/actions/load_composable_nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions launch_ros/launch_ros/actions/node.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]]] = {}
Expand Down
12 changes: 9 additions & 3 deletions ros2launch/ros2launch/api/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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
Expand Down