diff --git a/launch/launch/substitutions/__init__.py b/launch/launch/substitutions/__init__.py index 7fa7170e7..2e677c904 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 .find_launchfile import FindLaunchfile from .for_loop_var import ForEachVar from .for_loop_var import ForLoopIndex from .if_else_substitution import IfElseSubstitution @@ -51,6 +52,7 @@ 'EnvironmentVariable', 'FileContent', 'FindExecutable', + 'FindLaunchfile', 'ForEachVar', 'ForLoopIndex', 'IfElseSubstitution', diff --git a/launch/launch/substitutions/find_launchfile.py b/launch/launch/substitutions/find_launchfile.py new file mode 100644 index 000000000..baab8414b --- /dev/null +++ b/launch/launch/substitutions/find_launchfile.py @@ -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'} + + 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]} + + @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]) diff --git a/launch/test/launch/substitutions/test_find_launchfile.py b/launch/test/launch/substitutions/test_find_launchfile.py new file mode 100644 index 000000000..c040bbed4 --- /dev/null +++ b/launch/test/launch/substitutions/test_find_launchfile.py @@ -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' diff --git a/launch/test/launch/substitutions/test_find_launchfile/a.launch.xml b/launch/test/launch/substitutions/test_find_launchfile/a.launch.xml new file mode 100644 index 000000000..01be8527c --- /dev/null +++ b/launch/test/launch/substitutions/test_find_launchfile/a.launch.xml @@ -0,0 +1 @@ + diff --git a/launch/test/launch/substitutions/test_find_launchfile/a_launch.py b/launch/test/launch/substitutions/test_find_launchfile/a_launch.py new file mode 100644 index 000000000..360573ab2 --- /dev/null +++ b/launch/test/launch/substitutions/test_find_launchfile/a_launch.py @@ -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() diff --git a/launch/test/launch/substitutions/test_find_launchfile/b_launch.yaml b/launch/test/launch/substitutions/test_find_launchfile/b_launch.yaml new file mode 100644 index 000000000..b315bc20d --- /dev/null +++ b/launch/test/launch/substitutions/test_find_launchfile/b_launch.yaml @@ -0,0 +1 @@ +launch: [] diff --git a/launch/test/launch/substitutions/test_find_launchfile/c.py b/launch/test/launch/substitutions/test_find_launchfile/c.py new file mode 100644 index 000000000..360573ab2 --- /dev/null +++ b/launch/test/launch/substitutions/test_find_launchfile/c.py @@ -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()