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
11 changes: 11 additions & 0 deletions launch/doc/source/architecture.rst
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,17 @@ There are many possible variations of a substitution, but here are some of the c

- This substitution locates the full path to an executable on the PATH if it exists.

- :class:`launch.substitutions.IsEmptySubstitution`

- This substitution checks whether a string is empty, returning 'true' or 'false'.
- For example, ``$(is-empty $(var arg))`` returns 'true' if the launch configuration is empty.

- :class:`launch.substitutions.NotEmptySubstitution`

- This substitution checks whether a string is not empty, returning 'true' or 'false'.
- For example, ``$(not-empty $(var arg))`` returns 'true' if the launch configuration has content.
- This is the inverse of :class:`launch.substitutions.IsEmptySubstitution`.

The base substitution class provides some common introspection interfaces (which the specific derived substitutions may influence).

The Launch Service
Expand Down
4 changes: 4 additions & 0 deletions launch/launch/substitutions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,11 @@
from .for_loop_var import ForEachVar
from .for_loop_var import ForLoopIndex
from .if_else_substitution import IfElseSubstitution
from .is_empty_substitution import IsEmptySubstitution
from .launch_configuration import LaunchConfiguration
from .launch_log_dir import LaunchLogDir
from .local_substitution import LocalSubstitution
from .not_empty_substitution import NotEmptySubstitution
from .not_equals_substitution import NotEqualsSubstitution
from .path_join_substitution import PathJoinSubstitution
from .path_join_substitution import PathSubstitution
Expand All @@ -54,9 +56,11 @@
'ForEachVar',
'ForLoopIndex',
'IfElseSubstitution',
'IsEmptySubstitution',
'LaunchConfiguration',
'LaunchLogDir',
'LocalSubstitution',
'NotEmptySubstitution',
'NotSubstitution',
'NotEqualsSubstitution',
'OrSubstitution',
Expand Down
91 changes: 91 additions & 0 deletions launch/launch/substitutions/is_empty_substitution.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# 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 IsEmptySubstitution 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('is-empty')
class IsEmptySubstitution(Substitution):
"""
Substitution that checks whether a string is empty.

Returns 'true' or 'false' strings depending on whether the input is empty.

For example, checking if a launch configuration is empty:

.. code-block:: python

is_empty = IsEmptySubstitution(LaunchConfiguration('arg'))

.. code-block:: xml

<let name="empty_check" value="$(is-empty $(var arg))"/>

.. code-block:: yaml

- let:
name: empty_check
value: "$(is-empty $(var arg))"

This can be useful for conditional logic based on whether a string value
has content or not.
"""

def __init__(self, value: SomeSubstitutionsType) -> None:
"""
Create an IsEmptySubstitution.

:param value: string or substitutions whose emptiness is checked
"""
super().__init__()
self.__value = normalize_to_list_of_substitutions(value)

@classmethod
def parse(
cls, data: Sequence[SomeSubstitutionsType]
) -> Tuple[Type['IsEmptySubstitution'], Dict[str, Any]]:
"""Parse `IsEmptySubstitution` substitution."""
if len(data) != 1:
raise TypeError('is-empty substitution expects 1 argument')
return cls, {'value': data[0]}

@property
def value(self) -> List[Substitution]:
"""Getter for the value to check."""
return self.__value

def describe(self) -> Text:
"""Return a description of this substitution as a string."""
return 'IsEmpty({})'.format(
' + '.join([sub.describe() for sub in self.value]))

def perform(self, context: LaunchContext) -> Text:
"""Perform substitutions and check if the result is empty."""
result = perform_substitutions(context, self.value)
return str(result == '').lower()
91 changes: 91 additions & 0 deletions launch/launch/substitutions/not_empty_substitution.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# 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 NotEmptySubstitution 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('not-empty')
class NotEmptySubstitution(Substitution):
"""
Substitution that checks whether a string is not empty.

Returns 'true' or 'false' strings depending on whether the input has content.

For example, checking if launch configuration is not an empty string:

.. code-block:: python

not_empty = NotEmptySubstitution(LaunchConfiguration('arg'))

.. code-block:: xml

<let name="not_empty_check" value="$(not-empty $(var arg))"/>

.. code-block:: yaml

- let:
name: not_empty_check
value: "$(not-empty $(var arg))"

This can be useful for conditional logic based on whether a string value
has content or not. It is the inverse of IsEmptySubstitution.
"""

