Refactor expressions

* This patch moves code related to YAQL and Jinja into their
  specific modules so that there isn't any module that works with
  both. It makes it easier to understand how code related to one
  of these technologies works.
* Custome built-in functions for YAQL and Jinja are now in a
  separate module. It's easier now to see what's related with
  the expression framework now and what's with integration part,
  i.e. functions themselves.
* Renamed the base module of expressions similar to other packages.
* Other style changes.

Change-Id: I94f57a6534b9c10e202205dfae4d039296c26407
This commit is contained in:
Renat Akhmerov 2020-02-20 17:19:13 +07:00
parent 753f1bc03f
commit 592981f487
10 changed files with 184 additions and 205 deletions

View File

@ -517,11 +517,13 @@ class AdHocAction(PythonAction):
@profiler.trace('ad-hoc-action-gather-base-actions', hide_args=True) @profiler.trace('ad-hoc-action-gather-base-actions', hide_args=True)
def _gather_base_actions(self, action_def, base_action_def): def _gather_base_actions(self, action_def, base_action_def):
"""Find all base ad-hoc actions and store them """Find all base ad-hoc actions and store them.
An ad-hoc action may be based on another ad-hoc action (and this An ad-hoc action may be based on another ad-hoc action and this
recursively). Using twice the same base action is not allowed to works recursively, so that the base action can also be based on an
avoid infinite loops. It stores the list of ad-hoc actions. ad-hoc action. Using the same base action more than once in this
action hierarchy is not allowed to avoid infinite loops.
The method stores the list of ad-hoc actions.
:param action_def: Action definition :param action_def: Action definition
:type action_def: ActionDefinition :type action_def: ActionDefinition
@ -532,6 +534,7 @@ class AdHocAction(PythonAction):
""" """
self.adhoc_action_defs = [action_def] self.adhoc_action_defs = [action_def]
original_base_name = self.action_spec.get_name() original_base_name = self.action_spec.get_name()
action_names = set([original_base_name]) action_names = set([original_base_name])

View File

@ -15,6 +15,8 @@
import abc import abc
from stevedore import extension
class Evaluator(object): class Evaluator(object):
"""Expression evaluator interface. """Expression evaluator interface.
@ -53,3 +55,22 @@ class Evaluator(object):
:return: True if string is expression :return: True if string is expression
""" """
pass pass
def get_custom_functions():
"""Get custom functions.
Retrieves the list of custom functions used in YAQL/Jinja expressions.
"""
# {name => function object).
result = dict()
mgr = extension.ExtensionManager(
namespace='mistral.expression.functions',
invoke_on_load=False
)
for name in mgr.names():
result[name] = mgr[name].plugin
return result

View File

