Skip to content

Commit b6c8f4f

Browse files
authored
Enable Python reserved names in Action attributes (#12)
Closes #6 This enables the condition attribute of the base Action class, which is if in YAML/XML, but that's a Python language keyword. To handle this generally, instead suffix any reserved word with _, so it looks like executable(cmd='echo hello', if_='True')
1 parent 7da7c99 commit b6c8f4f

File tree

5 files changed

+75
-32
lines changed

5 files changed

+75
-32
lines changed

launch_frontend_py/actions.py

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,14 @@
1414

1515
from typing import Any, Callable
1616

17-
from .entity import Entity, is_reserved_identifier
17+
from .entity import Entity
18+
from .util import is_reserved_identifier
1819

1920
__all__ = []
2021

2122

22-
def make_action_factory(action_name: str, **kwargs) -> Callable[..., Any]:
23-
# Create a factory function for each action entity
23+
def _make_action_factory(action_name: str) -> Callable[..., Any]:
24+
"""Create a factory function that constructs an Entity of the given action type."""
2425
if is_reserved_identifier(action_name):
2526
action_name += '_'
2627

@@ -36,15 +37,25 @@ def fn(*args, **kwargs):
3637
return fn
3738

3839

39-
def preseed_all_actions() -> None:
40-
"""Pre-seed all actions in the module."""
40+
def _preseed_all_actions() -> None:
41+
"""
42+
Pre-seed all available actions into this module.
43+
44+
This is called at module load time, but will not have access to actions from plugin packages
45+
(such as launch_ros) until they have been imported into the Python process.
46+
"""
4147
from launch.frontend.expose import action_parse_methods
4248

4349
for action_name in action_parse_methods.keys():
44-
make_action_factory(action_name)
50+
_make_action_factory(action_name)
4551

4652

4753
def __getattr__(name: str) -> Any:
54+
"""
55+
Dynamically create action factory functions on demand.
56+
57+
This is called when user `from launch_frontend_py.actions import <name>`.
58+
"""
4859
from launch.frontend.expose import action_parse_methods
4960
import importlib
5061

@@ -55,12 +66,12 @@ def __getattr__(name: str) -> Any:
5566
# If not, perhaps it was exposed later or not accessed yet
5667
base_name = name[:-1] if name.endswith('_') else name
5768
if base_name in action_parse_methods:
58-
return make_action_factory(name)
69+
return _make_action_factory(name)
5970
else:
6071
msg = f'module {__name__} has no attribute "{name}"'
6172
if base_name != name:
6273
msg += ' (or "{base_name}")'
6374
raise AttributeError(msg)
6475

6576

66-
preseed_all_actions()
77+
_preseed_all_actions()

launch_frontend_py/entity.py

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

1515
"""Module for launch_frontend_py Entity class."""
16-
import builtins
1716
from collections.abc import Iterable
18-
import keyword
1917
from typing import (
2018
List,
2119
Optional,
@@ -32,10 +30,7 @@
3230
is_instance_of,
3331
)
3432

35-
36-
def is_reserved_identifier(name: str) -> bool:
37-
"""Check if a name is a reserved identifier in Python."""
38-
return keyword.iskeyword(name) or name in dir(builtins)
33+
from .util import is_reserved_identifier
3934

4035

4136
class Entity(BaseEntity):
@@ -113,6 +108,10 @@ def get_attr( # type: ignore[override]
113108
See :py:meth:`launch.frontend.Entity.get_attr`.
114109
Does not apply type coercion, only checks if the read value is of the correct type.
115110
"""
111+
# Reserved identifiers are all suffixed with an underscore
112+
if is_reserved_identifier(name):
113+
name += '_'
114+
116115
if name not in self.__attrs:
117116
if not optional:
118117
raise AttributeError(

launch_frontend_py/util.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
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 builtins
16+
import keyword
17+
18+
19+
def is_reserved_identifier(name: str) -> bool:
20+
"""
21+
Check if a name is a reserved identifier in Python.
22+
23+
Used to avoid naming issues in the launch DSL that overlap with Python reserved words.
24+
"""
25+
return keyword.iskeyword(name) or name in dir(builtins)

test/launch/basic_launch.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,5 +19,8 @@
1919
def generate_launch_description():
2020
return launch([
2121
arg(name='message', default='hello world'),
22+
arg(name='condition', default='True'),
2223
executable(cmd='echo $(var message)', output='both'),
24+
executable(cmd='echo hello conditional', if_='$(var condition)', output='both'),
25+
executable(cmd='echo hello not-condition', if_='$(not $(var condition))', output='both'),
2326
])

test/test_frontend.py

Lines changed: 23 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -15,35 +15,40 @@
1515
from pathlib import Path
1616

1717
from launch.actions import DeclareLaunchArgument, ExecuteProcess, IncludeLaunchDescription
18-
from launch.launch_context import LaunchContext
1918
from launch.launch_description import LaunchDescription
19+
from launch.launch_service import LaunchService
2020

2121
THIS_DIR = Path(__file__).parent
2222

2323

2424
def test_load_basic():
25-
context = LaunchContext()
26-
2725
include = IncludeLaunchDescription(str(THIS_DIR / 'launch' / 'basic_launch.py'))
28-
print(include)
26+
27+
ls = LaunchService()
28+
ld = LaunchDescription([include])
29+
ls.include_launch_description(ld)
30+
31+
ls.context.launch_configurations['condition'] = 'False'
32+
assert 0 == ls.run()
33+
2934
included_entities = include.get_sub_entities()
30-
print(included_entities)
3135
assert len(included_entities) == 1
3236

3337
launch_desc = included_entities[0]
3438
assert isinstance(launch_desc, LaunchDescription)
3539

3640
launchfile_entities = launch_desc.describe_sub_entities()
37-
assert len(launchfile_entities) == 2
38-
39-
# The first entity is a declare()
40-
should_be_declare = launchfile_entities[0]
41-
assert isinstance(should_be_declare, DeclareLaunchArgument)
42-
should_be_declare.visit(context)
43-
44-
# Second is executable()
45-
should_be_exec = launchfile_entities[1]
46-
assert isinstance(should_be_exec, ExecuteProcess)
47-
48-
should_be_exec.prepare(context)
49-
assert should_be_exec.process_description.final_cmd == ['echo', 'hello world']
41+
expected_types = (
42+
DeclareLaunchArgument,
43+
DeclareLaunchArgument,
44+
ExecuteProcess,
45+
ExecuteProcess,
46+
ExecuteProcess,
47+
)
48+
assert len(launchfile_entities) == len(expected_types)
49+
assert all(isinstance(e, t) for e, t in zip(launchfile_entities, expected_types))
50+
51+
assert launchfile_entities[2].process_description.final_cmd == ['echo', 'hello world']
52+
assert launchfile_entities[3].process_description.final_cmd is None
53+
assert launchfile_entities[4].process_description.final_cmd == [
54+
'echo', 'hello', 'not-condition']

0 commit comments

Comments
 (0)