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
33 changes: 29 additions & 4 deletions launch_ros/launch_ros/actions/node.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -62,6 +63,7 @@

from ..descriptions import Parameter
from ..descriptions import ParameterFile
from ..parameter_descriptions import ConditionalParameter


class NodeActionExtension:
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down
17 changes: 4 additions & 13 deletions launch_ros/launch_ros/descriptions/composable_node.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
24 changes: 24 additions & 0 deletions launch_ros/launch_ros/parameter_descriptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
5 changes: 5 additions & 0 deletions launch_ros/launch_ros/utilities/evaluate_parameters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))
Expand Down
3 changes: 3 additions & 0 deletions launch_ros/launch_ros/utilities/normalize_parameters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
@@ -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"""
<launch>
<let name="flag" value="true"/>
<node pkg="demo_nodes_cpp" exec="talker" name="my_node">
<param name="included" value="1" if="$(var flag)"/>
<param name="always" value="2"/>
</node>
</launch>
""")
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"""
<launch>
<let name="flag" value="false"/>
<node pkg="demo_nodes_cpp" exec="talker" name="my_node">
<param name="excluded" value="1" if="$(var flag)"/>
<param name="always" value="2"/>
</node>
</launch>
""")
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"""
<launch>
<let name="flag" value="true"/>
<node pkg="demo_nodes_cpp" exec="talker" name="my_node">
<param name="excluded" value="1" unless="$(var flag)"/>
<param name="always" value="2"/>
</node>
</launch>
""")
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"""
<launch>
<let name="flag" value="false"/>
<node pkg="demo_nodes_cpp" exec="talker" name="my_node">
<param name="included" value="1" unless="$(var flag)"/>
<param name="always" value="2"/>
</node>
</launch>
""")
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"""
<launch>
<let name="flag" value="true"/>
<node pkg="demo_nodes_cpp" exec="talker" name="my_node">
<param name="bad" value="1" if="$(var flag)" unless="$(var flag)"/>
</node>
</launch>
""")
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"""
<launch>
<let name="unused" value="x"/>
<node pkg="demo_nodes_cpp" exec="talker" name="my_node">
<param name="param1" value="hello"/>
<param name="param2" value="world"/>
</node>
</launch>
""")
keys = _get_evaluated_params(xml_file)
assert 'param1' in keys
assert 'param2' in keys