Skip to content

Commit 826b887

Browse files
authored
Handle creating exposed actions as needed regardless of import order (#4)
* Handle creating exposed actions as needed regardless of import order
1 parent 27ecc81 commit 826b887

File tree

4 files changed

+76
-22
lines changed

4 files changed

+76
-22
lines changed

CMakeLists.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ if(BUILD_TESTING)
1212
ament_lint_auto_find_test_dependencies()
1313

1414
find_package(ament_cmake_pytest REQUIRED)
15-
ament_add_pytest_test(test_all test)
15+
ament_add_pytest_test(test_pytests test)
1616
endif()
1717

1818
ament_package()

launch_py/actions.py

Lines changed: 29 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,37 +14,53 @@
1414

1515
from typing import Any, Callable
1616

17-
from launch.frontend.expose import action_parse_methods
18-
1917
from .entity import Entity, is_reserved_identifier
2018

2119
__all__ = []
2220

2321

24-
def make_action_factory(action_name: str, **kwargs) -> Callable[..., Entity]:
22+
def make_action_factory(action_name: str, **kwargs) -> Callable[..., Any]:
2523
# Create a factory function for each action entity
24+
if is_reserved_identifier(action_name):
25+
action_name += '_'
26+
2627
def fn(**kwargs):
27-
return Entity(action_name, kwargs) # noqa: F821
28+
return Entity(action_name, kwargs)
2829

29-
# fn = FunctionType(impl_template.__code__, globals(), name=action_name)
3030
fn.__doc__ = f'launch_py action: {action_name} (dynamically generated)'
3131
fn.__name__ = action_name
3232
fn.__qualname__ = action_name
3333
fn.__module__ = __name__
34+
globals()[action_name] = fn
35+
__all__.append(action_name)
3436
return fn
3537

3638

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)
39+
def preseed_all_actions() -> None:
40+
"""Pre-seed all actions in the module."""
41+
from launch.frontend.expose import action_parse_methods
42+
43+
for action_name in action_parse_methods.keys():
44+
make_action_factory(action_name)
4345

4446

4547
def __getattr__(name: str) -> Any:
46-
# This is a workaround for mypy complaint about dynamically created attrs
48+
from launch.frontend.expose import action_parse_methods
4749
import importlib
50+
51+
# If it's already been created, return
4852
if name in __all__:
4953
return importlib.import_module(f'.{name}', __name__)
50-
raise AttributeError(f'module {__name__} has no attribute {name}')
54+
55+
# If not, perhaps it was exposed later or not accessed yet
56+
base_name = name[:-1] if name.endswith('_') else name
57+
if base_name in action_parse_methods:
58+
return make_action_factory(name)
59+
else:
60+
msg = f'module {__name__} has no attribute "{name}"'
61+
if base_name != name:
62+
msg += ' (or "{base_name}")'
63+
raise AttributeError(msg)
64+
65+
66+
preseed_all_actions()

test/test_action_list_preseed.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
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+
import launch_py
16+
17+
18+
def test_action_list_preseed():
19+
# We can construct entities on the fly, but we want to make sure the list is
20+
# already filled where possible without explicit access
21+
# picking a representative subset of actions (that are present in Humble+)
22+
for x in ['arg', 'timer', 'executable', 'let', 'group', 'include', 'set_env']:
23+
assert x in launch_py.actions.__all__

test/test_dynamic_attrs.py

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,26 +14,28 @@
1414

1515
from launch.action import Action
1616
from launch.frontend import expose_action
17+
from launch_py import actions
1718
import pytest
1819

1920

2021
@expose_action('while')
2122
class BuiltinNameTest(Action):
2223
"""Test action that exposes a Python builtin name."""
2324

24-
def __init__(self, **kwargs):
25-
super().__init__(**kwargs)
26-
2725
@classmethod
2826
def parse(cls, entity, parser):
2927
_, kwargs = super().parse(entity, parser)
3028
return cls, kwargs
3129

32-
def execute(self, context):
33-
del context
3430

31+
@expose_action('foo')
32+
class DynamicCreationTest(Action):
33+
"""Test action that exposes an action after first import."""
3534

36-
from launch_py import actions # noqa: I100, E402
35+
@classmethod
36+
def parse(cls, entity, parser):
37+
_, kwargs = super().parse(entity, parser)
38+
return cls, kwargs
3739

3840

3941
def test_dynamic_attrs():
@@ -42,8 +44,6 @@ def test_dynamic_attrs():
4244
str_repr = str(actions.let)
4345
assert str_repr.startswith('<function let')
4446

45-
assert actions.while_.__name__ == 'while_'
46-
4747
with pytest.raises(AttributeError):
4848
getattr(actions, 'non_existent_action')
4949

@@ -57,3 +57,18 @@ def test_dynamic_attrs():
5757

5858
with pytest.raises(AttributeError):
5959
test_arg.get_attr('non_existent_attr')
60+
61+
62+
def test_dynamic_create():
63+
assert actions.foo is not None
64+
65+
assert actions.foo.__name__ == 'foo'
66+
assert actions.foo().type_name == 'foo'
67+
68+
with pytest.raises(AttributeError):
69+
actions.node
70+
71+
72+
def test_bultin_suffix():
73+
assert actions.while_.__name__ == 'while_'
74+
assert actions.while_().type_name == 'while_'

0 commit comments

Comments
 (0)