Script Engine Overview ====================== You may need to modify the default script engine, whether because you need to make additional functionality available to it, or because you might want to restrict its capabilities for security reasons. .. warning:: By default, the scripting environment passes input directly to :code:`eval` and :code:`exec`! In most cases, you'll want to replace the default scripting environment with one of your own. Restricting the Script Environment ================================== The following example replaces the default global enviroment with the one provided by `RestrictedPython `_ We've modified our engine configuration to use the restricted environment in :app:`misc/restricted.py` .. code:: python from RestrictedPython import safe_globals from SpiffWorkflow.bpmn.script_engine import TaskDataEnvironment script_env = TaskDataEnvironment(safe_globals) We've also included a dangerous process in :bpmn:`dangerous.bpmn` If you run this process using the regular script enviromment, the BPMN process will get the OS process ID and prompt you to kill it; if you answer 'Y', it will do so (it won't actually do anything more dangerous than screw up your terminal settings, but hopefully proves the point). .. code-block:: console ./runner.py -e spiff_example.spiff.file add -p end_it_all -b bpmn/tutorial/dangerous.bpmn ./runner.py -e spiff_example.spiff.file If you load the restricted engine: .. code-block:: console ./runner.py -e spiff_example.spiff.restricted You'll get an error, because imports have been restricted. .. note:: Since we used exactly the same parser and serializer, we can simply switch back and forth between these two script engines (that is the only difference between the two configurations). If you've made any serializer or parser customizations, this is not likely to be possible. .. _custom_classes_and_functions: Making Custom Classes and Functions Available ============================================= Another reason you might want to customize the scripting environment is to provide access to custom classes or functions. In many of our example models, we use DMN tables to obtain product information. DMN is a convenient way of making table data available to our processes. However, in a slightly more realistic scenario, we would surely have information about how the product could be customized in a database somewhere. We would not hard code product information in our diagram (although it is much easier to modify the BPMN and DMN models than to change the code itself!). Our shipping costs would not be static, but would depend on the size of the order and where it was being shipped -- maybe we'd query an API provided by our shipper. SpiffWorkflow is obviously **not** going to know how to query **your** database or make API calls to **your** vendors. However, one way of making this functionality available inside your diagram is to implement the calls in functions and add those functions to the scripting environment, where they can be called by Script Tasks. We are not going to actually include a database or API and write code for connecting to and querying it, but since we only have 7 products we can model our database with a simple dictionary lookup and just return the same static info for shipping for the purposes of the tutorial. We'll create these "services" along with serialization methods in :app:`spiff/product_info.py` (see :ref:`serializing_custom_objects` for more information about serialization): .. code:: python from collections import namedtuple ProductInfo = namedtuple('ProductInfo', ['color', 'size', 'style', 'price']) INVENTORY = { 'product_a': ProductInfo(False, False, False, 15.00), 'product_b': ProductInfo(False, False, False, 15.00), 'product_c': ProductInfo(True, False, False, 25.00), 'product_d': ProductInfo(True, True, False, 20.00), 'product_e': ProductInfo(True, True, True, 25.00), 'product_f': ProductInfo(True, True, True, 30.00), 'product_g': ProductInfo(False, False, True, 25.00), } def lookup_product_info(product_name): return INVENTORY[product_name] def lookup_shipping_cost(shipping_method): return 25.00 if shipping_method == 'Overnight' else 5.00 We'll then make the "services" available to our scripting environment. .. code:: python script_env = TaskDataEnvironment({ 'datetime': datetime, 'lookup_product_info': lookup_product_info, 'lookup_shipping_cost': lookup_shipping_cost, }) .. note:: We're also adding :code:`datetime`, because other parts of the process require it. We can use the custom functions in script tasks like any normal function. To load the example diagrams that use the custom script engine: .. code-block:: console ./runner.py -e spiff_example.spiff.custom_object add -p order_product \ -b bpmn/tutorial/{top_level_script,call_activity_script}.bpmn If you start the application in interactive mode and choose a product, you'll see tuple info reflected in the task data after selecting a product. Service Tasks ============= We can also use Service Tasks to accomplish the same goal. Service Tasks are also executed by the workflow's script engine, but through a different method, with the help of some custom extensions in the :code:`spiff` module: - `operation_name`, the name assigned to the service being called - `operation_params`, the parameters the operation requires The advantage of a Service Task is that it is a bit more transparent what is happening (at least at a conceptual level) than function calls embedded in a Script Task. We customize a scripting environment to implement the :code:`call_service` method in :app:`spiff/service_task.py`: .. code:: python class ServiceTaskEnvironment(TaskDataEnvironment): def __init__(self): super().__init__(({ 'datetime': datetime, 'product_info_from_dict': product_info_from_dict, }) def call_service(self, task_data, operation_name, operation_params): if operation_name == 'lookup_product_info': product_info = lookup_product_info(operation_params['product_name']['value']) result = product_info_to_dict(product_info) elif operation_name == 'lookup_shipping_cost': result = lookup_shipping_cost(operation_params['shipping_method']['value']) else: raise Exception("Unknown Service!") return json.dumps(result) script_env = ServiceTaskEnvironment() Instead of adding our custom functions to the environment, we'll override :code:`call_service` and call them directly according to the `operation_name` that was given. The :code:`spiff` Service Task also evaluates the parameters against the task data for us, so we can pass those in directly. The Service Task will also store our result in a user-specified variable. We need to send the result back as json, so we'll reuse the functions we wrote for the serializer (see :ref:`serializing_custom_objects`). The Service Task will assign the dictionary as the operation result, so we'll add a `postScript` to the Service Task that retrieves the product information that creates a :code:`ProductInfo` instance from the dictionary, so we need to add that to the scripting enviroment too. The XML for the Service Task looks like this: .. code:: xml product_info = product_info_from_dict(product_info) Flow_104dmrv Flow_06k811b Getting this information into the XML is a little bit beyond the scope of this tutorial, as it involves more than just SpiffWorkflow. I hand edited it for this case, but you can hardly ask your BPMN authors to do that! Our `modeler `_ has a means of providing a list of services and their parameters that can be displayed to a BPMN author in the Service Task configuration panel. There is an example of hard-coding a list of services in `app.js `_ and as suggested, it would be reasonably straightforward to replace this with a API call. `SpiffArena `_ has robust mechanisms for handling this that might serve as a model for you. How this all works is obviously heavily dependent on your application, so we won't go into further detail here, except to give you a bare bones starting point for implementing something yourself that meets your own needs. To add this workflow: .. code-block:: console ./runner.py -e spiff_example.spiff.service_task add -p order_product \ -b bpmn/tutorial/{top_level_service_task,call_activity_service_task}.bpmn Generating BPMN Events Inside the Scripting Environment ======================================================= When calling external services, there is course a possibility that a failure could occur, and you might want to be able to pass that information back into the workflow and define how to handle it there. In this example, we'll have a service that displays the contents of a file and handles :code:`FileNotFoundError`. We'll use the diagram :bpmn:`event_handler.bpmn` and the code in :app:`misc/event_handler.py`. As in the previous section, we'll use the :code:`ServiceTask` from the :code:`spiff` package, but we'll need to extend it. This is where we'll handle errors. We define the following error in our XML (we can do this in our `modeler `_): .. code:: xml filename In our scripting enviroment, we'll implement a "read_file" service. This will of course raise an exception if the requested file is missing, but will otherwise return the contents. .. code:: python class ServiceTaskEnvironment(TaskDataEnvironment): def call_service(self, context, operation_name, operation_params): if operation_name == 'read_file': return open(operation_params['filename']).read() else: raise ValueError('Unknown Service') And here is the code for our task spec. .. code:: python class EventHandlingServiceTask(ServiceTask): def _execute(self, my_task): script_engine = my_task.workflow.script_engine # The param also has a type, but I don't need it params = dict((name, script_engine.evaluate(my_task, p['value'])) for name, p in self.operation_params.items()) try: result = script_engine.call_service( task.data, operation_name=self.operation_name, operation_params=params) my_task.data[self.result_variable] = result return True except FileNotFoundError as exc: event_definition = ErrorEventDefinition('file_not_found', code='1') event = BpmnEvent(event_definition, payload=params['filename']) my_task.workflow.top_workflow.catch(event) return False except Exception as exc: raise WorkflowTaskException('Service Task execution error', task=my_task, exception=exc) If the file was read successfully, we'll set a variable in our task data with the result (the name of the result variable is optionally specified in the XML and the :code:`_result_variable` method returns either the specified name or a calculated name otherwise). We return :code:`True` because the operation was a success (see :doc:`../concepts` for more information about state transitions). We'll catch :code:`FileNotFoundError` and construct an event to send it back to the workflow. What we generate needs to match what's in the XML. .. note:: If you are building an application, you'll probably need to manage known exceptions in a way that is accesible to both your modeler and your execution engine, but here we'll just show how to build the event so that it can be caught in the diagram in the task spec. We have to construct an :code:`EventDefinition` that matches what will be generated from the parsed XML (see :ref:`events` for a general overview of BPMN event handling). SpiffWorkflow uses the :code:`EventDefinition` to determine whether a particular task handles an event. The BPMN spec allows certain events, including Error Events, to optionally contain a payload. In this case, we'll set the payload to be the name of the missing file, which can then be displayed to the user. We pass our contructed event to the workflow's :code:`catch` method, which will check to see if there are any tasks waiting for this event. Each task has a reference to its workflow, but this task occurs in a subworkflow. Event handling is done at the outermost level so we'll use :code:`my_task.workflow.top_workflow` to get access to the top level. We'll return :code:`False`, since the operation was not a success; this will prevent task execution on that branch, but will not halt overall workflow execution. An unhandled exception, as in the last case, will cause the entire workflow to halt. .. note:: The task spec is not the only place error handling could be implemented. I kind of like this approach, as the task spec defines the behavior for a particular type of task and this is part of that. It would also be possible to extend the :code:`PythonScriptEngine` to handle the errors. The main reason I didn't do that here is that this example application can be made less complex if only a scripting environment is supplied. The script engine, unlike the script enviroment, has access to the task and workflow (via the task), and the same thing could be done there as well. To load this example: .. code:: console ./runner.py -e spiff_example.misc.event_handler add -p read_file -b bpmn/tutorial/event_handler.bpmn ./runner.py -e spiff_example.misc.event_handler .. note:: When running this example, it will probably useful to change the task filter so that all tasks are visible. Set the state to `ANY_MASK` to see all tasks. Threaded Service Task ===================== Suppose that we have some potentially time-consuming tasks and we want to execute them in threads so that we aren't blocking the entire workflow from executing while it runs (the default behavior). In this section, we'll customize a scripting enviroment that contains a thread pool. First let's write a simple "service" that simply waits. .. code:: python def wait(seconds, job_id): time.sleep(seconds) return f'{job_id} slept {seconds} seconds' We'll make this "service" available in our environment: .. code:: python class ServiceTaskEnvironment(TaskDataEnvironment): def __init__(self): super().__init__() self.pool = ThreadPoolExecutor(max_workers=10) self.futures = {} def call_service(self, context, operation_name, operation_params): if operation_name == 'wait': seconds = randrange(1, 30) return self.pool.submit(wait, seconds, operation_params['job_id']) else: raise ValueError("Unknown Service!") Our service will return a future, and we'll manage these futures via a custom task spec. The parent class is the Service Task of the :code:`spiff` package, which provides us with an :code:`operation_name` and :code:`operation_parameters`. Each parameter has a name and a type, but I don't need the type, so I'll just get the values. The values are expressions that we evaluate against the task data. We'll map the future to the task in the script environment. .. code:: python class ThreadedServiceTask(ServiceTask): def _execute(self, my_task): script_engine = my_task.workflow.script_engine params = dict((name, script_engine.evaluate(my_task, p['value'])) for name, p in self.operation_params.items()) try: future = script_engine.call_service( my_task.data, operation_name=self.operation_name, operation_params=params ) script_engine.environment.futures[future] = my_task except Exception as exc: raise WorkflowTaskException('Service Task execution error', task=my_task, exception=exc) Since our :code:`_execute` method returns :code:`None`, our task will transition to a :code:`STARTED` state (see :doc:`../concepts` for more information about state transitions). SpiffWorkflow will ignore this task from this point on; this means our engine has to take over. We'll extend the :code:`Instance` class (defined in :app:`engine/instance.py`) to also check these futures when waiting tasks are refreshed. As jobs complete, we'll call :code:`task.complete` to mark the task :code:`COMPLETED`. The workflow will then be able to continue down that branch. .. code:: python class ThreadInstance(Instance): def update_completed_futures(self): futures = self.workflow.script_engine.environment.futures finished = [f for f in futures if f.done()] for future in finished: task = futures.pop(future) result = future.result() task.data[task.task_spec.result_variable] = result task.complete() def run_ready_events(self): self.update_completed_futures() super().run_ready_events() .. note:: In a real application, you would probably want a separate service keeping track of the jobs and checking the futures rather than polling in the engine, but that can't be easily set up in this example application. To load and run thie example (as in the previous example, it is probably a good idea to update the task filter to show all tasks with the `ANY_MASK` state. .. code:: console ./runner.py -e spiff_example.misc.threaded_service_task add -p threaded_service -b bpmn/tutorial/threaded_service_task.bpmn ./runner.py -e spiff_example.misc.threaded_service_task Executing Scripts in a Subprocess ================================= In this section, we'll show how you might execute your scripts outside of the workflow execution context. This ia a little contrived and there are undoubtedly better ways to accomplish it, but this has the advantage of being very simple. First we'll create an executable that can take a JSON-serialized context and an expression to evaluate or a script to execute (see :app:`spiff/subprocess_engine.py`). This little program simply replicates the behavior of the default script engine. We import our custom function here rather than our workflow's engine. We'll also import the registry used by our serializer; we need to be able to generate JSON when we write our output, so we might as well reuse what we have. .. code:: python from .custom_exec import ( lookup_product_info, lookup_shipping_cost, registry, ) This emulates how the default script engine handles evaluation and execution. .. code:: python local_ctx = registry.restore(json.loads(args.context)) global_ctx = globals() global_ctx.update(local_ctx) if args.external is not None: global_ctx.update(registry.restore(json.loads(args.external))) if args.method == 'eval': result = eval(args.expr, global_ctx, local_ctx) elif args.method == 'exec': exec(args.script, global_ctx, local_ctx) result = local_ctx print(json.dumps(registry.convert(result))) Then we'll tell our scripting enviroment to use the script rather directly invoke :code:`eval` and :code:`exec`. .. code:: python class SubprocessScriptingEnvironment(BasePythonScriptEngineEnvironment): def __init__(self, executable, serializer, **kwargs): super().__init__(**kwargs) self.executable = executable self.serializer = serializer def evaluate(self, expression, context, external_context=None): output = self.run(['eval', expression], context, external_context) return self.parse_output(output) def execute(self, script, context, external_context=None): output = self.run(['exec', script], context, external_context) DeepMerge.merge(context, self.parse_output(output)) return True def run(self, args, context, external_context): cmd = ['python', '-m', self.executable] + args + ['-c', json.dumps(registry.convert(context))] if external_context is not None: cmd.extend(['-x', json.dumps(registry.convert(external_context))]) return subprocess.run(cmd, capture_output=True) def parse_output(self, output): if output.stderr: raise Exception(output.stderr.decode('utf-8')) return registry.restore(json.loads(output.stdout)) executable = 'spiff_example.spiff.subprocess_engine' script_env = SubprocessScriptingEnvironment(executable, serializer) To load this example: .. code:: console ./runner.py -e spiff_example.spiff.custom_exec add -p order_product \ -b bpmn/tutorial/{top_level_script,call_activity_script}.bpmn ./runner.py -e spiff_example.spiff.custom_exec