From aa273219ef2dfce87354850daa82a231ce94d633 Mon Sep 17 00:00:00 2001 From: Emerson Knapp Date: Fri, 16 May 2025 10:42:37 -0700 Subject: [PATCH 1/3] Add test for positional list children --- launch_py/actions.py | 4 ++-- launch_py/entity.py | 1 + test/test_entity.py | 29 +++++++++++++++++++++++++++++ 3 files changed, 32 insertions(+), 2 deletions(-) create mode 100644 test/test_entity.py diff --git a/launch_py/actions.py b/launch_py/actions.py index 0e125fa..0d99ff2 100644 --- a/launch_py/actions.py +++ b/launch_py/actions.py @@ -24,8 +24,8 @@ def make_action_factory(action_name: str, **kwargs) -> Callable[..., Any]: if is_reserved_identifier(action_name): action_name += '_' - def fn(**kwargs): - return Entity(action_name, kwargs) + def fn(*args, **kwargs): + return Entity(action_name, args, kwargs) fn.__doc__ = f'launch_py action: {action_name} (dynamically generated)' fn.__name__ = action_name diff --git a/launch_py/entity.py b/launch_py/entity.py index 86ea3b7..0a796de 100644 --- a/launch_py/entity.py +++ b/launch_py/entity.py @@ -43,6 +43,7 @@ class Entity(BaseEntity): def __init__( self, type_name: Text, + args: list, kwargs: dict, *, parent: Optional['Entity'] = None, diff --git a/test/test_entity.py b/test/test_entity.py new file mode 100644 index 0000000..69319e9 --- /dev/null +++ b/test/test_entity.py @@ -0,0 +1,29 @@ +# 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. + +from launch_py.actions import group, log + + +def test_kwarg_children(): + g = group(children=[ + log(level='info', message='list child'), + ]) + assert len(g.children) == 1 + + +def test_list_children(): + g = group([ + log(level='info', message='list child') + ]) + assert len(g.children) == 1 From e4b78193f897c707ffbb5c7df43bd2ce2192d53c Mon Sep 17 00:00:00 2001 From: Emerson Knapp Date: Mon, 19 May 2025 10:13:49 -0700 Subject: [PATCH 2/3] Can take list of children now --- launch_py/__init__.py | 2 +- launch_py/actions.py | 2 +- launch_py/entity.py | 41 +++++++++++++++++++++-------------------- test/test_entity.py | 35 ++++++++++++++++++++++++++++++----- 4 files changed, 53 insertions(+), 27 deletions(-) diff --git a/launch_py/__init__.py b/launch_py/__init__.py index 3c945c1..4982371 100644 --- a/launch_py/__init__.py +++ b/launch_py/__init__.py @@ -23,7 +23,7 @@ def launch(actions: List[Entity]) -> LaunchDescription: parser = Parser() - root_entity = Entity('launch', {'children': actions}) + root_entity = Entity('launch', children=actions) return parser.parse_description(root_entity) diff --git a/launch_py/actions.py b/launch_py/actions.py index 0d99ff2..2a008f6 100644 --- a/launch_py/actions.py +++ b/launch_py/actions.py @@ -25,7 +25,7 @@ def make_action_factory(action_name: str, **kwargs) -> Callable[..., Any]: action_name += '_' def fn(*args, **kwargs): - return Entity(action_name, args, kwargs) + return Entity(action_name, *args, **kwargs) fn.__doc__ = f'launch_py action: {action_name} (dynamically generated)' fn.__name__ = action_name diff --git a/launch_py/entity.py b/launch_py/entity.py index 0a796de..3a841db 100644 --- a/launch_py/entity.py +++ b/launch_py/entity.py @@ -43,15 +43,22 @@ class Entity(BaseEntity): def __init__( self, type_name: Text, - args: list, - kwargs: dict, - *, - parent: Optional['Entity'] = None, + *args: BaseEntity, + **kwargs, ) -> None: """Create an Entity.""" + if args and kwargs: + raise ValueError( + 'Entity cannot take both positional arguments and keyword arguments. ' + f'Provided args={args}, kwargs={kwargs}. ' + 'To provide attributes & children, pass `children` kwarg with type list[Entity]') + + if kwargs: + self.__attrs = kwargs + else: + self.__attrs = {'children': args} + self.__type_name = type_name - self.__kwargs = kwargs - self.__parent = parent self.__read_keys: Set[Text] = set() @property @@ -62,27 +69,21 @@ def type_name(self) -> Text: @property def parent(self) -> Optional['Entity']: """Get Entity parent.""" - return self.__parent + return None @property def children(self) -> List[BaseEntity]: """Get the Entity's children.""" - if not isinstance(self.__kwargs, (dict)): - raise TypeError( - f'Expected a dict, got {type(self.__kwargs)}:' - f'\n---\n{self.__kwargs}\n---' - ) - - if 'children' not in self.__kwargs: + if 'children' not in self.__attrs: raise ValueError( f'Expected entity `{self.__type_name}` to have children entities.') self.__read_keys.add('children') - children: List[BaseEntity] = self.__kwargs['children'] + children: List[BaseEntity] = self.__attrs['children'] return children def assert_entity_completely_parsed(self): - unparsed_keys = set(self.__kwargs.keys()) - self.__read_keys + unparsed_keys = set(self.__attrs.keys()) - self.__read_keys if unparsed_keys: raise ValueError( f'Unexpected key(s) found in `{self.__type_name}`: {unparsed_keys}' @@ -105,7 +106,7 @@ def get_attr( # type: ignore[override] See :py:meth:`launch.frontend.Entity.get_attr`. Does not apply type coercion, only checks if the read value is of the correct type. """ - if name not in self.__kwargs: + if name not in self.__attrs: if not optional: raise AttributeError( "Can not find attribute '{}' in Entity '{}'".format( @@ -113,10 +114,10 @@ def get_attr( # type: ignore[override] else: return None self.__read_keys.add(name) - data = self.__kwargs[name] + data = self.__attrs[name] if check_is_list_entity(data_type): if isinstance(data, list) and isinstance(data[0], dict): - return [Entity(name, child) for child in data] + return [Entity(name, **child) for child in data] elif isinstance(data, list) and isinstance(data[0], Entity): return data raise TypeError( @@ -134,4 +135,4 @@ def get_attr( # type: ignore[override] def __repr__(self) -> str: """Return a string representation of the Entity.""" - return f'Entity(type_name={self.__type_name}, kwargs={self.__kwargs})' + return f'Entity(type_name={self.__type_name}, attrs={self.__attrs})' diff --git a/test/test_entity.py b/test/test_entity.py index 69319e9..0223a54 100644 --- a/test/test_entity.py +++ b/test/test_entity.py @@ -12,18 +12,43 @@ # See the License for the specific language governing permissions and # limitations under the License. +import pytest + from launch_py.actions import group, log def test_kwarg_children(): - g = group(children=[ - log(level='info', message='list child'), - ]) + g = group( + namespace='something', + children=[ + log(level='info', message='list child'), + ] + ) assert len(g.children) == 1 + assert g.getattr('namespace') == 'something' def test_list_children(): g = group([ - log(level='info', message='list child') + log(level='info', message='list child'), + log(level='info', message='list child 2') ]) - assert len(g.children) == 1 + assert len(g.children) == 2 + + +def test_positional_children(): + g = group( + log(level='info', message='positional child'), + log(level='info', message='positional child 2'), + log(level='info', message='positional child 3'), + ) + assert len(g.children) == 3 + + +def test_bad_arg_combo(): + with pytest.raises(ValueError): + g = group( + log(level='info', message='positional child'), + condition='Something' + ) + assert g From 5c2bd757035898a2f9697bf8dbcf8fbabbe8bfe9 Mon Sep 17 00:00:00 2001 From: Emerson Knapp Date: Mon, 19 May 2025 10:19:51 -0700 Subject: [PATCH 3/3] Fix the new children tests --- launch_py/entity.py | 9 ++++++++- test/test_entity.py | 18 +++++++++++++++--- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/launch_py/entity.py b/launch_py/entity.py index 3a841db..16a6aec 100644 --- a/launch_py/entity.py +++ b/launch_py/entity.py @@ -14,6 +14,7 @@ """Module for launch_py Entity class.""" import builtins +from collections.abc import Iterable import keyword from typing import ( List, @@ -56,7 +57,13 @@ def __init__( if kwargs: self.__attrs = kwargs else: - self.__attrs = {'children': args} + children: list[BaseEntity] = [] + for child in args: + if isinstance(child, Iterable): + children.extend(child) + else: + children.append(child) + self.__attrs = {'children': children} self.__type_name = type_name self.__read_keys: Set[Text] = set() diff --git a/test/test_entity.py b/test/test_entity.py index 0223a54..6abc544 100644 --- a/test/test_entity.py +++ b/test/test_entity.py @@ -12,9 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. -import pytest - from launch_py.actions import group, log +import pytest def test_kwarg_children(): @@ -25,7 +24,7 @@ def test_kwarg_children(): ] ) assert len(g.children) == 1 - assert g.getattr('namespace') == 'something' + assert g.get_attr('namespace') == 'something' def test_list_children(): @@ -45,6 +44,19 @@ def test_positional_children(): assert len(g.children) == 3 +def test_positional_lists(): + g = group( + [ + log(level='info', message='positional list 1 child 1'), + log(level='info', message='positional list 1 child 2'), + ], + [ + log(level='info', message='positional list 2 child 1'), + ] + ) + assert len(g.children) == 3 + + def test_bad_arg_combo(): with pytest.raises(ValueError): g = group(