@ -12,6 +12,7 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
from functools import partial
import re import re
import jinja2 import jinja2
@ -22,8 +23,7 @@ from oslo_log import log as logging
import six import six
from mistral import exceptions as exc from mistral import exceptions as exc
from mistral.expressions.base_expression import Evaluator from mistral.expressions import base
from mistral.utils import expression_utils
LOG = logging.getLogger(__name__) LOG = logging.getLogger(__name__)
@ -41,13 +41,33 @@ _environment = SandboxedEnvironment(
lstrip_blocks=True lstrip_blocks=True
) )
_filters = expression_utils.get_custom_functions() _filters = base.get_custom_functions()
for name in _filters: for name in _filters:
_environment.filters[name] = _filters[name] _environment.filters[name] = _filters[name]
class JinjaEvaluator(Evaluator): def get_jinja_context(data_context):
new_ctx = {'_': data_context}
_register_jinja_functions(new_ctx)
if isinstance(data_context, dict):
new_ctx['__env'] = data_context.get('__env')
new_ctx['__execution'] = data_context.get('__execution')
new_ctx['__task_execution'] = data_context.get('__task_execution')
return new_ctx
def _register_jinja_functions(jinja_ctx):
functions = base.get_custom_functions()
for name in functions:
jinja_ctx[name] = partial(functions[name], jinja_ctx['_'])
class JinjaEvaluator(base.Evaluator):
_env = _environment.overlay() _env = _environment.overlay()
@classmethod @classmethod
@ -62,18 +82,13 @@ class JinjaEvaluator(Evaluator):
parser.parse_expression() parser.parse_expression()
except jinja2.exceptions.TemplateError as e: except jinja2.exceptions.TemplateError as e:
raise exc.JinjaGrammarException( raise exc.JinjaGrammarException("Syntax error '%s'." % str(e))
"Syntax error '%s'." % str(e)
)
@classmethod @classmethod
def evaluate(cls, expression, data_context): def evaluate(cls, expression, data_context):
ctx = expression_utils.get_jinja_context(data_context) ctx = get_jinja_context(data_context)
result = cls._env.compile_expression( result = cls._env.compile_expression(expression, **JINJA_OPTS)(**ctx)
expression,
**JINJA_OPTS
)(**ctx)
# For StrictUndefined values, UndefinedError only gets raised when # For StrictUndefined values, UndefinedError only gets raised when
# the value is accessed, not when it gets created. The simplest way # the value is accessed, not when it gets created. The simplest way
@ -90,7 +105,7 @@ class JinjaEvaluator(Evaluator):
return False return False
class InlineJinjaEvaluator(Evaluator): class InlineJinjaEvaluator(base.Evaluator):
# The regular expression for Jinja variables and blocks # The regular expression for Jinja variables and blocks
find_expression_pattern = re.compile(JINJA_REGEXP) find_expression_pattern = re.compile(JINJA_REGEXP)
find_block_pattern = re.compile(JINJA_BLOCK_REGEXP) find_block_pattern = re.compile(JINJA_BLOCK_REGEXP)
@ -126,7 +141,8 @@ class InlineJinjaEvaluator(Evaluator):
if patterns[0][0] == expression: if patterns[0][0] == expression:
result = JinjaEvaluator.evaluate(patterns[0][1], data_context) result = JinjaEvaluator.evaluate(patterns[0][1], data_context)
else: else:
ctx = expression_utils.get_jinja_context(data_context) ctx = get_jinja_context(data_context)
result = cls._env.from_string(expression).render(**ctx) result = cls._env.from_string(expression).render(**ctx)
except Exception as e: except Exception as e:
# NOTE(rakhmerov): if we hit a database error then we need to # NOTE(rakhmerov): if we hit a database error then we need to

View File

