Skip to content

Commit 27ecc81

Browse files
authored
Try more action types, clean up exporting, add basic test (#3)
* Fix up dynamic attribute inclusion, adding basic pytest for it * some more basic tests around entity types
1 parent efd7757 commit 27ecc81

File tree

8 files changed

+193
-30
lines changed

8 files changed

+193
-30
lines changed

CMakeLists.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ ament_python_install_package(${PROJECT_NAME})
1010
if(BUILD_TESTING)
1111
find_package(ament_lint_auto REQUIRED)
1212
ament_lint_auto_find_test_dependencies()
13+
14+
find_package(ament_cmake_pytest REQUIRED)
15+
ament_add_pytest_test(test_all test)
1316
endif()
1417

1518
ament_package()

launch_py/__init__.py

Lines changed: 4 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,12 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15-
import importlib
16-
from typing import Any, List
15+
from typing import List
1716

18-
from launch.frontend.expose import action_parse_methods
1917
from launch.frontend.parser import Parser
2018
from launch.launch_description import LaunchDescription
2119

20+
from . import actions
2221
from .entity import Entity
2322

2423

@@ -28,25 +27,7 @@ def launch(actions: List[Entity]) -> LaunchDescription:
2827
return parser.parse_description(root_entity)
2928

3029

31-
def make_action(action_name):
32-
def impl(**kwargs):
33-
return Entity(action_name, kwargs)
34-
globals()[action_name] = impl
35-
return impl
36-
37-
38-
exposed_actions = [
39-
make_action(key) for key in action_parse_methods.keys()
40-
]
41-
4230
__all__ = [
4331
'launch',
44-
] + exposed_actions
45-
46-
47-
def __getattr__(name: str) -> Any:
48-
# This is a workaround to silence mypy complaint about attributes that are created dynamically
49-
try:
50-
return importlib.import_module(f'.{name}', __name__)
51-
except ModuleNotFoundError as e:
52-
raise AttributeError(f'module {__name__} has no attribute {name}') from e
32+
'actions',
33+
]

launch_py/actions.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# Copyright 2025 Polymath Robotics, Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from typing import Any, Callable
16+
17+
from launch.frontend.expose import action_parse_methods
18+
19+
from .entity import Entity, is_reserved_identifier
20+
21+
__all__ = []
22+
23+
24+
def make_action_factory(action_name: str, **kwargs) -> Callable[..., Entity]:
25+
# Create a factory function for each action entity
26+
def fn(**kwargs):
27+
return Entity(action_name, kwargs) # noqa: F821
28+
29+
# fn = FunctionType(impl_template.__code__, globals(), name=action_name)
30+
fn.__doc__ = f'launch_py action: {action_name} (dynamically generated)'
31+
fn.__name__ = action_name
32+
fn.__qualname__ = action_name
33+
fn.__module__ = __name__
34+
return fn
35+
36+
37+
for action_name in action_parse_methods.keys():
38+
if is_reserved_identifier(action_name):
39+
action_name += '_'
40+
fn = make_action_factory(action_name)
41+
globals()[action_name] = fn
42+
__all__.append(action_name)
43+
44+
45+
def __getattr__(name: str) -> Any:
46+
# This is a workaround for mypy complaint about dynamically created attrs
47+
import importlib
48+
if name in __all__:
49+
return importlib.import_module(f'.{name}', __name__)
50+
raise AttributeError(f'module {__name__} has no attribute {name}')

launch_py/entity.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
# limitations under the License.
1414

1515
"""Module for launch_py Entity class."""
16+
import builtins
17+
import keyword
1618
from typing import (
1719
List,
1820
Optional,
@@ -30,6 +32,11 @@
3032
)
3133

3234

35+
def is_reserved_identifier(name: str) -> bool:
36+
"""Check if a name is a reserved identifier in Python."""
37+
return keyword.iskeyword(name) or name in dir(builtins)
38+
39+
3340
class Entity(BaseEntity):
3441
"""Single item in the intermediate front_end representation."""
3542

