diff --git a/launch/doc/source/architecture.rst b/launch/doc/source/architecture.rst index 59602947b..112865ce4 100644 --- a/launch/doc/source/architecture.rst +++ b/launch/doc/source/architecture.rst @@ -131,6 +131,10 @@ There are many possible variations of a substitution, but here are some of the c - This substitution gets a launch configuration value, as a string, by name. +- :class:`launch.substitutions.IfElseSubstitution` + + - This substitution takes a substitution, and if it evaluates to true, then the result is the if_value, else the result is the else_value. + - :class:`launch.substitutions.LaunchDescriptionArgument` - This substitution gets the value of a launch description argument, as a string, by name. diff --git a/launch/launch/substitutions/__init__.py b/launch/launch/substitutions/__init__.py index f966916e4..1622debaa 100644 --- a/launch/launch/substitutions/__init__.py +++ b/launch/launch/substitutions/__init__.py @@ -25,6 +25,7 @@ from .equals_substitution import EqualsSubstitution from .file_content import FileContent from .find_executable import FindExecutable +from .if_else_substitution import IfElseSubstitution from .launch_configuration import LaunchConfiguration from .launch_log_dir import LaunchLogDir from .local_substitution import LocalSubstitution @@ -46,6 +47,7 @@ 'EnvironmentVariable', 'FileContent', 'FindExecutable', + 'IfElseSubstitution', 'LaunchConfiguration', 'LaunchLogDir', 'LocalSubstitution', diff --git a/launch/launch/substitutions/if_else_substitution.py b/launch/launch/substitutions/if_else_substitution.py new file mode 100644 index 000000000..cb384c84c --- /dev/null +++ b/launch/launch/substitutions/if_else_substitution.py @@ -0,0 +1,117 @@ +# Copyright 2023 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. + +"""Module for the IfElseSubstitution substitution.""" + +from typing import List +from typing import Sequence +from typing import Text + +from .substitution_failure import SubstitutionFailure +from ..frontend import expose_substitution +from ..launch_context import LaunchContext +from ..some_substitutions_type import SomeSubstitutionsType +from ..substitution import Substitution +from ..utilities import normalize_to_list_of_substitutions +from ..utilities import perform_substitutions +from ..utilities.type_utils import perform_typed_substitution + + +@expose_substitution('if') +class IfElseSubstitution(Substitution): + """ + Substitution that conditionally returns one of two substitutions. + + Depending on whether the condition substitution evaluates to true, either it returns + the if_value substitution or the else_value substitution. + + Example with a boolean launch configuration: + + .. doctest:: + + >>> from launch.substitutions import LaunchConfiguration + >>> subst = IfElseSubstitution( + ... LaunchConfiguration("arg"), + ... if_value="arg_evaluated_to_true", + ... else_value="arg_evaluated_to_false") + + Combine with boolean substitutions to create more complex conditions. + Example with multiple boolean launch configurations: + + .. doctest:: + + >>> from launch.substitutions import AllSubstitution + >>> from launch.substitutions import EqualsSubstitution + >>> from launch.substitutions import LaunchConfiguration + >>> from launch.substitutions import NotSubstitution + >>> subst = IfElseSubstitution( + ... AllSubstitution(EqualsSubstitution(LaunchConfiguration("arg1"), + ... LaunchConfiguration("arg2")), + ... NotSubstitution(LaunchConfiguration("arg3"))), + ... if_value="all_args_evaluated_to_true", + ... else_value="at_least_one_arg_evaluated_to_false") + + """ + + def __init__(self, condition: SomeSubstitutionsType, + if_value: SomeSubstitutionsType = '', + else_value: SomeSubstitutionsType = '') -> None: + """Create a IfElseSubstitution substitution.""" + super().__init__() + if if_value == else_value == '': + raise RuntimeError('One of if_value and else_value must be specified') + self._condition = normalize_to_list_of_substitutions(condition) + self._if_value = normalize_to_list_of_substitutions(if_value) + self._else_value = normalize_to_list_of_substitutions(else_value) + + @classmethod + def parse(cls, data: Sequence[SomeSubstitutionsType]): + """Parse `IfElseSubstitution` substitution.""" + if len(data) < 2 or len(data) > 3: + raise TypeError('if substitution expects from 2 or 3 arguments') + kwargs = {'condition': data[0], 'if_value': data[1]} + if len(data) == 3: + kwargs['else_value'] = data[2] + return cls, kwargs + + @property + def condition(self) -> List[Substitution]: + """Getter for condition.""" + return self._condition + + @property + def if_value(self) -> List[Substitution]: + """Getter for if value.""" + return self._if_value + + @property + def else_value(self) -> List[Substitution]: + """Getter for else value.""" + return self._else_value + + def describe(self) -> Text: + """Return a description of this substitution as a string.""" + return f'IfElseSubstitution({self.condition}, {self.if_value}, {self.else_value})' + + def perform(self, context: LaunchContext) -> Text: + """Perform the substitution by evaluating the condition.""" + try: + condition = perform_typed_substitution(context, self.condition, bool) + except (TypeError, ValueError) as e: + raise SubstitutionFailure(e) + + if condition: + return perform_substitutions(context, self.if_value) + else: + return perform_substitutions(context, self.else_value) diff --git a/launch/launch/substitutions/python_expression.py b/launch/launch/substitutions/python_expression.py index f891e2118..66f02c078 100644 --- a/launch/launch/substitutions/python_expression.py +++ b/launch/launch/substitutions/python_expression.py @@ -92,7 +92,7 @@ def expression(self) -> List[Substitution]: @property def python_modules(self) -> List[Substitution]: - """Getter for expression.""" + """Getter for python modules.""" return self.__python_modules def describe(self) -> Text: @@ -108,7 +108,7 @@ def perform(self, context: LaunchContext) -> Text: module_objects = [importlib.import_module(name) for name in module_names] expression_locals = {} for module in module_objects: - # For backwards compatility, we allow math definitions to be implicitly + # For backwards compatibility, we allow math definitions to be implicitly # referenced in expressions, without prepending the math module name # TODO: This may be removed in a future release. if module.__name__ == 'math': diff --git a/launch/test/launch/substitutions/test_if_else_substitution.py b/launch/test/launch/substitutions/test_if_else_substitution.py new file mode 100644 index 000000000..a15c3c74e --- /dev/null +++ b/launch/test/launch/substitutions/test_if_else_substitution.py @@ -0,0 +1,72 @@ +# Copyright 2023 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 the IfElseSubstitution substitution class.""" + +from launch import LaunchContext +from launch.substitutions import IfElseSubstitution + +import pytest + + +def test_if_else_substitution_no_values(): + """Check that construction fails if no values are specified.""" + # Should raise an error since neither the if value nor the else value is given + with pytest.raises(RuntimeError): + IfElseSubstitution('true') + + +def test_if_else_substitution_both_values(): + """Check that the right value is returned when both values are given.""" + # Condition is true case + subst = IfElseSubstitution('true', 'ivalue', 'evalue') + result = subst.perform(LaunchContext()) + assert result == 'ivalue' + subst = IfElseSubstitution('true', if_value='ivalue', else_value='evalue') + result = subst.perform(LaunchContext()) + assert result == 'ivalue' + + # Condition is false case + subst = IfElseSubstitution('false', 'ivalue', 'evalue') + result = subst.perform(LaunchContext()) + assert result == 'evalue' + + +def test_if_else_substitution_if_value(): + """Check that the right value is returned when only the if value is given.""" + # Condition is true case + subst = IfElseSubstitution('1', 'ivalue') + result = subst.perform(LaunchContext()) + assert result == 'ivalue' + subst = IfElseSubstitution('1', if_value='ivalue') + result = subst.perform(LaunchContext()) + assert result == 'ivalue' + + # Condition is false case + subst = IfElseSubstitution('0', 'ivalue') + result = subst.perform(LaunchContext()) + assert result == '' + + +def test_if_else_substitution_else_value(): + """Check that the right value is returned when only the else value is given.""" + # Condition is true case + subst = IfElseSubstitution('on', else_value='evalue') + result = subst.perform(LaunchContext()) + assert result == '' + + # Condition is false case + subst = IfElseSubstitution('off', else_value='evalue') + result = subst.perform(LaunchContext()) + assert result == 'evalue'