@ -1,4 +1,5 @@
# Copyright 2015 - Mirantis, Inc. # Copyright 2013 - Mirantis, Inc.
# Copyright 2015 - StackStorm, Inc.
# Copyright 2016 - Brocade Communications Systems, Inc. # Copyright 2016 - Brocade Communications Systems, Inc.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
@ -13,163 +14,67 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
from functools import partial
import warnings import warnings
from oslo_log import log as logging from oslo_log import log as logging
from oslo_serialization import jsonutils from oslo_serialization import jsonutils
from stevedore import extension
import yaml import yaml
from yaml import representer
import yaql
from yaql.language import utils as yaql_utils
from mistral.config import cfg
from mistral.db.v2 import api as db_api from mistral.db.v2 import api as db_api
from mistral.utils import filter_utils from mistral.utils import filter_utils
from mistral_lib import utils from mistral_lib import utils
# TODO(rakhmerov): it's work around the bug in YAQL. # Additional YAQL/Jinja functions provided by Mistral out of the box.
# YAQL shouldn't expose internal types to custom functions.
representer.SafeRepresenter.add_representer(
yaql_utils.FrozenDict,
representer.SafeRepresenter.represent_dict
)
LOG = logging.getLogger(__name__)
ROOT_YAQL_CONTEXT = None
def get_yaql_context(data_context):
global ROOT_YAQL_CONTEXT
if not ROOT_YAQL_CONTEXT:
ROOT_YAQL_CONTEXT = yaql.create_context()
_register_yaql_functions(ROOT_YAQL_CONTEXT)
new_ctx = ROOT_YAQL_CONTEXT.create_child_context()
new_ctx['$'] = (
data_context if not cfg.CONF.yaql.convert_input_data
else yaql_utils.convert_input_data(data_context)
)
if isinstance(data_context, dict):
new_ctx['__env'] = data_context.get('__env')
new_ctx['__execution'] = data_context.get('__execution')
new_ctx['__task_execution'] = data_context.get('__task_execution')
return new_ctx
def get_jinja_context(data_context):
new_ctx = {
'_': data_context
}
_register_jinja_functions(new_ctx)
if isinstance(data_context, dict):
new_ctx['__env'] = data_context.get('__env')
new_ctx['__execution'] = data_context.get('__execution')
new_ctx['__task_execution'] = data_context.get('__task_execution')
return new_ctx
def get_custom_functions():
"""Get custom functions
Retrieves the list of custom evaluation functions
"""
functions = dict()
mgr = extension.ExtensionManager(
namespace='mistral.expression.functions',
invoke_on_load=False
)
for name in mgr.names():
functions[name] = mgr[name].plugin
return functions
def _register_yaql_functions(yaql_ctx):
functions = get_custom_functions()
for name in functions:
yaql_ctx.register_function(functions[name], name=name)
def _register_jinja_functions(jinja_ctx):
functions = get_custom_functions()
for name in functions:
jinja_ctx[name] = partial(functions[name], jinja_ctx['_'])
# Additional YAQL functions needed by Mistral.
# If a function name ends with underscore then it doesn't need to pass # If a function name ends with underscore then it doesn't need to pass
# the name of the function when context registers it. # the name of the function when context registers it.
LOG = logging.getLogger(__name__)
def env_(context): def env_(context):
return context['__env'] return context['__env']
def executions_(context, def executions_(context, id=None, root_execution_id=None, state=None,
id=None, from_time=None, to_time=None):
root_execution_id=None, fltr = {}
state=None,
from_time=None,
to_time=None
):
filter = {}
if id is not None: if id is not None:
filter = filter_utils.create_or_update_filter( fltr = filter_utils.create_or_update_filter('id', id, "eq", fltr)
'id',
id,
"eq",
filter
)
if root_execution_id is not None: if root_execution_id is not None:
filter = filter_utils.create_or_update_filter( fltr = filter_utils.create_or_update_filter(
'root_execution_id', 'root_execution_id',
root_execution_id, root_execution_id,
"eq", 'eq',
filter fltr
) )
if state is not None: if state is not None:
filter = filter_utils.create_or_update_filter( fltr = filter_utils.create_or_update_filter(
'state', 'state',
state, state,
"eq", 'eq',
filter fltr
) )
if from_time is not None: if from_time is not None:
filter = filter_utils.create_or_update_filter( fltr = filter_utils.create_or_update_filter(
'created_at', 'created_at',
from_time, from_time,
"gte", 'gte',
filter fltr
) )
if to_time is not None: if to_time is not None:
filter = filter_utils.create_or_update_filter( fltr = filter_utils.create_or_update_filter(
'created_at', 'created_at',
to_time, to_time,
"lt", 'lt',
filter fltr
) )
return db_api.get_workflow_executions(**filter) return db_api.get_workflow_executions(**fltr)
def execution_(context): def execution_(context):

View File

