From 1e750350a47ed565d63ecd24ff34538bd9a01292 Mon Sep 17 00:00:00 2001 From: Emerson Knapp Date: Mon, 28 Apr 2025 15:56:23 -0700 Subject: [PATCH 1/3] Allow 'include' to take a single piece of text as the file Signed-off-by: Emerson Knapp --- .../launch/actions/include_launch_description.py | 4 +++- launch_xml/launch_xml/entity.py | 16 ++++++++++++++++ launch_yaml/launch_yaml/entity.py | 15 ++++++++++++++- 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/launch/launch/actions/include_launch_description.py b/launch/launch/actions/include_launch_description.py index e15ca5af4..e96dc876d 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.get_attr('file') if not entity.is_element() else entity.element + 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_xml/launch_xml/entity.py b/launch_xml/launch_xml/entity.py index c3c165df0..9571bbfc0 100644 --- a/launch_xml/launch_xml/entity.py +++ b/launch_xml/launch_xml/entity.py @@ -37,6 +37,15 @@ def __init__( parent: 'Entity' = None ) -> Text: """Construct the Entity.""" + text = xml_element.text.strip() if xml_element.text else None + num_children = len([i for i in xml_element]) + self.__is_element = False + if text and num_children: + raise ValueError(f'Cannot provide XML text alongside children. Found text "{text}" and {num_children} child(ren) in element {xml_element}') + elif text: + self.__is_element = True + self.__element = text + self.__xml_element = xml_element self.__parent = parent self.__read_attributes = set() @@ -72,6 +81,13 @@ def assert_entity_completely_parsed(self): f'{unparsed_attributes}' ) + def is_element(self) -> bool: + return self.__is_element + + @property + def element(self) -> Text: + return self.__element + def get_attr( self, name: Text, diff --git a/launch_yaml/launch_yaml/entity.py b/launch_yaml/launch_yaml/entity.py index 93e262688..83603bfbb 100644 --- a/launch_yaml/launch_yaml/entity.py +++ b/launch_yaml/launch_yaml/entity.py @@ -31,7 +31,7 @@ class Entity(BaseEntity): def __init__( self, - element: dict, + element: Union[dict, List, Text], type_name: Text = None, *, parent: 'Entity' = None @@ -70,6 +70,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 +85,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 +99,15 @@ def assert_entity_completely_parsed(self): f'Unexpected key(s) found in `{self.__type_name}`: {unparsed_keys}' ) + def is_element(self) -> bool: + return isinstance(self.__element, str) + + @property + def element(self) -> Text: + if not self.is_element(): + raise RuntimeError('Directly retrieving the element of a non-str Entity is ill-formed.') + return self.__element + def get_attr( self, name: Text, From bb1fd77cb6aa40fbfa352a62e9bb69e4cb071d6b Mon Sep 17 00:00:00 2001 From: Emerson Knapp Date: Mon, 28 Apr 2025 16:11:36 -0700 Subject: [PATCH 2/3] Fix the linters and simplify the interface Signed-off-by: Emerson Knapp --- .../actions/include_launch_description.py | 2 +- launch/launch/frontend/entity.py | 5 +++++ launch_xml/launch_xml/entity.py | 21 +++++++------------ launch_yaml/launch_yaml/entity.py | 8 ++----- 4 files changed, 16 insertions(+), 20 deletions(-) diff --git a/launch/launch/actions/include_launch_description.py b/launch/launch/actions/include_launch_description.py index e96dc876d..74f73eaf3 100644 --- a/launch/launch/actions/include_launch_description.py +++ b/launch/launch/actions/include_launch_description.py @@ -86,7 +86,7 @@ def __init__( def parse(cls, entity: Entity, parser: Parser): """Return `IncludeLaunchDescription` action and kwargs for constructing it.""" _, kwargs = super().parse(entity, parser) - file_attr = entity.get_attr('file') if not entity.is_element() else entity.element + file_attr = entity.bare_text or entity.get_attr('file') file_path = parser.parse_substitution(file_attr) kwargs['launch_description_source'] = file_path 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 9571bbfc0..d35023306 100644 --- a/launch_xml/launch_xml/entity.py +++ b/launch_xml/launch_xml/entity.py @@ -37,14 +37,12 @@ def __init__( parent: 'Entity' = None ) -> Text: """Construct the Entity.""" - text = xml_element.text.strip() if xml_element.text else None - num_children = len([i for i in xml_element]) - self.__is_element = False - if text and num_children: - raise ValueError(f'Cannot provide XML text alongside children. Found text "{text}" and {num_children} child(ren) in element {xml_element}') - elif text: - self.__is_element = True - self.__element = text + 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 @@ -81,12 +79,9 @@ def assert_entity_completely_parsed(self): f'{unparsed_attributes}' ) - def is_element(self) -> bool: - return self.__is_element - @property - def element(self) -> Text: - return self.__element + def bare_text(self) -> Optional[Text]: + return self.__bare_text def get_attr( self, diff --git a/launch_yaml/launch_yaml/entity.py b/launch_yaml/launch_yaml/entity.py index 83603bfbb..58d0b8fc8 100644 --- a/launch_yaml/launch_yaml/entity.py +++ b/launch_yaml/launch_yaml/entity.py @@ -37,6 +37,7 @@ def __init__( 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 @@ -99,13 +100,8 @@ def assert_entity_completely_parsed(self): f'Unexpected key(s) found in `{self.__type_name}`: {unparsed_keys}' ) - def is_element(self) -> bool: - return isinstance(self.__element, str) - @property - def element(self) -> Text: - if not self.is_element(): - raise RuntimeError('Directly retrieving the element of a non-str Entity is ill-formed.') + def bare_text(self) -> Optional[Text]: return self.__element def get_attr( From 361ae8c3de0612c4f9c66cc2c8e0548fca13853b Mon Sep 17 00:00:00 2001 From: Emerson Knapp Date: Mon, 28 Apr 2025 21:40:54 -0700 Subject: [PATCH 3/3] Add test for include to xml and yaml Signed-off-by: Emerson Knapp --- launch_xml/test/launch_xml/test_include.py | 17 +++++ launch_yaml/launch_yaml/entity.py | 2 +- launch_yaml/test/launch_yaml/executable.yaml | 15 +++++ launch_yaml/test/launch_yaml/test_include.py | 65 ++++++++++++++++++++ 4 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 launch_yaml/test/launch_yaml/executable.yaml create mode 100644 launch_yaml/test/launch_yaml/test_include.py 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 58d0b8fc8..57aa09775 100644 --- a/launch_yaml/launch_yaml/entity.py +++ b/launch_yaml/launch_yaml/entity.py @@ -102,7 +102,7 @@ def assert_entity_completely_parsed(self): @property def bare_text(self) -> Optional[Text]: - return self.__element + return self.__bare_text def get_attr( self, 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()