Skip to content

Commit 2fa8f00

Browse files
authored
Fix ComposableNode ignoring PushRosNamespace actions (#162)
Signed-off-by: Ivan Santiago Paunovic <[email protected]>
1 parent bd54a04 commit 2fa8f00

File tree

10 files changed

+289
-80
lines changed

10 files changed

+289
-80
lines changed

launch_ros/launch_ros/actions/load_composable_nodes.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@
3838
from ..utilities import add_node_name
3939
from ..utilities import evaluate_parameters
4040
from ..utilities import get_node_name_count
41+
from ..utilities import make_namespace_absolute
42+
from ..utilities import prefix_namespace
4143
from ..utilities import to_parameters_list
4244
from ..utilities.normalize_parameters import normalize_parameter_dict
4345

@@ -190,10 +192,13 @@ def get_composable_node_load_request(
190192
request.node_name = perform_substitutions(
191193
context, composable_node_description.node_name
192194
)
193-
if composable_node_description.node_namespace is not None:
194-
request.node_namespace = perform_substitutions(
195-
context, composable_node_description.node_namespace
196-
)
195+
expanded_ns = composable_node_description.node_namespace
196+
if expanded_ns is not None:
197+
expanded_ns = perform_substitutions(context, expanded_ns)
198+
base_ns = context.launch_configurations.get('ros_namespace', None)
199+
combined_ns = make_namespace_absolute(prefix_namespace(base_ns, expanded_ns))
200+
if combined_ns is not None:
201+
request.node_namespace = combined_ns
197202
# request.log_level = perform_substitutions(context, node_description.log_level)
198203
if composable_node_description.remappings is not None:
199204
for from_, to in composable_node_description.remappings:

launch_ros/launch_ros/actions/node.py

Lines changed: 8 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,10 @@
4848
from launch_ros.utilities import add_node_name
4949
from launch_ros.utilities import evaluate_parameters
5050
from launch_ros.utilities import get_node_name_count
51+
from launch_ros.utilities import make_namespace_absolute
5152
from launch_ros.utilities import normalize_parameters
5253
from launch_ros.utilities import normalize_remap_rules
54+
from launch_ros.utilities import prefix_namespace
5355

5456
from rclpy.validate_namespace import validate_namespace
5557
from rclpy.validate_node_name import validate_node_name
@@ -343,24 +345,15 @@ def _perform_substitutions(self, context: LaunchContext) -> None:
343345
context, normalize_to_list_of_substitutions(self.__node_name))
344346
validate_node_name(self.__expanded_node_name)
345347
self.__expanded_node_name.lstrip('/')
346-
expanded_node_namespace = None
348+
expanded_node_namespace: Optional[Text] = None
347349
if self.__node_namespace is not None:
348350
expanded_node_namespace = perform_substitutions(
349351
context, normalize_to_list_of_substitutions(self.__node_namespace))
350352
base_ns = context.launch_configurations.get('ros_namespace', None)
351-
if base_ns is not None or expanded_node_namespace is not None:
352-
if expanded_node_namespace is None:
353-
expanded_node_namespace = ''
354-
if base_ns is None:
355-
base_ns = ''
356-
if not expanded_node_namespace.startswith('/'):
357-
expanded_node_namespace = (
358-
base_ns + '/' + expanded_node_namespace
359-
).rstrip('/')
360-
if not expanded_node_namespace.startswith('/'):
361-
expanded_node_namespace = '/' + expanded_node_namespace
362-
self.__expanded_node_namespace = expanded_node_namespace
353+
expanded_node_namespace = make_namespace_absolute(
354+
prefix_namespace(base_ns, expanded_node_namespace))
363355
if expanded_node_namespace is not None:
356+
self.__expanded_node_namespace = expanded_node_namespace
364357
cmd_extension = ['-r', LocalSubstitution("ros_specific_arguments['ns']")]
365358
self.cmd.extend([normalize_to_list_of_substitutions(x) for x in cmd_extension])
366359
validate_namespace(self.__expanded_node_namespace)
@@ -375,10 +368,8 @@ def _perform_substitutions(self, context: LaunchContext) -> None:
375368
))
376369
)
377370
raise
378-
self.__final_node_name = ''
379-
if self.__expanded_node_namespace != '/':
380-
self.__final_node_name += self.__expanded_node_namespace
381-
self.__final_node_name += '/' + self.__expanded_node_name
371+
self.__final_node_name = prefix_namespace(
372+
self.__expanded_node_namespace, self.__expanded_node_name)
382373
# expand global parameters first,
383374
# so they can be overriden with specific parameters of this Node
384375
global_params = context.launch_configurations.get('ros_params', None)

