Skip to content

Commit d8f5284

Browse files
authored
Let all imports come from toplevel module, as one line (#16)
That way instead of ``` from launch_frontend_py import launch from launch_frontend_py.actions import executable ``` A user only has to remember a single module import ``` from launch_frontend_py import launch, executable ``` Collapsed the contents of `action.py` in to `__init__.py` to simplify the handling of `__all__` for that toplevel module. Added `make_valid_name` to handle the underscore suffixing, so it's done the same way both places it's used. Made the import unit tests a bit more complete. Signed-off-by: Emerson Knapp <[email protected]>
1 parent 86c03ed commit d8f5284

File tree

10 files changed

+133
-133
lines changed

10 files changed

+133
-133
lines changed

README.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,7 @@ The available actions and substitutions are identical to XML and YAML launchfile
2222
Here is an example launchfile with a few features:
2323

2424
```py
25-
from launch_frontend_py import launch
26-
from launch_frontend_py.actions import arg, executable
25+
from launch_frontend_py import arg, executable, launch
2726

2827

2928
def generate_launch_description():

launch_frontend_py/__init__.py

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

15-
from typing import List
15+
from typing import Any, Callable, List
1616

1717
from launch.frontend.parser import Parser
1818
from launch.launch_description import LaunchDescription
1919

20-
from . import actions
21-
from .entity import Entity
20+
from .entity import Entity, make_valid_name
21+
22+
__all__ = [
23+
'launch',
24+
]
2225

2326

2427
def launch(actions: List[Entity]) -> LaunchDescription:
28+
"""Entrypoint of launchfile, produce a LaunchDescription of the provided entities."""
2529
parser = Parser()
2630
root_entity = Entity('launch', children=actions)
2731
return parser.parse_description(root_entity)
2832

2933

30-
__all__ = [
31-
'launch',
32-
'actions',
33-
]
34+
def __make_action_factory(action_name: str) -> Callable[..., Any]:
35+
"""Create a factory function that constructs an Entity of the given action type."""
36+
action_name = make_valid_name(action_name)
37+
38+
def fn(*args, **kwargs):
39+
return Entity(action_name, *args, **kwargs)
40+
41+
fn.__doc__ = f'launch_frontend_py action: {action_name} (dynamically generated)'
42+
fn.__name__ = action_name
43+
fn.__qualname__ = action_name
44+
fn.__module__ = __name__
45+
globals()[action_name] = fn
46+
__all__.append(action_name)
47+
return fn
48+
49+
50+
def __getattr__(name: str) -> Any:
51+
"""
52+
Dynamically create action factory functions on demand.
53+
54+
This is called in `from actions import <name>`, `getattr(actions, <name>)`, or `actions.<name>`
55+
"""
56+
import importlib
57+
from launch.frontend import Parser
58+
from launch.frontend.expose import action_parse_methods
59+
60+
if name in __all__:
61+
# It's already been loaded, just return it
62+
return importlib.import_module(f'.{name}', __name__)
63+
64+
# Not loaded here yet, perhaps in an extension that wasn't imported yet
65+
Parser.load_launch_extensions()
66+
67+
if name in action_parse_methods:
68+
return __make_action_factory(name)
69+
elif name.endswith('_'):
70+
# The name has a trailing underscore, which we add to actions with reserved Python names
71+
# Try again without the underscore
72+
try:
73+
return __getattr__(name[:-1])
74+
except AttributeError as e:
75+
raise AttributeError(f'{e} (or "{name}")')
76+
else:
77+
# It's not registered, raise the usual error
78+
raise AttributeError(f'module {__name__} has no attribute "{name}"')
79+
80+
81+
def __preseed_all_actions() -> None:
82+
"""
83+
Pre-seed all available actions into this module.
84+
85+
This preloads all launch extensions and fills __all__ with available actions.
86+
87+
This is not necessary, since __getattr__ loads actions on demand,
88+
but it allows tools and users to see all available actions in __all__.
89+
"""
90+
from launch.frontend.expose import action_parse_methods
91+
from launch.frontend import Parser
92+
Parser.load_launch_extensions()
93+
94+
action_names = list(action_parse_methods.keys())
95+
for action_name in action_names:
96+
__getattr__(action_name)
97+
98+
99+
__preseed_all_actions()

launch_frontend_py/actions.py

Lines changed: 0 additions & 77 deletions
This file was deleted.

launch_frontend_py/entity.py

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

1515
"""Module for launch_frontend_py Entity class."""
16+
import builtins
1617
from collections.abc import Iterable
18+
import keyword
1719
from typing import (
1820
List,
1921
Optional,
@@ -30,7 +32,25 @@
3032
is_instance_of,
3133
)
3234

33-
from .util import is_reserved_identifier
35+
36+
def is_reserved_identifier(name: str) -> bool:
37+
"""
38+
Check if a name is a reserved identifier in Python.
39+
40+
Used to avoid naming issues in the launch DSL that overlap with Python reserved words.
41+
"""
42+
return keyword.iskeyword(name) or name in dir(builtins)
43+
44+
45+
def make_valid_name(name: str) -> str:
46+
"""
47+
Make a valid Python identifier for an action or attribute name.
48+
49+
If the name is a reserved identifier in Python, append an underscore to it.
50+
"""
51+
if is_reserved_identifier(name):
52+
return f'{name}_'
53+
return name
3454

3555

3656
class Entity(BaseEntity):
@@ -108,9 +128,7 @@ def get_attr( # type: ignore[override]
108128
See :py:meth:`launch.frontend.Entity.get_attr`.
109129
Does not apply type coercion, only checks if the read value is of the correct type.
110130
"""
111-
# Reserved identifiers are all suffixed with an underscore
112-
if is_reserved_identifier(name):
113-
name += '_'
131+
name = make_valid_name(name)
114132

115133
if name not in self.__attrs:
116134
if not optional:

launch_frontend_py/util.py

Lines changed: 0 additions & 25 deletions
This file was deleted.

test/launch/basic_launch.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,7 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15-
from launch_frontend_py import launch
16-
from launch_frontend_py.actions import arg, executable
15+
from launch_frontend_py import arg, executable, launch
1716

1817

1918
def generate_launch_description():

test/launch/include_launch.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,7 @@
1414

1515
from pathlib import Path
1616

17-
from launch_frontend_py import launch
18-
from launch_frontend_py.actions import arg, executable, group, include, let, log, set_env
17+
from launch_frontend_py import arg, executable, group, include, launch, let, log, set_env
1918

2019
THIS_DIR = Path(__file__).parent
2120

test/test_action_list_preseed.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,4 @@ def test_action_list_preseed():
2020
# already filled where possible without explicit access
2121
# picking a representative subset of actions (that are present in Humble+)
2222
for x in ['arg', 'timer', 'executable', 'let', 'group', 'include', 'set_env']:
23-
assert x in launch_frontend_py.actions.__all__
23+
assert x in launch_frontend_py.__all__

test/test_dynamic_attrs.py

Lines changed: 33 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414

1515
from launch.action import Action
1616
from launch.frontend import expose_action
17-
from launch_frontend_py import actions
17+
import launch_frontend_py as actions
1818
import pytest
1919

2020

@@ -32,24 +32,47 @@ def parse(cls, entity, parser):
3232
class DynamicCreationTest(Action):
3333
"""Test action that exposes an action after first import."""
3434

35+
def __init__(self, value, **kwargs) -> None:
36+
super().__init__(**kwargs)
37+
self.value = value
38+
3539
@classmethod
3640
def parse(cls, entity, parser):
3741
_, kwargs = super().parse(entity, parser)
42+
kwargs['value'] = kwargs.get('value', None)
3843
return cls, kwargs
3944

4045

41-
def test_dynamic_attrs():
42-
name = actions.let.__name__
43-
assert name == 'let'
44-
str_repr = str(actions.let)
46+
def test_action_getattr():
47+
group = actions.group
48+
assert group.__name__ == 'group'
49+
test_group = group()
50+
assert test_group.type_name == 'group'
51+
52+
53+
def test_action_import():
54+
from launch_frontend_py import let
55+
assert let.__name__ == 'let'
56+
str_repr = str(let)
4557
assert str_repr.startswith('<function let')
4658

59+
x = let(name='test_name', value='test_value')
60+
assert x.get_attr('name') == 'test_name'
61+
assert x.get_attr('value') == 'test_value'
62+
63+
64+
def test_invalid_action_raise():
4765
with pytest.raises(AttributeError):
4866
getattr(actions, 'non_existent_action')
4967

50-
test_group = actions.group()
51-
assert test_group.type_name == 'group'
68+
with pytest.raises(AttributeError):
69+
_ = actions.other_nonexistent
70+
71+
with pytest.raises(ImportError):
72+
import launch_frontend_py.non_existent_import # noqa: F401
5273

74+
75+
def test_dynamic_attrs():
5376
test_arg = actions.arg(name='argname', default='argvalue')
5477
assert test_arg.type_name == 'arg'
5578
assert test_arg.get_attr('name') == 'argname'
@@ -61,14 +84,12 @@ def test_dynamic_attrs():
6184

6285
def test_dynamic_create():
6386
assert actions.foo is not None
64-
6587
assert actions.foo.__name__ == 'foo'
6688
assert actions.foo().type_name == 'foo'
67-
68-
with pytest.raises(AttributeError):
69-
actions.node
89+
x = actions.foo(value='bar')
90+
assert x.get_attr('value') == 'bar'
7091

7192

72-
def test_bultin_suffix():
93+
def test_builtin_suffix():
7394
assert actions.while_.__name__ == 'while_'
7495
assert actions.while_().type_name == 'while_'

test/test_entity.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15-
from launch_frontend_py.actions import group, log
15+
from launch_frontend_py import group, log
1616
import pytest
1717

1818

0 commit comments

Comments
 (0)