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
2 changes: 2 additions & 0 deletions launch/launch/substitutions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
from .equals_substitution import EqualsSubstitution
from .file_content import FileContent
from .find_executable import FindExecutable
from .find_launchfile import FindLaunchfile
from .for_loop_var import ForEachVar
from .for_loop_var import ForLoopIndex
from .if_else_substitution import IfElseSubstitution
Expand All @@ -51,6 +52,7 @@
'EnvironmentVariable',
'FileContent',
'FindExecutable',
'FindLaunchfile',
'ForEachVar',
'ForLoopIndex',
'IfElseSubstitution',
Expand Down
126 changes: 126 additions & 0 deletions launch/launch/substitutions/find_launchfile.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
# Copyright 2025 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 FindLaunchfile substitution."""

from pathlib import Path
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 .substitution_failure import SubstitutionFailure
from ..frontend import expose_substitution
from ..launch_context import LaunchContext
from ..some_substitutions_type import SomeSubstitutionsType
from ..substitution import Substitution


def name_matches_launchfile(name: Text, file: Path) -> bool:
from ..frontend import Parser # avoid circular import
valid_extensions = {'py', *Parser.get_available_extensions()}
valid_suffixes = {'', '.launch', '_launch'}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't allowing a non-suffix lead to matching Python package files as well?

I could imagine a package containing my_node.py and my_node.launch.py, which might introduce issues in the IncludePackageLaunch case.


if not file.is_file():
return False

# Check if the file has a valid launch extension
extension = file.suffix
if extension.startswith('.'):
extension = extension[1:]
if extension not in valid_extensions:
return False

# Full filenames allowed, check for full match
if name == file.name:
return True

remainder = file.stem.removeprefix(name)
if remainder == file.stem:
# The filename did not begin with the search name
return False
elif remainder in valid_suffixes:
return True

return False


def find_launchfile_in_path(name: Text, path: Path) -> List[Path]:
return [file for file in path.iterdir() if name_matches_launchfile(name, file)]


@expose_substitution('find-launchfile')
class FindLaunchfile(Substitution):
"""
Substitution that tries to locate a launchfile by stem name in a directory.

:raise: SubstitutionFailure on invalid search directory
:raise: SubstitutionFailure when no matching launchfiles found
:raise: SubstitutionFailure when more than 1 matching files found
"""

def __init__(self, *, name: SomeSubstitutionsType, path: SomeSubstitutionsType) -> None:
"""Create a FindLaunchfile substitution."""
super().__init__()

from ..utilities import normalize_to_list_of_substitutions # import here to avoid loop
self.__name = normalize_to_list_of_substitutions(name)
self.__path = normalize_to_list_of_substitutions(path)

@classmethod
def parse(cls, data: Sequence[SomeSubstitutionsType]
) -> Tuple[Type['FindLaunchfile'], Dict[str, Any]]:
"""Parse `FindLaunchfile` substitution."""
if len(data) != 2:
raise AttributeError(
f'find-launchfile substitution expects 2 argument, {len(data)} given')
return cls, {'name': data[0], 'path': data[1]}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't it make more sense to make the arguments $(find-launchfile PATH FILE) instead of FILE PATH?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Open for debate, my reasoning was "find name in dir" which matches "grep pattern in location".

I'm sure a sensible argument could be made for the opposite as well. But I'll stick with this implementation as my suggestion.

Copy link

@SuperJappie08 SuperJappie08 Oct 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I guess it is a matter of opinion.

