-
Notifications
You must be signed in to change notification settings - Fork 158
Add FindLaunchfile substitution #915
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: rolling
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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'} | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
|
|
||
| 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]} | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Wouldn't it make more sense to make the arguments There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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]) | ||
| 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 /> |
| 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: [] |
| 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() |
Uh oh!
There was an error while loading. Please reload this page.