Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions SpiffWorkflow/bpmn/FeelLikeScriptEngine.py
Original file line number Diff line number Diff line change
Expand Up @@ -266,8 +266,8 @@ class FeelLikeScriptEngine(PythonScriptEngine):
provide a specialised subclass that parses and executes the scripts /
expressions in a mini-language of your own.
"""
def __init__(self):
super().__init__()
def __init__(self, environment=None):
super().__init__(environment=environment)

def validate(self, expression):
super().validate(self.patch_expression(expression))
Expand Down
121 changes: 17 additions & 104 deletions SpiffWorkflow/bpmn/PythonScriptEngine.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
import copy
import sys
import traceback
import warnings

from .PythonScriptEngineEnvironment import TaskDataEnvironment
from ..exceptions import SpiffWorkflowException, WorkflowTaskException
from ..operators import Operator

Expand All @@ -26,66 +28,6 @@
# 02110-1301 USA


class Box(dict):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like giving people the option of not using Box.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should deprecate box too. The ultimate goal here should be to provide a default that does nothing but execute and evaluate with the task data added to the context, with no additional changes to anything outside of execution/evaluation.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a deprecation warning and a BoxedTaskDataEnvironment if consumers need/want it. Updated two tests that relied on Box to use this new environment.

"""
Example:
m = Box({'first_name': 'Eduardo'}, last_name='Pool', age=24, sports=['Soccer'])
"""

def __init__(self, *args, **kwargs):
super(Box, self).__init__(*args, **kwargs)
for arg in args:
if isinstance(arg, dict):
for k, v in arg.items():
if isinstance(v, dict):
self[k] = Box(v)
else:
self[k] = v

if kwargs:
for k, v in kwargs.items():
if isinstance(v, dict):
self[k] = Box(v)
else:
self[k] = v

def __deepcopy__(self, memodict=None):
if memodict is None:
memodict = {}
my_copy = Box()
for k, v in self.items():
my_copy[k] = copy.deepcopy(v)
return my_copy

def __getattr__(self, attr):
try:
output = self[attr]
except:
raise AttributeError(
"Dictionary has no attribute '%s' " % str(attr))
return output

def __setattr__(self, key, value):
self.__setitem__(key, value)

def __setitem__(self, key, value):
super(Box, self).__setitem__(key, value)
self.__dict__.update({key: value})

def __getstate__(self):
return self.__dict__

def __setstate__(self, state):
self.__init__(state)

def __delattr__(self, item):
self.__delitem__(item)

def __delitem__(self, key):
super(Box, self).__delitem__(key)
del self.__dict__[key]


class PythonScriptEngine(object):
"""
This should serve as a base for all scripting & expression evaluation
Expand All @@ -97,10 +39,18 @@ class PythonScriptEngine(object):
expressions in a different way.
"""

def __init__(self, default_globals=None, scripting_additions=None):

self.globals = default_globals or {}
self.globals.update(scripting_additions or {})
def __init__(self, default_globals=None, scripting_additions=None, environment=None):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the environment is passed in, why would continue to accept default_globals and default_additions as separate arguments?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for backwards compatibility of course.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe this should log some sort of deprecation warning, so we can move people away from this in the future?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call, will do.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added the warning and updated tests that used default_globals/scripting_additions.

if default_globals is not None or scripting_additions is not None:
warnings.warn(f'default_globals and scripting_additions are deprecated. '
f'Please provide an environment such as TaskDataEnvrionment',
DeprecationWarning, stacklevel=2)
if environment is None:
environment_globals = {}
environment_globals.update(default_globals or {})
environment_globals.update(scripting_additions or {})
self.environment = TaskDataEnvironment(environment_globals)
else:
self.environment = environment
self.error_tasks = {}

def validate(self, expression):
Expand Down Expand Up @@ -175,53 +125,16 @@ def check_for_overwrite(self, task, external_methods):
same name as a pre-defined script, rending the script un-callable.
This results in a nearly indecipherable error. Better to fail
fast with a sensible error message."""
func_overwrites = set(self.globals).intersection(task.data)
func_overwrites = set(self.environment.globals).intersection(task.data)
func_overwrites.update(set(external_methods).intersection(task.data))
if len(func_overwrites) > 0:
msg = f"You have task data that overwrites a predefined " \
f"function(s). Please change the following variable or " \
f"field name(s) to something else: {func_overwrites}"
raise WorkflowTaskException(msg, task=task)