def __init__(self, value: SomeSubstitutionsType) -> None:
"""
Create a NotEmptySubstitution.

:param value: string or substitutions whose non-emptiness is checked
"""
super().__init__()
self.__value = normalize_to_list_of_substitutions(value)

@classmethod
def parse(
cls, data: Sequence[SomeSubstitutionsType]
) -> Tuple[Type['NotEmptySubstitution'], Dict[str, Any]]:
"""Parse `NotEmptySubstitution` substitution."""
if len(data) != 1:
raise TypeError('not-empty substitution expects 1 argument')
return cls, {'value': data[0]}

@property
def value(self) -> List[Substitution]:
"""Getter for the value to check."""
return self.__value

def describe(self) -> Text:
"""Return a description of this substitution as a string."""
return 'NotEmpty({})'.format(
' + '.join([sub.describe() for sub in self.value]))

def perform(self, context: LaunchContext) -> Text:
"""Perform substitutions and check if the result is not empty."""
result = perform_substitutions(context, self.value)
return str(result != '').lower()
54 changes: 54 additions & 0 deletions launch/test/launch/substitutions/test_is_empty_substitution.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# 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 IsEmptySubstitution substitution class."""

from launch import LaunchContext
from launch.substitutions import IsEmptySubstitution
from launch.substitutions import TextSubstitution

import pytest


@pytest.mark.parametrize('value, expected', [
('', 'true'),
('robot', 'false'),
(' ', 'false'),
('\t\n\r', 'false'),
])
def test_is_empty(value, expected):
"""Test checking if a string is empty."""
substitution = IsEmptySubstitution(value)
assert substitution.perform(LaunchContext()) == expected


def test_is_empty_nested_substitutions():
"""Test checking if a value assembled from multiple substitutions is empty."""
substitution = IsEmptySubstitution([
TextSubstitution(text='robot'),
TextSubstitution(text=' name'),
])
assert substitution.perform(LaunchContext()) == 'false'


def test_is_empty_parse():
"""Test the frontend parser contract."""
substitution_type, kwargs = IsEmptySubstitution.parse(['some value'])
assert substitution_type is IsEmptySubstitution
assert kwargs == {'value': 'some value'}

with pytest.raises(TypeError, match='expects 1 argument'):
IsEmptySubstitution.parse([])
with pytest.raises(TypeError, match='expects 1 argument'):
IsEmptySubstitution.parse(['one', 'two'])
54 changes: 54 additions & 0 deletions launch/test/launch/substitutions/test_not_empty_substitution.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# 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 NotEmptySubstitution substitution class."""

from launch import LaunchContext
from launch.substitutions import NotEmptySubstitution
from launch.substitutions import TextSubstitution

import pytest


@pytest.mark.parametrize('value, expected', [
('', 'false'),
('robot', 'true'),
(' ', 'true'),
('\t\n\r', 'true'),
])
def test_not_empty(value, expected):
"""Test checking if a string is not empty."""
substitution = NotEmptySubstitution(value)
assert substitution.perform(LaunchContext()) == expected


def test_not_empty_nested_substitutions():
"""Test checking if a value assembled from multiple substitutions is not empty."""
substitution = NotEmptySubstitution([
TextSubstitution(text='robot'),
TextSubstitution(text=' name'),
])
assert substitution.perform(LaunchContext()) == 'true'


def test_not_empty_parse():
"""Test the frontend parser contract."""
substitution_type, kwargs = NotEmptySubstitution.parse(['some value'])
assert substitution_type is NotEmptySubstitution
assert kwargs == {'value': 'some value'}

with pytest.raises(TypeError, match='expects 1 argument'):
NotEmptySubstitution.parse([])
with pytest.raises(TypeError, match='expects 1 argument'):
NotEmptySubstitution.parse(['one', 'two'])
Loading