diff --git a/launch_ros/launch_ros/actions/lifecycle_node.py b/launch_ros/launch_ros/actions/lifecycle_node.py index dd5cb46b2..59cd12010 100644 --- a/launch_ros/launch_ros/actions/lifecycle_node.py +++ b/launch_ros/launch_ros/actions/lifecycle_node.py @@ -123,7 +123,7 @@ def unblock(future): self.__logger.error( "Failed to make transition '{}' for LifecycleNode '{}'".format( ChangeState.valid_transitions[request.transition.id], - self.node_name, + self.name, ) ) @@ -142,20 +142,20 @@ def execute(self, context: launch.LaunchContext) -> Optional[List[Action]]: Delegated to :meth:`launch.actions.ExecuteProcess.execute`. """ - self._perform_substitutions(context) # ensure self.node_name is expanded - if '' in self.node_name: - raise RuntimeError('node_name unexpectedly incomplete for lifecycle node') + self.prepare(context) # ensure self.name is expanded + if '' in self.name: + raise RuntimeError('name unexpectedly incomplete for lifecycle node') node = get_ros_node(context) # Create a subscription to monitor the state changes of the subprocess. self.__rclpy_subscription = node.create_subscription( lifecycle_msgs.msg.TransitionEvent, - '{}/transition_event'.format(self.node_name), + '{}/transition_event'.format(self.name), functools.partial(self._on_transition_event, context), 10) # Create a service client to change state on demand. self.__rclpy_change_state_client = node.create_client( lifecycle_msgs.srv.ChangeState, - '{}/change_state'.format(self.node_name)) + '{}/change_state'.format(self.name)) # Register an event handler to change states on a ChangeState lifecycle event. context.register_event_handler(launch.EventHandler( matcher=lambda event: isinstance(event, ChangeState), diff --git a/launch_ros/launch_ros/actions/load_composable_nodes.py b/launch_ros/launch_ros/actions/load_composable_nodes.py index fda356a29..e1278d5fc 100644 --- a/launch_ros/launch_ros/actions/load_composable_nodes.py +++ b/launch_ros/launch_ros/actions/load_composable_nodes.py @@ -210,7 +210,7 @@ def execute( # resolve target container node name if is_a_subclass(self.__target_container, ComposableNodeContainer): - self.__final_target_container_name = self.__target_container.node_name + self.__final_target_container_name = self.__target_container.name elif isinstance(self.__target_container, SomeSubstitutionsType_types_tuple): subs = normalize_to_list_of_substitutions(self.__target_container) self.__final_target_container_name = perform_substitutions( @@ -261,11 +261,11 @@ def get_composable_node_load_request( request.plugin_name = perform_substitutions( context, composable_node_description.node_plugin ) - if composable_node_description.node_name is not None: + if composable_node_description.name is not None: request.node_name = perform_substitutions( - context, composable_node_description.node_name + context, composable_node_description.name ) - expanded_ns = composable_node_description.node_namespace + expanded_ns = composable_node_description.namespace if expanded_ns is not None: expanded_ns = perform_substitutions(context, expanded_ns) base_ns = context.launch_configurations.get('ros_namespace', None) diff --git a/launch_ros/launch_ros/actions/node.py b/launch_ros/launch_ros/actions/node.py index fd684ce21..9e010f176 100644 --- a/launch_ros/launch_ros/actions/node.py +++ b/launch_ros/launch_ros/actions/node.py @@ -14,108 +14,36 @@ """Module for the Node action.""" -import os -import pathlib -from tempfile import NamedTemporaryFile +import inspect + from typing import Dict from typing import Iterable from typing import List from typing import Optional from typing import Text # noqa: F401 from typing import Tuple # noqa: F401 -from typing import Union - -try: - import importlib.metadata as importlib_metadata -except ModuleNotFoundError: - import importlib_metadata -from launch.action import Action +from launch.actions import ExecuteLocal from launch.actions import ExecuteProcess +from launch.descriptions import Executable from launch.frontend import Entity from launch.frontend import expose_action from launch.frontend import Parser from launch.frontend.type_utils import get_data_type_from_identifier -from launch.launch_context import LaunchContext -import launch.logging from launch.some_substitutions_type import SomeSubstitutionsType -from launch.substitutions import LocalSubstitution -from launch.utilities import ensure_argument_type -from launch.utilities import normalize_to_list_of_substitutions -from launch.utilities import perform_substitutions +from launch_ros.descriptions import Node as NodeDescription +from launch_ros.descriptions import ParameterFile +from launch_ros.descriptions import RosExecutable from launch_ros.parameters_type import SomeParameters from launch_ros.remap_rule_type import SomeRemapRules -from launch_ros.substitutions import ExecutableInPackage -from launch_ros.utilities import add_node_name -from launch_ros.utilities import evaluate_parameters -from launch_ros.utilities import get_node_name_count -from launch_ros.utilities import make_namespace_absolute -from launch_ros.utilities import normalize_parameters -from launch_ros.utilities import normalize_remap_rules -from launch_ros.utilities import plugin_support -from launch_ros.utilities import prefix_namespace - - -from rclpy.validate_namespace import validate_namespace -from rclpy.validate_node_name import validate_node_name - -import yaml - -from ..descriptions import Parameter -from ..descriptions import ParameterFile - - -class NodeActionExtension: - """ - The extension point for launch_ros node action extensions. - - The following properties must be defined: - * `NAME` (will be set to the entry point name) - - The following methods may be defined: - * `command_extension` - * `execute` - """ - - NAME = None - EXTENSION_POINT_VERSION = '0.1' - - def __init__(self): - super(NodeActionExtension, self).__init__() - plugin_support.satisfies_version(self.EXTENSION_POINT_VERSION, '^0.1') - - def prepare_for_execute(self, context, ros_specific_arguments, node_action): - """ - Perform any actions prior to the node's process being launched. - - `context` is the context within which the launch is taking place, - containing amongst other things the command line arguments provided by - the user. - - `ros_specific_arguments` is a dictionary of command line arguments that - will be passed to the executable and are specific to ROS. - - `node_action` is the Node action instance that is calling the - extension. - - This method must return a tuple of command line additions as a list of - launch.substitutions.TextSubstitution objects, and - `ros_specific_arguments` with any modifications made to it. If no - modifications are made, it should return - `[], ros_specific_arguments`. - """ - return [], ros_specific_arguments @expose_action('node') -class Node(ExecuteProcess): +class Node(ExecuteLocal): """Action that executes a ROS node.""" - UNSPECIFIED_NODE_NAME = '' - UNSPECIFIED_NODE_NAMESPACE = '' - def __init__( self, *, executable: SomeSubstitutionsType, @@ -127,6 +55,7 @@ def __init__( remappings: Optional[SomeRemapRules] = None, ros_arguments: Optional[Iterable[SomeSubstitutionsType]] = None, arguments: Optional[Iterable[SomeSubstitutionsType]] = None, + additional_env: Optional[Dict[SomeSubstitutionsType, SomeSubstitutionsType]] = None, **kwargs ) -> None: """ @@ -198,46 +127,17 @@ def __init__( :param: ros_arguments list of ROS arguments for the node :param: arguments list of extra arguments for the node """ - if package is not None: - cmd = [ExecutableInPackage(package=package, executable=executable)] - else: - cmd = [executable] - cmd += [] if arguments is None else arguments - cmd += [] if ros_arguments is None else ['--ros-args'] + ros_arguments - # Reserve space for ros specific arguments. - # The substitutions will get expanded when the action is executed. - cmd += ['--ros-args'] # Prepend ros specific arguments with --ros-args flag - if name is not None: - cmd += ['-r', LocalSubstitution( - "ros_specific_arguments['name']", description='node name')] - if parameters is not None: - ensure_argument_type(parameters, (list), 'parameters', 'Node') - # All elements in the list are paths to files with parameters (or substitutions that - # evaluate to paths), or dictionaries of parameters (fields can be substitutions). - normalized_params = normalize_parameters(parameters) - # Forward 'exec_name' as to ExecuteProcess constructor - kwargs['name'] = exec_name - super().__init__(cmd=cmd, **kwargs) - self.__package = package - self.__node_executable = executable - self.__node_name = name - self.__node_namespace = namespace - self.__parameters = [] if parameters is None else normalized_params - self.__remappings = [] if remappings is None else list(normalize_remap_rules(remappings)) - self.__ros_arguments = ros_arguments - self.__arguments = arguments - - self.__expanded_node_name = self.UNSPECIFIED_NODE_NAME - self.__expanded_node_namespace = self.UNSPECIFIED_NODE_NAMESPACE - self.__expanded_parameter_arguments = None # type: Optional[List[Tuple[Text, bool]]] - self.__final_node_name = None # type: Optional[Text] - self.__expanded_remappings = None # type: Optional[List[Tuple[Text, Text]]] - - self.__substitutions_performed = False - - self.__logger = launch.logging.get_logger(__name__) - - self.__extensions = get_extensions(self.__logger) + self.__node_desc = NodeDescription(name=name, namespace=namespace, + parameters=parameters, remappings=remappings) + executable_keywords = inspect.signature(Executable).parameters.keys() + ros_exec_kwargs = {key: val for key, val in kwargs.items() if key in executable_keywords} + self.__ros_exec = RosExecutable(package=package, executable=executable, + arguments=arguments, ros_arguments=ros_arguments, + nodes=[self.__node_desc], **ros_exec_kwargs) + super().__init__(process_description=self.__ros_exec, **kwargs) + + def is_name_fully_specified(self): + return self.__node_desc.is_name_fully_specified() @staticmethod def parse_nested_parameters(params, parser): @@ -306,19 +206,23 @@ def get_nested_dictionary_from_nested_key_value_pairs(params): def parse(cls, entity: Entity, parser: Parser): """Parse node.""" # See parse method of `ExecuteProcess` - _, kwargs = super().parse(entity, parser, ignore=['cmd']) + # Note: This class originally was a direct descendant of ExecuteProcess, + # but has been refactored to better divide the concept of a node vs an + # executable process. This class remains as a compatibility layer, and + # must hand off parsing duties to its original ancestor. + _, kwargs = ExecuteProcess.parse(entity, parser, ignore=['cmd']) args = entity.get_attr('args', optional=True) if args is not None: - kwargs['arguments'] = super()._parse_cmdline(args, parser) + kwargs['arguments'] = ExecuteProcess._parse_cmdline(args, parser) ros_args = entity.get_attr('ros_args', optional=True) if ros_args is not None: - kwargs['ros_arguments'] = super()._parse_cmdline(ros_args, parser) - node_name = entity.get_attr('node-name', optional=True) - if node_name is not None: - kwargs['node_name'] = parser.parse_substitution(node_name) - node_name = entity.get_attr('name', optional=True) - if node_name is not None: - kwargs['name'] = parser.parse_substitution(node_name) + kwargs['ros_arguments'] = ExecuteProcess._parse_cmdline(ros_args, parser) + name = entity.get_attr('node-name', optional=True) + if name is not None: + kwargs['name'] = parser.parse_substitution(name) + name = entity.get_attr('name', optional=True) + if name is not None: + kwargs['name'] = parser.parse_substitution(name) exec_name = entity.get_attr('exec_name', optional=True) if exec_name is not None: kwargs['exec_name'] = parser.parse_substitution(exec_name) @@ -346,250 +250,31 @@ def parse(cls, entity: Entity, parser: Parser): return cls, kwargs @property - def node_package(self): - """Getter for node_package.""" - return self.__package + def package(self): + """Getter for package.""" + return self.__ros_exec.package @property - def node_executable(self): - """Getter for node_executable.""" - return self.__node_executable + def executable(self): + """Getter for executable.""" + return self.__ros_exec.executable @property - def node_name(self): - """Getter for node_name.""" - if self.__final_node_name is None: - raise RuntimeError("cannot access 'node_name' before executing action") - return self.__final_node_name - - def is_node_name_fully_specified(self): - keywords = (self.UNSPECIFIED_NODE_NAME, self.UNSPECIFIED_NODE_NAMESPACE) - return all(x not in self.node_name for x in keywords) - - def _create_params_file_from_dict(self, params): - with NamedTemporaryFile(mode='w', prefix='launch_params_', delete=False) as h: - param_file_path = h.name - param_dict = { - self.node_name if self.is_node_name_fully_specified() else '/**': - {'ros__parameters': params} - } - yaml.dump(param_dict, h, default_flow_style=False) - return param_file_path - - def _get_parameter_rule(self, param: 'Parameter', context: LaunchContext): - name, value = param.evaluate(context) - return f'{name}:={yaml.dump(value)}' - - def _perform_substitutions(self, context: LaunchContext) -> None: - # Here to avoid cyclic import - from ..descriptions import Parameter - try: - if self.__substitutions_performed: - # This function may have already been called by a subclass' `execute`, for example. - return - self.__substitutions_performed = True - if self.__node_name is not None: - self.__expanded_node_name = perform_substitutions( - context, normalize_to_list_of_substitutions(self.__node_name)) - validate_node_name(self.__expanded_node_name) - self.__expanded_node_name.lstrip('/') - expanded_node_namespace: Optional[Text] = None - if self.__node_namespace is not None: - expanded_node_namespace = perform_substitutions( - context, normalize_to_list_of_substitutions(self.__node_namespace)) - base_ns = context.launch_configurations.get('ros_namespace', None) - expanded_node_namespace = make_namespace_absolute( - prefix_namespace(base_ns, expanded_node_namespace)) - if expanded_node_namespace is not None: - self.__expanded_node_namespace = expanded_node_namespace - cmd_extension = ['-r', LocalSubstitution("ros_specific_arguments['ns']")] - self.cmd.extend([normalize_to_list_of_substitutions(x) for x in cmd_extension]) - validate_namespace(self.__expanded_node_namespace) - except Exception: - self.__logger.error( - "Error while expanding or validating node name or namespace for '{}':" - .format('package={}, executable={}, name={}, namespace={}'.format( - self.__package, - self.__node_executable, - self.__node_name, - self.__node_namespace, - )) - ) - raise - self.__final_node_name = prefix_namespace( - self.__expanded_node_namespace, self.__expanded_node_name) - - # Expand global parameters first, - # so they can be overridden with specific parameters of this Node - # The params_container list is expected to contain name-value pairs (tuples) - # and/or strings representing paths to parameter files. - params_container = context.launch_configurations.get('global_params', None) - - if any(x is not None for x in (params_container, self.__parameters)): - self.__expanded_parameter_arguments = [] - if params_container is not None: - for param in params_container: - if isinstance(param, tuple): - name, value = param - cmd_extension = ['-p', f'{name}:={value}'] - self.cmd.extend([normalize_to_list_of_substitutions(x) for x in cmd_extension]) - else: - param_file_path = os.path.abspath(param) - self.__expanded_parameter_arguments.append((param_file_path, True)) - cmd_extension = ['--params-file', f'{param_file_path}'] - assert os.path.isfile(param_file_path) - self.cmd.extend([normalize_to_list_of_substitutions(x) for x in cmd_extension]) - - # expand parameters too - if self.__parameters is not None: - evaluated_parameters = evaluate_parameters(context, self.__parameters) - for params in evaluated_parameters: - is_file = False - if isinstance(params, dict): - param_argument = self._create_params_file_from_dict(params) - is_file = True - assert os.path.isfile(param_argument) - elif isinstance(params, pathlib.Path): - param_argument = str(params) - is_file = True - elif isinstance(params, Parameter): - param_argument = self._get_parameter_rule(params, context) - else: - raise RuntimeError('invalid normalized parameters {}'.format(repr(params))) - if is_file and not os.path.isfile(param_argument): - self.__logger.warning( - 'Parameter file path is not a file: {}'.format(param_argument), - ) - continue - self.__expanded_parameter_arguments.append((param_argument, is_file)) - cmd_extension = ['--params-file' if is_file else '-p', f'{param_argument}'] - self.cmd.extend([normalize_to_list_of_substitutions(x) for x in cmd_extension]) - # expand remappings too - global_remaps = context.launch_configurations.get('ros_remaps', None) - if global_remaps or self.__remappings: - self.__expanded_remappings = [] - if global_remaps: - self.__expanded_remappings.extend(global_remaps) - if self.__remappings: - self.__expanded_remappings.extend([ - (perform_substitutions(context, src), perform_substitutions(context, dst)) - for src, dst in self.__remappings - ]) - if self.__expanded_remappings: - cmd_extension = [] - for src, dst in self.__expanded_remappings: - cmd_extension.extend(['-r', f'{src}:={dst}']) - self.cmd.extend([normalize_to_list_of_substitutions(x) for x in cmd_extension]) - - def execute(self, context: LaunchContext) -> Optional[List[Action]]: - """ - Execute the action. + def name(self): + """Getter for name.""" + return self.__node_desc.name - Delegated to :meth:`launch.actions.ExecuteProcess.execute`. - """ - self._perform_substitutions(context) - # 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]]] = {} - if self.__node_name is not None: - ros_specific_arguments['name'] = '__node:={}'.format(self.__expanded_node_name) - if self.__expanded_node_namespace != '': - ros_specific_arguments['ns'] = '__ns:={}'.format(self.__expanded_node_namespace) - - # Give extensions a chance to prepare for execution - for extension in self.__extensions.values(): - cmd_extension, ros_specific_arguments = extension.prepare_for_execute( - context, - ros_specific_arguments, - self - ) - self.cmd.extend(cmd_extension) - - context.extend_locals({'ros_specific_arguments': ros_specific_arguments}) - ret = super().execute(context) - - if self.is_node_name_fully_specified(): - add_node_name(context, self.node_name) - node_name_count = get_node_name_count(context, self.node_name) - if node_name_count > 1: - execute_process_logger = launch.logging.get_logger(self.name) - execute_process_logger.warning( - 'there are now at least {} nodes with the name {} created within this ' - 'launch context'.format(node_name_count, self.node_name) - ) - - return ret + @property + def ros_exec(self): + """Getter for ros_exec.""" + return self.__ros_exec @property - def expanded_node_namespace(self): - """Getter for expanded_node_namespace.""" - return self.__expanded_node_namespace + def expanded_namespace(self): + """Getter for expanded_namespace.""" + return self.__node_desc.expanded_namespace @property def expanded_remapping_rules(self): """Getter for expanded_remappings.""" - return self.__expanded_remappings - - -def instantiate_extension( - group_name, - extension_name, - extension_class, - extensions, - logger, - *, - unique_instance=False -): - if not unique_instance and extension_class in extensions: - return extensions[extension_name] - try: - extension_instance = extension_class() - except plugin_support.PluginException as e: # noqa: F841 - logger.warning( - f"Failed to instantiate '{group_name}' extension " - f"'{extension_name}': {e}") - return None - except Exception as e: # noqa: F841 - logger.error( - f"Failed to instantiate '{group_name}' extension " - f"'{extension_name}': {e}") - return None - if not unique_instance: - extensions[extension_name] = extension_instance - return extension_instance - - -g_entry_points_impl = None - - -def get_extensions(logger): - global g_entry_points_impl - group_name = 'launch_ros.node_action' - if g_entry_points_impl is None: - g_entry_points_impl = importlib_metadata.entry_points() - entry_points_impl = g_entry_points_impl - if hasattr(entry_points_impl, 'select'): - groups = entry_points_impl.select(group=group_name) - else: - groups = entry_points_impl.get(group_name, []) - entry_points = {} - for entry_point in groups: - entry_points[entry_point.name] = entry_point - extension_types = {} - for entry_point in entry_points: - try: - extension_type = entry_points[entry_point].load() - except Exception as e: # noqa: F841 - logger.warning(f"Failed to load entry point '{entry_points[entry_point].name}': {e}") - continue - extension_types[entry_points[entry_point].name] = extension_type - - extensions = {} - for extension_name, extension_class in extension_types.items(): - extension_instance = instantiate_extension( - group_name, extension_name, extension_class, extensions, logger) - if extension_instance is None: - continue - extension_instance.NAME = extension_name - extensions[extension_name] = extension_instance - return extensions + return self.__node_desc.expanded_remappings diff --git a/launch_ros/launch_ros/descriptions/__init__.py b/launch_ros/launch_ros/descriptions/__init__.py index fd5d9bebe..b920ce6ac 100644 --- a/launch_ros/launch_ros/descriptions/__init__.py +++ b/launch_ros/launch_ros/descriptions/__init__.py @@ -15,6 +15,9 @@ """descriptions Module.""" from .composable_node import ComposableNode +from .node import Node +from .node_trait import NodeTrait +from .ros_executable import RosExecutable from ..parameter_descriptions import Parameter from ..parameter_descriptions import ParameterFile from ..parameter_descriptions import ParameterValue @@ -22,7 +25,10 @@ __all__ = [ 'ComposableNode', + 'Node', + 'NodeTrait', 'Parameter', 'ParameterFile', 'ParameterValue', + 'RosExecutable', ] diff --git a/launch_ros/launch_ros/descriptions/composable_node.py b/launch_ros/launch_ros/descriptions/composable_node.py index 8ad35c5ce..9f4bdca21 100644 --- a/launch_ros/launch_ros/descriptions/composable_node.py +++ b/launch_ros/launch_ros/descriptions/composable_node.py @@ -62,13 +62,13 @@ def __init__( self.__package = normalize_to_list_of_substitutions(package) self.__node_plugin = normalize_to_list_of_substitutions(plugin) - self.__node_name = None # type: Optional[List[Substitution]] + self.__name = None # type: Optional[List[Substitution]] if name is not None: - self.__node_name = normalize_to_list_of_substitutions(name) + self.__name = normalize_to_list_of_substitutions(name) - self.__node_namespace = None # type: Optional[List[Substitution]] + self.__namespace = None # type: Optional[List[Substitution]] if namespace is not None: - self.__node_namespace = normalize_to_list_of_substitutions(namespace) + self.__namespace = normalize_to_list_of_substitutions(namespace) self.__parameters = None # type: Optional[Parameters] if parameters is not None: @@ -154,14 +154,14 @@ def node_plugin(self) -> List[Substitution]: return self.__node_plugin @property - def node_name(self) -> Optional[List[Substitution]]: + def name(self) -> Optional[List[Substitution]]: """Get node name as a sequence of substitutions to be performed.""" - return self.__node_name + return self.__name @property - def node_namespace(self) -> Optional[List[Substitution]]: + def namespace(self) -> Optional[List[Substitution]]: """Get node namespace as a sequence of substitutions to be performed.""" - return self.__node_namespace + return self.__namespace @property def parameters(self) -> Optional[Parameters]: diff --git a/launch_ros/launch_ros/descriptions/node.py b/launch_ros/launch_ros/descriptions/node.py new file mode 100644 index 000000000..8ae681093 --- /dev/null +++ b/launch_ros/launch_ros/descriptions/node.py @@ -0,0 +1,474 @@ +# Copyright 2021 Southwest Research Institute, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Module for a description of a Node.""" + +import os +import pathlib + +from tempfile import NamedTemporaryFile + +from typing import Dict +from typing import Iterable +from typing import List +from typing import Optional +from typing import Text +from typing import Tuple +from typing import Union + +try: + import importlib.metadata as importlib_metadata +except ModuleNotFoundError: + import importlib_metadata + +from launch import Action +from launch import LaunchContext +from launch import SomeSubstitutionsType +from launch.descriptions import Executable +import launch.logging +from launch.substitutions import LocalSubstitution +from launch.utilities import ensure_argument_type +from launch.utilities import normalize_to_list_of_substitutions +from launch.utilities import perform_substitutions + +from launch_ros.utilities import add_node_name +from launch_ros.utilities import evaluate_parameters +from launch_ros.utilities import get_node_name_count +from launch_ros.utilities import make_namespace_absolute +from launch_ros.utilities import normalize_parameters +from launch_ros.utilities import normalize_remap_rules +from launch_ros.utilities import plugin_support +from launch_ros.utilities import prefix_namespace + +from rclpy.validate_namespace import validate_namespace +from rclpy.validate_node_name import validate_node_name + +import yaml + +from .node_trait import NodeTrait +from ..parameter_descriptions import Parameter +from ..parameters_type import SomeParameters +from ..remap_rule_type import SomeRemapRules + + +class NodeActionExtension: + """ + The extension point for launch_ros node action extensions. + + The following properties must be defined: + * `NAME` (will be set to the entry point name) + + The following methods may be defined: + * `command_extension` + * `execute` + """ + + NAME = None + EXTENSION_POINT_VERSION = '0.1' + + def __init__(self): + super(NodeActionExtension, self).__init__() + plugin_support.satisfies_version(self.EXTENSION_POINT_VERSION, '^0.1') + + def prepare_for_execute( + self, + context, + ros_specific_arguments, + ros_executable, + node_description + ): + """ + Perform any actions prior to the node's process being launched. + + `context` is the context within which the launch is taking place, + containing amongst other things the command line arguments provided by + the user. + + `ros_specific_arguments` is a dictionary of command line arguments that + will be passed to the executable and are specific to ROS. + + `ros_executable` is the RosExecutable description instance that the node + is a part of. + + `node_description` is the Node description instance that is calling the + extension. + + This method must return a tuple of command line additions as a list of + launch.substitutions.TextSubstitution objects, and + `ros_specific_arguments` with any modifications made to it. If no + modifications are made, it should return + `[], ros_specific_arguments`. + """ + return [], ros_specific_arguments + + +class Node: + """Describes a ROS node.""" + + UNSPECIFIED_NODE_NAME = '' + UNSPECIFIED_NODE_NAMESPACE = '' + + def __init__( + self, *, + name: Optional[SomeSubstitutionsType] = None, + namespace: Optional[SomeSubstitutionsType] = None, + original_name: Optional[SomeSubstitutionsType] = None, + parameters: Optional[SomeParameters] = None, + remappings: Optional[SomeRemapRules] = None, + traits: Optional[Iterable[NodeTrait]] = None, + **kwargs + ) -> None: + """ + Construct a Node description. + + If the node name is not given (or is None) then no name is passed to + the node on creation and instead the default name specified within the + code of the node is used instead. + + The namespace can either be absolute (i.e. starts with /) or + relative. + If absolute, then nothing else is considered and this is passed + directly to the node to set the namespace. + If relative, the namespace in the 'ros_namespace' LaunchConfiguration + will be prepended to the given relative node namespace. + If no namespace is given, then the default namespace `/` is + assumed. + + The parameters are passed as a list, with each element either a yaml + file that contains parameter rules (string or pathlib.Path to the full + path of the file), or a dictionary that specifies parameter rules. + Keys of the dictionary can be strings or an iterable of Substitutions + that will be expanded to a string. + Values in the dictionary can be strings, integers, floats, or tuples + of Substitutions that will be expanded to a string. + Additionally, values in the dictionary can be lists of the + aforementioned types, or another dictionary with the same properties. + A yaml file with the resulting parameters from the dictionary will be + written to a temporary file, the path to which will be passed to the + node. + Multiple dictionaries/files can be passed: each file path will be + passed in in order to the node (where the last definition of a + parameter takes effect). + + :param: name the name of the node + :param: namespace the ROS namespace for this Node + :param: original_name the name of the node before remapping; if not specified, + remappings/parameters (including node name/namespace changes) may be applied + to all nodes which share a command line executable with this one + :param: parameters list of names of yaml files with parameter rules, + or dictionaries of parameters. + :param: remappings ordered list of 'to' and 'from' string pairs to be + passed to the node as ROS remapping rules + :param: traits list of special traits of the node + """ + if parameters is not None: + ensure_argument_type(parameters, (list), 'parameters', 'Node') + # All elements in the list are paths to files with parameters (or substitutions that + # evaluate to paths), or dictionaries of parameters (fields can be substitutions). + normalized_params = normalize_parameters(parameters) + + self.__name = name + self.__namespace = namespace + self.__original_name = original_name + self.__parameters = [] if parameters is None else normalized_params + self.__remappings = [] if remappings is None else list(normalize_remap_rules(remappings)) + self.__traits = traits + + self.__expanded_name = self.UNSPECIFIED_NODE_NAME + self.__expanded_namespace = self.UNSPECIFIED_NODE_NAMESPACE + self.__expanded_original_name = self.UNSPECIFIED_NODE_NAME + self.__expanded_parameter_arguments = None # type: Optional[List[Tuple[Text, bool]]] + self.__final_name = None # type: Optional[Text] + self.__expanded_remappings = None # type: Optional[List[Tuple[Text, Text]]] + + self.__substitutions_performed = False + + self.__logger = launch.logging.get_logger(__name__) + self.__extensions = get_extensions(self.__logger) + + @property + def name(self): + """Getter for name.""" + if self.__final_name is None: + raise RuntimeError("cannot access 'name' before executing action") + return self.__final_name + + @property + def namespace(self): + """Getter for namespace.""" + return self.__namespace + + @property + def original_name(self): + """Getter for original_name.""" + return self.__original_name + + @property + def parameters(self): + """Getter for parameters.""" + return self.__parameters + + @property + def remappings(self): + """Getter for remappings.""" + return self.__remappings + + @property + def traits(self): + """Getter for traits.""" + return self.__traits + + @property + def expanded_name(self): + """Getter for expanded_name.""" + return self.__expanded_name + + @property + def expanded_namespace(self): + """Getter for expanded_namespace.""" + return self.__expanded_namespace + + @property + def expanded_parameter_arguments(self): + """Getter for expanded_parameter_arguments.""" + return self.__expanded_parameter_arguments + + @property + def expanded_remappings(self): + """Getter for expanded_remappings.""" + return self.__expanded_remappings + + def is_name_fully_specified(self): + keywords = (self.UNSPECIFIED_NODE_NAME, self.UNSPECIFIED_NODE_NAMESPACE) + return all(x not in self.name for x in keywords) + + def _create_params_file_from_dict(self, params): + with NamedTemporaryFile(mode='w', prefix='launch_params_', delete=False) as h: + param_file_path = h.name + param_dict = { + self.name if self.is_name_fully_specified() else '/**': + {'ros__parameters': params} + } + yaml.dump(param_dict, h, default_flow_style=False) + return param_file_path + + def _get_parameter_rule(self, param: 'Parameter', context: LaunchContext): + name, value = param.evaluate(context) + return f'{name}:={yaml.dump(value)}' + + def prepare(self, context: LaunchContext, executable: Executable, action: Action) -> None: + self._perform_substitutions(context, executable) + + # Prepare any traits which may be defined for this node + if self.__traits is not None: + for trait in self.__traits: + trait.prepare(self, context, action) + + def _perform_substitutions(self, context: LaunchContext, executable: Executable) -> None: + cmd = executable.cmd + try: + if self.__substitutions_performed: + # This function may have already been called by a subclass' `execute`, for example. + return + self.__substitutions_performed = True + cmd_ext = ['--ros-args'] # Prepend ros specific arguments with --ros-args flag + cmd.extend([normalize_to_list_of_substitutions(x) for x in cmd_ext]) + if self.__name is not None: + self.__expanded_name = perform_substitutions( + context, normalize_to_list_of_substitutions(self.__name)) + cmd_ext = ['-r', LocalSubstitution("ros_specific_arguments['name']")] + cmd.extend([normalize_to_list_of_substitutions(x) for x in cmd_ext]) + validate_node_name(self.__expanded_name) + self.__expanded_name.lstrip('/') + expanded_namespace: Optional[Text] = None + if self.__namespace is not None: + expanded_namespace = perform_substitutions( + context, normalize_to_list_of_substitutions(self.__namespace)) + base_ns = context.launch_configurations.get('ros_namespace', None) + expanded_namespace = make_namespace_absolute( + prefix_namespace(base_ns, expanded_namespace)) + if expanded_namespace is not None: + self.__expanded_namespace = expanded_namespace + cmd_ext = ['-r', LocalSubstitution("ros_specific_arguments['ns']")] + cmd.extend([normalize_to_list_of_substitutions(x) for x in cmd_ext]) + validate_namespace(self.__expanded_namespace) + if self.__original_name is not None: + self.__expanded_original_name = perform_substitutions( + context, normalize_to_list_of_substitutions(self.__original_name)) + self.__expanded_name.lstrip('/') + except Exception: + self.__logger.error( + "Error while expanding or validating node name or namespace for '{}':" + .format('name={}, namespace={}'.format( + self.__name, + self.__namespace, + )) + ) + raise + self.__final_name = prefix_namespace( + self.__expanded_namespace, self.__expanded_name) + + # Expand global parameters first, + # so they can be overridden with specific parameters of this Node + # The params_container list is expected to contain name-value pairs (tuples) + # and/or strings representing paths to parameter files. + params_container = context.launch_configurations.get('global_params', None) + + if any(x is not None for x in (params_container, self.__parameters)): + self.__expanded_parameter_arguments = [] + if params_container is not None: + for param in params_container: + if isinstance(param, tuple): + name, value = param + cmd_extension = ['-p', f'{name}:={value}'] + cmd.extend([normalize_to_list_of_substitutions(x) for x in cmd_extension]) + else: + param_file_path = os.path.abspath(param) + self.__expanded_parameter_arguments.append((param_file_path, True)) + cmd_extension = ['--params-file', f'{param_file_path}'] + assert os.path.isfile(param_file_path) + cmd.extend([normalize_to_list_of_substitutions(x) for x in cmd_extension]) + # expand parameters too + if self.__parameters is not None: + evaluated_parameters = evaluate_parameters(context, self.__parameters) + for params in evaluated_parameters: + is_file = False + if isinstance(params, dict): + param_argument = self._create_params_file_from_dict(params) + is_file = True + assert os.path.isfile(param_argument) + elif isinstance(params, pathlib.Path): + param_argument = str(params) + is_file = True + elif isinstance(params, Parameter): + param_argument = self._get_parameter_rule(params, context) + else: + raise RuntimeError('invalid normalized parameters {}'.format(repr(params))) + if is_file and not os.path.isfile(param_argument): + self.__logger.warning( + 'Parameter file path is not a file: {}'.format(param_argument), + ) + continue + self.__expanded_parameter_arguments.append((param_argument, is_file)) + cmd_ext = ['--params-file' if is_file else '-p', f'{param_argument}'] + cmd.extend([normalize_to_list_of_substitutions(x) for x in cmd_ext]) + # expand remappings too + global_remaps = context.launch_configurations.get('ros_remaps', None) + if global_remaps or self.__remappings: + self.__expanded_remappings = [] + if global_remaps: + self.__expanded_remappings.extend(global_remaps) + if self.__remappings: + self.__expanded_remappings.extend([ + (perform_substitutions(context, src), perform_substitutions(context, dst)) + for src, dst in self.__remappings + ]) + if self.__expanded_remappings: + cmd_ext = [] + for src, dst in self.__expanded_remappings: + cmd_ext.extend(['-r', f'{src}:={dst}']) + cmd.extend([normalize_to_list_of_substitutions(x) for x in cmd_ext]) + # 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]]] = {} + original_name_prefix = '' + if self.__expanded_original_name is not self.UNSPECIFIED_NODE_NAME: + original_name_prefix = '{}:'.format(self.__expanded_original_name) + if self.__name is not None: + ros_specific_arguments['name'] = '{}__node:={}'.format( + original_name_prefix, self.__expanded_name + ) + if self.__expanded_namespace != '': + ros_specific_arguments['ns'] = '{}__ns:={}'.format( + original_name_prefix, self.__expanded_namespace + ) + # Give extensions a chance to prepare for execution + cmd_extension = [] + for extension in self.__extensions.values(): + cmd_extension, ros_specific_arguments = extension.prepare_for_execute( + context, + ros_specific_arguments, + executable, + self + ) + cmd.extend(cmd_extension) + context.extend_locals({'ros_specific_arguments': ros_specific_arguments}) + + if self.is_name_fully_specified(): + add_node_name(context, self.name) + node_name_count = get_node_name_count(context, self.name) + if node_name_count > 1: + execute_process_logger = launch.logging.get_logger(self.name) + execute_process_logger.warning( + 'there are now at least {} nodes with the name {} created within this ' + 'launch context'.format(node_name_count, self.name) + ) + + +def instantiate_extension( + group_name, + extension_name, + extension_class, + extensions, + logger, + *, + unique_instance=False +): + if not unique_instance and extension_class in extensions: + return extensions[extension_name] + try: + extension_instance = extension_class() + except plugin_support.PluginException as e: # noqa: F841 + logger.warning( + f"Failed to instantiate '{group_name}' extension " + f"'{extension_name}': {e}") + return None + except Exception as e: # noqa: F841 + logger.error( + f"Failed to instantiate '{group_name}' extension " + f"'{extension_name}': {e}") + return None + if not unique_instance: + extensions[extension_name] = extension_instance + return extension_instance + + +def get_extensions(logger): + group_name = 'launch_ros.node_action' + entry_points_impl = importlib_metadata.entry_points() + if hasattr(entry_points_impl, 'select'): + groups = entry_points_impl.select(group=group_name) + else: + groups = entry_points_impl.get(group_name, []) + entry_points = {} + for entry_point in groups: + entry_points[entry_point.name] = entry_point + extension_types = {} + for entry_point in entry_points: + try: + extension_type = entry_points[entry_point].load() + except Exception as e: # noqa: F841 + logger.warning(f"Failed to load entry point '{entry_point.name}': {e}") + continue + extension_types[entry_points[entry_point].name] = extension_type + + extensions = {} + for extension_name, extension_class in extension_types.items(): + extension_instance = instantiate_extension( + group_name, extension_name, extension_class, extensions, logger) + if extension_instance is None: + continue + extension_instance.NAME = extension_name + extensions[extension_name] = extension_instance + return extensions diff --git a/launch_ros/launch_ros/descriptions/node_trait.py b/launch_ros/launch_ros/descriptions/node_trait.py new file mode 100644 index 000000000..c566ce033 --- /dev/null +++ b/launch_ros/launch_ros/descriptions/node_trait.py @@ -0,0 +1,42 @@ +# Copyright 2021 Southwest Research Institute, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Module for a description of a NodeTrait.""" + +from typing import TYPE_CHECKING + +from launch import Action +from launch import LaunchContext + +if TYPE_CHECKING: + from . import Node + + +class NodeTrait: + """Describes a trait of a node.""" + + def __init__(self) -> None: + """ + Initialize a NodeTrait description. + + Note that this class provides no functionality itself, and is used + as a base class for traits which provide actual functionality. As + such, the base class itself should not be directly used by application + code. + """ + pass + + def prepare(self, node: 'Node', context: LaunchContext, action: Action): + """Perform any actions necessary to prepare the node for execution.""" + pass diff --git a/launch_ros/launch_ros/descriptions/ros_executable.py b/launch_ros/launch_ros/descriptions/ros_executable.py new file mode 100644 index 000000000..ade0f5cfd --- /dev/null +++ b/launch_ros/launch_ros/descriptions/ros_executable.py @@ -0,0 +1,93 @@ +# Copyright 2021 Southwest Research Institute, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Module for a description of a ROS Executable.""" + +from typing import Iterable +from typing import Optional + +from launch import Action +from launch import LaunchContext +from launch import SomeSubstitutionsType +from launch.descriptions import Executable + +from launch_ros.substitutions import ExecutableInPackage + +from ..descriptions import Node + + +class RosExecutable(Executable): + """Describes an executable with ROS features which may be run by the launch system.""" + + def __init__( + self, *, + executable: Optional[SomeSubstitutionsType] = None, + package: Optional[SomeSubstitutionsType] = None, + nodes: Iterable[Node] = None, + ros_arguments: Optional[Iterable[SomeSubstitutionsType]] = None, + arguments: Optional[Iterable[SomeSubstitutionsType]] = None, + **kwargs + ) -> None: + """ + Initialize an Executable description. + + :param: executable the name of the executable to find if a package + is provided or otherwise a path to the executable to run. + :param: package the package in which the node executable can be found + :param: nodes the ROS node(s) included in the executable + """ + if package is not None: + cmd = [ExecutableInPackage(package=package, executable=executable)] + else: + cmd = [executable] + + if ros_arguments is not None: + if arguments is not None: + arguments.append('--ros-args') + else: + arguments = ['--ros-args'] + arguments.extend(ros_arguments) + + self.__package = package + self.__executable = executable + self.__nodes = nodes + super().__init__(cmd=cmd, arguments=arguments, **kwargs) + + @property + def package(self): + """Getter for package.""" + return self.__package + + @property + def executable(self): + """Getter for executable.""" + return self.__executable + + @property + def nodes(self): + """Getter for nodes.""" + return self.__nodes + + def prepare(self, context: LaunchContext, action: Action): + """ + Prepare a ROS executable description for execution in a given environment. + + This does the following: + - prepares all nodes + - performs substitutions on various properties + """ + for node in self.__nodes: + node.prepare(context, self, action) + + super().prepare(context, action) diff --git a/test_launch_ros/test/test_launch_ros/actions/test_lifecycle_node.py b/test_launch_ros/test/test_launch_ros/actions/test_lifecycle_node.py index d195a57fe..22b27410d 100644 --- a/test_launch_ros/test/test_launch_ros/actions/test_lifecycle_node.py +++ b/test_launch_ros/test/test_launch_ros/actions/test_lifecycle_node.py @@ -51,5 +51,6 @@ def test_node_name(): namespace='my_ns', ) lc = LaunchContext() - node_object._perform_substitutions(lc) - assert node_object.is_node_name_fully_specified() is True + for node in node_object.ros_exec.nodes: + node._perform_substitutions(lc, node_object.ros_exec) + assert node_object.is_name_fully_specified() is True diff --git a/test_launch_ros/test/test_launch_ros/actions/test_node.py b/test_launch_ros/test/test_launch_ros/actions/test_node.py index a00485c99..acc4fd073 100644 --- a/test_launch_ros/test/test_launch_ros/actions/test_node.py +++ b/test_launch_ros/test/test_launch_ros/actions/test_node.py @@ -88,7 +88,8 @@ def test_launch_node_with_remappings(self): self._assert_launch_no_errors([node_action]) # Check the expanded parameters. - expanded_remappings = node_action._Node__expanded_remappings + for node in node_action.ros_exec.nodes: + expanded_remappings = node.expanded_remappings assert len(expanded_remappings) == 2 for i in range(2): assert expanded_remappings[i] == ('chatter', 'new_chatter') @@ -147,7 +148,8 @@ def test_launch_node_with_parameter_files(self): self._assert_launch_no_errors([node_action]) # Check the expanded parameters. - expanded_parameter_arguments = node_action._Node__expanded_parameter_arguments + for node in node_action.ros_exec.nodes: + expanded_parameter_arguments = node.expanded_parameter_arguments assert len(expanded_parameter_arguments) == 3 for i in range(3): assert expanded_parameter_arguments[i] == (str(parameters_file_path), True) @@ -184,7 +186,9 @@ def test_launch_node_with_parameter_descriptions(self): ) self._assert_launch_no_errors([node_action]) - expanded_parameter_arguments = node_action._Node__expanded_parameter_arguments + expanded_parameter_arguments = [] + for node in node_action.ros_exec.nodes: + expanded_parameter_arguments.extend(node.expanded_parameter_arguments) assert len(expanded_parameter_arguments) == 5 parameters = [] for item, is_file in expanded_parameter_arguments: @@ -221,7 +225,9 @@ def test_launch_node_with_parameter_dict(self): self._assert_launch_no_errors([node_action]) # Check the expanded parameters (will be written to a file). - expanded_parameter_arguments = node_action._Node__expanded_parameter_arguments + expanded_parameter_arguments = [] + for node in node_action.ros_exec.nodes: + expanded_parameter_arguments.extend(node.expanded_parameter_arguments) assert len(expanded_parameter_arguments) == 1 file_path, is_file = expanded_parameter_arguments[0] assert is_file @@ -356,5 +362,6 @@ def get_test_node_name_parameters(): ) def test_node_name(node_object, expected_result): lc = LaunchContext() - node_object._perform_substitutions(lc) - assert node_object.is_node_name_fully_specified() is expected_result + for node in node_object.ros_exec.nodes: + node._perform_substitutions(lc, node_object.ros_exec) + assert node_object.is_name_fully_specified() is expected_result diff --git a/test_launch_ros/test/test_launch_ros/actions/test_push_ros_namespace.py b/test_launch_ros/test/test_launch_ros/actions/test_push_ros_namespace.py index da37e5dbb..d377dbd41 100644 --- a/test_launch_ros/test/test_launch_ros/actions/test_push_ros_namespace.py +++ b/test_launch_ros/test/test_launch_ros/actions/test_push_ros_namespace.py @@ -14,10 +14,13 @@ """Tests for the PushROSNamespace Action.""" +from _collections import defaultdict + from launch_ros.actions import Node from launch_ros.actions import PushROSNamespace from launch_ros.actions.load_composable_nodes import get_composable_node_load_request from launch_ros.descriptions import ComposableNode +from launch_ros.descriptions import Node as NodeDescription import pytest @@ -26,6 +29,14 @@ class MockContext: def __init__(self): self.launch_configurations = {} + self.locals = lambda: None + self.locals.unique_ros_node_names = defaultdict(int) + + def extend_globals(self, val): + pass + + def extend_locals(self, val): + pass def perform_substitution(self, sub): return sub.perform(None) @@ -110,22 +121,24 @@ def test_push_ros_namespace(config): if config.second_push_ns is not None: pns2 = PushROSNamespace(config.second_push_ns) pns2.execute(lc) - node = Node( + node_object = Node( package='dont_care', executable='whatever', namespace=config.node_ns, name=config.node_name ) - node._perform_substitutions(lc) + for node in node_object.ros_exec.nodes: + node._perform_substitutions(lc, node_object.ros_exec) expected_ns = ( - config.expected_ns if config.expected_ns is not None else Node.UNSPECIFIED_NODE_NAMESPACE + config.expected_ns if config.expected_ns is not None else + NodeDescription.UNSPECIFIED_NODE_NAMESPACE ) expected_name = ( - config.node_name if config.node_name is not None else Node.UNSPECIFIED_NODE_NAME + config.node_name if config.node_name is not None else NodeDescription.UNSPECIFIED_NODE_NAME ) expected_fqn = expected_ns.rstrip('/') + '/' + expected_name - assert expected_ns == node.expanded_node_namespace - assert expected_fqn == node.node_name + assert expected_ns == node.expanded_namespace + assert expected_fqn == node.name @pytest.mark.parametrize('config', get_test_cases()) diff --git a/test_launch_ros/test/test_launch_ros/actions/test_ros_timer.py b/test_launch_ros/test/test_launch_ros/actions/test_ros_timer.py index b1f6dc976..82ac57891 100644 --- a/test_launch_ros/test/test_launch_ros/actions/test_ros_timer.py +++ b/test_launch_ros/test/test_launch_ros/actions/test_ros_timer.py @@ -195,6 +195,6 @@ def timer_callback(publisher, time_msg): # Timer is using sim time which is 100x faster than system time, # so 200 sec timer should finish in 2 sec - tolerance = 0.1 + tolerance = 0.5 assert (end_time - start_time) > 2 - tolerance assert (end_time - start_time) < 2 + tolerance diff --git a/test_launch_ros/test/test_launch_ros/actions/test_set_parameter.py b/test_launch_ros/test/test_launch_ros/actions/test_set_parameter.py index ddf866f74..51aa6a5b6 100644 --- a/test_launch_ros/test/test_launch_ros/actions/test_set_parameter.py +++ b/test_launch_ros/test/test_launch_ros/actions/test_set_parameter.py @@ -16,6 +16,8 @@ import os.path +from _collections import defaultdict + from launch import LaunchContext from launch.actions import PopLaunchConfigurations from launch.actions import PushLaunchConfigurations @@ -36,6 +38,14 @@ class MockContext: def __init__(self): self.launch_configurations = {} + self.locals = lambda: None + self.locals.unique_ros_node_names = defaultdict(int) + + def extend_globals(self, val): + pass + + def extend_locals(self, val): + pass def perform_substitution(self, sub): return sub.perform(None) @@ -109,9 +119,10 @@ def test_set_param_with_node(): ) set_param = SetParameter(name='my_param', value='my_value') set_param.execute(lc) - node._perform_substitutions(lc) + for node_instance in node.ros_exec.nodes: + node_instance._perform_substitutions(lc, node.ros_exec) actual_command = [perform_substitutions(lc, item) for item in - node.cmd if type(item[0]) == TextSubstitution] + node.ros_exec.cmd if type(item[0]) == TextSubstitution] assert actual_command.count('--params-file') == 1 assert actual_command.count('-p') == 1 diff --git a/test_launch_ros/test/test_launch_ros/actions/test_set_parameters_from_file.py b/test_launch_ros/test/test_launch_ros/actions/test_set_parameters_from_file.py index acb3a5f24..460a330d3 100644 --- a/test_launch_ros/test/test_launch_ros/actions/test_set_parameters_from_file.py +++ b/test_launch_ros/test/test_launch_ros/actions/test_set_parameters_from_file.py @@ -16,6 +16,8 @@ import os +from _collections import defaultdict + from launch import LaunchContext from launch.actions import PopLaunchConfigurations from launch.actions import PushLaunchConfigurations @@ -34,6 +36,14 @@ class MockContext: def __init__(self): self.launch_configurations = {} + self.locals = lambda: None + self.locals.unique_ros_node_names = defaultdict(int) + + def extend_globals(self, val): + pass + + def extend_locals(self, val): + pass def perform_substitution(self, sub): return sub.perform(None) @@ -74,14 +84,18 @@ def test_set_param_with_node(): set_param_single = SetParameter(name='my_param', value='my_value') set_param_file.execute(lc) set_param_single.execute(lc) - node_1._perform_substitutions(lc) - node_2._perform_substitutions(lc) + # node_1._perform_substitutions(lc) + for node_description in node_1.ros_exec.nodes: + node_description._perform_substitutions(lc, node_1.ros_exec) + # node_2._perform_substitutions(lc) + for node_description in node_2.ros_exec.nodes: + node_description._perform_substitutions(lc, node_2.ros_exec) actual_command_1 = [perform_substitutions(lc, item) for item in - node_1.cmd if type(item[0]) == TextSubstitution] + node_1.ros_exec.cmd if type(item[0]) == TextSubstitution] actual_command_2 = [perform_substitutions(lc, item) for item in - node_2.cmd if type(item[0]) == TextSubstitution] + node_2.ros_exec.cmd if type(item[0]) == TextSubstitution] assert actual_command_1[3] == '--params-file' assert os.path.isfile(actual_command_1[4]) diff --git a/test_launch_ros/test/test_launch_ros/actions/test_set_remap.py b/test_launch_ros/test/test_launch_ros/actions/test_set_remap.py index a7c820a30..98fa34c11 100644 --- a/test_launch_ros/test/test_launch_ros/actions/test_set_remap.py +++ b/test_launch_ros/test/test_launch_ros/actions/test_set_remap.py @@ -14,6 +14,8 @@ """Tests for the SetRemap Action.""" +from _collections import defaultdict + from launch import LaunchContext from launch.actions import PopLaunchConfigurations from launch.actions import PushLaunchConfigurations @@ -30,6 +32,14 @@ class MockContext: def __init__(self): self.launch_configurations = {} + self.locals = lambda: None + self.locals.unique_ros_node_names = defaultdict(int) + + def extend_globals(self, val): + pass + + def extend_locals(self, val): + pass def perform_substitution(self, sub): return sub.perform(None) @@ -83,7 +93,8 @@ def test_set_remap_with_node(): ) set_remap = SetRemap('from1', 'to1') set_remap.execute(lc) - node._perform_substitutions(lc) + for node_instance in node.ros_exec.nodes: + node_instance._perform_substitutions(lc, node.ros_exec) assert len(node.expanded_remapping_rules) == 2 assert node.expanded_remapping_rules == [('from1', 'to1'), ('from2', 'to2')] diff --git a/test_launch_ros/test/test_launch_ros/frontend/test_component_container.py b/test_launch_ros/test/test_launch_ros/frontend/test_component_container.py index 0ed304852..88f80954d 100644 --- a/test_launch_ros/test/test_launch_ros/frontend/test_component_container.py +++ b/test_launch_ros/test/test_launch_ros/frontend/test_component_container.py @@ -114,11 +114,11 @@ def perform(substitution): return perform_substitutions(ls.context, substitution) # Check container params - assert perform(node_container._Node__package) == 'rclcpp_components' - assert perform(node_container._Node__node_executable) == 'component_container' - assert perform(node_container._Node__node_name) == 'my_container' - assert perform(node_container._Node__node_namespace) == '' - assert perform(node_container._Node__arguments[0]) == 'test_args' + assert perform(node_container.package) == 'rclcpp_components' + assert perform(node_container.executable) == 'component_container' + assert perform(node_container._Node__node_desc._Node__name) == 'my_container' + assert perform(node_container._Node__node_desc._Node__namespace) == '' + assert perform(node_container._Node__ros_exec._Executable__arguments[0]) == 'test_args' assert perform(load_composable_node._LoadComposableNodes__target_container) == 'my_container' @@ -135,16 +135,16 @@ def perform(substitution): assert perform(talker._ComposableNode__package) == 'composition' assert perform(talker._ComposableNode__node_plugin) == 'composition::Talker' - assert perform(talker._ComposableNode__node_name) == 'talker' - assert perform(talker._ComposableNode__node_namespace) == 'test_namespace' + assert perform(talker._ComposableNode__name) == 'talker' + assert perform(talker._ComposableNode__namespace) == 'test_namespace' assert (perform(talker_remappings[0][0]), perform(talker_remappings[0][1])) == ('chatter', '/remap/chatter') assert talker_params[0]['use_sim_time'] is True assert perform(listener._ComposableNode__package) == 'composition' assert perform(listener._ComposableNode__node_plugin) == 'composition::Listener' - assert perform(listener._ComposableNode__node_name) == 'listener' - assert perform(listener._ComposableNode__node_namespace) == 'test_namespace' + assert perform(listener._ComposableNode__name) == 'listener' + assert perform(listener._ComposableNode__namespace) == 'test_namespace' assert (perform(listener_remappings[0][0]), perform(listener_remappings[0][1])) == ('chatter', '/remap/chatter') assert listener_params[0]['use_sim_time'] is True diff --git a/test_launch_ros/test/test_launch_ros/frontend/test_node_frontend.py b/test_launch_ros/test/test_launch_ros/frontend/test_node_frontend.py index 9ae1a7b50..414395b57 100644 --- a/test_launch_ros/test/test_launch_ros/frontend/test_node_frontend.py +++ b/test_launch_ros/test/test_launch_ros/frontend/test_node_frontend.py @@ -159,7 +159,7 @@ def check_launch_node(file): assert 0 == ls.run() evaluated_parameters = evaluate_parameters( ls.context, - ld.describe_sub_entities()[3]._Node__parameters + ld.describe_sub_entities()[3].process_description.nodes[0]._Node__parameters ) assert len(evaluated_parameters) == 3 assert isinstance(evaluated_parameters[0], dict) @@ -200,7 +200,7 @@ def check_launch_node(file): assert param_dict['param_group1.param15'] == ['2', '5', '8'] # Check remappings exist - remappings = ld.describe_sub_entities()[3]._Node__remappings + remappings = ld.describe_sub_entities()[3].process_description.nodes[0]._Node__remappings assert remappings is not None assert len(remappings) == 2