def convert_to_box(self, data):
if isinstance(data, dict):
for key, value in data.items():
if not isinstance(value, Box):
data[key] = self.convert_to_box(value)
return Box(data)
if isinstance(data, list):
for idx, value in enumerate(data):
data[idx] = self.convert_to_box(value)
return data
return data

def _evaluate(self, expression, context, external_methods=None):

globals = copy.copy(self.globals) # else we pollute all later evals.
self.convert_to_box(context)
globals.update(external_methods or {})
globals.update(context)
return eval(expression, globals)
return self.environment.evaluate(expression, context, external_methods)

def _execute(self, script, context, external_methods=None):

my_globals = copy.copy(self.globals)
self.convert_to_box(context)
my_globals.update(external_methods or {})
context.update(my_globals)
try:
exec(script, context)
finally:
self.remove_globals_and_functions_from_context(context,
external_methods)

def remove_globals_and_functions_from_context(self, context,
external_methods=None):
"""When executing a script, don't leave the globals, functions
and external methods in the context that we have modified."""
for k in list(context):
if k == "__builtins__" or \
hasattr(context[k], '__call__') or \
k in self.globals or \
external_methods and k in external_methods:
context.pop(k)
self.environment.execute(script, context, external_methods)
122 changes: 122 additions & 0 deletions SpiffWorkflow/bpmn/PythonScriptEngineEnvironment.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import copy
import warnings

class BasePythonScriptEngineEnvironment:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the interface I always wanted for SpiffWorkflows Python execution. Really beautiful.

def __init__(self, environment_globals=None):
self.globals = environment_globals or {}

def evaluate(self, expression, context, external_methods=None):
raise NotImplementedError("Subclass must implement this method")

def execute(self, script, context, external_methods=None):
raise NotImplementedError("Subclass must implement this method")

class TaskDataEnvironment(BasePythonScriptEngineEnvironment):
def evaluate(self, expression, context, external_methods=None):
my_globals = copy.copy(self.globals) # else we pollute all later evals.
self._prepare_context(context)
my_globals.update(external_methods or {})
my_globals.update(context)
return eval(expression, my_globals)

def execute(self, script, context, external_methods=None):
my_globals = copy.copy(self.globals)
self._prepare_context(context)
my_globals.update(external_methods or {})
context.update(my_globals)
try:
exec(script, context)
finally:
self._remove_globals_and_functions_from_context(context, external_methods)

def _prepare_context(self, context):
pass

def _remove_globals_and_functions_from_context(self, context,
external_methods=None):
"""When executing a script, don't leave the globals, functions
and external methods in the context that we have modified."""
for k in list(context):
if k == "__builtins__" or \
hasattr(context[k], '__call__') or \
k in self.globals or \
external_methods and k in external_methods:
context.pop(k)

class Box(dict):
"""
Example:
m = Box({'first_name': 'Eduardo'}, last_name='Pool', age=24, sports=['Soccer'])
"""

def __init__(self, *args, **kwargs):
warnings.warn('The usage of Box has been deprecated.', DeprecationWarning, stacklevel=2)
super(Box, self).__init__(*args, **kwargs)
for arg in args:
if isinstance(arg, dict):
for k, v in arg.items():
if isinstance(v, dict):
self[k] = Box(v)
else:
self[k] = v

if kwargs:
for k, v in kwargs.items():
if isinstance(v, dict):
self[k] = Box(v)
else:
self[k] = v

def __deepcopy__(self, memodict=None):
if memodict is None:
memodict = {}
my_copy = Box()
for k, v in self.items():
my_copy[k] = copy.deepcopy(v)
return my_copy

