Skip to content

Commit 94fd9a9

Browse files
committed
[IMP] safe_eval: simplify API
This commit simplifies `safe_eval` which, up until now, allowed passing global and local namespaces. There is no usecase for this anymore. We want safe_eval exec mode to behave like a top-level module scope. `nocopy` is not needed anymore either. It's a relic of when the context could be dynamic (e.g. for already deleted `RecordDictWrapper` or `QWebContext`). Passed in context is always a dict. It will always be mutated with the local eval namespace dict after evaluation. This all makes `safe_eval` API less confusing. see enterprise: odoo/enterprise#83818 see upgrade-util: odoo/upgrade-util#265 task-4378806
1 parent 1cece3d commit 94fd9a9

File tree

9 files changed

+97
-68
lines changed

9 files changed

+97
-68
lines changed

addons/account_tax_python/models/account_tax.py

+1-7
Original file line numberDiff line numberDiff line change
@@ -144,13 +144,7 @@ def _eval_tax_amount_formula(self, raw_base, evaluation_context):
144144
'max': max,
145145
}
146146
try:
147-
return safe_eval(
148-
self.formula_decoded_info['py_formula'],
149-
globals_dict=formula_context,
150-
locals_dict={},
151-
locals_builtins=False,
152-
nocopy=True,
153-
)
147+
return safe_eval(self.formula_decoded_info['py_formula'], formula_context)
154148
except ZeroDivisionError:
155149
return 0.0
156150

addons/account_test/report/report_account_test.py

+4-4
Original file line numberDiff line numberDiff line change
@@ -35,17 +35,17 @@ def order_columns(item, cols=None):
3535
cols = list(item)
3636
return [(col, item.get(col)) for col in cols if col in item]
3737

38-
localdict = {
38+
context = {
3939
'cr': self.env.cr,
4040
'uid': self.env.uid,
4141
'reconciled_inv': reconciled_inv, # specific function used in different tests
4242
'result': None, # used to store the result of the test
4343
'column_order': None, # used to choose the display order of columns (in case you are returning a list of dict)
4444
'_': lambda *a, **kw: self.env._(*a, **kw), # pylint: disable=E8502,
4545
}
46-
safe_eval(code_exec, localdict, mode="exec", nocopy=True)
47-
result = localdict['result']
48-
column_order = localdict.get('column_order', None)
46+
safe_eval(code_exec, context, mode="exec")
47+
result = context['result']
48+
column_order = context.get('column_order')
4949

5050
if not isinstance(result, (tuple, list, set)):
5151
result = [result]

addons/gamification/models/gamification_goal.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@ def update_goal(self):
163163
'time': time,
164164
}
165165
code = definition.compute_code.strip()
166-
safe_eval(code, cxt, mode="exec", nocopy=True)
166+
safe_eval(code, cxt, mode="exec")
167167
# the result of the evaluated codeis put in the 'result' local variable, propagated to the context
168168
result = cxt.get('result')
169169
if isinstance(result, (float, int)):

odoo/addons/base/models/ir_actions.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -903,7 +903,7 @@ def unlink_action(self):
903903
return True
904904

905905
def _run_action_code_multi(self, eval_context):
906-
safe_eval(self.code.strip(), eval_context, mode="exec", nocopy=True, filename=str(self)) # nocopy allows to return 'action'
906+
safe_eval(self.code.strip(), eval_context, mode="exec", filename=str(self))
907907
return eval_context.get('action')
908908

909909
def _run_action_multi(self, eval_context=None):

odoo/addons/base/models/ir_model.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,8 @@
4646

4747
def make_compute(text, deps):
4848
""" Return a compute function from its code body and dependencies. """
49-
func = lambda self: safe_eval(text, SAFE_EVAL_BASE, {'self': self}, mode="exec")
49+
def func(self):
50+
return safe_eval(text, SAFE_EVAL_BASE | {'self': self}, mode="exec")
5051
deps = [arg.strip() for arg in deps.split(",")] if deps else []
5152
return api.depends(*deps)(func)
5253

odoo/addons/base/tests/test_base.py

+44-5
Original file line numberDiff line numberDiff line change
@@ -34,17 +34,17 @@ def test_expr_eval_opcodes(self):
3434
self.assertEqual(expr_eval(expr), expected)
3535