@ -20,16 +20,18 @@ import re
from oslo_db import exception as db_exc from oslo_db import exception as db_exc
from oslo_log import log as logging from oslo_log import log as logging
import six import six
from yaml import representer
import yaql
from yaql.language import exceptions as yaql_exc from yaql.language import exceptions as yaql_exc
from yaql.language import factory from yaql.language import factory
from yaql.language import utils as yaql_utils from yaql.language import utils as yaql_utils
from mistral.config import cfg from mistral.config import cfg
from mistral import exceptions as exc from mistral import exceptions as exc
from mistral.expressions.base_expression import Evaluator from mistral.expressions import base
from mistral.utils import expression_utils
from mistral_lib import utils from mistral_lib import utils
LOG = logging.getLogger(__name__) LOG = logging.getLogger(__name__)
_YAQL_CONF = cfg.CONF.yaql _YAQL_CONF = cfg.CONF.yaql
@ -38,6 +40,45 @@ INLINE_YAQL_REGEXP = '<%.*?%>'
YAQL_ENGINE = None YAQL_ENGINE = None
ROOT_YAQL_CONTEXT = None
# TODO(rakhmerov): it's work around the bug in YAQL.
# YAQL shouldn't expose internal types to custom functions.
representer.SafeRepresenter.add_representer(
yaql_utils.FrozenDict,
representer.SafeRepresenter.represent_dict
)
def get_yaql_context(data_context):
global ROOT_YAQL_CONTEXT
if not ROOT_YAQL_CONTEXT:
ROOT_YAQL_CONTEXT = yaql.create_context()
_register_yaql_functions(ROOT_YAQL_CONTEXT)
new_ctx = ROOT_YAQL_CONTEXT.create_child_context()
new_ctx['$'] = (
data_context if not cfg.CONF.yaql.convert_input_data
else yaql_utils.convert_input_data(data_context)
)
if isinstance(data_context, dict):
new_ctx['__env'] = data_context.get('__env')
new_ctx['__execution'] = data_context.get('__execution')
new_ctx['__task_execution'] = data_context.get('__task_execution')
return new_ctx
def _register_yaql_functions(yaql_ctx):
functions = base.get_custom_functions()
for name in functions:
yaql_ctx.register_function(functions[name], name=name)
def get_yaql_engine_options(): def get_yaql_engine_options():
return { return {
@ -97,7 +138,7 @@ def _sanitize_yaql_result(result):
return result if not inspect.isgenerator(result) else list(result) return result if not inspect.isgenerator(result) else list(result)
class YAQLEvaluator(Evaluator): class YAQLEvaluator(base.Evaluator):
@classmethod @classmethod
def validate(cls, expression): def validate(cls, expression):
try: try:
@ -111,7 +152,7 @@ class YAQLEvaluator(Evaluator):
try: try:
result = get_yaql_engine_class()(expression).evaluate( result = get_yaql_engine_class()(expression).evaluate(
context=expression_utils.get_yaql_context(data_context) context=get_yaql_context(data_context)
) )
except Exception as e: except Exception as e:
# NOTE(rakhmerov): if we hit a database error then we need to # NOTE(rakhmerov): if we hit a database error then we need to

View File

@ -18,10 +18,11 @@ from oslo_db import exception as db_exc
from mistral.db.v2 import api as db_api from mistral.db.v2 import api as db_api
from mistral import exceptions as exc from mistral import exceptions as exc
from mistral.expressions import jinja_expression
from mistral.expressions import yaql_expression
from mistral.services import workbooks as wb_service from mistral.services import workbooks as wb_service
from mistral.services import workflows as wf_service from mistral.services import workflows as wf_service
from mistral.tests.unit.engine import base from mistral.tests.unit.engine import base
from mistral.utils import expression_utils
from mistral.workflow import states from mistral.workflow import states
from mistral_lib import actions as actions_base from mistral_lib import actions as actions_base
@ -743,11 +744,11 @@ class ErrorHandlingEngineTest(base.EngineTestCase):
self.assertIn("UnicodeDecodeError: utf", task_ex.state_info) self.assertIn("UnicodeDecodeError: utf", task_ex.state_info)
@mock.patch( @mock.patch(
'mistral.utils.expression_utils.get_yaql_context', 'mistral.expressions.yaql_expression.get_yaql_context',
mock.MagicMock( mock.MagicMock(
side_effect=[ side_effect=[
db_exc.DBDeadlock(), # Emulating DB deadlock db_exc.DBDeadlock(), # Emulating DB deadlock
expression_utils.get_yaql_context({}) # Successful run yaql_expression.get_yaql_context({}) # Successful run
] ]
) )
) )
@ -783,11 +784,11 @@ class ErrorHandlingEngineTest(base.EngineTestCase):
self.assertDictEqual({'my_var': 2}, task_ex.published) self.assertDictEqual({'my_var': 2}, task_ex.published)
@mock.patch( @mock.patch(
'mistral.utils.expression_utils.get_jinja_context', 'mistral.expressions.jinja_expression.get_jinja_context',
mock.MagicMock( mock.MagicMock(
side_effect=[ side_effect=[
db_exc.DBDeadlock(), # Emulating DB deadlock db_exc.DBDeadlock(), # Emulating DB deadlock
expression_utils.get_jinja_context({}) # Successful run jinja_expression.get_jinja_context({}) # Successful run
] ]
) )
) )

View File

@ -15,11 +15,11 @@
from oslo_config import cfg from oslo_config import cfg
from mistral.db.v2 import api as db_api from mistral.db.v2 import api as db_api
from mistral.expressions import std_functions
from mistral.services import workflows as wf_service from mistral.services import workflows as wf_service
from mistral.tests.unit.engine import base as engine_test_base from mistral.tests.unit.engine import base as engine_test_base
from mistral.workflow import states from mistral.workflow import states
# Use the set_default method to set value otherwise in certain test cases # Use the set_default method to set value otherwise in certain test cases
# the change in value is not permanent. # the change in value is not permanent.
cfg.CONF.set_default('auth_enable', False, group='pecan') cfg.CONF.set_default('auth_enable', False, group='pecan')
@ -459,3 +459,28 @@ class YAQLFunctionsEngineTest(engine_test_base.EngineTestCase):
self.assertIsNotNone(json_str) self.assertIsNotNone(json_str)
self.assertIn('"key1": "foo"', json_str) self.assertIn('"key1": "foo"', json_str)
self.assertIn('"key2": "bar"', json_str) self.assertIn('"key2": "bar"', json_str)
def test_yaml_dump(self):
data = [
{
"this": "is valid",
},
{
"so": "is this",
"and": "this too",
"might": "as well",
},
"validaswell"
]
expected = (
"- this: is valid\n"
"- and: this too\n"
" might: as well\n"
" so: is this\n"
"- validaswell\n"
)
yaml_str = std_functions.yaml_dump_(None, data)
self.assertEqual(expected, yaml_str)

View File

@ -71,7 +71,7 @@ WF_EXECS = [
] ]
class JinjaEvaluatorTest(base.BaseTest): class JinjaEvaluatorTest(base.DbTestCase):
def setUp(self): def setUp(self):
super(JinjaEvaluatorTest, self).setUp() super(JinjaEvaluatorTest, self).setUp()
@ -79,6 +79,7 @@ class JinjaEvaluatorTest(base.BaseTest):
def test_expression_result(self): def test_expression_result(self):
res = self._evaluator.evaluate('_.server', DATA) res = self._evaluator.evaluate('_.server', DATA)
self.assertEqual({ self.assertEqual({
'id': '03ea824a-aa24-4105-9131-66c48ae54acf', 'id': '03ea824a-aa24-4105-9131-66c48ae54acf',
'name': 'cloud-fedora', 'name': 'cloud-fedora',
@ -86,9 +87,11 @@ class JinjaEvaluatorTest(base.BaseTest):
}, res) }, res)
res = self._evaluator.evaluate('_.server.id', DATA) res = self._evaluator.evaluate('_.server.id', DATA)
self.assertEqual('03ea824a-aa24-4105-9131-66c48ae54acf', res) self.assertEqual('03ea824a-aa24-4105-9131-66c48ae54acf', res)
res = self._evaluator.evaluate("_.server.status == 'ACTIVE'", DATA) res = self._evaluator.evaluate("_.server.status == 'ACTIVE'", DATA)
self.assertTrue(res) self.assertTrue(res)
def test_select_result(self): def test_select_result(self):
@ -96,7 +99,9 @@ class JinjaEvaluatorTest(base.BaseTest):
'_.servers|selectattr("name", "equalto", "ubuntu")', '_.servers|selectattr("name", "equalto", "ubuntu")',
SERVERS SERVERS
) )
item = list(res)[0] item = list(res)[0]
self.assertEqual({'name': 'ubuntu'}, item) self.assertEqual({'name': 'ubuntu'}, item)
def test_function_string(self): def test_function_string(self):
@ -104,8 +109,11 @@ class JinjaEvaluatorTest(base.BaseTest):
self.assertEqual('3', self._evaluator.evaluate('_|string', 3)) self.assertEqual('3', self._evaluator.evaluate('_|string', 3))
def test_function_len(self): def test_function_len(self):
self.assertEqual(3, self.assertEqual(
self._evaluator.evaluate('_|length', 'hey')) 3,
self._evaluator.evaluate('_|length', 'hey')
)
data = [{'some': 'thing'}] data = [{'some': 'thing'}]
self.assertEqual( self.assertEqual(
@ -184,10 +192,12 @@ class JinjaEvaluatorTest(base.BaseTest):
def test_function_env(self): def test_function_env(self):
ctx = {'__env': 'some'} ctx = {'__env': 'some'}
self.assertEqual(ctx['__env'], self._evaluator.evaluate('env()', ctx)) self.assertEqual(ctx['__env'], self._evaluator.evaluate('env()', ctx))
def test_filter_env(self): def test_filter_env(self):
ctx = {'__env': 'some'} ctx = {'__env': 'some'}
self.assertEqual(ctx['__env'], self._evaluator.evaluate('_|env', ctx)) self.assertEqual(ctx['__env'], self._evaluator.evaluate('_|env', ctx))
@mock.patch('mistral.db.v2.api.get_task_executions') @mock.patch('mistral.db.v2.api.get_task_executions')
@ -196,6 +206,7 @@ class JinjaEvaluatorTest(base.BaseTest):
task_executions): task_executions):
task = mock.MagicMock(return_value={}) task = mock.MagicMock(return_value={})
task_executions.return_value = [task] task_executions.return_value = [task]
ctx = { ctx = {
'__task_execution': None, '__task_execution': None,
'__execution': { '__execution': {

View File

@ -1,44 +0,0 @@
# Copyright 2014 - Mirantis, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from mistral.tests.unit import base
from mistral.utils import expression_utils as e_u
JSON_INPUT = [
{
"this": "is valid",
},
{
"so": "is this",
"and": "this too",
"might": "as well",
},
"validaswell"
]
JSON_TO_YAML_STR = """- this: is valid
- and: this too
might: as well
so: is this
- validaswell
"""
class ExpressionUtilsTest(base.BaseTest):
def test_yaml_dump(self):
yaml_str = e_u.yaml_dump_(None, JSON_INPUT)
self.assertEqual(JSON_TO_YAML_STR, yaml_str)

View File

@ -77,19 +77,19 @@ mistral.notification.publishers =
mistral.expression.functions = mistral.expression.functions =
# json_pp was deprecated in Queens and will be removed in the S cycle # json_pp was deprecated in Queens and will be removed in the S cycle
json_pp = mistral.utils.expression_utils:json_pp_ json_pp = mistral.expressions.std_functions:json_pp_
env = mistral.utils.expression_utils:env_ env = mistral.expressions.std_functions:env_
execution = mistral.utils.expression_utils:execution_ execution = mistral.expressions.std_functions:execution_
executions = mistral.utils.expression_utils:executions_ executions = mistral.expressions.std_functions:executions_
global = mistral.utils.expression_utils:global_ global = mistral.expressions.std_functions:global_
json_parse = mistral.utils.expression_utils:json_parse_ json_parse = mistral.expressions.std_functions:json_parse_
json_dump = mistral.utils.expression_utils:json_dump_ json_dump = mistral.expressions.std_functions:json_dump_
task = mistral.utils.expression_utils:task_ task = mistral.expressions.std_functions:task_
tasks = mistral.utils.expression_utils:tasks_ tasks = mistral.expressions.std_functions:tasks_
uuid = mistral.utils.expression_utils:uuid_ uuid = mistral.expressions.std_functions:uuid_
yaml_parse = mistral.utils.expression_utils:yaml_parse_ yaml_parse = mistral.expressions.std_functions:yaml_parse_
yaml_dump = mistral.utils.expression_utils:yaml_dump_ yaml_dump = mistral.expressions.std_functions:yaml_dump_
mistral.expression.evaluators = mistral.expression.evaluators =
yaql = mistral.expressions.yaql_expression:InlineYAQLEvaluator yaql = mistral.expressions.yaql_expression:InlineYAQLEvaluator