diff --git a/launch/launch/actions/include_launch_description.py b/launch/launch/actions/include_launch_description.py index e15ca5af4..74f73eaf3 100644 --- a/launch/launch/actions/include_launch_description.py +++ b/launch/launch/actions/include_launch_description.py @@ -86,7 +86,9 @@ def __init__( def parse(cls, entity: Entity, parser: Parser): """Return `IncludeLaunchDescription` action and kwargs for constructing it.""" _, kwargs = super().parse(entity, parser) - file_path = parser.parse_substitution(entity.get_attr('file')) + file_attr = entity.bare_text or entity.get_attr('file') + file_path = parser.parse_substitution(file_attr) + kwargs['launch_description_source'] = file_path args = entity.get_attr('arg', data_type=List[Entity], optional=True) if args is not None: diff --git a/launch/launch/frontend/entity.py b/launch/launch/frontend/entity.py index eedd3a2cc..693515045 100644 --- a/launch/launch/frontend/entity.py +++ b/launch/launch/frontend/entity.py @@ -116,3 +116,8 @@ def assert_entity_completely_parsed(self): function completed. """ raise NotImplementedError() + + @property + def bare_text(self) -> Optional[Text]: + """Return the bare text of this element if it is a bare text element, or None.""" + raise NotImplementedError() diff --git a/launch_xml/launch_xml/entity.py b/launch_xml/launch_xml/entity.py index c3c165df0..d35023306 100644 --- a/launch_xml/launch_xml/entity.py +++ b/launch_xml/launch_xml/entity.py @@ -37,6 +37,13 @@ def __init__( parent: 'Entity' = None ) -> Text: """Construct the Entity.""" + self.__bare_text = xml_element.text.strip() if xml_element.text else None + num_children = len(list(xml_element)) + if self.__bare_text and num_children: + raise ValueError( + f'Cannot provide XML text alongside children. Found text "{self.__bare_text}" ' + f'and {num_children} child(ren) in element {xml_element}') + self.__xml_element = xml_element self.__parent = parent self.__read_attributes = set() @@ -72,6 +79,10 @@ def assert_entity_completely_parsed(self): f'{unparsed_attributes}' ) + @property + def bare_text(self) -> Optional[Text]: + return self.__bare_text + def get_attr( self, name: Text, diff --git a/launch_xml/test/launch_xml/test_include.py b/launch_xml/test/launch_xml/test_include.py index 0380f32c8..26c023ba7 100644 --- a/launch_xml/test/launch_xml/test_include.py +++ b/launch_xml/test/launch_xml/test_include.py @@ -46,5 +46,22 @@ def test_include(): assert 0 == ls.run() +def test_include_bare_text(): + path = (Path(__file__).parent / 'executable.xml').as_posix() + xml_file = f""" + + {path} + + """ + xml_file = textwrap.dedent(xml_file) + root_entity, parser = load_no_extensions(io.StringIO(xml_file)) + ld = parser.parse_description(root_entity) + include = ld.entities[0] + assert isinstance(include, IncludeLaunchDescription) + assert isinstance(include.launch_description_source, AnyLaunchDescriptionSource) + # No need to run a second time, just testing parsing + + if __name__ == '__main__': test_include() + test_include_bare_text() diff --git a/launch_yaml/launch_yaml/entity.py b/launch_yaml/launch_yaml/entity.py index 93e262688..57aa09775 100644 --- a/launch_yaml/launch_yaml/entity.py +++ b/launch_yaml/launch_yaml/entity.py @@ -31,12 +31,13 @@ class Entity(BaseEntity): def __init__( self, - element: dict, + element: Union[dict, List, Text], type_name: Text = None, *, parent: 'Entity' = None ) -> Text: """Create an Entity.""" + self.__bare_text = element if isinstance(element, str) else None self.__type_name = type_name self.__element = element self.__parent = parent @@ -70,6 +71,8 @@ def children(self) -> List['Entity']: 'list element') self.__read_keys.add('children') children = self.__element['children'] + elif isinstance(self.__element, str): + return [self] else: children = self.__element entities = [] @@ -83,6 +86,8 @@ def children(self) -> List['Entity']: return entities def assert_entity_completely_parsed(self): + if isinstance(self.__element, str): + return if isinstance(self.__element, list): if not self.__children_called: raise ValueError( @@ -95,6 +100,10 @@ def assert_entity_completely_parsed(self): f'Unexpected key(s) found in `{self.__type_name}`: {unparsed_keys}' ) + @property + def bare_text(self) -> Optional[Text]: + return self.__bare_text + def get_attr( self, name: Text, diff --git a/launch_yaml/test/launch_yaml/executable.yaml b/launch_yaml/test/launch_yaml/executable.yaml new file mode 100644 index 000000000..730fd4546 --- /dev/null +++ b/launch_yaml/test/launch_yaml/executable.yaml @@ -0,0 +1,15 @@ +--- +launch: + - executable: + cmd: ls -l -a -s + cwd: / + name: my_ls + shell: true + output: log + emulate_tty: true + sigkill_timeout: 4.0 + sigterm_timeout: 7.0 + launch-prefix: $(env LAUNCH_PREFIX '') + env: + - name: var + value: "1" diff --git a/launch_yaml/test/launch_yaml/test_include.py b/launch_yaml/test/launch_yaml/test_include.py new file mode 100644 index 000000000..c7bc13314 --- /dev/null +++ b/launch_yaml/test/launch_yaml/test_include.py @@ -0,0 +1,65 @@ +# Copyright 2025 Polymath Robotics, 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 parsing a launch file inclusion.""" + +import io +from pathlib import Path +import textwrap + +from launch import LaunchService +from launch.actions import IncludeLaunchDescription +from launch.launch_description_sources import AnyLaunchDescriptionSource + +from parser_no_extensions import load_no_extensions + + +def test_include(): + """Parse include yaml example.""" + path = (Path(__file__).parent / 'executable.yaml').as_posix() + yaml_file = f"""\ + launch: + - include: + file: {path} + """ + yaml_file = textwrap.dedent(yaml_file) + root_entity, parser = load_no_extensions(io.StringIO(yaml_file)) + ld = parser.parse_description(root_entity) + include = ld.entities[0] + assert isinstance(include, IncludeLaunchDescription) + assert isinstance(include.launch_description_source, AnyLaunchDescriptionSource) + ls = LaunchService(debug=True) + ls.include_launch_description(ld) + assert 0 == ls.run() + + +def test_include_bare_text(): + """Parse include yaml example.""" + path = (Path(__file__).parent / 'executable.yaml').as_posix() + yaml_file = f""" + launch: + - include: {path} + """ + yaml_file = textwrap.dedent(yaml_file) + root_entity, parser = load_no_extensions(io.StringIO(yaml_file)) + ld = parser.parse_description(root_entity) + include = ld.entities[0] + assert isinstance(include, IncludeLaunchDescription) + assert isinstance(include.launch_description_source, AnyLaunchDescriptionSource) + # No need to run a second time, just testing parsing + + +if __name__ == '__main__': + test_include() + test_include_bare_text()