@@ -44,7 +51,6 @@ def __init__(
4451
self.__type_name = type_name
4552
self.__kwargs = kwargs
4653
self.__parent = parent
47-
self.__children_called = False
4854
self.__read_keys: Set[Text] = set()
4955

5056
@property
@@ -60,7 +66,6 @@ def parent(self) -> Optional['Entity']:
6066
@property
6167
def children(self) -> List[BaseEntity]:
6268
"""Get the Entity's children."""
63-
self.__children_called = True
6469
if not isinstance(self.__kwargs, (dict)):
6570
raise TypeError(
6671
f'Expected a dict, got {type(self.__kwargs)}:'
@@ -97,8 +102,7 @@ def get_attr( # type: ignore[override]
97102
Access an attribute of the entity.
98103
99104
See :py:meth:`launch.frontend.Entity.get_attr`.
100-
`launch_yaml` does not apply type coercion,
101-
it only checks if the read value is of the correct type.
105+
Does not apply type coercion, only checks if the read value is of the correct type.
102106
"""
103107
if name not in self.__kwargs:
104108
if not optional:
@@ -112,6 +116,8 @@ def get_attr( # type: ignore[override]
112116
if check_is_list_entity(data_type):
113117
if isinstance(data, list) and isinstance(data[0], dict):
114118
return [Entity(name, child) for child in data]
119+
elif isinstance(data, list) and isinstance(data[0], Entity):
120+
return data
115121
raise TypeError(
116122
"Attribute '{}' of Entity '{}' expected to be a list of dictionaries.".format(
117123
name, self.type_name

package.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
<test_depend>ament_lint_auto</test_depend>
1818
<test_depend>ament_lint_common</test_depend>
1919
<test_depend>ament_cmake_mypy</test_depend>
20-
<test_depend>python3-pytest</test_depend>
20+
<test_depend>ament_cmake_pytest</test_depend>
2121

2222
<member_of_group>launch_frontend_packages</member_of_group>
2323

test/basic_launch.py

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,37 @@
1111
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
14-
from launch_py import executable, launch
14+
from pathlib import Path
15+
16+
from launch_py import launch
17+
from launch_py.actions import arg, executable, group, include, let, log, set_env
18+
19+
THIS_DIR = Path(__file__).parent
1520

1621

1722
def generate_launch_description():
1823
return launch([
19-
executable(cmd='echo hello launch_py world!', output='screen')
24+
arg(name='arg1', default='arg1_value'),
25+
let(name='arg2', value='let_$(var arg1)'),
26+
executable(
27+
cmd='echo hello launch_py executable',
28+
output='screen',
29+
),
30+
log(level='INFO', message='Log warning: arg1=$(var arg1), arg2=$(var arg2)'),
31+
group(
32+
children=[
33+
set_env(
34+
name='MY_ENV_VAR',
35+
value='my_value',
36+
),
37+
log(level='WARNING', message='In group env MY_ENV_VAR=$(env MY_ENV_VAR)'),
38+
]
39+
),
40+
log(level='ERROR', message='Outside group: env MY_ENV_VAR=$(env MY_ENV_VAR "<unset>")'),
41+
include(
42+
file=f'{THIS_DIR}/include_launch.py',
43+
arg=[
44+
arg(name='foo', value='True'),
45+
],
46+
),
2047
])

test/include_launch.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# Copyright 2025 Polymath Robotics, Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from launch_py import launch
16+
from launch_py.actions import arg, log
17+
18+
19+
def generate_launch_description():
20+
return launch([
21+
arg(name='foo'),
22+
log(
23+
level='INFO',
24+
message='I am an included launch file: foo=$(var foo)',
25+
),
26+
# TODO(emerson) make if_ work
27+
# log(
28+
# if_='$(var foo)',
29+
# level='INFO',
30+
# message='This conditional log only happened because foo is true',
31+
# ),
32+
# log(
33+
# if_='$(not $(var foo))',
34+
# level='INFO',
35+
# message='This conditional log only happened because foo is false',
36+
# ),
37+
])

test/test_dynamic_attrs.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# Copyright 2025 Polymath Robotics, Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from launch.action import Action
16+
from launch.frontend import expose_action
17+
import pytest
18+
19+
20+
@expose_action('while')
21+
class BuiltinNameTest(Action):
22+
"""Test action that exposes a Python builtin name."""
23+
24+
def __init__(self, **kwargs):
25+
super().__init__(**kwargs)
26+
27+
@classmethod
28+
def parse(cls, entity, parser):
29+
_, kwargs = super().parse(entity, parser)
30+
return cls, kwargs
31+
32+
def execute(self, context):
33+
del context
34+
35+
36+
from launch_py import actions # noqa: I100, E402
37+
38+
39+
def test_dynamic_attrs():
40+
name = actions.let.__name__
41+
assert name == 'let'
42+
str_repr = str(actions.let)
43+
assert str_repr.startswith('<function let')
44+
45+
assert actions.while_.__name__ == 'while_'
46+
47+
with pytest.raises(AttributeError):
48+
getattr(actions, 'non_existent_action')
49+
50+
test_group = actions.group()
51+
assert test_group.type_name == 'group'
52+
53+
test_arg = actions.arg(name='argname', default='argvalue')
54+
assert test_arg.type_name == 'arg'
55+
assert test_arg.get_attr('name') == 'argname'
56+
assert test_arg.get_attr('default') == 'argvalue'
57+
58+
with pytest.raises(AttributeError):
59+
test_arg.get_attr('non_existent_attr')

0 commit comments

Comments
 (0)