From fd774ae858643d60507696e5e833aecd86fd414e Mon Sep 17 00:00:00 2001 From: Nathan Wiebe Neufeldt Date: Mon, 2 Mar 2026 21:30:57 -0500 Subject: [PATCH 1/4] Define AmentIndexResource substitution type This takes two arguments: a resource type and a resource name. These will be resolved and then used to look up a resource in the ament index. Due to how launch files parse substitution arguments by splitting on spaces, the resource type and name cannot contain spaces when using the XML and YAML front-ends. We could try using a different separator for the two, such as a `/` character (since this is guaranteed not to appear in a directory name, and therefore not in an ament resource type either); however, this would complicate things significantly and mean we have to delay the splitting until `perform()` is called and we can resolve any nested substitutions. Signed-off-by: Nathan Wiebe Neufeldt --- .../launch_ros/substitutions/__init__.py | 2 + .../launch_ros/substitutions/ament_index.py | 80 +++++++++++++++++++ 2 files changed, 82 insertions(+) create mode 100644 launch_ros/launch_ros/substitutions/ament_index.py diff --git a/launch_ros/launch_ros/substitutions/__init__.py b/launch_ros/launch_ros/substitutions/__init__.py index c3a2d1331..a224c888d 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 000000000..1d9025d4c --- /dev/null +++ b/launch_ros/launch_ros/substitutions/ament_index.py @@ -0,0 +1,80 @@ +# 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 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.substitutions import Substitution, PathSubstitution +from launch.utilities import normalize_to_list_of_substitutions, perform_substitutions + +from typing import Sequence, Tuple, Type, Dict, Any, List, Text + + +@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, + type: SomeSubstitutionsType, + name: SomeSubstitutionsType, + ) -> None: + """Create an AmentIndexResource substitution.""" + super().__init__(self) + + self.__type = normalize_to_list_of_substitutions(type) + self.__name = normalize_to_list_of_substitutions(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 = { + "type": data[0], + "name": data[1], + } + return cls, kwargs + + @property + def type(self) -> List[Substitution]: + """Getter for type.""" + return self.__type + + @property + def name(self) -> List[Substitution]: + """Getter for 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.type) + name_str = " + ".join(sub.describe() for sub in self.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.""" + type = perform_substitutions(context, self.type) + name = perform_substitutions(context, self.name) + return get_resource(type, name)[1] From ac51372fdab20ee675cb3619e2df04797c180bb3 Mon Sep 17 00:00:00 2001 From: Nathan Wiebe Neufeldt Date: Mon, 2 Mar 2026 22:20:22 -0500 Subject: [PATCH 2/4] Fix linting failures Signed-off-by: Nathan Wiebe Neufeldt --- .../launch_ros/substitutions/ament_index.py | 45 ++++++++++--------- 1 file changed, 23 insertions(+), 22 deletions(-) diff --git a/launch_ros/launch_ros/substitutions/ament_index.py b/launch_ros/launch_ros/substitutions/ament_index.py index 1d9025d4c..20acee651 100644 --- a/launch_ros/launch_ros/substitutions/ament_index.py +++ b/launch_ros/launch_ros/substitutions/ament_index.py @@ -14,18 +14,19 @@ """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.substitutions import Substitution, PathSubstitution +from launch.substitution import Substitution +from launch.substitutions import PathSubstitution from launch.utilities import normalize_to_list_of_substitutions, perform_substitutions -from typing import Sequence, Tuple, Type, Dict, Any, List, Text - -@expose_substitution("ament-index-resource") +@expose_substitution('ament-index-resource') class AmentIndexResource(PathSubstitution): """ Substitution that looks up the path for the given resource type and name. @@ -35,46 +36,46 @@ class AmentIndexResource(PathSubstitution): def __init__( self, - type: SomeSubstitutionsType, - name: SomeSubstitutionsType, + resource_type: SomeSubstitutionsType, + resource_name: SomeSubstitutionsType, ) -> None: """Create an AmentIndexResource substitution.""" super().__init__(self) - self.__type = normalize_to_list_of_substitutions(type) - self.__name = normalize_to_list_of_substitutions(name) + 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]]: + ) -> Tuple[Type['AmentIndexResource'], Dict[str, Any]]: """Parse an AmentIndexResource subtitution.""" if len(data) != 2: - raise TypeError("ament-index-resource expects 2 arguments") + raise TypeError('ament-index-resource expects 2 arguments') kwargs = { - "type": data[0], - "name": data[1], + 'resource_type': data[0], + 'resource_name': data[1], } return cls, kwargs @property - def type(self) -> List[Substitution]: - """Getter for type.""" + def resource_type(self) -> List[Substitution]: + """Getter for resource_type.""" return self.__type @property - def name(self) -> List[Substitution]: - """Getter for name.""" + 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.type) - name_str = " + ".join(sub.describe() for sub in self.name) - return f"AmentIndexResource({type_str}, {name_str})" + 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.""" - type = perform_substitutions(context, self.type) - name = perform_substitutions(context, self.name) - return get_resource(type, name)[1] + resource_type = perform_substitutions(context, self.resource_type) + resource_name = perform_substitutions(context, self.resource_name) + return get_resource(resource_type, resource_name)[1] From 844e6e5b95dc4377de7fb2434e4a12cf448d4a39 Mon Sep 17 00:00:00 2001 From: Nathan Wiebe Neufeldt Date: Mon, 2 Mar 2026 22:58:11 -0500 Subject: [PATCH 3/4] Add unit tests for the AmentIndexResource substitution Signed-off-by: Nathan Wiebe Neufeldt --- .../launch_ros/substitutions/ament_index.py | 7 ++- .../substitutions/test_ament_index.py | 44 +++++++++++++++++++ 2 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 test_launch_ros/test/test_launch_ros/substitutions/test_ament_index.py diff --git a/launch_ros/launch_ros/substitutions/ament_index.py b/launch_ros/launch_ros/substitutions/ament_index.py index 20acee651..e25109088 100644 --- a/launch_ros/launch_ros/substitutions/ament_index.py +++ b/launch_ros/launch_ros/substitutions/ament_index.py @@ -22,7 +22,7 @@ from launch.launch_context import LaunchContext from launch.some_substitutions_type import SomeSubstitutionsType from launch.substitution import Substitution -from launch.substitutions import PathSubstitution +from launch.substitutions import PathSubstitution, SubstitutionFailure from launch.utilities import normalize_to_list_of_substitutions, perform_substitutions @@ -78,4 +78,7 @@ 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) - return get_resource(resource_type, resource_name)[1] + 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/substitutions/test_ament_index.py b/test_launch_ros/test/test_launch_ros/substitutions/test_ament_index.py new file mode 100644 index 000000000..9778eb829 --- /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.substitution_failure 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) From 5d507b3edebc87baa761bedb7b92776f40c148c0 Mon Sep 17 00:00:00 2001 From: Nathan Wiebe Neufeldt Date: Mon, 2 Mar 2026 23:21:00 -0500 Subject: [PATCH 4/4] Add front-end tests for AmentIndexResource These check that the substitution works in XML and YAML launch files. Signed-off-by: Nathan Wiebe Neufeldt --- .../test_ament_index_substitution_frontend.py | 131 ++++++++++++++++++ .../substitutions/test_ament_index.py | 2 +- 2 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 test_launch_ros/test/test_launch_ros/frontend/test_ament_index_substitution_frontend.py 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 000000000..a19b36841 --- /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 index 9778eb829..3e2ea17ed 100644 --- 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 @@ -17,7 +17,7 @@ from pathlib import Path from launch import LaunchContext -from launch.substitutions.substitution_failure import SubstitutionFailure +from launch.substitutions import SubstitutionFailure from launch_ros.substitutions import AmentIndexResource import pytest