Skip to content

Commit 7fc56b8

Browse files
Add support for eval args in roslaunch XML reader (fixes #267) (#372)
* added _resolve_eval method * added separate _resolve methods * updated method names * implemented _resolve_eval * fixed bad syntax * fixed various issues detected by mypy and flake8 * updated _resolve_find to take optional second argument * added husky config for testing * fixed incorrect image name * bug fix: missing import * bug fix: use empty string instead * added regression test * updated CHANGELOG
1 parent 89f24d8 commit 7fc56b8

File tree

4 files changed

+94
-29
lines changed

4 files changed

+94
-29
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
* Updated handling of `command` attributes in `param` tags during parsing
1919
of XML launch files: Only the output of `stdout` is recorded, and `stderr`
2020
is now ignored.
21+
* Added handling of `$(eval ...)` tags in XML launch files.
2122

2223

2324
# 1.3.0 (2020-06-02)

src/roswire/proxy/roslaunch/substitution.py

Lines changed: 75 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -31,15 +31,13 @@ class ArgumentResolver:
3131
files: dockerblade.FileSystem
3232
context: Dict[str, Any] = attr.ib(default=None)
3333

34-
def _resolve_arg(self, s: str) -> str:
34+
def _resolve_substitution_arg(self, s: str) -> str:
3535
"""
3636
Raises
3737
------
3838
EnvNotFoundError
3939
if a given environment variable is not found.
4040
"""
41-
shell = self.shell
42-
context = self.context
4341
logger.debug(f"resolving substitution argument: {s}")
4442
s = s[2:-1]
4543
logger.debug(f"stripped delimiters: {s}")
@@ -49,31 +47,49 @@ def _resolve_arg(self, s: str) -> str:
4947
# we deal with find in a later stage
5048
if kind == 'find':
5149
return f'$({s})'
52-
if kind == 'env':
53-
return shell.environ(params[0])
54-
if kind == 'optenv':
55-
try:
56-
return shell.environ(params[0])
57-
except dockerblade.exceptions.EnvNotFoundError:
58-
return ' '.join(params[1:])
59-
if kind == 'dirname':
60-
try:
61-
dirname = os.path.dirname(context['filename'])
62-
except KeyError:
63-
m = 'filename is not provided by the launch context'
64-
raise SubstitutionError(m)
65-
dirname = os.path.normpath(dirname)
66-
return dirname
67-
if kind == 'arg':
50+
elif kind == 'env':
51+
var = params[0]
52+
return self._resolve_env(var)
53+
elif kind == 'optenv':
54+
var = params[0]
55+
default = ' '.join(params[1:])
56+
return self._resolve_optenv(var, default)
57+
elif kind == 'dirname':
58+
return self._resolve_dirname()
59+
elif kind == 'arg':
6860
arg_name = params[0]
69-
if 'arg' not in context or arg_name not in context['arg']:
70-
m = f'arg not supplied to launch context [{arg_name}]'
71-
raise SubstitutionError(m)
72-
return context['arg'][arg_name]
73-
74-
# TODO $(anon name)
61+
return self._resolve_arg(arg_name)
62+
elif kind == 'anon':
63+
return self._resolve_anon(params[0])
7564
return s
7665

66+
def _resolve_dirname(self) -> str:
67+
try:
68+
dirname = os.path.dirname(self.context['filename'])
69+
except KeyError:
70+
m = 'filename is not provided by the launch context'
71+
raise SubstitutionError(m)
72+
return os.path.normpath(dirname)
73+
74+
def _resolve_anon(self, name: str) -> str:
75+
raise NotImplementedError
76+
77+
def _resolve_env(self, var: str) -> str:
78+
return self.shell.environ(var)
79+
80+
def _resolve_optenv(self, var: str, default: str) -> str:
81+
try:
82+
return self.shell.environ(var)
83+
except dockerblade.exceptions.EnvNotFoundError:
84+
return default
85+
86+
def _resolve_arg(self, arg_name: str) -> str:
87+
context = self.context
88+
if 'arg' not in context or arg_name not in context['arg']:
89+
m = f'arg not supplied to launch context [{arg_name}]'
90+
raise SubstitutionError(m)
91+
return context['arg'][arg_name]
92+
7793
def _find_package_path(self, package: str) -> str:
7894
cmd = f'rospack find {shlex.quote(package)}'
7995
try:
@@ -125,7 +141,7 @@ def _find_resource(self, package: str, path: str) -> str:
125141
raise SubstitutionError(m)
126142
return path_in_package
127143

128-
def _resolve_find(self, package: str, path: str) -> str:
144+
def _resolve_find(self, package: str, path: str = '') -> str:
129145
logger.debug(f'resolving find: {package}')
130146
path_original = path
131147

@@ -153,12 +169,42 @@ def _resolve_find(self, package: str, path: str) -> str:
153169
resolved_path = self._find_package_path(package) + path_original
154170
return resolved_path
155171

172+
def _resolve_eval(self, attribute_string: str) -> str:
173+
logger.debug(f'resolving eval: {attribute_string}')
174+
assert attribute_string.startswith('$(eval ')
175+
assert attribute_string[-1] == ')'
176+
eval_string = attribute_string[7:-1]
177+
178+
if '__' in attribute_string:
179+
m = ("$(eval ...): refusing to evaluate potentially dangerous "
180+
"expression -- must not contain double underscores")
181+
raise SubstitutionError(m)
182+
183+
_builtins = {x: __builtins__[x] # type: ignore
184+
for x in ('dict', 'float', 'int', 'list', 'map')}
185+
_locals = {
186+
'true': True,
187+
'True': True,
188+
'false': False,
189+
'False': False,
190+
'__builtins__': _builtins,
191+
'arg': self._resolve_arg,
192+
'anon': self._resolve_anon,
193+
'dirname': self._resolve_dirname,
194+
'env': self._resolve_env,
195+
'find': self._resolve_find,
196+
'optenv': self._resolve_optenv
197+
}
198+
199+
result = str(eval(eval_string, {}, _locals))
200+
logger.debug(f'resolved eval [{attribute_string}]: {result}')
201+
return result
202+
156203
def resolve(self, s: str) -> str:
157204
"""Resolves a given argument string."""
158-
# TODO $(eval ...)
159205
if s.startswith('$(eval ') and s[-1] == ')':
160-
raise NotImplementedError
161-
s = R_ARG.sub(lambda m: self._resolve_arg(m.group(0)), s)
206+
return self._resolve_eval(s)
207+
s = R_ARG.sub(lambda m: self._resolve_substitution_arg(m.group(0)), s)
162208

163209
def process_find_arg(match: Match[str]) -> str:
164210
# split tag and optional trailing path

test/systems/husky.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
image: therobotcooperative/husky
2+
sources:
3+
- /opt/ros/melodic/setup.bash
4+
- /ros_ws/devel/setup.bash

test/test_roslaunch.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,20 @@ def test_read(sut):
2626
assert actual_node_names == expected_node_names
2727

2828

29+
@pytest.mark.parametrize('sut', ['husky'], indirect=True)
30+
def test_eval_args_in_launch_file(sut):
31+
with sut.roscore() as ros:
32+
config = ros.roslaunch.read('spawn_husky.launch', package='husky_gazebo')
33+
actual_node_names = {node.name for node in config.nodes}
34+
expected_node_names = {'base_controller_spawner',
35+
'ekf_localization',
36+
'robot_state_publisher',
37+
'spawn_husky_model',
38+
'twist_marker_server',
39+
'twist_mux'}
40+
assert actual_node_names == expected_node_names
41+
42+
2943
@pytest.mark.parametrize('sut', ['fetch'], indirect=True)
3044
def test_remappings(sut):
3145
with sut.roscore() as ros:

0 commit comments

Comments
 (0)