An argument for my alternative would be that it would match the ros2launch argument ordering (in the package case), which makes the most sense for the suggested IncludePackageLaunch.
Keeping the same order of [package/folder, launchfile'] for this substitution would keep everything consistent.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That is sensible, yes. Internal consistency is a good argument 👍 I'll change it


@property
def name(self) -> List[Substitution]:
"""Getter for name."""
return self.__name

@property
def path(self) -> List[Substitution]:
"""Getter for path."""
return self.__path

def describe(self) -> Text:
"""Return a description of this substitution as a string."""
name = ' + '.join([sub.describe() for sub in self.name])
path = ' + '.join([sub.describe() for sub in self.path])
return f'FindLaunchfile(name={name}, path={path})'

def perform(self, context: LaunchContext) -> Text:
"""Perform the substitution by locating the executable on the PATH."""
from ..utilities import perform_substitutions # import here to avoid loop
name = perform_substitutions(context, self.name)
path = Path(perform_substitutions(context, self.path))
if not path.is_dir():
raise SubstitutionFailure(f"Path '{path}' is not a directory")

results = find_launchfile_in_path(name, path)
if len(results) == 0:
raise SubstitutionFailure(
f"No launchfile matching name '{name}' found in directory '{path}'")
elif len(results) > 1:
raise SubstitutionFailure(
f"Multiple launchfiles matching name '{name}' "
f"found in directory '{path}': {results}")
return str(results[0])
79 changes: 79 additions & 0 deletions launch/test/launch/substitutions/test_find_launchfile.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# Copyright 2025 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 FindLaunchfile substitution class."""

from pathlib import Path

from launch import LaunchContext
from launch.frontend import Parser
from launch.frontend.parse_substitution import parse_substitution
from launch.substitutions import FindLaunchfile
from launch.substitutions import SubstitutionFailure
from launch.utilities import perform_substitutions
import pytest

TEST_DIR = Path(__file__).parent / 'test_find_launchfile'


# Fake some valid extensions, since those packages aren't available here
@pytest.fixture
def mock_frontends():
Parser.frontend_parsers = {
'xml': None,
'yaml': None,
}
Parser.extensions_loaded = True
yield
Parser.frontend_parsers = None
Parser.extensions_loaded = False


def test_fullname(mock_frontends):
assert FindLaunchfile(name='a_launch.py', path=TEST_DIR).perform(LaunchContext())
assert FindLaunchfile(name='a.launch.xml', path=TEST_DIR).perform(LaunchContext())
assert FindLaunchfile(name='b_launch.yaml', path=TEST_DIR).perform(LaunchContext())
assert FindLaunchfile(name='c.py', path=TEST_DIR).perform(LaunchContext())


def test_valid_suffix(mock_frontends):
assert FindLaunchfile(name='a_launch', path=TEST_DIR).perform(LaunchContext())
assert FindLaunchfile(name='a.launch', path=TEST_DIR).perform(LaunchContext())
assert FindLaunchfile(name='b', path=TEST_DIR).perform(LaunchContext())
assert FindLaunchfile(name='b_launch', path=TEST_DIR).perform(LaunchContext())
assert FindLaunchfile(name='c', path=TEST_DIR).perform(LaunchContext())


def test_invalid_suffix(mock_frontends):
with pytest.raises(SubstitutionFailure):
FindLaunchfile(name='b_l', path=TEST_DIR).perform(LaunchContext())


def test_notfound(mock_frontends):
with pytest.raises(SubstitutionFailure):
FindLaunchfile(name='d', path=TEST_DIR).perform(LaunchContext())


def test_multiple(mock_frontends):
with pytest.raises(SubstitutionFailure):
FindLaunchfile(name='a', path=TEST_DIR).perform(LaunchContext())


def test_frontend(mock_frontends):
subst = parse_substitution('$(find-launchfile foo bar)')
assert len(subst) == 1
result = subst[0]
assert isinstance(result, FindLaunchfile)
assert perform_substitutions(LaunchContext(), result.name) == 'foo'
assert perform_substitutions(LaunchContext(), result.path) == 'bar'
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<launch />
19 changes: 19 additions & 0 deletions launch/test/launch/substitutions/test_find_launchfile/a_launch.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Copyright 2025 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.

from launch import LaunchDescription


def generate_launch_description():
return LaunchDescription()
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
launch: []
19 changes: 19 additions & 0 deletions launch/test/launch/substitutions/test_find_launchfile/c.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Copyright 2025 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.

from launch import LaunchDescription


def generate_launch_description():
return LaunchDescription()