3636
def test_safe_eval_opcodes(self):
37-
for expr, locals_dict, expected in [
37+
for expr, context, expected in [
3838
('[x for x in (1,2)]', {}, [1, 2]), # LOAD_FAST_AND_CLEAR
3939
('list(x for x in (1,2))', {}, [1, 2]), # END_FOR, CALL_INTRINSIC_1
4040
('v if v is None else w', {'v': False, 'w': 'foo'}, 'foo'), # POP_JUMP_IF_NONE
4141
('v if v is not None else w', {'v': None, 'w': 'foo'}, 'foo'), # POP_JUMP_IF_NOT_NONE
4242
('{a for a in (1, 2)}', {}, {1, 2}), # RERAISE
4343
]:
44-
self.assertEqual(safe_eval(expr, locals_dict=locals_dict), expected)
44+
self.assertEqual(safe_eval(expr, context), expected)
4545

4646
def test_safe_eval_exec_opcodes(self):
47-
for expr, locals_dict, expected in [
47+
for expr, context, expected in [
4848
("""
4949
def f(v):
5050
if v:
@@ -53,8 +53,47 @@ def f(v):
5353
result = f(42)
5454
""", {}, 1), # LOAD_FAST_CHECK
5555
]:
56-
safe_eval(dedent(expr), locals_dict=locals_dict, mode="exec", nocopy=True)
57-
self.assertEqual(locals_dict['result'], expected)
56+
safe_eval(dedent(expr), context, mode="exec")
57+
self.assertEqual(context['result'], expected)
58+
59+
def test_safe_eval_strips(self):
60+
# cpython strips spaces and tabs by deafult since 3.10
61+
# https://github.com/python/cpython/commit/e799aa8b92c195735f379940acd9925961ad04ec
62+
# but we need to strip all whitespaces
63+
for expr, expected in [
64+
# simple ascii
65+
("\n 1 + 2", 3),
66+
("\n\n\t 1 + 2 \n", 3),
67+
(" 1 + 2", 3),
68+
("1 + 2 ", 3),
69+
70+
# Unicode (non-ASCII spaces)
71+
("\u00A01 + 2\u00A0", 3), # nbsp
72+
]:
73+
self.assertEqual(safe_eval(expr), expected)
74+
75+
def test_runs_top_level_scope(self):
76+
# when we define var in top-level scope, it should become available in locals and globals
77+
# such that f's frame will be able to access var too.
78+
expr = dedent("""
79+
var = 1
80+
def f():
81+
return var
82+
f()
83+
""")
84+
safe_eval(expr, mode="exec")
85+
safe_eval(expr, context={}, mode="exec")
86+
87+
def test_safe_eval_ctx_mutation(self):
88+
# simple eval also has side-effect on context
89+
expr = '(answer := 42)'
90+
context = {}
91+
self.assertEqual(safe_eval(expr, context), 42)
92+
self.assertEqual(context, {'answer': 42})
93+
94+
def test_safe_eval_ctx_no_builtins(self):
95+
ctx = {'__builtins__': {'max', min}}
96+
self.assertEqual(safe_eval('max(1, 2)', ctx), 2)
5897

5998
def test_01_safe_eval(self):
6099
""" Try a few common expressions to verify they work with safe_eval """

odoo/addons/test_exceptions/models.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -64,5 +64,5 @@ def generate_validation_error_safe_eval(self):
6464
self.generate_safe_eval(self.generate_validation_error)
6565

6666
def generate_safe_eval(self, f):
67-
globals_dict = { 'generate': f }
68-
safe_eval("generate()", mode='exec', globals_dict=globals_dict)
67+
context = {'generate': f}
68+
safe_eval("generate()", context, mode='exec')

odoo/tools/convert.py

+1-5
Original file line numberDiff line numberDiff line change
@@ -26,15 +26,11 @@
2626
from .misc import file_open, file_path, SKIPPED_ELEMENT_TYPES
2727
from odoo.exceptions import ValidationError
2828

29-
from .safe_eval import safe_eval as s_eval, pytz, time
29+
from .safe_eval import safe_eval, pytz, time
3030

3131
_logger = logging.getLogger(__name__)
3232

3333

34-
def safe_eval(expr, ctx={}):
35-
return s_eval(expr, ctx, nocopy=True)
36-
37-
3834
class ParseError(Exception):
3935
...
4036

odoo/tools/safe_eval.py

+41-42
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,7 @@ def assert_valid_codeobj(allowed_codes, code_obj, expr):
239239
if isinstance(const, CodeType):
240240
assert_valid_codeobj(allowed_codes, const, 'lambda')
241241

242+
242243
def test_expr(expr, allowed_codes, mode="eval", filename=None):
243244
"""test_expr(expression, allowed_codes[, mode[, filename]]) -> code_object
244245
@@ -344,18 +345,35 @@ def expr_eval(expr):
344345
'zip': zip,
345346
'Exception': Exception,
346347
}
347-
def safe_eval(expr, globals_dict=None, locals_dict=None, mode="eval", nocopy=False, locals_builtins=False, filename=None):
348-
"""safe_eval(expression[, globals[, locals[, mode[, nocopy]]]]) -> result
349348

350-
System-restricted Python expression evaluation
349+
350+
_BUBBLEUP_EXCEPTIONS = (
351+
odoo.exceptions.UserError,
352+
odoo.exceptions.RedirectWarning,
353+
werkzeug.exceptions.HTTPException,
354+
OperationalError, # let auto-replay of serialized transactions work its magic
355+
ZeroDivisionError,
356+
)
357+
358+
359+
def safe_eval(expr, /, context=None, *, mode="eval", filename=None):
360+
"""System-restricted Python expression evaluation
351361
352362
Evaluates a string that contains an expression that mostly
353363
uses Python constants, arithmetic expressions and the
354364
objects directly provided in context.
355365
356366
This can be used to e.g. evaluate
357-
an OpenERP domain expression from an untrusted source.
358-
367+
a domain expression from an untrusted source.
368+
369+
:param expr: The Python expression (or block, if ``mode='exec'``) to evaluate.
370+
:type expr: string | bytes
371+
:param context: Namespace available to the expression.
372+
This dict will be mutated with any variables created during
373+
evaluation
374+
:type context: dict
375+
:param mode: ``exec`` or ``eval``
376+
:type mode: str
359377
:param filename: optional pseudo-filename for the compiled expression,
360378
displayed for example in traceback frames
361379
:type filename: string
@@ -367,48 +385,29 @@ def safe_eval(expr, globals_dict=None, locals_dict=None, mode="eval", nocopy=Fal
367385
if type(expr) is CodeType:
368386
raise TypeError("safe_eval does not allow direct evaluation of code objects.")
369387

370-
# prevent altering the globals/locals from within the sandbox
371-
# by taking a copy.
372-
if not nocopy:
373-
# isinstance() does not work below, we want *exactly* the dict class
374-
if (globals_dict is not None and type(globals_dict) is not dict) \
375-
or (locals_dict is not None and type(locals_dict) is not dict):
376-
_logger.warning(
377-
"Looks like you are trying to pass a dynamic environment, "
378-
"you should probably pass nocopy=True to safe_eval().")
379-
if globals_dict is not None:
380-
globals_dict = dict(globals_dict)
381-
if locals_dict is not None:
382-
locals_dict = dict(locals_dict)
383-
384-
check_values(globals_dict)
385-
check_values(locals_dict)
386-
387-
if globals_dict is None:
388-
globals_dict = {}
389-
390-
globals_dict['__builtins__'] = dict(_BUILTINS)
391-
if locals_builtins:
392-
if locals_dict is None:
393-
locals_dict = {}
394-
locals_dict.update(_BUILTINS)
388+
assert context is None or type(context) is dict, "Context must be a dict"
389+
390+
check_values(context)
391+
392+
globals_dict = dict(context or {}, __builtins__=dict(_BUILTINS))
393+
395394
c = test_expr(expr, _SAFE_OPCODES, mode=mode, filename=filename)
396395
try:
397-
return unsafe_eval(c, globals_dict, locals_dict)
398-
except odoo.exceptions.UserError:
399-
raise
400-
except odoo.exceptions.RedirectWarning:
401-
raise
402-
except werkzeug.exceptions.HTTPException:
403-
raise
404-
except OperationalError:
405-
# Do not hide PostgreSQL low-level exceptions, to let the auto-replay
406-
# of serialized transactions work its magic
407-
raise
408-
except ZeroDivisionError:
396+
# empty locals dict makes the eval behave like top-level code
397+
return unsafe_eval(c, globals_dict, None)
398+
399+
except _BUBBLEUP_EXCEPTIONS:
409400
raise
401+
410402
except Exception as e:
411403
raise ValueError('%r while evaluating\n%r' % (e, expr))
404+
405+
finally:
406+
if context is not None:
407+
del globals_dict['__builtins__']
408+
context.update(globals_dict)
409+
410+
412411
def test_python_expr(expr, mode="eval"):
413412
try:
414413
test_expr(expr, _SAFE_OPCODES, mode=mode)

0 commit comments

Comments
 (0)