def __getattr__(self, attr):
try:
output = self[attr]
except:
raise AttributeError(
"Dictionary has no attribute '%s' " % str(attr))
return output

def __setattr__(self, key, value):
self.__setitem__(key, value)

def __setitem__(self, key, value):
super(Box, self).__setitem__(key, value)
self.__dict__.update({key: value})

def __getstate__(self):
return self.__dict__

def __setstate__(self, state):
self.__init__(state)

def __delattr__(self, item):
self.__delitem__(item)

def __delitem__(self, key):
super(Box, self).__delitem__(key)
del self.__dict__[key]

@classmethod
def convert_to_box(cls, data):
if isinstance(data, dict):
for key, value in data.items():
if not isinstance(value, Box):
data[key] = cls.convert_to_box(value)
return Box(data)
if isinstance(data, list):
for idx, value in enumerate(data):
data[idx] = cls.convert_to_box(value)
return data
return data

class BoxedTaskDataEnvironment(TaskDataEnvironment):
def _prepare_context(self, context):
Box.convert_to_box(context)

2 changes: 1 addition & 1 deletion tests/SpiffWorkflow/bpmn/BoxDeepCopyTest.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import unittest

from SpiffWorkflow.bpmn.PythonScriptEngine import Box
from SpiffWorkflow.bpmn.PythonScriptEngineEnvironment import Box


class BoxDeepCopyTest(unittest.TestCase):
Expand Down
5 changes: 3 additions & 2 deletions tests/SpiffWorkflow/bpmn/CustomScriptTest.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from SpiffWorkflow.exceptions import WorkflowTaskException
from SpiffWorkflow.task import TaskState
from SpiffWorkflow.bpmn.PythonScriptEngine import PythonScriptEngine
from SpiffWorkflow.bpmn.PythonScriptEngineEnvironment import TaskDataEnvironment
from SpiffWorkflow.bpmn.workflow import BpmnWorkflow
from tests.SpiffWorkflow.bpmn.BpmnWorkflowTestCase import BpmnWorkflowTestCase

Expand All @@ -17,8 +18,8 @@ class CustomBpmnScriptEngine(PythonScriptEngine):
It will execute python code read in from the bpmn. It will also make any scripts in the
scripts directory available for execution. """
def __init__(self):
augment_methods = {'custom_function': my_custom_function}
super().__init__(scripting_additions=augment_methods)
environment = TaskDataEnvironment({'custom_function': my_custom_function})
super().__init__(environment=environment)


class CustomInlineScriptTest(BpmnWorkflowTestCase):
Expand Down
5 changes: 3 additions & 2 deletions tests/SpiffWorkflow/bpmn/FeelExpressionEngineTest.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import unittest

from SpiffWorkflow.bpmn.FeelLikeScriptEngine import FeelLikeScriptEngine, FeelInterval
from SpiffWorkflow.bpmn.PythonScriptEngineEnvironment import BoxedTaskDataEnvironment
from tests.SpiffWorkflow.bpmn.BpmnWorkflowTestCase import BpmnWorkflowTestCase
import datetime

Expand All @@ -12,7 +13,7 @@
class FeelExpressionTest(BpmnWorkflowTestCase):

def setUp(self):
self.expressionEngine = FeelLikeScriptEngine()
self.expressionEngine = FeelLikeScriptEngine(environment=BoxedTaskDataEnvironment())

def testRunThroughExpressions(self):
tests = [("string length('abcd')", 4, {}),
Expand Down Expand Up @@ -62,7 +63,7 @@ def testRunThroughDMNExpression(self):
]
}
x = self.expressionEngine._evaluate(
"""sum([1 for x in exclusive if x.get('ExclusiveSpaceAMComputingID',None)==None])""",
"""sum([1 for x in exclusive if x.get('ExclusiveSpaceAMComputingID',None)==None])""",
data
)
self.assertEqual(x, 1)
Expand Down
Loading