diff --git a/launch_ros/launch_ros/substitutions/__init__.py b/launch_ros/launch_ros/substitutions/__init__.py index c3a2d133..a224c888 100644 --- a/launch_ros/launch_ros/substitutions/__init__.py +++ b/launch_ros/launch_ros/substitutions/__init__.py @@ -14,6 +14,7 @@ """substitutions Module.""" +from .ament_index import AmentIndexResource from .executable_in_package import ExecutableInPackage from .find_package import FindPackage from .find_package import FindPackagePrefix @@ -22,6 +23,7 @@ __all__ = [ + 'AmentIndexResource', 'ExecutableInPackage', 'FindPackage', 'FindPackagePrefix', diff --git a/launch_ros/launch_ros/substitutions/ament_index.py b/launch_ros/launch_ros/substitutions/ament_index.py new file mode 100644 index 00000000..e2510908 --- /dev/null +++ b/launch_ros/launch_ros/substitutions/ament_index.py @@ -0,0 +1,84 @@ +# 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 AmentIndex substitution.""" + +from typing import Any, Dict, List, Sequence, Text, Tuple, Type + +from ament_index_python.resources import get_resource + +from launch.frontend import expose_substitution +from launch.launch_context import LaunchContext +from launch.some_substitutions_type import SomeSubstitutionsType +from launch.substitution import Substitution +from launch.substitutions import PathSubstitution, SubstitutionFailure +from launch.utilities import normalize_to_list_of_substitutions, perform_substitutions + + +@expose_substitution('ament-index-resource') +class AmentIndexResource(PathSubstitution): + """ + Substitution that looks up the path for the given resource type and name. + + The resource is located using ament_index_python. + """ + + def __init__( + self, + resource_type: SomeSubstitutionsType, + resource_name: SomeSubstitutionsType, + ) -> None: + """Create an AmentIndexResource substitution.""" + super().__init__(self) + + self.__type = normalize_to_list_of_substitutions(resource_type) + self.__name = normalize_to_list_of_substitutions(resource_name) + + @classmethod + def parse( + cls, data: Sequence[SomeSubstitutionsType] + ) -> Tuple[Type['AmentIndexResource'], Dict[str, Any]]: + """Parse an AmentIndexResource subtitution.""" + if len(data) != 2: + raise TypeError('ament-index-resource expects 2 arguments') + kwargs = { + 'resource_type': data[0], + 'resource_name': data[1], + } + return cls, kwargs + + @property + def resource_type(self) -> List[Substitution]: + """Getter for resource_type.""" + return self.__type + + @property + def resource_name(self) -> List[Substitution]: + """Getter for resource_name.""" + return self.__name + + def describe(self) -> Text: + """Return a description of this substitution as a string.""" + type_str = ' + '.join(sub.describe() for sub in self.resource_type) + name_str = ' + '.join(sub.describe() for sub in self.resource_name) + return f'AmentIndexResource({type_str}, {name_str})' + + def perform(self, context: LaunchContext) -> Text: + """Perform the substitution by looking up the resource in the ament index.""" + resource_type = perform_substitutions(context, self.resource_type) + resource_name = perform_substitutions(context, self.resource_name) + try: + return get_resource(resource_type, resource_name)[1] + except Exception as e: + raise SubstitutionFailure(e) diff --git a/test_launch_ros/test/test_launch_ros/frontend/test_ament_index_substitution_frontend.py b/test_launch_ros/test/test_launch_ros/frontend/test_ament_index_substitution_frontend.py new file mode 100644 index 00000000..a19b3684 --- /dev/null +++ b/test_launch_ros/test/test_launch_ros/frontend/test_ament_index_substitution_frontend.py @@ -0,0 +1,131 @@ +# 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. + +import io +from pathlib import Path +import textwrap + +from launch import LaunchService +from launch.frontend import Parser +from launch.substitutions import SubstitutionFailure +from launch.utilities import perform_substitutions + +import pytest + + +def test_ament_index_resource_substitution_yaml(): + yaml_file = textwrap.dedent( + r""" + launch: + - let: + name: launch_ros_prefix + value: $(ament-index-resource packages launch_ros) + """ + ) + with io.StringIO(yaml_file) as f: + check_ament_index_resource_substitution(f) + + +def test_ament_index_resource_substitution_xml(): + xml_file = textwrap.dedent( + r""" + + + + """ + ) + with io.StringIO(xml_file) as f: + check_ament_index_resource_substitution(f) + + +def check_ament_index_resource_substitution(file): + root_entity, parser = Parser.load(file) + ld = parser.parse_description(root_entity) + ls = LaunchService() + ls.include_launch_description(ld) + assert 0 == ls.run() + + def perform(substitution): + return perform_substitutions(ls.context, substitution) + + let, = ld.describe_sub_entities() + assert perform(let.name) == 'launch_ros_prefix' + assert Path(perform(let.value)).is_dir() + + +def test_ament_index_resource_substitution_yaml_nonexistent_name(): + yaml_file = textwrap.dedent( + r""" + launch: + - let: + name: bad_name + value: $(ament-index-resource packages package_that_certainly_does_not_exist) + """ + ) + with io.StringIO(yaml_file) as f: + check_ament_index_resource_substitution_failure(f) + + +def test_ament_index_resource_substitution_yaml_nonexistent_type(): + yaml_file = textwrap.dedent( + r""" + launch: + - let: + name: bad_type + value: $(ament-index-resource resource_type_that_does_not_exist launch_ros) + """ + ) + with io.StringIO(yaml_file) as f: + check_ament_index_resource_substitution_failure(f) + + +def test_ament_index_resource_substitution_xml_nonexistent_name(): + xml_file = textwrap.dedent( + r""" + + + + """ + ) + with io.StringIO(xml_file) as f: + check_ament_index_resource_substitution_failure(f) + + +def test_ament_index_resource_substitution_xml_nonexistent_type(): + xml_file = textwrap.dedent( + r""" + + + + """ + ) + with io.StringIO(xml_file) as f: + check_ament_index_resource_substitution_failure(f) + + +def check_ament_index_resource_substitution_failure(file): + root_entity, parser = Parser.load(file) + ld = parser.parse_description(root_entity) + ls = LaunchService() + ls.include_launch_description(ld) + assert 0 != ls.run() + + def perform(substitution): + return perform_substitutions(ls.context, substitution) + + let, = ld.describe_sub_entities() + with pytest.raises(SubstitutionFailure): + perform(let.value) diff --git a/test_launch_ros/test/test_launch_ros/substitutions/test_ament_index.py b/test_launch_ros/test/test_launch_ros/substitutions/test_ament_index.py new file mode 100644 index 00000000..3e2ea17e --- /dev/null +++ b/test_launch_ros/test/test_launch_ros/substitutions/test_ament_index.py @@ -0,0 +1,44 @@ +# 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 for the AmentIndexResource substitution.""" + +from pathlib import Path + +from launch import LaunchContext +from launch.substitutions import SubstitutionFailure +from launch_ros.substitutions import AmentIndexResource + +import pytest + + +def test_ament_index_resource(): + sub = AmentIndexResource('packages', 'launch_ros') + context = LaunchContext() + resource_path = Path(sub.perform(context)) + assert resource_path.is_dir() + + +def test_ament_index_resource_nonexistent_name(): + sub = AmentIndexResource('packages', 'package_that_certainly_does_not_exist') + context = LaunchContext() + with pytest.raises(SubstitutionFailure): + sub.perform(context) + + +def test_ament_index_resource_nonexistent_type(): + sub = AmentIndexResource('resource_type_that_certainly_does_not_exist', 'launch_ros') + context = LaunchContext() + with pytest.raises(SubstitutionFailure): + sub.perform(context)