diff --git a/launch_ros/launch_ros/actions/node.py b/launch_ros/launch_ros/actions/node.py index 0b5c0180..d4a7be39 100644 --- a/launch_ros/launch_ros/actions/node.py +++ b/launch_ros/launch_ros/actions/node.py @@ -32,6 +32,7 @@ from launch.frontend import Entity from launch.frontend import expose_action from launch.frontend import Parser +from launch.conditions import IfCondition, UnlessCondition from launch.frontend.type_utils import get_data_type_from_identifier from launch.launch_context import LaunchContext @@ -62,6 +63,7 @@ from ..descriptions import Parameter from ..descriptions import ParameterFile +from ..parameter_descriptions import ConditionalParameter class NodeActionExtension: @@ -106,6 +108,19 @@ def prepare_for_execute(self, context, ros_specific_arguments, node_action): return [], ros_specific_arguments +def _parse_if_unless_condition(entity, parser): + """Parse if/unless condition from a frontend entity.""" + if_cond = entity.get_attr('if', optional=True) + unless_cond = entity.get_attr('unless', optional=True) + if if_cond is not None and unless_cond is not None: + raise RuntimeError("if and unless conditions can't be used simultaneously") + if if_cond is not None: + return IfCondition(predicate_expression=parser.parse_substitution(if_cond)) + if unless_cond is not None: + return UnlessCondition(predicate_expression=parser.parse_substitution(unless_cond)) + return None + + @expose_action('node') class Node(ExecuteProcess): """Action that executes a ROS node.""" @@ -252,6 +267,9 @@ def get_nested_dictionary_from_nested_key_value_pairs(params): data_type = get_data_type_from_identifier(type_identifier) value = param.get_attr('value', data_type=data_type, optional=True) nested_params = param.get_attr('param', data_type=List[Entity], optional=True) + # Consume if/unless so assert_entity_completely_parsed doesn't raise + param.get_attr('if', optional=True) + param.get_attr('unless', optional=True) param.assert_entity_completely_parsed() if value is not None and nested_params: raise RuntimeError( @@ -285,16 +303,23 @@ def get_nested_dictionary_from_nested_key_value_pairs(params): allow_substs = parser.parse_substitution(allow_substs) else: allow_substs = bool(allow_substs) + condition = _parse_if_unless_condition(param, parser) param.assert_entity_completely_parsed() - normalized_params.append( - ParameterFile(parser.parse_substitution(from_attr), allow_substs=allow_substs)) + result = ParameterFile( + parser.parse_substitution(from_attr), allow_substs=allow_substs) + if condition is not None: + result = ConditionalParameter(result, condition=condition) + normalized_params.append(result) continue elif name is not None: if allow_substs is not None: raise RuntimeError( "'allow_substs' can only be used together with 'from' attribute") - normalized_params.append( - get_nested_dictionary_from_nested_key_value_pairs([param])) + condition = _parse_if_unless_condition(param, parser) + result = get_nested_dictionary_from_nested_key_value_pairs([param]) + if condition is not None: + result = ConditionalParameter(result, condition=condition) + normalized_params.append(result) continue raise ValueError('param Entity should have name or from attribute') return normalized_params diff --git a/launch_ros/launch_ros/descriptions/composable_node.py b/launch_ros/launch_ros/descriptions/composable_node.py index e3028ea0..9218d8c5 100644 --- a/launch_ros/launch_ros/descriptions/composable_node.py +++ b/launch_ros/launch_ros/descriptions/composable_node.py @@ -18,7 +18,6 @@ from typing import Optional from launch.condition import Condition -from launch.conditions import IfCondition, UnlessCondition from launch.frontend import Entity from launch.frontend import Parser from launch.some_substitutions_type import SomeSubstitutionsType @@ -87,24 +86,16 @@ def __init__( def parse(cls, parser: Parser, entity: Entity): """Parse composable_node.""" from launch_ros.actions import Node + from launch_ros.actions.node import _parse_if_unless_condition kwargs = {} kwargs['package'] = parser.parse_substitution(entity.get_attr('pkg')) kwargs['plugin'] = parser.parse_substitution(entity.get_attr('plugin')) kwargs['name'] = parser.parse_substitution(entity.get_attr('name')) - if_cond = entity.get_attr('if', optional=True) - unless_cond = entity.get_attr('unless', optional=True) - if if_cond is not None and unless_cond is not None: - raise RuntimeError("if and unless conditions can't be used simultaneously") - if if_cond is not None: - kwargs['condition'] = IfCondition( - predicate_expression=parser.parse_substitution(if_cond) - ) - if unless_cond is not None: - kwargs['condition'] = UnlessCondition( - predicate_expression=parser.parse_substitution(unless_cond) - ) + condition = _parse_if_unless_condition(entity, parser) + if condition is not None: + kwargs['condition'] = condition namespace = entity.get_attr('namespace', optional=True) if namespace is not None: diff --git a/launch_ros/launch_ros/parameter_descriptions.py b/launch_ros/launch_ros/parameter_descriptions.py index 46f226d3..713e0cdb 100644 --- a/launch_ros/launch_ros/parameter_descriptions.py +++ b/launch_ros/launch_ros/parameter_descriptions.py @@ -45,6 +45,30 @@ from .parameters_type import EvaluatedParameterValue +class ConditionalParameter: + """Wraps a parameter (dict or ParameterFile) with an optional condition.""" + + def __init__(self, parameter, *, condition=None): + """ + Construct a conditional parameter. + + :param parameter: The wrapped parameter (dict or ParameterFile). + :param condition: An optional Condition to evaluate at runtime. + """ + self.__parameter = parameter + self.__condition = condition + + @property + def parameter(self): + """Getter for the wrapped parameter.""" + return self.__parameter + + @property + def condition(self): + """Getter for condition.""" + return self.__condition + + class ParameterValue: """Describes a ROS parameter value.""" diff --git a/launch_ros/launch_ros/utilities/evaluate_parameters.py b/launch_ros/launch_ros/utilities/evaluate_parameters.py index ade9594a..65ebd29d 100644 --- a/launch_ros/launch_ros/utilities/evaluate_parameters.py +++ b/launch_ros/launch_ros/utilities/evaluate_parameters.py @@ -31,6 +31,7 @@ import yaml +from ..parameter_descriptions import ConditionalParameter from ..parameter_descriptions import Parameter as ParameterDescription from ..parameter_descriptions import ParameterFile from ..parameter_descriptions import ParameterValue as ParameterValueDescription @@ -159,6 +160,10 @@ def evaluate_parameters(context: LaunchContext, parameters: Parameters) -> Evalu """ output_params: List[Union[pathlib.Path, Dict[str, EvaluatedParameterValue]]] = [] for param in parameters: + if isinstance(param, ConditionalParameter): + if param.condition is not None and not param.condition.evaluate(context): + continue + param = param.parameter if isinstance(param, ParameterFile): # Evaluate a list of Substitution to a file path output_params.append(param.evaluate(context)) diff --git a/launch_ros/launch_ros/utilities/normalize_parameters.py b/launch_ros/launch_ros/utilities/normalize_parameters.py index f61957b9..9ad802e9 100644 --- a/launch_ros/launch_ros/utilities/normalize_parameters.py +++ b/launch_ros/launch_ros/utilities/normalize_parameters.py @@ -32,6 +32,7 @@ import yaml +from ..parameter_descriptions import ConditionalParameter from ..parameter_descriptions import Parameter as ParameterDescription from ..parameter_descriptions import ParameterFile from ..parameter_descriptions import ParameterValue as ParameterValueDescription @@ -184,6 +185,8 @@ def normalize_parameters(parameters: SomeParameters) -> Parameters: normalized_params.append(normalize_parameter_dict(param)) elif isinstance(param, ParameterDescription): normalized_params.append(param) + elif isinstance(param, ConditionalParameter): + normalized_params.append(param) elif isinstance(param, ParameterFile): normalized_params.append(param) else: diff --git a/test_launch_ros/test/test_launch_ros/frontend/test_node_conditional_param.py b/test_launch_ros/test/test_launch_ros/frontend/test_node_conditional_param.py new file mode 100644 index 00000000..5b050cf7 --- /dev/null +++ b/test_launch_ros/test/test_launch_ros/frontend/test_node_conditional_param.py @@ -0,0 +1,144 @@ +# Copyright 2026 Open Source Robotics Foundation, Inc. +# +# 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. + +"""Tests for conditional if/unless on param elements.""" + +import io +import textwrap + +import pytest + +from launch import LaunchService +from launch.frontend import Parser + +from launch_ros.utilities import evaluate_parameters + + +def _get_evaluated_params(xml_file): + """Parse XML, run launch service, and return evaluated parameters.""" + with io.StringIO(xml_file) as f: + root_entity, parser = Parser.load(f) + ld = parser.parse_description(root_entity) + ls = LaunchService() + ls.include_launch_description(ld) + assert 0 == ls.run() + evaluated = evaluate_parameters( + ls.context, ld.describe_sub_entities()[1]._Node__parameters) + all_keys = {} + for p in evaluated: + if isinstance(p, dict): + all_keys.update(p) + return all_keys + + +def test_param_if_true(): + """Param with if=true should be included.""" + xml_file = textwrap.dedent( + r""" + + + + + + + + """) + keys = _get_evaluated_params(xml_file) + assert 'included' in keys + assert 'always' in keys + + +def test_param_if_false(): + """Param with if=false should be excluded.""" + xml_file = textwrap.dedent( + r""" + + + + + + + + """) + keys = _get_evaluated_params(xml_file) + assert 'excluded' not in keys + assert 'always' in keys + + +def test_param_unless_true(): + """Param with unless=true should be excluded.""" + xml_file = textwrap.dedent( + r""" + + + + + + + + """) + keys = _get_evaluated_params(xml_file) + assert 'excluded' not in keys + assert 'always' in keys + + +def test_param_unless_false(): + """Param with unless=false should be included.""" + xml_file = textwrap.dedent( + r""" + + + + + + + + """) + keys = _get_evaluated_params(xml_file) + assert 'included' in keys + assert 'always' in keys + + +def test_param_if_and_unless_raises(): + """Using both if and unless on same param should raise RuntimeError.""" + xml_file = textwrap.dedent( + r""" + + + + + + + """) + with pytest.raises(RuntimeError, match="if and unless"): + with io.StringIO(xml_file) as f: + root_entity, parser = Parser.load(f) + parser.parse_description(root_entity) + + +def test_param_no_condition(): + """Param without if/unless should always be included (regression test).""" + xml_file = textwrap.dedent( + r""" + + + + + + + + """) + keys = _get_evaluated_params(xml_file) + assert 'param1' in keys + assert 'param2' in keys