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
70 changes: 70 additions & 0 deletions launch/launch/actions/execute_local.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import logging
import os
import platform
import re
import signal
import threading
import traceback
Expand All @@ -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
Expand Down Expand Up @@ -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."""

Expand Down Expand Up @@ -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
Expand Down
11 changes: 10 additions & 1 deletion launch/launch/launch_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,17 +39,21 @@ def __init__(
self,
*,
argv: Optional[Iterable[Text]] = None,
noninteractive: bool = False
noninteractive: bool = False,
dry_run: bool = False
) -> None:
"""
Create a LaunchContext.

: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
Expand Down Expand Up @@ -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

Expand Down
10 changes: 7 additions & 3 deletions launch/launch/launch_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,26 +55,30 @@ def __init__(
*,
argv: Optional[Iterable[Text]] = None,
noninteractive: bool = False,
debug: bool = False
debug: bool = False,
dry_run: bool = False
) -> None:
"""
Create a LaunchService.

: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))

Expand Down