Skip to content
Merged
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
5 changes: 5 additions & 0 deletions launch/doc/source/architecture.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions launch/launch/substitutions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -64,6 +65,7 @@
'PathSubstitution',
'PythonExpression',
'StringJoinSubstitution',
'StringStripSubstitution',
'SubstitutionFailure',
'TextSubstitution',
'ThisLaunchFile',
Expand Down
90 changes: 90 additions & 0 deletions launch/launch/substitutions/string_strip_substitution.py
Original file line number Diff line number Diff line change
@@ -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

<let name="hostname" value="$(string-strip $(command 'hostname'))"/>

.. 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()
9 changes: 9 additions & 0 deletions launch/test/launch/frontend/test_substitutions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)")')
Expand Down
14 changes: 14 additions & 0 deletions launch/test/launch/substitutions/test_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
59 changes: 59 additions & 0 deletions launch/test/launch/substitutions/test_string_strip_substitution.py
Original file line number Diff line number Diff line change
@@ -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'])
48 changes: 48 additions & 0 deletions launch_xml/test/launch_xml/test_string_strip_substitution.py
Original file line number Diff line number Diff line change
@@ -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(
"""
<launch>
<arg name="robot_name" default=" rover "/>
<let name="trimmed" value="$(string-strip $(var robot_name))"/>
</launch>
"""
)
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'
51 changes: 51 additions & 0 deletions launch_yaml/test/launch_yaml/test_string_strip_substitution.py
Original file line number Diff line number Diff line change
@@ -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'