launch_ros/launch_ros/actions/push_ros_namespace.py

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@
2727
from launch.utilities import normalize_to_list_of_substitutions
2828
from launch.utilities import perform_substitutions
2929

30+
from launch_ros.utilities import make_namespace_absolute
31+
from launch_ros.utilities import prefix_namespace
32+
3033
from rclpy.validate_namespace import validate_namespace
3134

3235

@@ -63,19 +66,16 @@ def namespace(self) -> List[Substitution]:
6366
def execute(self, context: LaunchContext):
6467
"""Execute the action."""
6568
pushed_namespace = perform_substitutions(context, self.namespace)
66-
previous_namespace = context.launch_configurations.get('ros_namespace', '')
67-
namespace = pushed_namespace
68-
if not pushed_namespace.startswith('/'):
69-
namespace = previous_namespace + '/' + pushed_namespace
70-
namespace = namespace.rstrip('/')
71-
if namespace != '':
72-
try:
73-
validate_namespace(namespace)
74-
except Exception:
75-
raise SubstitutionFailure(
76-
'The resulting namespace is invalid:'
77-
" [previous_namespace='{}', pushed_namespace='{}']".format(
78-
previous_namespace, pushed_namespace
79-
)
69+
previous_namespace = context.launch_configurations.get('ros_namespace', None)
70+
namespace = make_namespace_absolute(
71+
prefix_namespace(previous_namespace, pushed_namespace))
72+
try:
73+
validate_namespace(namespace)
74+
except Exception:
75+
raise SubstitutionFailure(
76+
'The resulting namespace is invalid:'
77+
" [previous_namespace='{}', pushed_namespace='{}']".format(
78+
previous_namespace, pushed_namespace
8079
)
80+
)
8181
context.launch_configurations['ros_namespace'] = namespace

launch_ros/launch_ros/utilities/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@
1919
"""
2020

2121
from .evaluate_parameters import evaluate_parameters
22+
from .namespace_utils import is_namespace_absolute
23+
from .namespace_utils import is_root_namespace
24+
from .namespace_utils import make_namespace_absolute
25+
from .namespace_utils import prefix_namespace
2226
from .normalize_parameters import normalize_parameters
2327
from .normalize_remap_rule import normalize_remap_rule
2428
from .normalize_remap_rule import normalize_remap_rules
@@ -31,9 +35,13 @@
3135
'evaluate_parameters',
3236
'evaluate_parameters_dict',
3337
'get_node_name_count',
38+
'is_namespace_absolute',
39+
'is_root_namespace',
40+
'make_namespace_absolute',
3441
'normalize_parameters',
3542
'normalize_parameters_dict',
3643
'normalize_remap_rule',
3744
'normalize_remap_rules',
45+
'prefix_namespace',
3846
'to_parameters_list',
3947
]
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
# Copyright 2020 Open Source Robotics Foundation, 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+
"""Module with utility functions for handling namespaces."""
16+
17+
18+
from typing import Optional
19+
from typing import Text
20+
from typing import Type
21+
from typing import TypeVar
22+
23+
24+
def is_root_namespace(ns: Text) -> bool:
25+
"""Return `True` if `ns` is `'/'`."""
26+
return ns == '/'
27+
28+
29+
def is_namespace_absolute(ns: Text) -> bool:
30+
"""Return `True` if `ns` is absolute."""
31+
return ns.startswith('/')
32+
33+
34+
def prefix_namespace(
35+
base_ns: Optional[Text],
36+
ns: Optional[Text],
37+
) -> Optional[Text]:
38+
"""
39+
Return `ns` prefixed with `base_ns` if `ns` is relative, return `ns` if not.
40+
41+
:param `base_ns`: Prefix to be added to `ns`.
42+
:param `ns`: Namespace to be prefixed.
43+
:return: `None` if both `base_ns` and `ns` are `None`, or
44+
`base_ns` if `ns` is `None`, or
45+
`ns` if `base_ns` is `None`, or
46+
`ns` if `ns` is absolute, or
47+
`ns` prefixed with `base_ns`.
48+
In all cases, trailing `/` are stripped from the result.
49+
50+
## Examples:
51+
52+
```python3
53+
combined_ns = prefix_namespace('my_ns', 'original_ns')
54+
assert combined_ns == 'my_ns/original_ns'
55+
56+
combined_ns = prefix_namespace('/my_ns', 'original_ns')
57+
assert combined_ns == '/my_ns/original_ns'
58+
59+
combined_ns = prefix_namespace('my_ns', '/original_ns')
60+
assert combined_ns == '/original_ns'
61+
62+
combined_ns = prefix_namespace(None, 'original_ns')
63+
assert combined_ns == 'original_ns'
64+
65+
combined_ns = prefix_namespace('my_ns', None)
66+
assert combined_ns == 'my_ns'
67+
```
68+
"""
69+
combined_ns: Optional[Text] = None
70+
if base_ns is not None or ns is not None:
71+
if ns is None:
72+
combined_ns = base_ns
73+
elif not base_ns or is_namespace_absolute(ns):
74+
combined_ns = ns
75+
else:
76+
if is_root_namespace(base_ns):
77+
base_ns = ''
78+
combined_ns = (
79+
base_ns + '/' + ns
80+
)
81+
if not is_root_namespace(combined_ns):
82+
combined_ns = combined_ns.rstrip('/')
83+
return combined_ns
84+
85+
86+
OptionalText = TypeVar('OptionalText', Text, Type[None])
87+
88+
89+
def make_namespace_absolute(ns: OptionalText) -> OptionalText:
90+
"""Make a relative namespace absolute."""
91+
if ns is None:
92+
return None
93+
if not is_namespace_absolute(ns):
94+
return '/' + ns
95+
return ns

test_launch_ros/test/test_launch_ros/actions/test_push_ros_namespace.py

Lines changed: 85 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,23 @@
1414

1515
"""Tests for the PushRosNamespace Action."""
1616

17-
from launch import LaunchContext
18-
1917
from launch_ros.actions import Node
2018
from launch_ros.actions import PushRosNamespace
19+
from launch_ros.actions.load_composable_nodes import get_composable_node_load_request
20+
from launch_ros.descriptions import ComposableNode
2121

2222
import pytest
2323

2424

25+
class MockContext:
26+
27+
def __init__(self):
28+
self.launch_configurations = {}
29+
30+
def perform_substitution(self, sub):
31+
return sub.perform(None)
32+
33+
2534
class Config:
2635

2736
def __init__(
@@ -39,51 +48,62 @@ def __init__(
3948
self.expected_ns = expected_ns
4049
self.second_push_ns = second_push_ns
4150

51+
def __repr__(self):
52+
return (
53+
f'TestConfig(node_name={self.node_name}, node_ns={self.node_ns}, '
54+
f'push_ns={self.push_ns}, expected_ns={self.expected_ns}, '
55+
f'second_push_ns={self.second_push_ns})'
56+
)
4257

43-
@pytest.mark.parametrize('config', (
44-
Config(
45-
push_ns='relative_ns',
46-
node_ns='node_ns',
47-
expected_ns='/relative_ns/node_ns'),
48-
Config(
49-
push_ns='relative_ns',
50-
node_ns='/node_ns',
51-
expected_ns='/node_ns'),
52-
Config(
53-
push_ns='relative_ns',
54-
node_ns='/',
55-
expected_ns='/'),
56-
Config(
57-
push_ns='relative_ns',
58-
node_ns='',
59-
expected_ns='/relative_ns'),
60-
Config(
61-
push_ns='relative_ns',
62-
second_push_ns='another_relative_ns',
63-
node_ns='node_ns',
64-
expected_ns='/relative_ns/another_relative_ns/node_ns'),
65-
Config(
66-
push_ns='relative_ns',
67-
second_push_ns='/absolute_ns',
68-
node_ns='node_ns',
69-
expected_ns='/absolute_ns/node_ns'),
70-
Config(
71-
node_name='my_node',
72-
push_ns='relative_ns',
73-
second_push_ns='/absolute_ns',
74-
node_ns='node_ns',
75-
expected_ns='/absolute_ns/node_ns'),
76-
Config(
77-
node_name='my_node',
78-
node_ns='node_ns',
79-
expected_ns='/node_ns'),
80-
Config(),
81-
Config(
82-
push_ns='',
83-
expected_ns='/'),
84-
))
58+
59+
def get_test_cases():
60+
return (
61+
Config(
62+
push_ns='relative_ns',
63+
node_ns='node_ns',
64+
expected_ns='/relative_ns/node_ns'),
65+
Config(
66+
push_ns='relative_ns',
67+
node_ns='/node_ns',
68+
expected_ns='/node_ns'),
69+
Config(
70+
push_ns='relative_ns',
71+
node_ns='/',
72+
expected_ns='/'),
73+
Config(
74+
push_ns='relative_ns',
75+
node_ns='',
76+
expected_ns='/relative_ns'),
77+
Config(
78+
push_ns='relative_ns',
79+
second_push_ns='another_relative_ns',
80+
node_ns='node_ns',
81+
expected_ns='/relative_ns/another_relative_ns/node_ns'),
82+
Config(
83+
push_ns='relative_ns',
84+
second_push_ns='/absolute_ns',
85+
node_ns='node_ns',
86+
expected_ns='/absolute_ns/node_ns'),
87+
Config(
88+
node_name='my_node',
89+
push_ns='relative_ns',
90+
second_push_ns='/absolute_ns',
91+
node_ns='node_ns',
92+
expected_ns='/absolute_ns/node_ns'),
93+
Config(
94+
node_name='my_node',
95+
node_ns='node_ns',
96+
expected_ns='/node_ns'),
97+
Config(),
98+
Config(
99+
push_ns='',
100+
expected_ns='/'),
101+
)
102+
103+
104+
@pytest.mark.parametrize('config', get_test_cases())
85105
def test_push_ros_namespace(config):
86-
lc = LaunchContext()
106+
lc = MockContext()
87107
if config.push_ns is not None:
88108
pns1 = PushRosNamespace(config.push_ns)
89109
pns1.execute(lc)
@@ -106,3 +126,23 @@ def test_push_ros_namespace(config):
106126
expected_fqn = expected_ns.rstrip('/') + '/' + expected_name
107127
assert expected_ns == node.expanded_node_namespace
108128
assert expected_fqn == node.node_name
129+
130+
131+
@pytest.mark.parametrize('config', get_test_cases())
132+
def test_push_ros_namespace_with_composable_node(config):
133+
lc = MockContext()
134+
if config.push_ns is not None:
135+
pns1 = PushRosNamespace(config.push_ns)
136+
pns1.execute(lc)
137+
if config.second_push_ns is not None:
138+
pns2 = PushRosNamespace(config.second_push_ns)
139+
pns2.execute(lc)
140+
node_description = ComposableNode(
141+
package='asd',
142+
plugin='my_plugin',
143+
namespace=config.node_ns,
144+
name=config.node_name,
145+
)
146+
request = get_composable_node_load_request(node_description, lc)
147+
expected_ns = config.expected_ns if config.expected_ns is not None else ''
148+
assert expected_ns == request.node_namespace

0 commit comments

Comments
 (0)