Skip to content

Commit b129eb6

Browse files
Pass modules to PythonExpression (#655)
* Added ability to pass Python modules to the PythonExpression substitution. Allows eval of more expressions. Signed-off-by: Blake Anderson Signed-off-by: Blake Anderson <[email protected]> * Fix style error WRT default arg whitespace. Signed-off-by: Blake Anderson <[email protected]> * Improve module passing to PythonExpression It is now easier to pass multiple modules. Also simpler syntax. Signed-off-by: Blake Anderson <[email protected]> * Tests for PythonExpression class Signed-off-by: Blake Anderson <[email protected]> * Fix style error for disallowed blank line. Signed-off-by: Blake Anderson <[email protected]> * Fixed wrong test method name. Looks like a copy/paste error from another test. Signed-off-by: Blake Anderson <[email protected]> * Additional documentation for PythonExpression. Signed-off-by: Blake Anderson <[email protected]> * PythonExpression: update describe() method Add more tests for describe() Signed-off-by: Blake Anderson <[email protected]> * PythonExpression: Take module names as substitutions Signed-off-by: Blake Anderson <[email protected]> * PythonExpression: Expression syntax change Definitions from modules must be prepended by the module name. Ex: 'sys.getrefcount' instead of just 'getrefcount'. The math module is an exception for backwards compatibility. Signed-off-by: Blake Anderson <[email protected]> * PythonExpression: Rename modules to python_modules Signed-off-by: Blake Anderson <[email protected]> * PythonExpression: More robust wrt XML spacing syntax Signed-off-by: Blake Anderson <[email protected]> * PythonExpression: Flake8 appeasement. Signed-off-by: Blake Anderson <[email protected]> Signed-off-by: Blake Anderson <[email protected]>
1 parent 8cffb1c commit b129eb6

File tree

6 files changed

+217
-9
lines changed

6 files changed

+217
-9
lines changed

launch/doc/source/architecture.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ There are many possible variations of a substitution, but here are some of the c
125125
- :class:`launch.substitutions.PythonExpression`
126126

127127
- This substitution will evaluate a python expression and get the result as a string.
128+
- You may pass a list of Python modules to the constructor to allow the use of those modules in the evaluated expression.
128129

129130
- :class:`launch.substitutions.LaunchConfiguration`
130131

launch/launch/substitutions/python_expression.py

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
"""Module for the PythonExpression substitution."""
1616

1717
import collections.abc
18-
import math
18+
import importlib
1919
from typing import Iterable
2020
from typing import List
2121
from typing import Text
@@ -37,7 +37,8 @@ class PythonExpression(Substitution):
3737
It also may contain math symbols and functions.
3838
"""
3939

40-
def __init__(self, expression: SomeSubstitutionsType) -> None:
40+
def __init__(self, expression: SomeSubstitutionsType,
41+
python_modules: SomeSubstitutionsType = ['math']) -> None:
4142
"""Create a PythonExpression substitution."""
4243
super().__init__()
4344

@@ -47,26 +48,61 @@ def __init__(self, expression: SomeSubstitutionsType) -> None:
4748
'expression',
4849
'PythonExpression')
4950

51+
ensure_argument_type(
52+
python_modules,
53+
(str, Substitution, collections.abc.Iterable),
54+
'python_modules',
55+
'PythonExpression')
56+
5057
from ..utilities import normalize_to_list_of_substitutions
5158
self.__expression = normalize_to_list_of_substitutions(expression)
59+
self.__python_modules = normalize_to_list_of_substitutions(python_modules)
5260

5361
@classmethod
5462
def parse(cls, data: Iterable[SomeSubstitutionsType]):
5563
"""Parse `PythonExpression` substitution."""
56-
if len(data) != 1:
57-
raise TypeError('eval substitution expects 1 argument')
58-
return cls, {'expression': data[0]}
64+
if len(data) < 1 or len(data) > 2:
65+
raise TypeError('eval substitution expects 1 or 2 arguments')
66+
kwargs = {}
67+
kwargs['expression'] = data[0]
68+
if len(data) == 2:
69+
# We get a text subsitution from XML,
70+
# whose contents are comma-separated module names
71+
kwargs['python_modules'] = []
72+
# Check if we got empty list from XML
73+
if len(data[1]) > 0:
74+
modules_str = data[1][0].perform(None)
75+
kwargs['python_modules'] = [module.strip() for module in modules_str.split(',')]
76+
return cls, kwargs
5977

6078
@property
6179
def expression(self) -> List[Substitution]:
6280
"""Getter for expression."""
6381
return self.__expression
6482

83+
@property
84+
def python_modules(self) -> List[Substitution]:
85+
"""Getter for expression."""
86+
return self.__python_modules
87+
6588
def describe(self) -> Text:
6689
"""Return a description of this substitution as a string."""
67-
return 'PythonExpr({})'.format(' + '.join([sub.describe() for sub in self.expression]))
90+
return 'PythonExpr({}, [{}])'.format(
91+
' + '.join([sub.describe() for sub in self.expression]),
92+
', '.join([sub.describe() for sub in self.python_modules]))
6893

6994
def perform(self, context: LaunchContext) -> Text:
7095
"""Perform the substitution by evaluating the expression."""
7196
from ..utilities import perform_substitutions
72-
return str(eval(perform_substitutions(context, self.expression), {}, math.__dict__))
97+
module_names = [context.perform_substitution(sub) for sub in self.python_modules]
98+
module_objects = [importlib.import_module(name) for name in module_names]
99+
expression_locals = {}
100+
for module in module_objects:
101+
# For backwards compatility, we allow math definitions to be implicitly
102+
# referenced in expressions, without prepending the math module name
103+
# TODO: This may be removed in a future release.
104+
if module.__name__ == 'math':
105+
expression_locals.update(vars(module))
106+
107+
expression_locals[module.__name__] = module
108+
return str(eval(perform_substitutions(context, self.expression), {}, expression_locals))

launch/test/launch/frontend/test_substitutions.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,12 +206,68 @@ def test_eval_subst():
206206

207207

208208
def test_eval_subst_of_math_expr():
209+
# Math module is included by default
209210
subst = parse_substitution(r'$(eval "ceil(1.3)")')
210211
assert len(subst) == 1
211212
expr = subst[0]
212213
assert isinstance(expr, PythonExpression)
213214
assert '2' == expr.perform(LaunchContext())
214215

216+
# Do it again, with the math module explicitly given
217+
subst = parse_substitution(r'$(eval "ceil(1.3)" "math")')
218+
assert len(subst) == 1
219+
expr = subst[0]
220+
assert isinstance(expr, PythonExpression)
221+
assert '2' == expr.perform(LaunchContext())
222+
223+
# Do it again, with the math module explicitly given and referenced in the expression
224+
subst = parse_substitution(r'$(eval "math.ceil(1.3)" "math")')
225+
assert len(subst) == 1
226+
expr = subst[0]
227+
assert isinstance(expr, PythonExpression)
228+
assert '2' == expr.perform(LaunchContext())
229+
230+
231+
def test_eval_missing_module():
232+
# Test with implicit math definition
233+
subst = parse_substitution(r'$(eval "ceil(1.3)" "")')
234+
assert len(subst) == 1
235+
expr = subst[0]
236+
assert isinstance(expr, PythonExpression)
237+
238+
# Should raise NameError since it does not have math module
239+
with pytest.raises(NameError):
240+
assert expr.perform(LaunchContext())
241+
242+
# Test with explicit math definition
243+
subst = parse_substitution(r'$(eval "math.ceil(1.3)" "")')
244+
assert len(subst) == 1
245+
expr = subst[0]
246+
assert isinstance(expr, PythonExpression)
247+
248+
# Should raise NameError since it does not have math module
249+
with pytest.raises(NameError):
250+
assert expr.perform(LaunchContext())
251+
252+
253+
def test_eval_subst_multiple_modules():
254+
subst = parse_substitution(
255+
r'$(eval "math.isfinite(sys.getrefcount(str(\'hello world!\')))" "math, sys")')
256+
assert len(subst) == 1
257+
expr = subst[0]
258+
assert isinstance(expr, PythonExpression)
259+
assert expr.perform(LaunchContext())
260+
261+
262+
def test_eval_subst_multiple_modules_alt_syntax():
263+
# Case where the module names are listed with irregular spacing
264+
subst = parse_substitution(
265+
r'$(eval "math.isfinite(sys.getrefcount(str(\'hello world!\')))" " math,sys ")')
266+
assert len(subst) == 1
267+
expr = subst[0]
268+
assert isinstance(expr, PythonExpression)
269+
assert expr.perform(LaunchContext())
270+
215271

216272
def expand_cmd_subs(cmd_subs: List[SomeSubstitutionsType]):
217273
return [perform_substitutions_without_context(x) for x in cmd_subs]

launch/test/launch/substitutions/test_path_join_substitution.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
from launch.substitutions import PathJoinSubstitution
2020

2121

22-
def test_this_launch_file_path():
22+
def test_path_join():
2323
path = ['asd', 'bsd', 'cds']
2424
sub = PathJoinSubstitution(path)
2525
assert sub.perform(None) == os.path.join(*path)
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
# Copyright 2022 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+
"""Tests for the PythonExpression substitution class."""
16+
17+
from launch import LaunchContext
18+
from launch.substitutions import PythonExpression
19+
from launch.substitutions import SubstitutionFailure
20+
21+
import pytest
22+
23+
24+
def test_python_substitution_missing_module():
25+
"""Check that evaluation fails if we do not pass a needed module (sys)."""
26+
lc = LaunchContext()
27+
expr = 'sys.getrefcount(str("hello world!"))'
28+
29+
subst = PythonExpression([expr])
30+
31+
# Should raise a NameError since it doesn't see the sys module
32+
with pytest.raises(NameError):
33+
subst.perform(lc)
34+
35+
# Test the describe() method
36+
assert subst.describe() == "PythonExpr('sys.getrefcount(str(\"hello world!\"))', ['math'])"
37+
38+
39+
def test_python_substitution_no_module():
40+
"""Check that PythonExpression has the math module by default."""
41+
lc = LaunchContext()
42+
expr = 'math.ceil(1.6)'
43+
44+
subst = PythonExpression([expr])
45+
result = subst.perform(lc)
46+
47+
assert result == '2'
48+
49+
# Test the describe() method
50+
assert subst.describe() == "PythonExpr('math.ceil(1.6)', ['math'])"
51+
52+
53+
def test_python_substitution_implicit_math():
54+
"""Check that PythonExpression will accept math definitions implicitly."""
55+
lc = LaunchContext()
56+
expr = 'ceil(1.6)'
57+
58+
subst = PythonExpression([expr])
59+
result = subst.perform(lc)
60+
61+
assert result == '2'
62+
63+
# Test the describe() method
64+
assert subst.describe() == "PythonExpr('ceil(1.6)', ['math'])"
65+
66+
67+
def test_python_substitution_empty_module_list():
68+
"""Case where user provides empty module list."""
69+
lc = LaunchContext()
70+
expr = 'math.ceil(1.6)'
71+
72+
subst = PythonExpression([expr], [])
73+
74+
# Should raise a NameError since it doesn't have the math module
75+
with pytest.raises(NameError):
76+
subst.perform(lc)
77+
78+
# Test the describe() method
79+
assert subst.describe() == "PythonExpr('math.ceil(1.6)', [])"
80+
81+
82+
def test_python_substitution_one_module():
83+
"""Evaluation while passing one module."""
84+
lc = LaunchContext()
85+
expr = 'sys.getrefcount(str("hello world!"))'
86+
87+
subst = PythonExpression([expr], ['sys'])
88+
try:
89+
result = subst.perform(lc)
90+
except SubstitutionFailure:
91+
pytest.fail('Failed to evaluate PythonExpression containing sys module.')
92+
93+
# A refcount should be some positive number
94+
assert int(result) > 0
95+
96+
# Test the describe() method
97+
assert subst.describe() == "PythonExpr('sys.getrefcount(str(\"hello world!\"))', ['sys'])"
98+
99+
100+
def test_python_substitution_two_modules():
101+
"""Evaluation while passing two modules."""
102+
lc = LaunchContext()
103+
expr = 'math.isfinite(sys.getrefcount(str("hello world!")))'
104+
105+
subst = PythonExpression([expr], ['sys', 'math'])
106+
try:
107+
result = subst.perform(lc)
108+
except SubstitutionFailure:
109+
pytest.fail('Failed to evaluate PythonExpression containing sys module.')
110+
111+
# The expression should evaluate to True - the refcount is finite
112+
assert result
113+
114+
# Test the describe() method
115+
assert subst.describe() ==\
116+
"PythonExpr('math.isfinite(sys.getrefcount(str(\"hello world!\")))', ['sys', 'math'])"

launch/test/temporary_environment.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,6 @@ def __exit__(self, t, v, tb):
4040

4141
def sandbox_environment_variables(func):
4242
"""Decorate a function to give it a temporary environment."""
43-
4443
@functools.wraps(func)
4544
def wrapper_func(*args, **kwargs):
4645
with TemporaryEnvironment():

0 commit comments

Comments
 (0)