diff --git a/launch/doc/source/architecture.rst b/launch/doc/source/architecture.rst index c76aef835..74f064e60 100644 --- a/launch/doc/source/architecture.rst +++ b/launch/doc/source/architecture.rst @@ -128,6 +128,11 @@ There are many possible variations of a substitution, but here are some of the c - This substitution will evaluate a python expression and get the result as a string. - You may pass a list of Python modules to the constructor to allow the use of those modules in the evaluated expression. +- :class:`launch.substitutions.StringStripSubstitution` + + - This substitution removes leading and trailing whitespace from the result of one or more substitutions. + - It can remove a newline terminator from command output before composition, for example ``$(string-strip $(command 'hostname'))``. + - :class:`launch.substitutions.LaunchConfiguration` - This substitution gets a launch configuration value, as a string, by name. diff --git a/launch/launch/substitutions/__init__.py b/launch/launch/substitutions/__init__.py index 7fa7170e7..12aebaa25 100644 --- a/launch/launch/substitutions/__init__.py +++ b/launch/launch/substitutions/__init__.py @@ -36,6 +36,7 @@ from .path_join_substitution import PathSubstitution from .python_expression import PythonExpression from .string_join_substitution import StringJoinSubstitution +from .string_strip_substitution import StringStripSubstitution from .substitution_failure import SubstitutionFailure from .text_substitution import TextSubstitution from .this_launch_file import ThisLaunchFile @@ -64,6 +65,7 @@ 'PathSubstitution', 'PythonExpression', 'StringJoinSubstitution', + 'StringStripSubstitution', 'SubstitutionFailure', 'TextSubstitution', 'ThisLaunchFile', diff --git a/launch/launch/substitutions/string_strip_substitution.py b/launch/launch/substitutions/string_strip_substitution.py new file mode 100644 index 000000000..e1fa05608 --- /dev/null +++ b/launch/launch/substitutions/string_strip_substitution.py @@ -0,0 +1,90 @@ +# 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. + +"""Module for the StringStripSubstitution substitution.""" + +from typing import Any +from typing import Dict +from typing import List +from typing import Sequence +from typing import Text +from typing import Tuple +from typing import Type + +from ..frontend.expose 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 + + +@expose_substitution('string-strip') +class StringStripSubstitution(Substitution): + """ + Substitution that removes leading and trailing whitespace from a string. + + For example, command output can be stripped before it is composed with + other substitutions: + + .. code-block:: python + + hostname = StringStripSubstitution(Command('hostname')) + + .. code-block:: xml + + + + .. code-block:: yaml + + - let: + name: hostname + value: "$(string-strip $(command 'hostname'))" + + This strips whitespace only; it does not otherwise escape or transform the + value for use in another language or expression. + """ + + def __init__(self, value: SomeSubstitutionsType) -> None: + """ + Create a StringStripSubstitution. + + :param value: string or substitutions whose leading and trailing + whitespace is removed + """ + super().__init__() + self.__value = normalize_to_list_of_substitutions(value) + + @classmethod + def parse( + cls, data: Sequence[SomeSubstitutionsType] + ) -> Tuple[Type['StringStripSubstitution'], Dict[str, Any]]: + """Parse `StringStripSubstitution` substitution.""" + if len(data) != 1: + raise TypeError('string-strip substitution expects 1 argument') + return cls, {'value': data[0]} + + @property + def value(self) -> List[Substitution]: + """Getter for the value to strip.""" + return self.__value + + def describe(self) -> Text: + """Return a description of this substitution as a string.""" + return 'StringStrip({})'.format( + ' + '.join([sub.describe() for sub in self.value])) + + def perform(self, context: LaunchContext) -> Text: + """Perform substitutions and remove leading and trailing whitespace.""" + return perform_substitutions(context, self.value).strip() diff --git a/launch/test/launch/frontend/test_substitutions.py b/launch/test/launch/frontend/test_substitutions.py index 99dd26e33..8c35f5697 100644 --- a/launch/test/launch/frontend/test_substitutions.py +++ b/launch/test/launch/frontend/test_substitutions.py @@ -27,6 +27,7 @@ from launch.frontend.parse_substitution import parse_substitution from launch.substitutions import EnvironmentVariable from launch.substitutions import PythonExpression +from launch.substitutions import StringStripSubstitution from launch.substitutions import TextSubstitution from launch.substitutions import ThisLaunchFileDir from launch.utilities import normalize_to_list_of_substitutions @@ -208,6 +209,14 @@ def test_eval_subst(): assert 'asdbsd' == expr.perform(LaunchContext()) +def test_string_strip_subst(): + subst = parse_substitution("$(string-strip ' $(test asd) ')") + assert len(subst) == 1 + string_strip = subst[0] + assert isinstance(string_strip, StringStripSubstitution) + assert string_strip.perform(LaunchContext()) == 'asd' + + def test_eval_subst_of_math_expr(): # Math module is included by default subst = parse_substitution(r'$(eval "ceil(1.3)")') diff --git a/launch/test/launch/substitutions/test_command.py b/launch/test/launch/substitutions/test_command.py index dbb19eced..0d0e80376 100644 --- a/launch/test/launch/substitutions/test_command.py +++ b/launch/test/launch/substitutions/test_command.py @@ -19,6 +19,8 @@ from launch.launch_context import LaunchContext from launch.substitutions import Command +from launch.substitutions import PythonExpression +from launch.substitutions import StringStripSubstitution from launch.substitutions.substitution_failure import SubstitutionFailure import pytest @@ -48,6 +50,18 @@ def test_command(commands): assert output == 'asd bsd csd\n' +def test_command_output_can_be_stripped_in_python_expression(commands): + """Test composing a newline-emitting command with a Python expression.""" + context = LaunchContext() + command = StringStripSubstitution(Command(commands['normal'])) + expression = PythonExpression([ + "'", + command, + "' == 'asd bsd csd'", + ]) + assert expression.perform(context) == 'True' + + def test_missing_command_raises(commands): """Test that a command that doesn't exist raises.""" context = LaunchContext() diff --git a/launch/test/launch/substitutions/test_string_strip_substitution.py b/launch/test/launch/substitutions/test_string_strip_substitution.py new file mode 100644 index 000000000..3e7ec74b7 --- /dev/null +++ b/launch/test/launch/substitutions/test_string_strip_substitution.py @@ -0,0 +1,59 @@ +# 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 the StringStripSubstitution substitution class.""" + +from launch import LaunchContext +from launch.substitutions import StringStripSubstitution +from launch.substitutions import TextSubstitution + +import pytest + + +@pytest.mark.parametrize( + ('value', 'expected'), + [ + ('', ''), + ('robot', 'robot'), + (' \trobot\r\n', 'robot'), + (' \t\r\n', ''), + ], +) +def test_string_strip(value, expected): + """Test stripping leading and trailing whitespace.""" + substitution = StringStripSubstitution(value) + assert substitution.perform(LaunchContext()) == expected + + +def test_string_strip_nested_substitutions(): + """Test stripping a value assembled from multiple substitutions.""" + substitution = StringStripSubstitution([ + ' \t', + TextSubstitution(text='robot'), + TextSubstitution(text=' name'), + '\r\n', + ]) + assert substitution.perform(LaunchContext()) == 'robot name' + + +def test_string_strip_parse(): + """Test the frontend parser contract.""" + substitution_type, kwargs = StringStripSubstitution.parse([' value ']) + assert substitution_type is StringStripSubstitution + assert kwargs == {'value': ' value '} + + with pytest.raises(TypeError, match='expects 1 argument'): + StringStripSubstitution.parse([]) + with pytest.raises(TypeError, match='expects 1 argument'): + StringStripSubstitution.parse(['one', 'two']) diff --git a/launch_xml/test/launch_xml/test_string_strip_substitution.py b/launch_xml/test/launch_xml/test_string_strip_substitution.py new file mode 100644 index 000000000..d508b2516 --- /dev/null +++ b/launch_xml/test/launch_xml/test_string_strip_substitution.py @@ -0,0 +1,48 @@ +# 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. + +"""Test parsing a StringStripSubstitution in an XML launch file.""" + +import io +import textwrap + +from launch.actions import DeclareLaunchArgument +from launch.actions import SetLaunchConfiguration +from launch.frontend import Parser +from launch.launch_context import LaunchContext +from launch.substitutions import StringStripSubstitution + + +def test_nested(): + xml_file = textwrap.dedent( + """ + + + + + """ + ) + root_entity, parser = Parser.load(io.StringIO(xml_file)) + launch_description = parser.parse_description(root_entity) + + assert len(launch_description.entities) == 2 + assert isinstance(launch_description.entities[0], DeclareLaunchArgument) + assert isinstance(launch_description.entities[1], SetLaunchConfiguration) + + context = LaunchContext() + launch_description.entities[0].visit(context) + + let = launch_description.entities[1] + assert isinstance(let.value[0], StringStripSubstitution) + assert let.value[0].perform(context) == 'rover' diff --git a/launch_yaml/test/launch_yaml/test_string_strip_substitution.py b/launch_yaml/test/launch_yaml/test_string_strip_substitution.py new file mode 100644 index 000000000..86118572a --- /dev/null +++ b/launch_yaml/test/launch_yaml/test_string_strip_substitution.py @@ -0,0 +1,51 @@ +# 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. + +"""Test parsing a StringStripSubstitution in a YAML launch file.""" + +import io +import textwrap + +from launch.actions import DeclareLaunchArgument +from launch.actions import SetLaunchConfiguration +from launch.frontend import Parser +from launch.launch_context import LaunchContext +from launch.substitutions import StringStripSubstitution + + +def test_nested(): + yaml_file = textwrap.dedent( + """ + launch: + - arg: + name: robot_name + default: " rover " + - let: + name: trimmed + value: "$(string-strip $(var robot_name))" + """ + ) + root_entity, parser = Parser.load(io.StringIO(yaml_file)) + launch_description = parser.parse_description(root_entity) + + assert len(launch_description.entities) == 2 + assert isinstance(launch_description.entities[0], DeclareLaunchArgument) + assert isinstance(launch_description.entities[1], SetLaunchConfiguration) + + context = LaunchContext() + launch_description.entities[0].visit(context) + + let = launch_description.entities[1] + assert isinstance(let.value[0], StringStripSubstitution) + assert let.value[0].perform(context) == 'rover'