From 6ada3b3750f1bb625f040979f14b2ab05e1aec7c Mon Sep 17 00:00:00 2001 From: Yuxuan Liu <619684051@qq.com> Date: Wed, 4 Mar 2026 17:05:49 +0900 Subject: [PATCH] feat: dry run option Signed-off-by: Yuxuan Liu <619684051@qq.com> --- launch/launch/actions/execute_local.py | 70 ++++++++++++++++++++++++++ launch/launch/launch_context.py | 11 +++- launch/launch/launch_service.py | 10 ++-- 3 files changed, 87 insertions(+), 4 deletions(-) diff --git a/launch/launch/actions/execute_local.py b/launch/launch/actions/execute_local.py index 8fa0845b2..e8361c748 100644 --- a/launch/launch/actions/execute_local.py +++ b/launch/launch/actions/execute_local.py @@ -19,6 +19,7 @@ import logging import os import platform +import re import signal import threading import traceback @@ -32,6 +33,7 @@ from typing import Tuple # noqa: F401 from typing import Union +import yaml import launch.logging from osrf_pycommon.process_utils import async_execute_process @@ -79,6 +81,58 @@ _global_process_counter = 0 # in Python3, this number is unbounded (no rollover) +def _check_param_file_integrity(cmd: List[str], logger: logging.Logger) -> None: + """ + Check integrity of parameter files referenced in the command. + + For full path files (not in /tmp/): + - Check if file exists + - Check if file is readable + - Validate YAML syntax + + For temporary files (in /tmp/): + - Log as temporary and skip integrity check + """ + if not cmd: + return + + cmd_str = ' '.join(cmd) + + # Extract --params-file arguments using regex + param_files = re.findall(r'--params-file\s+(\S+)', cmd_str) + + for param_file in param_files: + # Skip temporary files + if param_file.startswith('/tmp/'): + logger.info(f"[dry-run] Parameter file is temporary (tmp): {param_file}") + continue + + # Skip non-absolute paths (relative paths would be resolved at runtime) + if not os.path.isabs(param_file): + logger.info(f"[dry-run] Parameter file uses relative path: {param_file}") + continue + + # Check if file exists + if not os.path.exists(param_file): + logger.error(f"[dry-run] Parameter file NOT FOUND: {param_file}") + continue + + # Check if file is readable + if not os.access(param_file, os.R_OK): + logger.error(f"[dry-run] Parameter file not readable: {param_file}") + continue + + # Validate YAML syntax + try: + with open(param_file, 'r') as f: + yaml.safe_load(f) + logger.info(f"[dry-run] Parameter file OK: {param_file}") + except yaml.YAMLError as e: + logger.error(f"[dry-run] Invalid YAML in parameter file: {param_file} - {e}") + except Exception as e: + logger.error(f"[dry-run] Error reading parameter file: {param_file} - {e}") + + class ExecuteLocal(Action): """Action that begins executing a process on the local system and sets up event handlers.""" @@ -634,6 +688,22 @@ def execute(self, context: LaunchContext) -> Optional[List[LaunchDescriptionEnti # If shutdown starts before execution can start, don't start execution. return None + # Dry run mode: log what would be executed and return without spawning + if context.dry_run: + cmd = self.__process_description.final_cmd + cwd = self.__process_description.final_cwd + self.__logger = launch.logging.get_logger(name or 'execute_process') + self.__logger.info( + f"[DRY RUN] Would execute process: cmd='{' '.join(cmd) if cmd else 'None'}', " + f"cwd='{cwd}'" + ) + # Check integrity of parameter files + _check_param_file_integrity(cmd, self.__logger) + # Set a completed future so the launch system knows we're done + self.__completed_future = context.asyncio_loop.create_future() + self.__completed_future.set_result(None) + return None + if self.__cached_output: on_output_method = self.__on_process_output_cached flush_buffers_method = self.__flush_cached_buffers diff --git a/launch/launch/launch_context.py b/launch/launch/launch_context.py index 498b8a7de..a63fda146 100644 --- a/launch/launch/launch_context.py +++ b/launch/launch/launch_context.py @@ -39,7 +39,8 @@ def __init__( self, *, argv: Optional[Iterable[Text]] = None, - noninteractive: bool = False + noninteractive: bool = False, + dry_run: bool = False ) -> None: """ Create a LaunchContext. @@ -47,9 +48,12 @@ def __init__( :param: argv stored in the context for access by the entities, None results in [] :param: noninteractive if True (not default), this service will assume it has no terminal associated e.g. it is being executed from a non interactive script + :param: dry_run if True, the launch system will process events but skip actual + process spawning """ self.__argv = argv if argv is not None else [] self.__noninteractive = noninteractive + self.__dry_run = dry_run self._event_queue = asyncio.Queue() # type: asyncio.Queue self._event_handlers = collections.deque() # type: collections.deque @@ -82,6 +86,11 @@ def noninteractive(self): """Getter for noninteractive.""" return self.__noninteractive + @property + def dry_run(self) -> bool: + """Getter for dry_run.""" + return self.__dry_run + def _set_is_shutdown(self, state: bool) -> None: self.__is_shutdown = state diff --git a/launch/launch/launch_service.py b/launch/launch/launch_service.py index 3dd1e7429..e1bc692fa 100644 --- a/launch/launch/launch_service.py +++ b/launch/launch/launch_service.py @@ -55,7 +55,8 @@ def __init__( *, argv: Optional[Iterable[Text]] = None, noninteractive: bool = False, - debug: bool = False + debug: bool = False, + dry_run: bool = False ) -> None: """ Create a LaunchService. @@ -63,18 +64,21 @@ def __init__( :param: argv stored in the context for access by the entities, None results in [] :param: noninteractive if True (not default), this service will assume it has no terminal associated e.g. it is being executed from a non interactive script - :param: debug if True (not default), asyncio the logger are seutp for debug + :param: debug if True (not default), asyncio and the logger are set up for debug + :param: dry_run if True, the launch system will process events but skip actual + process spawning, logging what actions would be executed instead """ # Setup logging and debugging. launch.logging.launch_config.level = logging.DEBUG if debug else logging.INFO self.__debug = debug self.__argv = argv if argv is not None else [] + self.__dry_run = dry_run # Setup logging self.__logger = launch.logging.get_logger('launch') # Setup context and register a built-in event handler for bootstrapping. - self.__context = LaunchContext(argv=self.__argv, noninteractive=noninteractive) + self.__context = LaunchContext(argv=self.__argv, noninteractive=noninteractive, dry_run=self.__dry_run) self.__context.register_event_handler(OnIncludeLaunchDescription()) self.__context.register_event_handler(OnShutdown(on_shutdown=self.__on_shutdown))