├── .gitignore ├── .tool-versions ├── COPYING ├── README.rst ├── bpmn ├── camunda │ ├── call_activity.bpmn │ ├── call_activity_multi.bpmn │ ├── call_activity_script.bpmn │ ├── events.bpmn │ ├── multiinstance.bpmn │ ├── product_prices.dmn │ ├── shipping_costs.dmn │ ├── task_types.bpmn │ └── top_level_script.bpmn └── tutorial │ ├── call_activity.bpmn │ ├── call_activity_multi.bpmn │ ├── call_activity_script.bpmn │ ├── call_activity_service_task.bpmn │ ├── dangerous.bpmn │ ├── data_output.bpmn │ ├── event_handler.bpmn │ ├── events.bpmn │ ├── forms │ ├── ask_for_feedback.json │ ├── charge_customer.json │ ├── continue_shopping.json │ ├── dangerous.json │ ├── enter_payment_info.json │ ├── enter_shipping_address.json │ ├── get_filename.json │ ├── investigate_delay.json │ ├── resolve_failed_charge.json │ ├── retrieve_product.json │ ├── review_order.json │ ├── select_product_and_quantity.json │ ├── select_product_color.json │ ├── select_product_size.json │ ├── select_product_style.json │ ├── select_shipping_method.json │ └── ship_product.json │ ├── gateway_types.bpmn │ ├── lanes.bpmn │ ├── product_prices.dmn │ ├── shipping_costs.dmn │ ├── signal_event.bpmn │ ├── task_types.bpmn │ ├── threaded_service_task.bpmn │ ├── timer_start.bpmn │ ├── top_level.bpmn │ ├── top_level_multi.bpmn │ ├── top_level_script.bpmn │ ├── top_level_service_task.bpmn │ └── transaction.bpmn ├── requirements.txt ├── runner.py └── spiff_example ├── camunda ├── __init__.py ├── curses_handlers.py └── default.py ├── cli ├── __init__.py ├── diff_result.py └── subcommands.py ├── curses_ui ├── __init__.py ├── content.py ├── human_task_handler.py ├── list_view.py ├── log_view.py ├── menu.py ├── spec_view.py ├── task_filter_view.py ├── ui.py ├── user_input.py └── workflow_view.py ├── engine ├── __init__.py ├── engine.py └── instance.py ├── misc ├── __init__.py ├── curses_handlers.py ├── custom_start_event.py ├── event_handler.py ├── restricted.py └── threaded_service_task.py ├── serializer ├── __init__.py ├── file │ ├── __init__.py │ └── serializer.py └── sqlite │ ├── __init__.py │ ├── schema-sqlite.sql │ └── serializer.py └── spiff ├── __init__.py ├── curses_handlers.py ├── custom_exec.py ├── custom_object.py ├── diffs.py ├── file.py ├── product_info.py ├── service_task.py ├── sqlite.py └── subprocess_engine.py /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .vscode 3 | venv 4 | .venv 5 | docs/build 6 | docs/_build 7 | __pycache__ 8 | *.log 9 | *.db 10 | wfdata 11 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | python 3.10.4 2 | -------------------------------------------------------------------------------- /COPYING: -------------------------------------------------------------------------------- 1 | GNU LESSER GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | 9 | This version of the GNU Lesser General Public License incorporates 10 | the terms and conditions of version 3 of the GNU General Public 11 | License, supplemented by the additional permissions listed below. 12 | 13 | 0. Additional Definitions. 14 | 15 | As used herein, "this License" refers to version 3 of the GNU Lesser 16 | General Public License, and the "GNU GPL" refers to version 3 of the GNU 17 | General Public License. 18 | 19 | "The Library" refers to a covered work governed by this License, 20 | other than an Application or a Combined Work as defined below. 21 | 22 | An "Application" is any work that makes use of an interface provided 23 | by the Library, but which is not otherwise based on the Library. 24 | Defining a subclass of a class defined by the Library is deemed a mode 25 | of using an interface provided by the Library. 26 | 27 | A "Combined Work" is a work produced by combining or linking an 28 | Application with the Library. The particular version of the Library 29 | with which the Combined Work was made is also called the "Linked 30 | Version". 31 | 32 | The "Minimal Corresponding Source" for a Combined Work means the 33 | Corresponding Source for the Combined Work, excluding any source code 34 | for portions of the Combined Work that, considered in isolation, are 35 | based on the Application, and not on the Linked Version. 36 | 37 | The "Corresponding Application Code" for a Combined Work means the 38 | object code and/or source code for the Application, including any data 39 | and utility programs needed for reproducing the Combined Work from the 40 | Application, but excluding the System Libraries of the Combined Work. 41 | 42 | 1. Exception to Section 3 of the GNU GPL. 43 | 44 | You may convey a covered work under sections 3 and 4 of this License 45 | without being bound by section 3 of the GNU GPL. 46 | 47 | 2. Conveying Modified Versions. 48 | 49 | If you modify a copy of the Library, and, in your modifications, a 50 | facility refers to a function or data to be supplied by an Application 51 | that uses the facility (other than as an argument passed when the 52 | facility is invoked), then you may convey a copy of the modified 53 | version: 54 | 55 | a) under this License, provided that you make a good faith effort to 56 | ensure that, in the event an Application does not supply the 57 | function or data, the facility still operates, and performs 58 | whatever part of its purpose remains meaningful, or 59 | 60 | b) under the GNU GPL, with none of the additional permissions of 61 | this License applicable to that copy. 62 | 63 | 3. Object Code Incorporating Material from Library Header Files. 64 | 65 | The object code form of an Application may incorporate material from 66 | a header file that is part of the Library. You may convey such object 67 | code under terms of your choice, provided that, if the incorporated 68 | material is not limited to numerical parameters, data structure 69 | layouts and accessors, or small macros, inline functions and templates 70 | (ten or fewer lines in length), you do both of the following: 71 | 72 | a) Give prominent notice with each copy of the object code that the 73 | Library is used in it and that the Library and its use are 74 | covered by this License. 75 | 76 | b) Accompany the object code with a copy of the GNU GPL and this license 77 | document. 78 | 79 | 4. Combined Works. 80 | 81 | You may convey a Combined Work under terms of your choice that, 82 | taken together, effectively do not restrict modification of the 83 | portions of the Library contained in the Combined Work and reverse 84 | engineering for debugging such modifications, if you also do each of 85 | the following: 86 | 87 | a) Give prominent notice with each copy of the Combined Work that 88 | the Library is used in it and that the Library and its use are 89 | covered by this License. 90 | 91 | b) Accompany the Combined Work with a copy of the GNU GPL and this license 92 | document. 93 | 94 | c) For a Combined Work that displays copyright notices during 95 | execution, include the copyright notice for the Library among 96 | these notices, as well as a reference directing the user to the 97 | copies of the GNU GPL and this license document. 98 | 99 | d) Do one of the following: 100 | 101 | 0) Convey the Minimal Corresponding Source under the terms of this 102 | License, and the Corresponding Application Code in a form 103 | suitable for, and under terms that permit, the user to 104 | recombine or relink the Application with a modified version of 105 | the Linked Version to produce a modified Combined Work, in the 106 | manner specified by section 6 of the GNU GPL for conveying 107 | Corresponding Source. 108 | 109 | 1) Use a suitable shared library mechanism for linking with the 110 | Library. A suitable mechanism is one that (a) uses at run time 111 | a copy of the Library already present on the user's computer 112 | system, and (b) will operate properly with a modified version 113 | of the Library that is interface-compatible with the Linked 114 | Version. 115 | 116 | e) Provide Installation Information, but only if you would otherwise 117 | be required to provide such information under section 6 of the 118 | GNU GPL, and only to the extent that such information is 119 | necessary to install and execute a modified version of the 120 | Combined Work produced by recombining or relinking the 121 | Application with a modified version of the Linked Version. (If 122 | you use option 4d0, the Installation Information must accompany 123 | the Minimal Corresponding Source and Corresponding Application 124 | Code. If you use option 4d1, you must provide the Installation 125 | Information in the manner specified by section 6 of the GNU GPL 126 | for conveying Corresponding Source.) 127 | 128 | 5. Combined Libraries. 129 | 130 | You may place library facilities that are a work based on the 131 | Library side by side in a single library together with other library 132 | facilities that are not Applications and are not covered by this 133 | License, and convey such a combined library under terms of your 134 | choice, if you do both of the following: 135 | 136 | a) Accompany the combined library with a copy of the same work based 137 | on the Library, uncombined with any other library facilities, 138 | conveyed under the terms of this License. 139 | 140 | b) Give prominent notice with the combined library that part of it 141 | is a work based on the Library, and explaining where to find the 142 | accompanying uncombined form of the same work. 143 | 144 | 6. Revised Versions of the GNU Lesser General Public License. 145 | 146 | The Free Software Foundation may publish revised and/or new versions 147 | of the GNU Lesser General Public License from time to time. Such new 148 | versions will be similar in spirit to the present version, but may 149 | differ in detail to address new problems or concerns. 150 | 151 | Each version is given a distinguishing version number. If the 152 | Library as you received it specifies that a certain numbered version 153 | of the GNU Lesser General Public License "or any later version" 154 | applies to it, you have the option of following the terms and 155 | conditions either of that published version or of any later version 156 | published by the Free Software Foundation. If the Library as you 157 | received it does not specify a version number of the GNU Lesser 158 | General Public License, you may choose any version of the GNU Lesser 159 | General Public License ever published by the Free Software Foundation. 160 | 161 | If the Library as you received it specifies that a proxy can decide 162 | whether future versions of the GNU Lesser General Public License shall 163 | apply, that proxy's public statement of acceptance of any version is 164 | permanent authorization for you to choose that version for the 165 | Library. 166 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | spiff-example-cli 2 | ============== 3 | 4 | .. sidebar:: Note 5 | 6 | As of writing, this documentation has not been tried on Windows 7 | 8 | This is the documentation and example repository for the SpiffWorkflow BPMN workflow engine. 9 | Below is a brief outline on how to get started using this documentation - which in itself is designed as a tool for 10 | getting started with Spiffworkflow. 11 | 12 | Clone this repository 13 | ------------------ 14 | 15 | .. code:: bash 16 | 17 | git clone https://github.com/sartography/spiff-example-cli.git 18 | 19 | Set up virtual environment 20 | -------------------------- 21 | 22 | .. code:: bash 23 | 24 | cd spiff-example-cli 25 | python3 -m venv venv 26 | source ./venv/bin/activate 27 | 28 | Install Requirements 29 | -------------------- 30 | 31 | .. code:: bash 32 | 33 | pip3 install -r requirements.txt 34 | 35 | 36 | Using the Application 37 | --------------------- 38 | 39 | This application is intended to accompany the documentation for `SpiffWorkflow 40 | `_. Further discussion of 41 | the models and application can be found there. 42 | 43 | Models 44 | ^^^^^^ 45 | 46 | Example BPMN and DMN files can be found in the `bpmn` directory of this repository. 47 | There are several versions of a product ordering process of variying complexity located in the 48 | `bpmn/tutorial` directory of the repo which contain most of the elements that SpiffWorkflow supports. These 49 | diagrams can be viewed in any BPMN editor, but many of them have custom extensions created with 50 | `bpmn-js-spiffworflow `_. 51 | 52 | Loading Workflows 53 | ^^^^^^^^^^^^^^^^^ 54 | 55 | To add a workflow via the command line and store serialized specs in JSON files: 56 | 57 | .. code-block:: console 58 | 59 | ./runner.py -e spiff_example.spiff.file add \ 60 | -p order_product \ 61 | -b bpmn/tutorial/{top_level,call_activity}.bpmn \ 62 | -d bpmn/tutorial/{product_prices,shipping_costs}.dmn 63 | 64 | Running Workflows 65 | ^^^^^^^^^^^^^^^^^ 66 | 67 | To run the curses application using serialized JSON files: 68 | 69 | .. code-block:: console 70 | 71 | ./runner.py -e spiff_example.spiff.file 72 | 73 | Select the 'Start Workflow' screen and start the process. 74 | 75 | ## License 76 | GNU LESSER GENERAL PUBLIC LICENSE 77 | -------------------------------------------------------------------------------- /bpmn/camunda/call_activity_script.bpmn: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Flow_04tpb3e 6 | Flow_0ikn93z 7 | Flow_1h8w6f7 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | Flow_06gb1zr 25 | Flow_104dmrv 26 | 27 | 28 | Flow_06gb1zr 29 | 30 | 31 | product_info.color == True 32 | 33 | 34 | 35 | 36 | 37 | Flow_1h8w6f7 38 | Flow_16qjxga 39 | Flow_0b4pvj2 40 | Flow_0apn5fw 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | Flow_0ikn93z 55 | Flow_16qjxga 56 | 57 | 58 | 59 | Flow_0b4pvj2 60 | Flow_1y8t5or 61 | Flow_043j5w0 62 | Flow_1pvko5s 63 | 64 | 65 | product_info.size == True 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | Flow_0apn5fw 79 | Flow_1y8t5or 80 | 81 | 82 | Flow_171nf3j 83 | Flow_1pvko5s 84 | 85 | 86 | product_info.style == True 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | Flow_043j5w0 98 | Flow_171nf3j 99 | 100 | 101 | 102 | Flow_104dmrv 103 | Flow_04tpb3e 104 | product_info = lookup_product_info(product_name) 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | -------------------------------------------------------------------------------- /bpmn/camunda/product_prices.dmn: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | product_name 8 | 9 | 10 | 11 | 12 | Product A 13 | 14 | "product_a" 15 | 16 | 17 | 15.00 18 | 19 | 20 | 21 | Product B 22 | 23 | "product_b" 24 | 25 | 26 | 15.00 27 | 28 | 29 | 30 | Product C: color 31 | 32 | "product_c" 33 | 34 | 35 | 25.00 36 | 37 | 38 | 39 | Product D: color, size 40 | 41 | "product_d" 42 | 43 | 44 | 20.00 45 | 46 | 47 | 48 | Product E: color, size, style 49 | 50 | "product_e" 51 | 52 | 53 | 25.00 54 | 55 | 56 | 57 | Product F: color, size, style 58 | 59 | "product_f" 60 | 61 | 62 | 30.00 63 | 64 | 65 | 66 | Product G: style 67 | 68 | "product_g" 69 | 70 | 71 | 25.00 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | -------------------------------------------------------------------------------- /bpmn/camunda/shipping_costs.dmn: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | shipping_method 8 | 9 | 10 | 11 | 12 | Ground 13 | 14 | "standard" 15 | 16 | 17 | 5.00 18 | 19 | 20 | 21 | Express 22 | 23 | "overnight" 24 | 25 | 26 | 25.00 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /bpmn/camunda/task_types.bpmn: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Flow_19d1ca2 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | Flow_19d1ca2 20 | Flow_1m9aack 21 | 22 | 23 | 24 | Flow_1m9aack 25 | Flow_03cch6u 26 | 27 | 28 | 29 | Flow_03cch6u 30 | Flow_0rd1dlt 31 | order_total = product_quantity * product_price 32 | 33 | 34 | 35 | Order Summary 36 | {{ product_name }} 37 | Quantity: {{ product_quantity }} 38 | Order Total: {{ order_total }} 39 | Flow_0rd1dlt 40 | Flow_1aftqy6 41 | 42 | 43 | Flow_1aftqy6 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | -------------------------------------------------------------------------------- /bpmn/tutorial/call_activity.bpmn: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Flow_104dmrv 6 | Flow_0ikn93z 7 | Flow_1h8w6f7 8 | 9 | 10 | 11 | product_prices 12 | 13 | Flow_1r5bppm 14 | Flow_0uy2bcm 15 | Flow_1gj4orb 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | Flow_06gb1zr 24 | Flow_104dmrv 25 | 26 | 27 | Flow_06gb1zr 28 | 29 | 30 | product_name in [ 'product_c', 'product_d', 'product_e', 'product_f' ] 31 | 32 | 33 | 34 | 35 | 36 | Flow_1h8w6f7 37 | Flow_16qjxga 38 | Flow_0b4pvj2 39 | Flow_0apn5fw 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | Flow_0ikn93z 49 | Flow_16qjxga 50 | 51 | 52 | 53 | Flow_0b4pvj2 54 | Flow_1y8t5or 55 | Flow_1r5bppm 56 | Flow_043j5w0 57 | 58 | 59 | 60 | product_name in [ 'product_d', 'product_e', 'product_f' ] 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | Flow_0apn5fw 70 | Flow_1y8t5or 71 | 72 | 73 | Flow_1gj4orb 74 | 75 | 76 | 77 | product_name in [ 'product_e', 'product_f', 'product_g' ] 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | Flow_043j5w0 87 | Flow_0uy2bcm 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | -------------------------------------------------------------------------------- /bpmn/tutorial/call_activity_script.bpmn: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Flow_06k811b 6 | Flow_0ikn93z 7 | Flow_1h8w6f7 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | Flow_06gb1zr 16 | Flow_104dmrv 17 | 18 | 19 | Flow_06gb1zr 20 | 21 | 22 | product_info.color == True 23 | 24 | 25 | 26 | 27 | 28 | Flow_1h8w6f7 29 | Flow_16qjxga 30 | Flow_0b4pvj2 31 | Flow_0apn5fw 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | Flow_0ikn93z 41 | Flow_16qjxga 42 | 43 | 44 | 45 | Flow_0b4pvj2 46 | Flow_1y8t5or 47 | Flow_043j5w0 48 | Flow_076jkq7 49 | 50 | 51 | product_info.size == True 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | Flow_0apn5fw 61 | Flow_1y8t5or 62 | 63 | 64 | Flow_0ndmg19 65 | 66 | 67 | product_info.style == True 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | Flow_043j5w0 76 | Flow_0wedkbj 77 | 78 | 79 | Flow_076jkq7 80 | Flow_0wedkbj 81 | Flow_0ndmg19 82 | 83 | 84 | 85 | 86 | 87 | 88 | Flow_104dmrv 89 | Flow_06k811b 90 | product_info = lookup_product_info(product_name) 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | -------------------------------------------------------------------------------- /bpmn/tutorial/dangerous.bpmn: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Flow_0m91lb4 6 | 7 | 8 | 9 | Flow_0m91lb4 10 | Flow_14oh26j 11 | from os import getpid 12 | pid = getpid() 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | Process ID: {{ pid }} 21 | 22 | Flow_14oh26j 23 | Flow_030s9hw 24 | 25 | 26 | Flow_030s9hw 27 | Flow_1mp6zm4 28 | Flow_0jsq853 29 | 30 | 31 | 32 | Flow_1mp6zm4 33 | 34 | 35 | 36 | kill_it == 'Y' 37 | 38 | 39 | Flow_0uef7p4 40 | 41 | 42 | 43 | Flow_0jsq853 44 | Flow_0uef7p4 45 | from os import kill 46 | from signal import SIGKILL 47 | kill(pid, SIGKILL) 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | -------------------------------------------------------------------------------- /bpmn/tutorial/event_handler.bpmn: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Flow_12bbl0r 6 | 7 | 8 | 9 | Flow_12bbl0r 10 | Flow_0324p4o 11 | 12 | Flow_1thi1o4 13 | 14 | 15 | 16 | Flow_03a555m 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | Flow_1xaaugx 28 | Flow_08r1j82 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | Enter Filename 37 | 38 | Flow_1thi1o4 39 | Flow_1xaaugx 40 | 41 | 42 | 43 | 44 | {{filename}} 45 | ------------------------------ 46 | {{content}} 47 | 48 | Flow_08r1j82 49 | Flow_03a555m 50 | 51 | 52 | 53 | Flow_0324p4o 54 | 55 | 56 | 57 | Flow_04zo08z 58 | 59 | 60 | 61 | 62 | Flow_0ce59ik 63 | 64 | 65 | 66 | 67 | 68 | {{filename}} 69 | 70 | Flow_04zo08z 71 | Flow_0ce59ik 72 | 73 | 74 | 75 | 76 | filename 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | -------------------------------------------------------------------------------- /bpmn/tutorial/forms/ask_for_feedback.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Customer Feedback", 3 | "type": "object", 4 | "required": [ 5 | "reason_cancelled" 6 | ], 7 | "properties": { 8 | "reason_cancelled": { 9 | "title": "Why did you cancel your order?", 10 | "type": "string" 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /bpmn/tutorial/forms/charge_customer.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Charge Customer", 3 | "type": "object", 4 | "required": [ 5 | "customer_charged" 6 | ], 7 | "properties": { 8 | "customer_charged": { 9 | "title": "Was the customer charged?", 10 | "type": "string", 11 | "default": "Y" 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /bpmn/tutorial/forms/continue_shopping.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Continue Shopping", 3 | "type": "object", 4 | "required": [ 5 | "continue_shopping" 6 | ], 7 | "properties": { 8 | "continue_shopping": { 9 | "title": "Select another product?", 10 | "type": "string", 11 | "default": "N" 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /bpmn/tutorial/forms/dangerous.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Kill This Process?", 3 | "type": "object", 4 | "required": [ 5 | "kill_it" 6 | ], 7 | "properties": { 8 | "kill_it": { 9 | "title": "Kill it?", 10 | "type": "string" 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /bpmn/tutorial/forms/enter_payment_info.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Enter Payment Info", 3 | "type": "object", 4 | "required": [ 5 | "card_number" 6 | ], 7 | "properties": { 8 | "card_number": { 9 | "title": "Card Number", 10 | "type": "string" 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /bpmn/tutorial/forms/enter_shipping_address.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Enter Shipping Address", 3 | "type": "object", 4 | "required": [ 5 | "shipping_address" 6 | ], 7 | "properties": { 8 | "shipping_address": { 9 | "title": "Shipping Address", 10 | "type": "string" 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /bpmn/tutorial/forms/get_filename.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Open File?", 3 | "type": "object", 4 | "required": [ 5 | "filename" 6 | ], 7 | "properties": { 8 | "filename": { 9 | "title": "Filename", 10 | "type": "string" 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /bpmn/tutorial/forms/investigate_delay.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Investigate Delay", 3 | "type": "object", 4 | "required": [ 5 | "reason_delayed" 6 | ], 7 | "properties": { 8 | "reason_delayed": { 9 | "title": "Why was the order delayed?", 10 | "type": "string" 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /bpmn/tutorial/forms/resolve_failed_charge.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Charge Customer", 3 | "type": "object", 4 | "required": [ 5 | "customer_charged" 6 | ], 7 | "properties": { 8 | "customer_charged": { 9 | "title": "Was the customer charged?", 10 | "type": "string", 11 | "default": "Y" 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /bpmn/tutorial/forms/retrieve_product.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Retrieve Product", 3 | "type": "object", 4 | "required": [ 5 | "product_available" 6 | ], 7 | "properties": { 8 | "product_available": { 9 | "title": "Was the product available?", 10 | "type": "string", 11 | "default": "Y" 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /bpmn/tutorial/forms/review_order.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Review Order", 3 | "type": "object", 4 | "required": [ 5 | "place_order" 6 | ], 7 | "properties": { 8 | "place_order": { 9 | "title": "Place Order?", 10 | "type": "string", 11 | "default": "N" 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /bpmn/tutorial/forms/select_product_and_quantity.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Select Product and Quantity", 3 | "type": "object", 4 | "required": [ 5 | "product_name", 6 | "product_quantity" 7 | ], 8 | "properties": { 9 | "product_name": { 10 | "title": "Product Name", 11 | "type": "string", 12 | "oneOf": [ 13 | { 14 | "const": "product_a", 15 | "title": "A" 16 | }, 17 | { 18 | "const": "product_b", 19 | "title": "B" 20 | }, 21 | { 22 | "const": "product_c", 23 | "title": "C" 24 | }, 25 | { 26 | "const": "product_d", 27 | "title": "D" 28 | }, 29 | { 30 | "const": "product_e", 31 | "title": "E" 32 | }, 33 | { 34 | "const": "product_f", 35 | "title": "F" 36 | }, 37 | { 38 | "const": "product_g", 39 | "title": "G" 40 | } 41 | ] 42 | }, 43 | "product_quantity": { 44 | "title": "Quantity", 45 | "type": "integer" 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /bpmn/tutorial/forms/select_product_color.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Select Product Color", 3 | "type": "object", 4 | "required": [ 5 | "product_color" 6 | ], 7 | "properties": { 8 | "product_color": { 9 | "title": "Product Color", 10 | "type": "string", 11 | "oneOf": [ 12 | { 13 | "const": "white", 14 | "title": "White" 15 | }, 16 | { 17 | "const": "black", 18 | "title": "Black" 19 | }, 20 | { 21 | "const": "gray", 22 | "title": "Gray" 23 | }, 24 | { 25 | "const": "navy", 26 | "title": "Navy" 27 | } 28 | ] 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /bpmn/tutorial/forms/select_product_size.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Select Product Size", 3 | "type": "object", 4 | "required": [ 5 | "product_size" 6 | ], 7 | "properties": { 8 | "product_size": { 9 | "title": "Product Size", 10 | "type": "string", 11 | "oneOf": [ 12 | { 13 | "const": "small", 14 | "title": "S" 15 | }, 16 | { 17 | "const": "medium", 18 | "title": "M" 19 | }, 20 | { 21 | "const": "large", 22 | "title": "L" 23 | } 24 | ] 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /bpmn/tutorial/forms/select_product_style.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Select Product Style", 3 | "type": "object", 4 | "required": [ 5 | "product_style" 6 | ], 7 | "properties": { 8 | "product_style": { 9 | "title": "Product Style", 10 | "type": "string", 11 | "oneOf": [ 12 | { 13 | "const": "short", 14 | "title": "Short" 15 | }, 16 | { 17 | "const": "long", 18 | "title": "Long" 19 | } 20 | ] 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /bpmn/tutorial/forms/select_shipping_method.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Select Shipping Method", 3 | "type": "object", 4 | "required": [ 5 | "shipping_method" 6 | ], 7 | "properties": { 8 | "shipping_method": { 9 | "title": "Shipping Method", 10 | "type": "string", 11 | "oneOf": [ 12 | { 13 | "const": "standard", 14 | "title": "Standard" 15 | }, 16 | { 17 | "const": "overnight", 18 | "title": "Overnight" 19 | } 20 | ] 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /bpmn/tutorial/forms/ship_product.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Ship Product", 3 | "type": "object", 4 | "required": [ 5 | "product_shipped" 6 | ], 7 | "properties": { 8 | "product_shipped": { 9 | "title": "Was the product shipped?", 10 | "type": "string", 11 | "default": "Y" 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /bpmn/tutorial/product_prices.dmn: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | product_name 8 | 9 | 10 | 11 | 12 | Product A 13 | 14 | "product_a" 15 | 16 | 17 | 15.00 18 | 19 | 20 | 21 | Product B 22 | 23 | "product_b" 24 | 25 | 26 | 15.00 27 | 28 | 29 | 30 | Product C: color 31 | 32 | "product_c" 33 | 34 | 35 | 25.00 36 | 37 | 38 | 39 | Product D: color, size 40 | 41 | "product_d" 42 | 43 | 44 | 20.00 45 | 46 | 47 | 48 | Product E: color, size, style 49 | 50 | "product_e" 51 | 52 | 53 | 25.00 54 | 55 | 56 | 57 | Product F: color, size, style 58 | 59 | "product_f" 60 | 61 | 62 | 30.00 63 | 64 | 65 | 66 | Product G: style 67 | 68 | "product_g" 69 | 70 | 71 | 25.00 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | -------------------------------------------------------------------------------- /bpmn/tutorial/shipping_costs.dmn: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | shipping_method 8 | 9 | 10 | 11 | 12 | Ground 13 | 14 | "standard" 15 | 16 | 17 | 5.00 18 | 19 | 20 | 21 | Express 22 | 23 | "overnight" 24 | 25 | 26 | 25.00 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /bpmn/tutorial/task_types.bpmn: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Flow_19d1ca2 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | Flow_19d1ca2 15 | Flow_1m9aack 16 | 17 | 18 | 19 | 20 | product_prices 21 | 22 | Flow_1m9aack 23 | Flow_03cch6u 24 | 25 | 26 | 27 | Flow_03cch6u 28 | Flow_0rd1dlt 29 | order_total = product_quantity * product_price 30 | 31 | 32 | 33 | 34 | Order Summary 35 | {{ product_name }} 36 | Quantity: {{ product_quantity }} 37 | Order Total: {{ order_total }} 38 | 39 | Flow_0rd1dlt 40 | Flow_1aftqy6 41 | 42 | 43 | Flow_1aftqy6 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | -------------------------------------------------------------------------------- /bpmn/tutorial/threaded_service_task.bpmn: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Flow_032rj36 6 | 7 | 8 | 9 | Flow_0v2d6wi 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | Flow_032rj36 21 | Flow_0v2d6wi 22 | 23 | 15 24 | results 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /bpmn/tutorial/timer_start.bpmn: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Flow_0v2d6wi 7 | 8 | 9 | 10 | 11 | Press Enter! 12 | 13 | Flow_032rj36 14 | Flow_0exxc43 15 | Flow_0v2d6wi 16 | 17 | 18 | Flow_032rj36 19 | 20 | "P1D" 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | SpiffWorkflow @ git+https://github.com/sartography/SpiffWorkflow@main 2 | Jinja2==3.1.2 3 | RestrictedPython==6.0 4 | -------------------------------------------------------------------------------- /runner.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import curses 4 | import importlib 5 | import sys, traceback 6 | from argparse import ArgumentParser 7 | 8 | from spiff_example.curses_ui import CursesUI, CursesUIError 9 | from spiff_example.cli import add_subparsers, configure_logging 10 | 11 | if __name__ == '__main__': 12 | 13 | parser = ArgumentParser('Simple BPMN App') 14 | parser.add_argument('-e', '--engine', dest='engine', required=True, metavar='MODULE', help='load engine from %(metavar)s') 15 | subparsers = parser.add_subparsers(dest='subcommand') 16 | add_subparsers(subparsers) 17 | 18 | args = parser.parse_args() 19 | config = importlib.import_module(args.engine) 20 | 21 | try: 22 | if args.subcommand is None: 23 | curses.wrapper(CursesUI, config.engine, config.handlers) 24 | else: 25 | configure_logging() 26 | args.func(config.engine, args) 27 | except Exception as exc: 28 | sys.stderr.write(traceback.format_exc()) 29 | sys.exit(1) 30 | -------------------------------------------------------------------------------- /spiff_example/camunda/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sartography/spiff-example-cli/9876974e9acfaae61f40f3c29ab37da18bb152f5/spiff_example/camunda/__init__.py -------------------------------------------------------------------------------- /spiff_example/camunda/curses_handlers.py: -------------------------------------------------------------------------------- 1 | from jinja2 import Template 2 | 3 | from SpiffWorkflow.util.deep_merge import DeepMerge 4 | from SpiffWorkflow.camunda.specs.user_task import EnumFormField 5 | 6 | from ..curses_ui.user_input import Field, Option, SimpleField 7 | from ..curses_ui.human_task_handler import TaskHandler 8 | 9 | class CamundaTaskHandler(TaskHander): 10 | 11 | def set_instructions(self, task): 12 | text = f'{self.task.task_spec.bpmn_name}' 13 | if self.task.task_spec.documentation is not None: 14 | template = Template(self.task.task_spec.documentation) 15 | text += template.render(self.task.data) 16 | text += '\n\n' 17 | self.ui._states['user_input'].instructions = text 18 | 19 | 20 | class ManualTaskHandler(TaskHandler): 21 | pass 22 | 23 | 24 | class UserTaskHandler(TaskHandler): 25 | 26 | def set_fields(self, task): 27 | for field in task.task_spec.form.fields: 28 | if isinstance(field, EnumFormField): 29 | options = dict((opt.name, opt.id) for opt in field.options) 30 | label = field.label + ' (' + ', '.join(options) + ')' 31 | field = Option(options, field.id, label, '') 32 | elif field.type == 'long': 33 | field = SimpleField(int, field.id, field.label, '') 34 | else: 35 | field = Field(field.id, field.label, '') 36 | self.ui._states['user_input'].fields.append(field) 37 | 38 | def update_data(self, dct, name, value): 39 | path = name.split('.') 40 | current = dct 41 | for component in path[:-1]: 42 | if component not in current: 43 | current[component] = {} 44 | current = current[component] 45 | current[path[-1]] = value 46 | 47 | def on_complete(self, results): 48 | dct = {} 49 | for name, value in results.items(): 50 | self.update_data(dct, name, value) 51 | DeepMerge.merge(self.task.data, dct) 52 | super().on_complete({}) 53 | -------------------------------------------------------------------------------- /spiff_example/camunda/default.py: -------------------------------------------------------------------------------- 1 | import sqlite3 2 | import logging 3 | 4 | from SpiffWorkflow.camunda.serializer import DEFAULT_CONFIG 5 | from SpiffWorkflow.camunda.parser import CamundaParser 6 | from SpiffWorkflow.camunda.specs import UserTask 7 | from SpiffWorkflow.bpmn.specs.defaults import ManualTask, NoneTask 8 | from SpiffWorkflow.bpmn import BpmnWorkflow 9 | from SpiffWorkflow.bpmn.util.subworkflow import BpmnSubWorkflow 10 | from SpiffWorkflow.bpmn.specs import BpmnProcessSpec 11 | 12 | from ..serializer.sqlite import ( 13 | SqliteSerializer, 14 | WorkflowConverter, 15 | SubworkflowConverter, 16 | WorkflowSpecConverter 17 | ) 18 | from ..engine import BpmnEngine 19 | from .curses_handlers import UserTaskHandler, ManualTaskHandler 20 | 21 | logger = logging.getLogger('spiff_engine') 22 | logger.setLevel(logging.INFO) 23 | 24 | DEFAULT_CONFIG[BpmnWorkflow] = WorkflowConverter 25 | DEFAULT_CONFIG[BpmnSubWorkflow] = SubworkflowConverter 26 | DEFAULT_CONFIG[BpmnProcessSpec] = WorkflowSpecConverter 27 | 28 | dbname = 'camunda.db' 29 | 30 | with sqlite3.connect(dbname) as db: 31 | SqliteSerializer.initialize(db) 32 | 33 | registry = SqliteSerializer.configure(DEFAULT_CONFIG) 34 | serializer = SqliteSerializer(dbname, registry=registry) 35 | 36 | parser = CamundaParser() 37 | 38 | handlers = { 39 | UserTask: UserTaskHandler, 40 | ManualTask: ManualTaskHandler, 41 | NoneTask: ManualTaskHandler, 42 | } 43 | 44 | engine = BpmnEngine(parser, serializer) 45 | -------------------------------------------------------------------------------- /spiff_example/cli/__init__.py: -------------------------------------------------------------------------------- 1 | from .subcommands import add_subparsers, configure_logging 2 | -------------------------------------------------------------------------------- /spiff_example/cli/diff_result.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from SpiffWorkflow import TaskState 4 | 5 | def get_name_and_start(spec_diff): 6 | for ts in spec_diff.alignment: 7 | if ts == ts._wf_spec.start: 8 | return ts._wf_spec.name, ts 9 | 10 | def show_spec_diff(spec_diff): 11 | 12 | name, start = get_name_and_start(spec_diff) 13 | updates, checked = [], [] 14 | 15 | # This determines what needs to be be displayed (omits specs with exact mappings) 16 | def order_specs(ts): 17 | for output in [s for s in ts.outputs if s not in checked]: 18 | checked.append(output) 19 | if output in spec_diff.changed or spec_diff.alignment[output] is None: 20 | updates.append(output) 21 | order_specs(output) 22 | 23 | order_specs(start) 24 | 25 | sys.stdout.write(f'\nResults for {name}\n\n') 26 | sys.stdout.write('Changed or Removed\n') 27 | 28 | for ts in updates: 29 | # Ignore tasks that don't appear in diagrams 30 | detail = '' 31 | if ts.bpmn_id is not None: 32 | if spec_diff.alignment[ts] is None: 33 | detail = 'removed' 34 | elif ts in spec_diff.changed: 35 | detail = ', '.join(spec_diff.changed[ts]) 36 | if detail: 37 | label = f'{ts.bpmn_name or "-"} [{ts.name}]' 38 | sys.stdout.write(f'{label:<50s} {detail}\n') 39 | 40 | if len(spec_diff.added) > 0: 41 | sys.stdout.write('\nAdded\n') 42 | for ts in spec_diff.added: 43 | if ts.bpmn_id is not None: 44 | sys.stdout.write(f'{ts.bpmn_name or "-"} [{ts.name}]\n') 45 | 46 | 47 | def show_workflow_diff(wf_diff, task_id=None): 48 | 49 | duplicates = [] 50 | if task_id is not None: 51 | sys.stdout.write(f'\nResults for {task_id}\n\n') 52 | else: 53 | sys.stdout.write('Results\n\n') 54 | 55 | sys.stdout.write('Removed\n') 56 | for task in wf_diff.removed: 57 | if task.task_spec not in duplicates: 58 | duplicates.append(task.task_spec) 59 | label = f'{task.task_spec.bpmn_name or "-"} [{task.task_spec.name}]' 60 | sys.stdout.write(f'{label:<50s} {TaskState.get_name(task.state)}\n') 61 | 62 | sys.stdout.write('Changed\n') 63 | for task in wf_diff.changed: 64 | if task.task_spec not in duplicates: 65 | duplicates.append(task.task_spec) 66 | label = f'{task.task_spec.bpmn_name or "-"} [{task.task_spec.name}]' 67 | sys.stdout.write(f'{label:<50s} {TaskState.get_name(task.state)}\n') 68 | 69 | -------------------------------------------------------------------------------- /spiff_example/cli/subcommands.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import json 3 | import logging 4 | 5 | from .diff_result import show_spec_diff, show_workflow_diff 6 | 7 | def configure_logging(): 8 | task_logger = logging.getLogger('spiff.task') 9 | task_handler = logging.StreamHandler() 10 | task_handler.setFormatter(logging.Formatter('%(asctime)s [%(name)s:%(levelname)s] (%(workflow_spec)s:%(task_spec)s) %(message)s')) 11 | task_logger.addHandler(task_handler) 12 | 13 | wf_logger = logging.getLogger('spiff.workflow') 14 | wf_handler = logging.StreamHandler() 15 | wf_handler.setFormatter(logging.Formatter('%(asctime)s [%(name)s:%(levelname)s] (%(workflow_spec)s) %(message)s')) 16 | wf_logger.addHandler(wf_handler) 17 | 18 | def add(engine, args): 19 | if args.process is not None: 20 | engine.add_spec(args.process, args.bpmn, args.dmn) 21 | else: 22 | engine.add_collaboration(args.collaboration, args.bpmn, args.dmn) 23 | 24 | def show_library(engine, args): 25 | for spec_id, name, filename in engine.list_specs(): 26 | sys.stdout.write(f'{spec_id} {name:<20s} {filename}\n') 27 | 28 | def show_workflows(engine, args): 29 | for wf_id, name, filename, active, started, updated in engine.list_workflows(): 30 | sys.stdout.write(f'{wf_id} {name:<20s} {active} {started} {updated or ""}\n') 31 | 32 | def run(engine, args): 33 | instance = engine.start_workflow(args.spec_id) 34 | instance.run_until_user_input_required() 35 | instance.save() 36 | if not args.active and not instance.workflow.is_completed(): 37 | raise Exception('Expected the workflow to complete') 38 | sys.stdout.write(json.dumps(instance.data, indent=2, separators=[', ', ': '])) 39 | sys.stdout.write('\n') 40 | 41 | def diff_spec(engine, args): 42 | spec_diff = engine.diff_spec(args.original, args.new) 43 | show_spec_diff(spec_diff) 44 | if args.deps: 45 | diffs, added = engine.diff_dependencies(args.original, args.new) 46 | for diff in diffs.values(): 47 | show_spec_diff(diff) 48 | if len(added) > 0: 49 | sys.stdout.write('\nNew subprocesses\n') 50 | sys.stdout.write('\n'.join(added)) 51 | 52 | def diff_workflow(engine, args): 53 | wf_diff, sp_diffs = engine.diff_workflow(args.wf_id, args.spec_id) 54 | show_workflow_diff(wf_diff) 55 | for task_id, result in sp_diffs.items(): 56 | show_workflow_diff(result, task_id=task_id) 57 | 58 | def migrate(engine, args): 59 | engine.migrate_workflow(args.wf_id, args.spec_id, validate=not args.force) 60 | 61 | def add_subparsers(subparsers): 62 | 63 | add_spec = subparsers.add_parser('add', help='Add a worfklow spec') 64 | group = add_spec.add_mutually_exclusive_group(required=True) 65 | group.add_argument('-p', '--process', dest='process', metavar='BPMN ID', help='The top-level BPMN Process ID') 66 | group.add_argument('-c', '--collabortion', dest='collaboration', metavar='BPMN ID', help='The ID of the collaboration') 67 | add_spec.add_argument('-b', '--bpmn', dest='bpmn', nargs='+', metavar='FILE', help='BPMN files to load') 68 | add_spec.add_argument('-d', '--dmn', dest='dmn', nargs='*', metavar='FILE', help='DMN files to load') 69 | add_spec.set_defaults(func=add) 70 | 71 | list_specs = subparsers.add_parser('list_specs', help='List available specs') 72 | list_specs.set_defaults(func=show_library) 73 | 74 | list_wf = subparsers.add_parser('list_instances', help='List instances') 75 | list_wf.set_defaults(func=show_workflows) 76 | 77 | run_wf = subparsers.add_parser('run', help='Run a workflow') 78 | run_wf.add_argument('-s', '--spec-id', dest='spec_id', metavar='SPEC ID', help='The ID of the spec to run') 79 | run_wf.add_argument('-a', '--active-ok', dest='active', action='store_true', 80 | help='Suppress exception if the workflow does not complete') 81 | run_wf.set_defaults(func=run) 82 | 83 | compare_spec = subparsers.add_parser('diff_spec', help='Compare two workflow specs') 84 | compare_spec.add_argument('-o', '--original', dest='original', metavar='SPEC ID', help='The ID of the original spec') 85 | compare_spec.add_argument('-n', '--new', dest='new', metavar='SPEC ID', help='The ID of the new spec') 86 | compare_spec.add_argument('-d', '--include-dependencies', action='store_true', dest='deps', 87 | help='Include dependencies in the output') 88 | compare_spec.set_defaults(func=diff_spec) 89 | 90 | compare_wf = subparsers.add_parser('diff_workflow', help='Compare a workflow against a new spec') 91 | compare_wf.add_argument('-w', '--wf-id', dest='wf_id', metavar='WORKFLOW ID', help='The ID of the workflow') 92 | compare_wf.add_argument('-s', '--spec-id', dest='spec_id', metavar='SPEC ID', help='The ID of the new spec') 93 | compare_wf.set_defaults(func=diff_workflow) 94 | 95 | migrate_wf = subparsers.add_parser('migrate', help='Update a workflow spec') 96 | migrate_wf.add_argument('-w', '--wf-id', dest='wf_id', metavar='WORKFLOW ID', help='The ID of the workflow') 97 | migrate_wf.add_argument('-s', '--spec-id', dest='spec_id', metavar='SPEC ID', help='The ID of the new spec') 98 | migrate_wf.add_argument('-f', '--force', dest='force', action='store_true', help='Omit validation') 99 | migrate_wf.set_defaults(func=migrate) 100 | -------------------------------------------------------------------------------- /spiff_example/curses_ui/__init__.py: -------------------------------------------------------------------------------- 1 | from .ui import CursesUI, CursesUIError 2 | -------------------------------------------------------------------------------- /spiff_example/curses_ui/content.py: -------------------------------------------------------------------------------- 1 | import curses, curses.ascii 2 | 3 | class Region: 4 | 5 | def __init__(self): 6 | self.top = 0 7 | self.left = 0 8 | self.height = 1 9 | self.width = 1 10 | 11 | @property 12 | def bottom(self): 13 | return self.top + self.height - 1 14 | 15 | @property 16 | def right(self): 17 | return self.left + self.width - 1 18 | 19 | @property 20 | def box(self): 21 | return self.top, self.left, self.bottom, self.right 22 | 23 | def resize(self, top, left, height, width): 24 | self.top, self.left, self.height, self.width = top, left, height, width 25 | 26 | 27 | class Content: 28 | 29 | def __init__(self, region): 30 | 31 | self.region = region 32 | 33 | self.screen = curses.newpad(self.region.height, self.region.width) 34 | self.screen.keypad(True) 35 | self.screen.scrollok(True) 36 | self.screen.idlok(True) 37 | self.screen.attron(curses.COLOR_WHITE) 38 | 39 | self.content_height = 1 40 | self.first_visible = 0 41 | 42 | self.menu = ['[ESC] return to main menu'] 43 | 44 | @property 45 | def last_visible(self): 46 | return self.first_visible + self.region.height - 1 47 | 48 | def scroll_up(self, y): 49 | if self.first_visible > 0 and y == self.first_visible: 50 | self.first_visible -= 1 51 | self.screen.move(max(0, y - 1), 0) 52 | 53 | def scroll_down(self, y): 54 | if self.last_visible < self.content_height - 1 and y == self.last_visible - 1: 55 | self.first_visible += 1 56 | self.screen.move(min(self.content_height - 1, y + 1), 0) 57 | 58 | def page_up(self, y): 59 | self.first_visible = max(0, y - self.region.height) 60 | self.screen.move(self.first_visible, 0) 61 | 62 | def page_down(self, y): 63 | self.first_visible = min(self.content_height - self.region.height, y + self.region.height) 64 | self.screen.move(self.first_visible, 0) 65 | 66 | def draw(self): 67 | pass 68 | 69 | def handle_key(self, ch, y, x): 70 | pass 71 | 72 | def resize(self): 73 | self.screen.resize(max(self.region.height, self.content_height), self.region.width) 74 | self.screen.noutrefresh(self.first_visible, 0, *self.region.box) 75 | 76 | -------------------------------------------------------------------------------- /spiff_example/curses_ui/human_task_handler.py: -------------------------------------------------------------------------------- 1 | class TaskHandler: 2 | 3 | def __init__(self, ui): 4 | self.ui = ui 5 | self.task = None 6 | 7 | def set_instructions(self, task): 8 | pass 9 | 10 | def set_fields(self, task): 11 | pass 12 | 13 | def on_complete(self, results): 14 | instance = self.ui._states['workflow_view'].instance 15 | instance.run_task(self.task, results) 16 | self.ui._states['user_input'].clear() 17 | self.ui.state = 'workflow_view' 18 | 19 | def show(self, task): 20 | self.task = task 21 | self.set_instructions(task) 22 | self.set_fields(task) 23 | self.ui._states['user_input'].on_complete = self.on_complete 24 | self.ui.state = 'user_input' 25 | 26 | -------------------------------------------------------------------------------- /spiff_example/curses_ui/list_view.py: -------------------------------------------------------------------------------- 1 | import curses, curses.ascii 2 | from .content import Content 3 | 4 | 5 | class ListView(Content): 6 | 7 | def __init__(self, region, header, select_action, delete_action, refresh_action): 8 | 9 | super().__init__(region) 10 | self.header = header 11 | self.select_action = select_action 12 | self.delete_action = delete_action 13 | self.refresh_action = refresh_action 14 | 15 | self.items = [] 16 | self.item_ids = [] 17 | self.selected = 0 18 | self.selected_attr = curses.color_pair(6) | curses.A_BOLD 19 | 20 | self.menu = [ 21 | '[ENTER] run', 22 | '[s]tep', 23 | '[d]elete', 24 | ] + self.menu 25 | 26 | def refresh(self): 27 | items = self.refresh_action() 28 | if len(items) > 0: 29 | item_ids, items = zip(*[(item[0], item[1:]) for item in items]) 30 | self.item_ids = list(item_ids) 31 | self.items = [ [str(v) if v is not None else '' for v in item] for item in items ] 32 | else: 33 | self.items, self.item_ids = [], [] 34 | 35 | def draw(self): 36 | 37 | self.screen.erase() 38 | self.screen.move(0, 0) 39 | col_widths = [2] + [max([len(val) for val in item]) for item in zip(*[self.header] + list(self.items))] 40 | fmt = ' '.join([ f'{{{idx}:{w}s}}' for idx, w in enumerate(col_widths) ]) 41 | self.screen.addstr(fmt.format('', *self.header) + '\n', curses.A_BOLD) 42 | 43 | for idx, item in enumerate(self.items): 44 | attr = self.selected_attr if idx == self.selected else 0 45 | self.screen.addstr('\n' + fmt.format('*', *item), attr) 46 | 47 | self.screen.move(self.selected + 2, 0) 48 | self.screen.noutrefresh(self.first_visible, 0, *self.region.box) 49 | 50 | def handle_key(self, ch, y, x): 51 | if ch == curses.ascii.NL: 52 | item_id = self.item_ids[self.selected] 53 | self.select_action(item_id, step=False) 54 | elif chr(ch).lower() == 's': 55 | item_id = self.item_ids[self.selected] 56 | self.select_action(item_id, step=True) 57 | elif ch == curses.KEY_DOWN and self.selected < len(self.items) - 1: 58 | self.selected += 1 59 | self.draw() 60 | elif ch == curses.KEY_UP and self.selected > 0: 61 | self.selected -= 1 62 | self.draw() 63 | elif chr(ch).lower() == 'd': 64 | item_id = self.item_ids[self.selected] 65 | self.delete_action(item_id) 66 | self.items.pop(self.selected) 67 | self.item_ids.pop(self.selected) 68 | if self.selected == len(self.item_ids): 69 | self.selected = max(0, self.selected - 1) 70 | self.draw() 71 | 72 | 73 | class SpecListView(ListView): 74 | def __init__(self, ui): 75 | super().__init__( 76 | ui.top, 77 | ['Name', 'Filename'], 78 | ui.start_workflow, 79 | ui.engine.delete_workflow_spec, 80 | ui.engine.list_specs, 81 | ) 82 | 83 | class WorkflowListView(ListView): 84 | def __init__(self, ui): 85 | super().__init__( 86 | ui.top, 87 | ['Spec', 'Active tasks', 'Started', 'Updated', 'Ended'], 88 | ui.run_workflow, 89 | ui.engine.delete_workflow, 90 | lambda: ui.engine.list_workflows(self.include_completed), 91 | ) 92 | self.include_completed = False 93 | self.menu.insert(-1, '[i]nclude/exclude completed') 94 | 95 | def handle_key(self, ch, y, x): 96 | if chr(ch).lower() == 'i': 97 | self.include_completed = not self.include_completed 98 | self.refresh() 99 | self.draw() 100 | else: 101 | super().handle_key(ch, y, x) 102 | -------------------------------------------------------------------------------- /spiff_example/curses_ui/log_view.py: -------------------------------------------------------------------------------- 1 | import curses, curses.ascii 2 | import traceback, logging 3 | 4 | from datetime import datetime 5 | 6 | from .content import Content 7 | 8 | logger = logging.getLogger('root') 9 | 10 | 11 | class LogHandler(logging.Handler): 12 | 13 | def __init__(self, write): 14 | super().__init__() 15 | self.write = write 16 | 17 | def emit(self, record): 18 | self.write(record) 19 | 20 | 21 | class LogView(Content): 22 | 23 | def __init__(self, ui): 24 | 25 | super().__init__(ui.bottom) 26 | 27 | logger.addHandler(LogHandler(self.write)) 28 | self.styles = { 29 | 'ERROR': curses.color_pair(9), 30 | 'WARNING': curses.color_pair(11), 31 | } 32 | self.menu = ['[ESC] return to previous screen'] 33 | 34 | def write(self, record): 35 | 36 | y, x = curses.getsyx() 37 | 38 | if record.exc_info is not None and record.levelno >= 40: 39 | trace = traceback.format_exception(*record.exc_info) 40 | else: 41 | trace = [] 42 | 43 | self.content_height += sum([ l.count('\n') for l in trace]) + 1 44 | self.first_visible = max(0, self.content_height - self.region.height) 45 | self.resize() 46 | 47 | for line in trace: 48 | self.screen.addstr(line) 49 | dt = datetime.fromtimestamp(record.created).strftime('%Y-%m-%d %H:%M:%S.%f') 50 | if record.name == 'spiff.task': 51 | message = f'{dt} [{record.name}:{record.levelname}] ({record.workflow_spec}:{record.task_spec}) {record.msg}' 52 | elif record.name == 'spiff.workflow': 53 | message = f'{dt} [{record.name}:{record.levelname}] ({record.workflow_spec}) {record.msg}' 54 | else: 55 | message = f'{dt} [{record.name}:{record.levelname}] {record.msg}' 56 | self.screen.addstr(f'\n{message}', self.styles.get(record.levelname, 0)) 57 | 58 | self.screen.clrtoeol() 59 | self.screen.refresh(self.first_visible, 0, *self.region.box) 60 | 61 | curses.setsyx(y, x) 62 | 63 | def draw(self): 64 | curses.curs_set(1) 65 | 66 | def handle_key(self, ch, y, x): 67 | if ch == curses.KEY_UP: 68 | self.scroll_up(y) 69 | elif ch == curses.KEY_DOWN: 70 | self.scroll_down(y) 71 | elif ch == curses.KEY_PPAGE: 72 | self.page_up(y) 73 | elif ch == curses.KEY_NPAGE: 74 | self.page_down(y) 75 | self.screen.refresh(self.first_visible, 0, *self.region.box) 76 | 77 | -------------------------------------------------------------------------------- /spiff_example/curses_ui/menu.py: -------------------------------------------------------------------------------- 1 | import curses, curses.ascii 2 | from .content import Content 3 | 4 | 5 | class Menu(Content): 6 | 7 | def __init__(self, ui): 8 | 9 | super().__init__(ui.top) 10 | self.current_option = 0 11 | self.options, self.handlers = zip(*[ 12 | ('Add spec', lambda: setattr(ui, 'state', 'add_spec')), 13 | ('Start Workflow', lambda: setattr(ui, 'state', 'spec_list')), 14 | ('View workflows', lambda: setattr(ui, 'state', 'workflow_list')), 15 | ('Quit', ui.quit), 16 | ]) 17 | self.menu = None 18 | 19 | def draw(self): 20 | 21 | curses.curs_set(0) 22 | self.screen.erase() 23 | mid_x = self.region.width // 2 24 | mid_y = self.region.height // 2 25 | self.screen.move(1, mid_x) 26 | for idx, option in enumerate(self.options): 27 | attr = curses.A_BOLD if idx == self.current_option else 0 28 | self.screen.addstr(mid_y + idx, mid_x - len(option) // 2, f'{option}\n', attr) 29 | self.screen.noutrefresh(self.first_visible, 0, *self.region.box) 30 | 31 | def handle_key(self, ch, y, x): 32 | 33 | if ch == curses.KEY_DOWN and self.current_option < len(self.options) - 1: 34 | self.current_option += 1 35 | self.draw() 36 | self.screen.noutrefresh(self.first_visible, 0, *self.region.box) 37 | elif ch == curses.KEY_UP and self.current_option > 0: 38 | self.current_option -= 1 39 | self.draw() 40 | self.screen.noutrefresh(self.first_visible, 0, *self.region.box) 41 | elif ch == curses.ascii.NL: 42 | self.handlers[self.current_option]() 43 | 44 | 45 | -------------------------------------------------------------------------------- /spiff_example/curses_ui/spec_view.py: -------------------------------------------------------------------------------- 1 | import curses, curses.ascii 2 | from .content import Content 3 | 4 | 5 | class SpecView: 6 | 7 | def __init__(self, ui): 8 | 9 | self.left = Content(ui.left) 10 | self.right = Content(ui.right) 11 | self.add_spec = ui.engine.add_spec 12 | 13 | self.bpmn_id = None 14 | self.bpmn_files = [] 15 | self.dmn_files = [] 16 | 17 | self.bpmn_id_line = 2 18 | self.bpmn_line = 4 19 | self.dmn_line = 6 20 | self.add_line = 8 21 | 22 | self.screen = self.right.screen 23 | self.menu = self.right.menu 24 | 25 | def can_edit(self, lineno): 26 | return lineno in [self.bpmn_id_line, self.bpmn_line, self.dmn_line] 27 | 28 | def bpmn_filename(self, lineno): 29 | return lineno > self.bpmn_line and lineno <= self.bpmn_line + len(self.bpmn_files) 30 | 31 | def dmn_filename(self, lineno): 32 | return lineno > self.dmn_line and lineno <= self.dmn_line + len(self.dmn_files) 33 | 34 | def draw(self, lineno=None, clear=False): 35 | 36 | self.bpmn_line = 2 + self.bpmn_id_line 37 | self.dmn_line = 2 + self.bpmn_line + len(self.bpmn_files) 38 | self.add_line = 2 + self.dmn_line + len(self.dmn_files) 39 | 40 | self.left.screen.erase() 41 | self.right.screen.erase() 42 | 43 | self.left.screen.addstr(self.bpmn_id_line, self.left.region.width - 13, 'Process ID: ') 44 | self.left.screen.addstr(self.bpmn_line, self.left.region.width - 13, 'BPMN files: ') 45 | self.left.screen.addstr(self.dmn_line, self.left.region.width - 12, 'DMN files: ') 46 | 47 | if self.bpmn_id is not None: 48 | self.right.screen.addstr(self.bpmn_id_line, 0, self.bpmn_id) 49 | for offset, filename in enumerate(self.bpmn_files): 50 | self.right.screen.addstr(self.bpmn_line + offset + 1, 0, f'[X] {filename}') 51 | for offset, filename in enumerate(self.dmn_files): 52 | self.right.screen.addstr(self.dmn_line + offset + 1, 0, f'[X] {filename}') 53 | 54 | self.right.screen.addstr(self.add_line, 0, '[Add]', curses.A_BOLD) 55 | self.right.screen.addstr(' (Press ESC to cancel)') 56 | 57 | self.right.screen.move(lineno or self.bpmn_id_line, 0) 58 | if clear: 59 | self.right.screen.clrtoeol() 60 | 61 | self.left.screen.noutrefresh(self.left.first_visible, 0, *self.left.region.box) 62 | self.right.screen.noutrefresh(self.right.first_visible, 0, *self.right.region.box) 63 | 64 | curses.curs_set(1) 65 | curses.ungetch(curses.KEY_LEFT) 66 | 67 | def handle_key(self, ch, y, x): 68 | 69 | if ch == curses.KEY_BACKSPACE and self.can_edit(y): 70 | self.right.screen.move(y, max(0, x - 1)) 71 | self.right.screen.delch(y, max(0, x - 1)) 72 | elif ch == curses.KEY_LEFT and self.can_edit(y): 73 | self.right.screen.move(y, max(0, x - 1)) 74 | elif ch == curses.KEY_RIGHT and self.can_edit(y): 75 | line = self.right.screen.instr(y, 0, self.right.region.width).decode('utf-8').rstrip() 76 | self.right.screen.move(y, min(len(line), x + 1)) 77 | elif ch == curses.KEY_DOWN: 78 | if self.bpmn_filename(y + 1) or self.dmn_filename(y + 1): 79 | self.right.screen.move(y + 1, 1) 80 | elif ch == curses.KEY_UP: 81 | if y - 1 == self.bpmn_line or y - 1 == self.dmn_line: 82 | self.right.screen.move(y - 1, 0) 83 | elif self.bpmn_filename(y - 1) or self.dmn_filename(y - 1): 84 | self.right.screen.move(y - 1, 1) 85 | elif ch == curses.ascii.TAB: 86 | if y == self.bpmn_id_line: 87 | self.right.screen.move(self.bpmn_line, 0) 88 | elif y == self.bpmn_line or self.bpmn_filename(y): 89 | self.right.screen.move(self.dmn_line, 0) 90 | elif y == self.dmn_line or self.dmn_filename(y): 91 | self.right.screen.move(self.add_line, 1) 92 | elif y == self.add_line: 93 | self.right.screen.move(self.bpmn_id_line, 0) 94 | elif ch == curses.ascii.NL: 95 | text = self.right.screen.instr(y, 0, x).decode('utf-8').strip() 96 | if y == self.bpmn_id_line and text != '': 97 | self.bpmn_id = text 98 | self.right.screen.addstr(y, 0, text, curses.A_ITALIC) 99 | self.right.screen.move(self.bpmn_line, 0) 100 | elif y == self.bpmn_line and text != '': 101 | self.bpmn_files.append(text) 102 | self.draw(self.bpmn_line, True) 103 | elif y == self.dmn_line and text != '': 104 | self.dmn_files.append(text) 105 | self.draw(self.dmn_line, True) 106 | elif self.bpmn_filename(y): 107 | self.bpmn_files.pop(y - self.bpmn_line - 1) 108 | self.draw(self.bpmn_line) 109 | elif self.dmn_filename(y): 110 | self.dmn_files.pop(y - self.dmn_line - 1) 111 | self.draw(self.dmn_line) 112 | elif y == self.add_line: 113 | spec_id = self.add_spec(self.bpmn_id, self.bpmn_files, self.dmn_files or None) 114 | self.bpmn_id = None 115 | self.bpmn_files = [] 116 | self.dmn_files = [] 117 | self.draw() 118 | elif curses.ascii.unctrl(ch) == '^E': 119 | line = self.right.screen.instr(y, 0, self.right.region.width).decode('utf-8').rstrip() 120 | self.right.screen.move(y, len(line)) 121 | elif curses.ascii.unctrl(ch) == '^A': 122 | self.right.screen.move(y, 0) 123 | elif curses.ascii.unctrl(ch) == '^U': 124 | self.right.screen.move(y, 0) 125 | self.right.screen.clrtoeol() 126 | elif curses.ascii.isprint(ch): 127 | self.right.screen.echochar(ch) 128 | self.left.screen.noutrefresh(0, 0, *self.left.region.box) 129 | self.right.screen.noutrefresh(0, 0, *self.right.region.box) 130 | 131 | def resize(self): 132 | self.left.resize() 133 | self.right.resize() 134 | -------------------------------------------------------------------------------- /spiff_example/curses_ui/task_filter_view.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from SpiffWorkflow import TaskState 4 | 5 | from .user_input import UserInput, Field, SimpleField 6 | 7 | greedy_view = { 8 | 'state': TaskState.READY|TaskState.WAITING, 9 | 'spec_name': None, 10 | 'updated_ts': 0, 11 | } 12 | 13 | step_view = { 14 | 'state': TaskState.ANY_MASK, 15 | 'spec_name': None, 16 | 'updated_ts': 0 17 | } 18 | 19 | 20 | class TaskStateField(Field): 21 | def to_str(self, value): return TaskState.get_name(value) 22 | def from_str(self, value): return TaskState.get_value(value) 23 | 24 | class TimestampField(Field): 25 | def to_str(self, value): return datetime.fromtimestamp(value).isoformat() 26 | def from_str(self, value): return datetime.fromisoformat(value).timestamp() 27 | 28 | class TaskFilterView: 29 | 30 | def __init__(self, ui): 31 | self.ui = ui 32 | 33 | def show(self, task_filter): 34 | user_input = self.ui._states['user_input'] 35 | user_input.clear() 36 | user_input.fields = [ 37 | TaskStateField('state', 'State', task_filter['state']), 38 | SimpleField(str, 'spec_name', 'Task spec', task_filter['spec_name']), 39 | TimestampField('updated_ts', 'Updated on or after', task_filter['updated_ts']), 40 | ] 41 | user_input.instructions = '' 42 | 43 | def on_complete(results): 44 | self.ui._states['workflow_view'].instance.update_task_filter(results) 45 | self.ui.state = 'workflow_view' 46 | user_input.on_complete = on_complete 47 | 48 | self.ui.state = 'user_input' 49 | -------------------------------------------------------------------------------- /spiff_example/curses_ui/ui.py: -------------------------------------------------------------------------------- 1 | import curses, curses.ascii 2 | import sys 3 | import logging 4 | from datetime import datetime 5 | 6 | from .content import Region, Content 7 | 8 | from .menu import Menu 9 | from .log_view import LogView 10 | from .list_view import SpecListView, WorkflowListView 11 | from .workflow_view import WorkflowView 12 | from .spec_view import SpecView 13 | from .user_input import UserInput, Field 14 | from .task_filter_view import greedy_view, step_view 15 | 16 | logger = logging.getLogger(__name__) 17 | 18 | class CursesUIError(Exception): 19 | pass 20 | 21 | 22 | class CursesUI: 23 | 24 | def __init__(self, window, engine, handlers): 25 | 26 | for i in range(1, int(curses.COLOR_PAIRS / 256)): 27 | curses.init_pair(i, i, 0) 28 | 29 | self.engine = engine 30 | self.handlers = dict((spec, handler(self)) for spec, handler in handlers.items()) 31 | 32 | self.window = window 33 | y, x = self.window.getmaxyx() 34 | if y < 13: 35 | raise CursesUIError(f'A minimum height of 13 lines is required.') 36 | 37 | self.window.attron(curses.COLOR_WHITE) 38 | self.window.nodelay(True) 39 | 40 | self.left = Region() 41 | self.right = Region() 42 | self.top = Region() 43 | self.menu = Region() 44 | self.bottom = Region() 45 | 46 | self.menu_content = Content(self.menu) 47 | 48 | self._states = { 49 | 'main_menu': Menu(self), 50 | 'add_spec': SpecView(self), 51 | 'log_view': LogView(self), 52 | 'spec_list': SpecListView(self), 53 | 'workflow_list': WorkflowListView(self), 54 | 'workflow_view': WorkflowView(self), 55 | 'user_input': UserInput(self), 56 | } 57 | self.resize() 58 | self._state = None 59 | self._escape_state = 'main_menu' 60 | self.state = 'main_menu' 61 | self.run() 62 | 63 | @property 64 | def state(self): 65 | return self._states[self._state] 66 | 67 | @state.setter 68 | def state(self, state): 69 | if state == 'log_view': 70 | self._escape_state = self._state 71 | elif state == 'user_input': 72 | self._escape_state = 'workflow_view' 73 | else: 74 | self._escape_state = 'main_menu' 75 | self._state = state 76 | self.menu_content.screen.erase() 77 | self.menu_content.screen.move(0, 0) 78 | if self.state.menu is not None: 79 | for action in self.state.menu: 80 | self.menu_content.screen.addstr(f'{action} ') 81 | self.menu_content.screen.noutrefresh(0, 0, *self.menu.box) 82 | if self._state in ['spec_list', 'workflow_list']: 83 | self.state.refresh() 84 | self.state.draw() 85 | curses.doupdate() 86 | 87 | def run(self): 88 | 89 | while True: 90 | y, x = self.state.screen.getyx() 91 | ch = self.state.screen.getch() 92 | if ch == curses.KEY_RESIZE: 93 | self.resize() 94 | self.state.draw() 95 | elif ch == curses.ascii.ESC: 96 | self.state = self._escape_state 97 | elif chr(ch) == ';': 98 | self.state = 'log_view' 99 | else: 100 | try: 101 | self.state.handle_key(ch, y, x) 102 | except Exception as exc: 103 | logger.error(str(exc), exc_info=True) 104 | curses.doupdate() 105 | 106 | def start_workflow(self, spec_id, step): 107 | instance = self.engine.start_workflow(spec_id) 108 | self.set_workflow(instance, step, 'spec_list') 109 | 110 | def run_workflow(self, wf_id, step): 111 | instance = self.engine.get_workflow(wf_id) 112 | self.set_workflow(instance, step, 'workflow_list') 113 | 114 | def set_workflow(self, instance, step, prev_state): 115 | instance.step = step 116 | instance.update_task_filter(step_view.copy() if step else greedy_view.copy()) 117 | if not step: 118 | instance.run_until_user_input_required() 119 | self._states['workflow_view'].instance = instance 120 | self._states['workflow_view']._previous_state = prev_state 121 | self.state = 'workflow_view' 122 | 123 | def quit(self): 124 | sys.exit(0) 125 | 126 | def resize(self): 127 | 128 | y, x = self.window.getmaxyx() 129 | div_y, div_x = y // 4, x // 3 130 | 131 | self.top.resize(1, 1, y - div_y - 3, x - 2) 132 | self.left.resize(1, 1, y - div_y - 3, div_x - 1) 133 | self.right.resize(1, div_x, y - div_y - 3, x - div_x - 1) 134 | self.menu.resize(y - div_y - 1, 1, 1, x - 2) 135 | self.bottom.resize(y - div_y + 1, 0, div_y - 1, x) 136 | 137 | for state in self._states.values(): 138 | state.resize() 139 | self.menu_content.resize() 140 | 141 | self.window.hline(y - div_y, 0, curses.ACS_HLINE, x) 142 | self.window.refresh() 143 | 144 | -------------------------------------------------------------------------------- /spiff_example/curses_ui/user_input.py: -------------------------------------------------------------------------------- 1 | import json 2 | import curses, curses.ascii 3 | from .content import Content 4 | 5 | 6 | class Field: 7 | 8 | def __init__(self, name, label, default): 9 | self.name = name 10 | self.label = label 11 | self.value = default 12 | 13 | def to_str(self, value): 14 | return value 15 | 16 | def from_str(self, value): 17 | return value 18 | 19 | class JsonField(Field): 20 | 21 | def to_str(self, value): 22 | return json.dumps(value, indent=2, separators=[', ', ': ']) 23 | 24 | def from_str(self, value): 25 | return json.loads(value) 26 | 27 | class SimpleField(Field): 28 | 29 | def __init__(self, _type, name, label, default): 30 | super().__init__(name, label, default) 31 | self._type = _type 32 | 33 | def to_str(self, value): 34 | return '' if value is None else str(value) 35 | 36 | def from_str(self, value): 37 | return None if value == '' else self._type(value) 38 | 39 | class Option(Field): 40 | 41 | def __init__(self, options, name, label, default): 42 | super().__init__(name, label, default) 43 | self.options = options 44 | 45 | def to_str(self, value): 46 | return value 47 | 48 | def from_str(self, value): 49 | if value in self.options: 50 | return self.options[value] 51 | else: 52 | raise Exception(f'Invalid option: {value}') 53 | 54 | class UserInput: 55 | 56 | def __init__(self, ui): 57 | 58 | self.left = Content(ui.left) 59 | self.right = Content(ui.right) 60 | self.screen = self.right.screen 61 | 62 | self.on_complete = lambda results: results 63 | self.instructions = '' 64 | self.fields = [] 65 | 66 | self.current_field = 0 67 | self.offsets = [] 68 | 69 | self.menu = ['[ESC] to cancel'] 70 | 71 | def draw(self, itemno=None): 72 | 73 | self.left.screen.erase() 74 | self.right.screen.erase() 75 | self.left.screen.move(0, 0) 76 | self.right.screen.move(0, 0) 77 | 78 | if self.instructions != '': 79 | self.right.screen.addstr(2, 0, self.instructions) 80 | 81 | y, x = self.right.screen.getyx() 82 | for idx, field in enumerate(self.fields): 83 | offset = y + idx * 2 84 | self.offsets.append(offset) 85 | text = f'{field.label}: ' 86 | self.left.screen.addstr(offset, self.left.region.width - len(text), text, curses.A_BOLD) 87 | self.right.screen.addstr(offset, 0, field.to_str(field.value)) 88 | 89 | y, x = self.right.screen.getyx() 90 | offset = y + 2 91 | self.right.screen.addstr(offset, 0, '[Done]', curses.A_BOLD) 92 | self.offsets.append(offset) 93 | 94 | self.right.screen.move(self.offsets[itemno or 0], 0) 95 | 96 | self.left.screen.noutrefresh(0, 0, *self.left.region.box) 97 | self.right.screen.noutrefresh(0, 0, *self.right.region.box) 98 | 99 | curses.curs_set(1) 100 | curses.ungetch(curses.KEY_LEFT) 101 | 102 | def clear(self): 103 | self.instructions = '' 104 | self.fields = [] 105 | self.offsets = [] 106 | self.current_field = 0 107 | 108 | def handle_key(self, ch, y, x): 109 | 110 | if ch == curses.KEY_BACKSPACE: 111 | self.right.screen.move(y, max(0, x - 1)) 112 | self.right.screen.delch(y, max(0, x - 1)) 113 | elif ch == curses.KEY_LEFT: 114 | self.right.screen.move(y, max(0, x - 1)) 115 | elif ch == curses.KEY_RIGHT: 116 | line = self.right.screen.instr(y, 0, self.right.region.width).decode('utf-8').rstrip() 117 | self.right.screen.move(y, min(len(line), x + 1)) 118 | elif ch == curses.ascii.TAB: 119 | self.current_field = 0 if self.current_field == len(self.offsets) - 1 else self.current_field + 1 120 | self.right.screen.move(self.offsets[self.current_field], 0) 121 | elif curses.ascii.unctrl(ch) == '^E': 122 | line = self.right.screen.instr(y, 0, self.right.region.width).decode('utf-8').rstrip() 123 | self.right.screen.move(y, len(line)) 124 | elif curses.ascii.unctrl(ch) == '^A': 125 | self.right.screen.move(y, 0) 126 | elif curses.ascii.unctrl(ch) == '^U': 127 | self.right.screen.move(y, 0) 128 | self.right.screen.clrtoeol() 129 | elif curses.ascii.isprint(ch): 130 | self.right.screen.echochar(ch) 131 | 132 | self.left.screen.noutrefresh(self.left.first_visible, 0, *self.left.region.box) 133 | self.right.screen.noutrefresh(self.left.first_visible, 0, *self.right.region.box) 134 | 135 | if ch == curses.ascii.NL: 136 | if self.current_field < len(self.offsets) - 1: 137 | field = self.fields[self.current_field] 138 | line = self.right.screen.instr(y, 0, self.right.region.width).decode('utf-8').rstrip() 139 | self.right.screen.addstr(self.offsets[self.current_field], 0, line, curses.A_ITALIC) 140 | field.value = field.from_str(line) 141 | curses.ungetch(curses.ascii.TAB) 142 | self.left.screen.noutrefresh(self.left.first_visible, 0, *self.left.region.box) 143 | self.right.screen.noutrefresh(self.left.first_visible, 0, *self.right.region.box) 144 | else: 145 | self.on_complete(dict((f.name, f.value) for f in self.fields)) 146 | 147 | def resize(self): 148 | self.left.resize() 149 | self.right.resize() 150 | -------------------------------------------------------------------------------- /spiff_example/curses_ui/workflow_view.py: -------------------------------------------------------------------------------- 1 | import curses, curses.ascii 2 | import json 3 | from datetime import datetime 4 | 5 | from SpiffWorkflow.util.task import TaskState 6 | 7 | from .content import Content 8 | from .task_filter_view import TaskFilterView 9 | 10 | 11 | class WorkflowView: 12 | 13 | def __init__(self, ui): 14 | 15 | self.left = Content(ui.left) 16 | self.right = Content(ui.right) 17 | 18 | self.handlers = ui.handlers 19 | self.task_filter_view = TaskFilterView(ui) 20 | 21 | self.instance = None 22 | 23 | self.task_view = 'list' 24 | self.info_view = 'task' 25 | self.scroll = 'left' 26 | self.selected = 0 27 | 28 | self.screen = self.left.screen 29 | self.menu = [ 30 | '[l]ist/tree view', 31 | '[w]orkflow/task data view', 32 | '[g]reedy/step execution', 33 | '[f]ilter tasks', 34 | '[u]pdate waiting tasks', 35 | '[s]ave workflow state', 36 | ] 37 | 38 | self.styles = { 39 | 'MAYBE': curses.color_pair(4), 40 | 'LIKELY': curses.color_pair(4), 41 | 'FUTURE': curses.color_pair(6), 42 | 'WAITING': curses.color_pair(3), 43 | 'READY': curses.color_pair(2), 44 | 'STARTED': curses.color_pair(6), 45 | 'ERROR': curses.color_pair(1), 46 | 'CANCELLED': curses.color_pair(5), 47 | } 48 | 49 | def draw(self): 50 | self.update_task_tree() 51 | self.update_info() 52 | 53 | def update_task_tree(self): 54 | 55 | if self.selected > len(self.instance.filtered_tasks) - 1: 56 | self.selected = 0 57 | self.left.screen.erase() 58 | if len(self.instance.filtered_tasks) > 0: 59 | self.left.content_height = len(self.instance.filtered_tasks) 60 | self.left.resize() 61 | for idx, task in enumerate(self.instance.filtered_tasks): 62 | task_info = self.instance.get_task_display_info(task) 63 | indent = 2 * task_info['depth'] 64 | color = self.styles.get(task_info['state'], 0) 65 | attr = color | curses.A_BOLD if idx == self.selected else color 66 | name = task_info['name'] 67 | lane = task_info['lane'] or '' 68 | task_info = f'{lane}{name} [{task_info["state"]}]' 69 | if self.task_view == 'list': 70 | self.left.screen.addstr(idx, 0, task_info, attr) 71 | else: 72 | self.left.screen.addstr(idx, 0, ' ' * indent + task_info, attr) 73 | if self.info_view == 'task': 74 | self.show_task() 75 | self.left.screen.move(self.selected, 0) 76 | else: 77 | self.info_view = 'workflow' 78 | self.left.content_height = self.left.region.height - 1 79 | self.left.resize() 80 | self.left.screen.addstr(0, 0, 'No tasks available') 81 | self.left.screen.noutrefresh(self.left.first_visible, 0, *self.left.region.box) 82 | 83 | def update_info(self): 84 | if self.info_view == 'task' and len(self.instance.filtered_tasks) > 0: 85 | self.show_task() 86 | else: 87 | self.show_workflow() 88 | 89 | def show_task(self): 90 | task = self.instance.filtered_tasks[self.selected] 91 | info = { 92 | 'Name': task.task_spec.name, 93 | 'Bpmn ID': task.task_spec.bpmn_id or '', 94 | 'Bpmn name': task.task_spec.bpmn_name or '', 95 | 'Description': task.task_spec.description, 96 | 'Last state change': datetime.fromtimestamp(task.last_state_change), 97 | } 98 | self._show_details(info, task.data) 99 | 100 | def show_workflow(self): 101 | info = { 102 | 'Spec': self.instance.name, 103 | 'Ready tasks': len(self.instance.ready_tasks), 104 | 'Waiting tasks': len(self.instance.waiting_tasks), 105 | 'Finished tasks': len(self.instance.finished_tasks), 106 | 'Total tasks': len(self.instance.tasks), 107 | 'Running subprocesses': len(self.instance.running_subprocesses), 108 | 'Total subprocesses': len(self.instance.subprocesses) 109 | } 110 | self._show_details(info, self.instance.data) 111 | 112 | def _show_details(self, info, data=None): 113 | 114 | self.right.screen.erase() 115 | 116 | lines = len(info) 117 | if data is not None: 118 | lines += 2 119 | serialized = {} 120 | for key, value in data.items(): 121 | serialized[key] = json.dumps(value, indent=2) 122 | lines += len(serialized[key].split('\n')) 123 | self.right.content_height = lines + 1 124 | 125 | for name, value in info.items(): 126 | self.right.screen.addstr(f'{name}: ', curses.A_BOLD) 127 | self.right.screen.addstr(f'{value}\n') 128 | 129 | if data is not None: 130 | self.right.screen.addstr('\nData\n', curses.A_BOLD) 131 | for name, value in serialized.items(): 132 | self.right.screen.addstr(f'{name}: ', curses.A_ITALIC) 133 | self.right.screen.addstr(f'{value}\n') 134 | 135 | self.right.screen.noutrefresh(self.right.first_visible, 0, *self.right.region.box) 136 | 137 | def complete_task(self, task): 138 | handler = self.handlers.get(task.task_spec.__class__) 139 | if handler is not None: 140 | handler.show(task) 141 | else: 142 | self.instance.run_task(task) 143 | 144 | def handle_key(self, ch, y, x): 145 | 146 | if chr(ch).lower() == 'l': 147 | self.task_view = 'tree' if self.task_view == 'list' else 'list' 148 | self.update_task_tree() 149 | elif chr(ch).lower() == 'w': 150 | self.info_view = 'workflow' if self.info_view == 'task' else 'task' 151 | self.update_info() 152 | elif chr(ch).lower() == 'f': 153 | self.task_filter_view.show(self.instance.task_filter) 154 | elif chr(ch).lower() == 'u': 155 | self.instance.run_ready_events() 156 | if self.instance.step is False: 157 | self.instance.run_until_user_input_required() 158 | self.update_task_tree() 159 | elif chr(ch).lower() == 'g': 160 | self.instance.step = not self.instance.step 161 | if self.instance.step: 162 | self.instance.update_task_filter({'state': TaskState.ANY_MASK}) 163 | self.update_task_tree() 164 | elif chr(ch).lower() == 's': 165 | self.instance.save() 166 | elif ch == curses.ascii.TAB: 167 | if self.scroll == 'right': 168 | self.scroll = 'left' 169 | self.screen = self.left.screen 170 | curses.curs_set(0) 171 | else: 172 | self.scroll = 'right' 173 | self.screen = self.right.screen 174 | self.right.screen.move(0, 0) 175 | curses.curs_set(1) 176 | elif ch == curses.KEY_DOWN: 177 | if self.scroll == 'left' and self.selected < len(self.instance.filtered_tasks) - 1: 178 | self.selected += 1 179 | self.left.scroll_down(y) 180 | self.update_task_tree() 181 | else: 182 | self.right.scroll_down(y) 183 | self.right.screen.noutrefresh(self.right.first_visible, 0, *self.right.region.box) 184 | elif ch == curses.KEY_UP: 185 | if self.scroll == 'left' and self.selected > 0: 186 | self.selected -= 1 187 | self.left.scroll_up(y) 188 | self.update_task_tree() 189 | elif self.scroll == 'right': 190 | self.right.scroll_up(y) 191 | self.right.screen.noutrefresh(self.right.first_visible, 0, *self.right.region.box) 192 | elif ch == curses.ascii.NL: 193 | if self.scroll == 'left' and len(self.instance.filtered_tasks) > 0: 194 | task = self.instance.filtered_tasks[self.selected] 195 | if task.state == TaskState.READY: 196 | self.complete_task(task) 197 | self.draw() 198 | 199 | def resize(self): 200 | self.left.resize() 201 | self.right.resize() 202 | -------------------------------------------------------------------------------- /spiff_example/engine/__init__.py: -------------------------------------------------------------------------------- 1 | from .engine import BpmnEngine 2 | from .instance import Instance 3 | -------------------------------------------------------------------------------- /spiff_example/engine/engine.py: -------------------------------------------------------------------------------- 1 | import curses 2 | import logging 3 | 4 | from SpiffWorkflow.bpmn.parser.ValidationException import ValidationException 5 | from SpiffWorkflow.bpmn.specs.mixins.events.event_types import CatchingEvent 6 | from SpiffWorkflow.bpmn import BpmnWorkflow 7 | from SpiffWorkflow.bpmn.script_engine import PythonScriptEngine 8 | from SpiffWorkflow.bpmn.util.diff import ( 9 | SpecDiff, 10 | diff_dependencies, 11 | diff_workflow, 12 | filter_tasks, 13 | migrate_workflow, 14 | ) 15 | from SpiffWorkflow import TaskState 16 | 17 | from .instance import Instance 18 | 19 | 20 | logger = logging.getLogger('spiff_engine') 21 | 22 | class BpmnEngine: 23 | 24 | def __init__(self, parser, serializer, script_env=None, instance_cls=None): 25 | 26 | self.parser = parser 27 | self.serializer = serializer 28 | # Ideally this would be recreated for each instance 29 | self._script_engine = PythonScriptEngine(script_env) 30 | self.instance_cls = instance_cls or Instance 31 | 32 | def add_spec(self, process_id, bpmn_files, dmn_files): 33 | self.add_files(bpmn_files, dmn_files) 34 | try: 35 | spec = self.parser.get_spec(process_id) 36 | dependencies = self.parser.get_subprocess_specs(process_id) 37 | except ValidationException as exc: 38 | # Clear the process parsers so the files can be re-added 39 | # There's probably plenty of other stuff that should be here 40 | # However, our parser makes me mad so not investigating further at this time 41 | self.parser.process_parsers = {} 42 | raise exc 43 | spec_id = self.serializer.create_workflow_spec(spec, dependencies) 44 | logger.info(f'Added {process_id} with id {spec_id}') 45 | return spec_id 46 | 47 | def add_collaboration(self, collaboration_id, bpmn_files, dmn_files=None): 48 | self.add_files(bpmn_files, dmn_files) 49 | try: 50 | spec, dependencies = self.parser.get_collaboration(collaboration_id) 51 | except ValidationException as exc: 52 | self.parser.process_parsers = {} 53 | raise exc 54 | spec_id = self.serializer.create_workflow_spec(spec, dependencies) 55 | logger.info(f'Added {collaboration_id} with id {spec_id}') 56 | return spec_id 57 | 58 | def add_files(self, bpmn_files, dmn_files): 59 | self.parser.add_bpmn_files(bpmn_files) 60 | if dmn_files is not None: 61 | self.parser.add_dmn_files(dmn_files) 62 | 63 | def list_specs(self): 64 | return self.serializer.list_specs() 65 | 66 | def delete_workflow_spec(self, spec_id): 67 | self.serializer.delete_workflow_spec(spec_id) 68 | logger.info(f'Deleted workflow spec with id {spec_id}') 69 | 70 | def start_workflow(self, spec_id): 71 | spec, sp_specs = self.serializer.get_workflow_spec(spec_id) 72 | wf = BpmnWorkflow(spec, sp_specs, script_engine=self._script_engine) 73 | wf_id = self.serializer.create_workflow(wf, spec_id) 74 | logger.info(f'Created workflow with id {wf_id}') 75 | return self.instance_cls(wf_id, wf, save=self.update_workflow) 76 | 77 | def get_workflow(self, wf_id): 78 | wf = self.serializer.get_workflow(wf_id) 79 | wf.script_engine = self._script_engine 80 | return self.instance_cls(wf_id, wf, save=self.update_workflow) 81 | 82 | def update_workflow(self, instance): 83 | logger.info(f'Saved workflow {instance.wf_id}') 84 | self.serializer.update_workflow(instance.workflow, instance.wf_id) 85 | 86 | def list_workflows(self, include_completed=False): 87 | return self.serializer.list_workflows(include_completed) 88 | 89 | def delete_workflow(self, wf_id): 90 | self.serializer.delete_workflow(wf_id) 91 | logger.info(f'Deleted workflow with id {wf_id}') 92 | 93 | def diff_spec(self, original_id, new_id): 94 | original, _ = self.serializer.get_workflow_spec(original_id, include_dependencies=False) 95 | new, _ = self.serializer.get_workflow_spec(new_id, include_dependencies=False) 96 | return SpecDiff(self.serializer.registry, original, new) 97 | 98 | def diff_dependencies(self, original_id, new_id): 99 | _, original = self.serializer.get_workflow_spec(original_id, include_dependencies=True) 100 | _, new = self.serializer.get_workflow_spec(new_id, include_dependencies=True) 101 | return diff_dependencies(self.serializer.registry, original, new) 102 | 103 | def diff_workflow(self, wf_id, spec_id): 104 | wf = self.serializer.get_workflow(wf_id) 105 | spec, deps = self.serializer.get_workflow_spec(spec_id) 106 | return diff_workflow(self.serializer.registry, wf, spec, deps) 107 | 108 | def can_migrate(self, wf_diff, sp_diffs): 109 | 110 | def safe(result): 111 | mask = TaskState.COMPLETED|TaskState.STARTED 112 | tasks = result.changed + result.removed 113 | return len(filter_tasks(tasks, state=mask)) == 0 114 | 115 | for diff in sp_diffs.values(): 116 | if diff is None or not safe(diff): 117 | return False 118 | return safe(wf_diff) 119 | 120 | def migrate_workflow(self, wf_id, spec_id, validate=True): 121 | 122 | wf = self.serializer.get_workflow(wf_id) 123 | spec, deps = self.serializer.get_workflow_spec(spec_id) 124 | wf_diff, sp_diffs = diff_workflow(self.serializer.registry, wf, spec, deps) 125 | 126 | if validate and not self.can_migrate(wf_diff, sp_diffs): 127 | raise Exception('Workflow is not safe to migrate!') 128 | 129 | migrate_workflow(wf_diff, wf, spec) 130 | for sp_id, sp in wf.subprocesses.items(): 131 | migrate_workflow(sp_diffs[sp_id], sp, deps.get(sp.spec.name)) 132 | wf.subprocess_specs = deps 133 | 134 | self.serializer.delete_workflow(wf_id) 135 | return self.serializer.create_workflow(wf, spec_id) 136 | -------------------------------------------------------------------------------- /spiff_example/engine/instance.py: -------------------------------------------------------------------------------- 1 | from SpiffWorkflow import TaskState 2 | from SpiffWorkflow.bpmn.specs.mixins.events.event_types import CatchingEvent 3 | 4 | 5 | class Instance: 6 | 7 | def __init__(self, wf_id, workflow, save=None): 8 | self.wf_id = wf_id 9 | self.workflow = workflow 10 | self.step = False 11 | self.task_filter = {} 12 | self.filtered_tasks = [] 13 | self._save = save 14 | 15 | @property 16 | def name(self): 17 | return self.workflow.spec.name 18 | 19 | @property 20 | def tasks(self): 21 | return self.workflow.get_tasks() 22 | 23 | @property 24 | def ready_tasks(self): 25 | return self.workflow.get_tasks(state=TaskState.READY) 26 | 27 | @property 28 | def ready_human_tasks(self): 29 | return self.workflow.get_tasks(state=TaskState.READY, manual=True) 30 | 31 | @property 32 | def ready_engine_tasks(self): 33 | return self.workflow.get_tasks(state=TaskState.READY, manual=False) 34 | 35 | @property 36 | def waiting_tasks(self): 37 | return self.workflow.get_tasks(state=TaskState.WAITING) 38 | 39 | @property 40 | def finished_tasks(self): 41 | return self.workflow.get_tasks(state=TaskState.FINISHED_MASK) 42 | 43 | @property 44 | def running_subprocesses(self): 45 | return [sp for sp in self.workflow.subprocesses.values() if not sp.is_completed()] 46 | 47 | @property 48 | def subprocesses(self): 49 | return [sp for sp in self.workflow.subprocesses.values()] 50 | 51 | @property 52 | def data(self): 53 | return self.workflow.data 54 | 55 | def get_task_display_info(self, task): 56 | return { 57 | 'depth': task.depth, 58 | 'state': TaskState.get_name(task.state), 59 | 'name': task.task_spec.bpmn_name or task.task_spec.name, 60 | 'lane': task.task_spec.lane, 61 | } 62 | 63 | def update_task_filter(self, task_filter=None): 64 | if task_filter is not None: 65 | self.task_filter.update(task_filter) 66 | self.filtered_tasks = [t for t in self.workflow.get_tasks(**self.task_filter)] 67 | 68 | def run_task(self, task, data=None): 69 | if data is not None: 70 | task.set_data(**data) 71 | task.run() 72 | if not self.step: 73 | self.run_until_user_input_required() 74 | else: 75 | self.update_task_filter() 76 | 77 | def run_until_user_input_required(self): 78 | task = self.workflow.get_next_task(state=TaskState.READY, manual=False) 79 | while task is not None: 80 | task.run() 81 | self.run_ready_events() 82 | task = self.workflow.get_next_task(state=TaskState.READY, manual=False) 83 | self.update_task_filter() 84 | 85 | def run_ready_events(self): 86 | self.workflow.refresh_waiting_tasks() 87 | task = self.workflow.get_next_task(state=TaskState.READY, spec_class=CatchingEvent) 88 | while task is not None: 89 | task.run() 90 | task = self.workflow.get_next_task(state=TaskState.READY, spec_class=CatchingEvent) 91 | self.update_task_filter() 92 | 93 | def save(self): 94 | self._save(self) 95 | 96 | -------------------------------------------------------------------------------- /spiff_example/misc/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sartography/spiff-example-cli/9876974e9acfaae61f40f3c29ab37da18bb152f5/spiff_example/misc/__init__.py -------------------------------------------------------------------------------- /spiff_example/misc/curses_handlers.py: -------------------------------------------------------------------------------- 1 | ../spiff/curses_handlers.py -------------------------------------------------------------------------------- /spiff_example/misc/custom_start_event.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from RestrictedPython import safe_globals 4 | 5 | from SpiffWorkflow.spiff.parser import SpiffBpmnParser 6 | from SpiffWorkflow.spiff.specs.defaults import UserTask, ManualTask 7 | from SpiffWorkflow.spiff.serializer.config import SPIFF_CONFIG 8 | from SpiffWorkflow.bpmn.specs.mixins.none_task import NoneTask 9 | from SpiffWorkflow.bpmn.script_engine import TaskDataEnvironment 10 | 11 | from SpiffWorkflow.bpmn.specs.event_definitions import NoneEventDefinition 12 | from SpiffWorkflow.bpmn.specs.event_definitions.timer import TimerEventDefinition 13 | from SpiffWorkflow.bpmn.specs.mixins import StartEventMixin 14 | from SpiffWorkflow.spiff.specs import SpiffBpmnTask 15 | 16 | from SpiffWorkflow.spiff.parser.event_parsers import StartEventParser 17 | from SpiffWorkflow.bpmn.parser.util import full_tag 18 | from SpiffWorkflow.bpmn.serializer.default import EventConverter 19 | from SpiffWorkflow.spiff.serializer.task_spec import SpiffBpmnTaskConverter 20 | 21 | from ..serializer.file import FileSerializer 22 | from ..engine import BpmnEngine 23 | from .curses_handlers import UserTaskHandler, ManualTaskHandler 24 | 25 | logger = logging.getLogger('spiff_engine') 26 | logger.setLevel(logging.INFO) 27 | 28 | spiff_logger = logging.getLogger('spiff') 29 | spiff_logger.setLevel(logging.INFO) 30 | 31 | class CustomStartEvent(StartEventMixin, SpiffBpmnTask): 32 | 33 | def __init__(self, wf_spec, bpmn_id, event_definition, **kwargs): 34 | 35 | if isinstance(event_definition, TimerEventDefinition): 36 | super().__init__(wf_spec, bpmn_id, NoneEventDefinition(), **kwargs) 37 | self.timer_event = event_definition 38 | else: 39 | super().__init__(wf_spec, bpmn_id, event_definition, **kwargs) 40 | self.timer_event = None 41 | 42 | class CustomStartEventConverter(SpiffBpmnTaskConverter): 43 | 44 | def to_dict(self, spec): 45 | dct = super().to_dict(spec) 46 | dct['event_definition'] = self.registry.convert(spec.event_definition) 47 | dct['timer_event'] = self.registry.convert(spec.timer_event) 48 | return dct 49 | 50 | def from_dict(self, dct): 51 | spec = super().from_dict(dct) 52 | spec.event_definition = self.registry.restore(dct['event_definition']) 53 | spec.timer_event = self.registry.restore(dct['timer_event']) 54 | return spec 55 | 56 | dirname = 'wfdata' 57 | FileSerializer.initialize(dirname) 58 | 59 | SPIFF_CONFIG[CustomStartEvent] = CustomStartEventConverter 60 | 61 | registry = FileSerializer.configure(SPIFF_CONFIG) 62 | serializer = FileSerializer(dirname, registry=registry) 63 | 64 | parser = SpiffBpmnParser() 65 | parser.OVERRIDE_PARSER_CLASSES[full_tag('startEvent')] = (StartEventParser, CustomStartEvent) 66 | 67 | handlers = { 68 | UserTask: UserTaskHandler, 69 | ManualTask: ManualTaskHandler, 70 | NoneTask: ManualTaskHandler, 71 | } 72 | 73 | script_env = TaskDataEnvironment(safe_globals) 74 | 75 | engine = BpmnEngine(parser, serializer, script_env) 76 | 77 | -------------------------------------------------------------------------------- /spiff_example/misc/event_handler.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from SpiffWorkflow.spiff.parser import SpiffBpmnParser 4 | from SpiffWorkflow.spiff.specs.defaults import UserTask, ManualTask, ServiceTask 5 | from SpiffWorkflow.spiff.serializer.config import SPIFF_CONFIG 6 | from SpiffWorkflow.bpmn.specs.mixins.none_task import NoneTask 7 | from SpiffWorkflow.bpmn.script_engine import TaskDataEnvironment 8 | 9 | from SpiffWorkflow.spiff.specs.event_definitions import ErrorEventDefinition 10 | from SpiffWorkflow.spiff.parser.task_spec import ServiceTaskParser 11 | from SpiffWorkflow.bpmn.parser.util import full_tag 12 | from SpiffWorkflow.bpmn.exceptions import WorkflowTaskException 13 | from SpiffWorkflow.bpmn import BpmnEvent 14 | 15 | from ..serializer.file import FileSerializer 16 | from ..engine import BpmnEngine, Instance 17 | from .curses_handlers import UserTaskHandler, ManualTaskHandler 18 | 19 | logger = logging.getLogger('spiff_engine') 20 | logger.setLevel(logging.INFO) 21 | 22 | spiff_logger = logging.getLogger('spiff') 23 | spiff_logger.setLevel(logging.INFO) 24 | 25 | dirname = 'wfdata' 26 | FileSerializer.initialize(dirname) 27 | 28 | handlers = { 29 | UserTask: UserTaskHandler, 30 | ManualTask: ManualTaskHandler, 31 | NoneTask: ManualTaskHandler, 32 | } 33 | 34 | class EventHandlingServiceTask(ServiceTask): 35 | 36 | def _execute(self, my_task): 37 | script_engine = my_task.workflow.script_engine 38 | # The param also has a type, but I don't need it 39 | params = dict((name, script_engine.evaluate(my_task, p['value'])) for name, p in self.operation_params.items()) 40 | try: 41 | result = script_engine.call_service( 42 | my_task, 43 | operation_name=self.operation_name, 44 | operation_params=params 45 | ) 46 | my_task.data[self.result_variable] = result 47 | return True 48 | except FileNotFoundError as exc: 49 | event_definition = ErrorEventDefinition('file_not_found', code='1') 50 | event = BpmnEvent(event_definition, payload=params['filename']) 51 | my_task.workflow.top_workflow.catch(event) 52 | return False 53 | except Exception as exc: 54 | raise WorkflowTaskException('Service Task execution error', task=my_task, exception=exc) 55 | 56 | 57 | class ServiceTaskEnvironment(TaskDataEnvironment): 58 | 59 | def call_service(self, context, operation_name, operation_params): 60 | if operation_name == 'read_file': 61 | return open(operation_params['filename']).read() 62 | else: 63 | raise ValueError('Unknown Service') 64 | 65 | 66 | parser = SpiffBpmnParser() 67 | parser.OVERRIDE_PARSER_CLASSES[full_tag('serviceTask')] = (ServiceTaskParser, EventHandlingServiceTask) 68 | 69 | SPIFF_CONFIG[EventHandlingServiceTask] = SPIFF_CONFIG.pop(ServiceTask) 70 | registry = FileSerializer.configure(SPIFF_CONFIG) 71 | serializer = FileSerializer(dirname, registry=registry) 72 | 73 | script_env = ServiceTaskEnvironment() 74 | 75 | engine = BpmnEngine(parser, serializer, script_env) 76 | -------------------------------------------------------------------------------- /spiff_example/misc/restricted.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from RestrictedPython import safe_globals 4 | 5 | from SpiffWorkflow.spiff.parser import SpiffBpmnParser 6 | from SpiffWorkflow.spiff.specs.defaults import UserTask, ManualTask 7 | from SpiffWorkflow.spiff.serializer.config import SPIFF_CONFIG 8 | from SpiffWorkflow.bpmn.specs.mixins.none_task import NoneTask 9 | from SpiffWorkflow.bpmn.script_engine import TaskDataEnvironment 10 | 11 | from ..serializer.file import FileSerializer 12 | from ..engine import BpmnEngine 13 | from .curses_handlers import UserTaskHandler, ManualTaskHandler 14 | 15 | logger = logging.getLogger('spiff_engine') 16 | logger.setLevel(logging.INFO) 17 | 18 | spiff_logger = logging.getLogger('spiff') 19 | spiff_logger.setLevel(logging.INFO) 20 | 21 | dirname = 'wfdata' 22 | FileSerializer.initialize(dirname) 23 | 24 | registry = FileSerializer.configure(SPIFF_CONFIG) 25 | serializer = FileSerializer(dirname, registry=registry) 26 | 27 | parser = SpiffBpmnParser() 28 | 29 | handlers = { 30 | UserTask: UserTaskHandler, 31 | ManualTask: ManualTaskHandler, 32 | NoneTask: ManualTaskHandler, 33 | } 34 | 35 | script_env = TaskDataEnvironment(safe_globals) 36 | 37 | engine = BpmnEngine(parser, serializer, script_env) 38 | -------------------------------------------------------------------------------- /spiff_example/misc/threaded_service_task.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import time 3 | from random import randrange 4 | from concurrent.futures import ThreadPoolExecutor 5 | 6 | from SpiffWorkflow.spiff.parser import SpiffBpmnParser 7 | from SpiffWorkflow.spiff.specs.defaults import UserTask, ManualTask, ServiceTask 8 | from SpiffWorkflow.spiff.serializer.config import SPIFF_CONFIG 9 | from SpiffWorkflow.bpmn.specs.mixins.none_task import NoneTask 10 | from SpiffWorkflow.bpmn.script_engine import TaskDataEnvironment 11 | 12 | from SpiffWorkflow.spiff.parser.task_spec import ServiceTaskParser 13 | from SpiffWorkflow.bpmn.parser.util import full_tag 14 | from SpiffWorkflow.bpmn.exceptions import WorkflowTaskException 15 | 16 | from ..serializer.file import FileSerializer 17 | from ..engine import BpmnEngine, Instance 18 | from .curses_handlers import UserTaskHandler, ManualTaskHandler 19 | 20 | logger = logging.getLogger('spiff_engine') 21 | logger.setLevel(logging.INFO) 22 | 23 | spiff_logger = logging.getLogger('spiff') 24 | spiff_logger.setLevel(logging.INFO) 25 | 26 | dirname = 'wfdata' 27 | FileSerializer.initialize(dirname) 28 | 29 | handlers = { 30 | UserTask: UserTaskHandler, 31 | ManualTask: ManualTaskHandler, 32 | NoneTask: ManualTaskHandler, 33 | } 34 | 35 | def wait(seconds, job_id): 36 | time.sleep(seconds) 37 | return f'{job_id} slept {seconds} seconds' 38 | 39 | class ThreadedServiceTask(ServiceTask): 40 | 41 | def _execute(self, my_task): 42 | script_engine = my_task.workflow.script_engine 43 | params = dict((name, script_engine.evaluate(my_task, p['value'])) for name, p in self.operation_params.items()) 44 | try: 45 | future = script_engine.call_service( 46 | my_task, 47 | operation_name=self.operation_name, 48 | operation_params=params 49 | ) 50 | script_engine.environment.futures[future] = my_task 51 | except Exception as exc: 52 | raise WorkflowTaskException('Service Task execution error', task=my_task, exception=exc) 53 | 54 | class ServiceTaskEnvironment(TaskDataEnvironment): 55 | 56 | def __init__(self): 57 | super().__init__() 58 | self.pool = ThreadPoolExecutor(max_workers=10) 59 | self.futures = {} 60 | 61 | def call_service(self, context, operation_name, operation_params): 62 | if operation_name == 'wait': 63 | seconds = randrange(1, 30) 64 | return self.pool.submit(wait, seconds, operation_params['job_id']) 65 | else: 66 | raise ValueError("Unknown Service!") 67 | 68 | class ThreadInstance(Instance): 69 | 70 | def update_completed_futures(self): 71 | futures = self.workflow.script_engine.environment.futures 72 | finished = [f for f in futures if f.done()] 73 | for future in finished: 74 | task = futures.pop(future) 75 | result = future.result() 76 | task.data[task.task_spec.result_variable] = result 77 | task.complete() 78 | 79 | def run_ready_events(self): 80 | self.update_completed_futures() 81 | super().run_ready_events() 82 | 83 | parser = SpiffBpmnParser() 84 | parser.OVERRIDE_PARSER_CLASSES[full_tag('serviceTask')] = (ServiceTaskParser, ThreadedServiceTask) 85 | 86 | SPIFF_CONFIG[ThreadedServiceTask] = SPIFF_CONFIG.pop(ServiceTask) 87 | registry = FileSerializer.configure(SPIFF_CONFIG) 88 | serializer = FileSerializer(dirname, registry=registry) 89 | 90 | script_env = ServiceTaskEnvironment() 91 | 92 | engine = BpmnEngine(parser, serializer, script_env, instance_cls=ThreadInstance) 93 | -------------------------------------------------------------------------------- /spiff_example/serializer/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sartography/spiff-example-cli/9876974e9acfaae61f40f3c29ab37da18bb152f5/spiff_example/serializer/__init__.py -------------------------------------------------------------------------------- /spiff_example/serializer/file/__init__.py: -------------------------------------------------------------------------------- 1 | from .serializer import FileSerializer 2 | -------------------------------------------------------------------------------- /spiff_example/serializer/file/serializer.py: -------------------------------------------------------------------------------- 1 | import json, re 2 | import os 3 | import logging 4 | from uuid import uuid4, UUID 5 | from datetime import datetime 6 | 7 | from SpiffWorkflow.bpmn.serializer.workflow import BpmnWorkflowSerializer 8 | from SpiffWorkflow.bpmn.serializer.default.workflow import BpmnWorkflowConverter, BpmnSubWorkflowConverter 9 | from SpiffWorkflow.bpmn.serializer.default.process_spec import BpmnProcessSpecConverter 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | class FileSerializer(BpmnWorkflowSerializer): 14 | 15 | @staticmethod 16 | def initialize(dirname): 17 | try: 18 | os.makedirs(dirname, exist_ok=True) 19 | os.mkdir(os.path.join(dirname, 'spec')) 20 | os.mkdir(os.path.join(dirname, 'instance')) 21 | except FileExistsError: 22 | pass 23 | 24 | def __init__(self, dirname, **kwargs): 25 | super().__init__(**kwargs) 26 | self.dirname = dirname 27 | self.fmt = {'indent': 2, 'separators': [', ', ': ']} 28 | 29 | def create_workflow_spec(self, spec, dependencies): 30 | 31 | spec_dir = os.path.join(self.dirname, 'spec') 32 | if spec.file is not None: 33 | dirname = os.path.join(spec_dir, os.path.dirname(spec.file), spec.name) 34 | else: 35 | dirname = os.path.join(spec_dir, spec.name) 36 | filename = os.path.join(dirname, f'{spec.name}.json') 37 | try: 38 | os.makedirs(dirname, exist_ok=True) 39 | with open(filename, 'x') as fh: 40 | fh.write(json.dumps(self.to_dict(spec), **self.fmt)) 41 | if len(dependencies) > 0: 42 | os.mkdir(os.path.join(dirname, 'dependencies')) 43 | for name, sp in dependencies.items(): 44 | with open(os.path.join(dirname, 'dependencies', f'{name}.json'), 'w') as fh: 45 | fh.write(json.dumps(self.to_dict(sp), **self.fmt)) 46 | except FileExistsError: 47 | pass 48 | return filename 49 | 50 | def delete_workflow_spec(self, filename): 51 | try: 52 | os.remove(filename) 53 | except FileNotFoundError: 54 | pass 55 | 56 | def get_workflow_spec(self, filename, **kwargs): 57 | dirname = os.path.dirname(filename) 58 | with open(filename) as fh: 59 | spec = self.from_dict(json.loads(fh.read())) 60 | subprocess_specs = {} 61 | depdir = os.path.join(os.path.dirname(filename), 'dependencies') 62 | if os.path.exists(depdir): 63 | for f in os.listdir(depdir): 64 | name = re.sub('\.json$', '', os.path.basename(f)) 65 | with open(os.path.join(depdir, f)) as fh: 66 | subprocess_specs[name] = self.from_dict(json.loads(fh.read())) 67 | return spec, subprocess_specs 68 | 69 | def list_specs(self): 70 | library = [] 71 | for root, dirs, files in os.walk(os.path.join(self.dirname, 'spec')): 72 | if 'dependencies' not in root: 73 | for f in files: 74 | filename = os.path.join(root, f) 75 | name = re.sub('\.json$', '', os.path.basename(f)) 76 | path = re.sub(os.path.join(self.dirname, 'spec'), '', filename).lstrip('/') 77 | library.append((filename, name, path)) 78 | return library 79 | 80 | def create_workflow(self, workflow, spec_id): 81 | name = re.sub('\.json$', '', os.path.basename(spec_id)) 82 | dirname = os.path.join(self.dirname, 'instance', name) 83 | os.makedirs(dirname, exist_ok=True) 84 | wf_id = uuid4() 85 | with open(os.path.join(dirname, f'{wf_id}.json'), 'w') as fh: 86 | fh.write(json.dumps(self.to_dict(workflow), **self.fmt)) 87 | return os.path.join(dirname, f'{wf_id}.json') 88 | 89 | def get_workflow(self, filename, **kwargs): 90 | with open(filename) as fh: 91 | return self.from_dict(json.loads(fh.read())) 92 | 93 | def update_workflow(self, workflow, filename): 94 | with open(filename, 'w') as fh: 95 | fh.write(json.dumps(self.to_dict(workflow), **self.fmt)) 96 | 97 | def delete_workflow(self, filename): 98 | try: 99 | os.remove(filename) 100 | except FileNotFoundError: 101 | pass 102 | 103 | def list_workflows(self, include_completed): 104 | instances = [] 105 | for root, dirs, files in os.walk(os.path.join(self.dirname, 'instance')): 106 | for f in files: 107 | filename = os.path.join(root, f) 108 | name = os.path.split(os.path.dirname(filename))[-1] 109 | stat = os.lstat(filename) 110 | created = datetime.fromtimestamp(stat.st_ctime).strftime('%Y-%^m-%d %H:%M:%S') 111 | updated = datetime.fromtimestamp(stat.st_mtime).strftime('%Y-%^m-%d %H:%M:%S') 112 | # '?' is active tasks -- we can't know this unless we reydrate the workflow 113 | # We also have to lose the ability to filter out completed workflows 114 | instances.append((filename, name, '-', created, updated, '-')) 115 | return instances 116 | 117 | 118 | -------------------------------------------------------------------------------- /spiff_example/serializer/sqlite/__init__.py: -------------------------------------------------------------------------------- 1 | from .serializer import ( 2 | SqliteSerializer, 3 | WorkflowConverter, 4 | SubworkflowConverter, 5 | WorkflowSpecConverter, 6 | ) 7 | -------------------------------------------------------------------------------- /spiff_example/serializer/sqlite/schema-sqlite.sql: -------------------------------------------------------------------------------- 1 | create table if not exists _workflow_spec ( 2 | id uuid primary key, 3 | serialization json 4 | ); 5 | 6 | create table if not exists _task_spec ( 7 | workflow_spec_id uuid references _workflow_spec (id) on delete cascade, 8 | name text generated always as (serialization->>'name') stored, 9 | serialization json 10 | ); 11 | create index if not exists task_spec_workflow on _task_spec (workflow_spec_id); 12 | create index if not exists task_spec_name on _task_spec (name); 13 | 14 | create view if not exists workflow_spec as 15 | with 16 | ts as (select workflow_spec_id, json_group_object(name, json(serialization)) task_specs from _task_spec group by workflow_spec_id) 17 | select 18 | id, json_insert(serialization, '$.task_specs', json(task_specs)) serialization 19 | from ( 20 | select _workflow_spec.id id, serialization, task_specs from _workflow_spec join ts on _workflow_spec.id=ts.workflow_spec_id 21 | ); 22 | 23 | create trigger if not exists insert_workflow_spec instead of insert on workflow_spec 24 | begin 25 | insert into _workflow_spec (id, serialization) values (new.id, json_remove(new.serialization, '$.task_specs')); 26 | insert into _task_spec (workflow_spec_id, serialization) select new.id, value from json_each(new.serialization->>'task_specs'); 27 | end; 28 | 29 | create trigger if not exists delete_workflow_spec instead of delete on workflow_spec 30 | begin 31 | delete from _workflow_spec where id=old.id; 32 | delete from _task_spec where workflow_spec_id=old.id; 33 | end; 34 | 35 | create table if not exists _spec_dependency ( 36 | parent_id uuid references _workflow_spec (id) on delete cascade, 37 | child_id uuid references _workflow_spec (id) on delete cascade 38 | ); 39 | create index if not exists spec_parent on _spec_dependency (parent_id); 40 | create index if not exists spec_child on _spec_dependency (child_id); 41 | 42 | create view if not exists spec_dependency as 43 | with recursive 44 | dependency(root, descendant, depth) as ( 45 | select parent_id, child_id, 0 from _spec_dependency 46 | union 47 | select root, child_id, depth + 1 from _spec_dependency, dependency where parent_id=dependency.descendant 48 | ) 49 | select root, descendant, depth, serialization from dependency join workflow_spec on dependency.descendant=workflow_spec.id; 50 | 51 | create table if not exists _workflow ( 52 | id uuid primary key, 53 | workflow_spec_id uuid references _workflow_spec (id), 54 | serialization json 55 | ); 56 | 57 | create table if not exists _task ( 58 | workflow_id references _workflow (id) on delete cascade, 59 | id uuid generated always as (serialization->>'id') stored unique, 60 | serialization json 61 | ); 62 | create index if not exists task_id on _task (id); 63 | create index if not exists task_workflow_id on _task (workflow_id); 64 | 65 | create table if not exists _task_data ( 66 | workflow_id uuid references _workflow (id) on delete cascade, 67 | task_id uuid, 68 | name text, 69 | value json, 70 | last_updated timestamp default current_timestamp, 71 | unique (task_id, name) 72 | ); 73 | create index if not exists task_data_id on _task_data (task_id); 74 | create index if not exists task_data_name on _task_data (name); 75 | 76 | create view if not exists task as 77 | with data as ( 78 | select task_id, json_group_object(name, iif(json_valid(value), json(value), value)) task_data 79 | from _task_data group by task_id 80 | ) 81 | select _task.workflow_id, _task.id, json_insert(serialization, '$.data', json(ifnull(task_data, '{}'))) serialization 82 | from _task left join data on _task.id=data.task_id; 83 | 84 | create trigger if not exists insert_task instead of insert on task 85 | begin 86 | insert into _task (workflow_id, serialization) values (new.workflow_id, json_remove(new.serialization, '$.data')); 87 | insert into _task_data (workflow_id, task_id, name, value) 88 | select new.workflow_id, new.serialization->>'id', key, value from json_each(new.serialization->>'data') where true 89 | on conflict (task_id, name) do nothing; 90 | end; 91 | 92 | create trigger if not exists update_task instead of update on task 93 | begin 94 | update _task set serialization=json_remove(new.serialization, '$.data') where _task.id=new.serialization->>'id'; 95 | delete from _task_data where task_id=new.id and name not in (select key from json_each(new.serialization->>'data')); 96 | insert into _task_data (workflow_id, task_id, name, value) 97 | select new.workflow_id, new.serialization->>'id', key, value from json_each(new.serialization->>'data') where true 98 | on conflict (task_id, name) do update set value=excluded.value; 99 | end; 100 | 101 | create trigger if not exists delete_task instead of delete on task 102 | begin 103 | delete from _task where id=old.id; 104 | delete from _task_data where task_id=old.id; 105 | end; 106 | 107 | create table if not exists _workflow_data ( 108 | workflow_id uuid references _workflow (id) on delete cascade, 109 | name text, 110 | value json, 111 | last_updated timestamp default current_timestamp, 112 | unique (workflow_id, name) 113 | ); 114 | create index if not exists workflow_data_id on _workflow_data (workflow_id); 115 | create index if not exists wokflow_data_name on _workflow_data (name); 116 | 117 | create view if not exists workflow as 118 | with 119 | tasks as (select workflow_id, json_group_object(id, json(serialization)) tasks from task group by workflow_id), 120 | data as (select workflow_id, json_group_object(name, iif(json_valid(value), json(value), value)) data from _workflow_data group by workflow_id) 121 | select 122 | _workflow.id, 123 | _workflow.workflow_spec_id, 124 | json_insert( 125 | json_insert( 126 | json_insert( 127 | _workflow.serialization, 128 | '$.data', 129 | json(ifnull(data, '{}')) 130 | ), 131 | '$.tasks', 132 | json(tasks) 133 | ), 134 | '$.spec', 135 | json(workflow_spec.serialization) 136 | ) serialization 137 | from _workflow 138 | left join data on _workflow.id=data.workflow_id 139 | join tasks on _workflow.id=tasks.workflow_id 140 | join workflow_spec on _workflow.workflow_spec_id=workflow_spec.id; 141 | 142 | create trigger if not exists insert_workflow instead of insert on workflow 143 | begin 144 | insert into _workflow (id, workflow_spec_id, serialization) 145 | values ( 146 | new.id, 147 | new.workflow_spec_id, 148 | json_remove(json_remove(new.serialization, '$.tasks'), '$.data') 149 | ); 150 | insert into task (workflow_id, serialization) select new.id, value from json_each(new.serialization->>'tasks'); 151 | insert into _workflow_data (workflow_id, name, value) select new.id, key, value from json_each(new.serialization->>'data'); 152 | end; 153 | 154 | create trigger if not exists update_workflow instead of update on workflow 155 | begin 156 | update _workflow set serialization=json_remove(json_remove(new.serialization, '$.tasks'), '$.data') where _workflow.id=new.id; 157 | delete from task where workflow_id=new.id and id not in (select value->>'id' from json_each(new.serialization->>'tasks')); 158 | update task set serialization=value from ( 159 | select value, serialization from json_each(new.serialization->>'tasks') t join _task on value->>'id'=_task.id 160 | ) t 161 | where value->>'id'=task.id and value->>'last_state_change' > t.serialization->>'last_state_change'; 162 | insert into task (workflow_id, serialization) select new.id, value from json_each(new.serialization->>'tasks') 163 | where value->>'id' not in (select id from _task where workflow_id=new.id); 164 | delete from _workflow_data where workflow_id=new.id and name not in (select key from json_each(new.serialization->>'data')); 165 | insert into _workflow_data (workflow_id, name, value) select new.id, key, value from json_each(new.serialization->>'$.data') where true 166 | on conflict (workflow_id, name) do update set value=excluded.value; 167 | end; 168 | 169 | create trigger if not exists delete_workflow instead of delete on workflow 170 | begin 171 | delete from _workflow where id=old.id; 172 | delete from _task where workflow_id=old.id; 173 | delete from _workflow_data where workflow_id=old.id; 174 | end; 175 | 176 | create view if not exists workflow_dependency as 177 | with recursive 178 | subworkflow as (select workflow_id, id from _task where id in (select id from _workflow)), 179 | dependency(root, descendant, depth) as ( 180 | select workflow_id, id, 1 from subworkflow 181 | union 182 | select workflow_id, dependency.descendant, depth + 1 from subworkflow, dependency where subworkflow.id=dependency.root 183 | ) 184 | select root, descendant, depth, serialization from dependency join workflow on dependency.descendant=workflow.id; 185 | 186 | create view if not exists spec_library as 187 | select id, serialization->>'name' name, serialization->>'file' filename from workflow_spec 188 | where id not in (select distinct child_id from _spec_dependency); 189 | 190 | create table if not exists instance ( 191 | id uuid primary key references _workflow (id) on delete cascade, 192 | bullshit text, 193 | spec_name text, 194 | active_tasks int, 195 | started timestamp, 196 | updated timestamp, 197 | ended timestamp 198 | ); 199 | 200 | create trigger if not exists create_instance instead of insert on workflow 201 | begin 202 | insert into instance (id, spec_name, active_tasks, started) select * from ( 203 | select new.id, name, count(value), current_timestamp 204 | from (select name from spec_library where id=new.workflow_spec_id), json_each(new.serialization->>'tasks') 205 | where value->>'state' between 8 and 32 206 | ) where new.serialization->>'typename'='BpmnWorkflow'; 207 | end; 208 | 209 | create trigger if not exists update_instance instead of update on workflow 210 | begin 211 | update instance set updated=current_timestamp, ended=t.ended from ( 212 | select iif(count(value)=0, current_timestamp, null) ended 213 | from json_each(new.serialization->>'tasks') where value->>'state' < 64 214 | ) t where id=new.id; 215 | end; 216 | 217 | create trigger if not exists delete_instance instead of delete on workflow 218 | begin 219 | delete from instance where id=old.id; 220 | end; 221 | 222 | -------------------------------------------------------------------------------- /spiff_example/serializer/sqlite/serializer.py: -------------------------------------------------------------------------------- 1 | import sqlite3, json 2 | import os 3 | import logging 4 | from uuid import uuid4, UUID 5 | 6 | from SpiffWorkflow.bpmn.serializer.workflow import BpmnWorkflowSerializer 7 | from SpiffWorkflow.bpmn.serializer.default.workflow import BpmnWorkflowConverter, BpmnSubWorkflowConverter 8 | from SpiffWorkflow.bpmn.serializer.default.process_spec import BpmnProcessSpecConverter 9 | from SpiffWorkflow.bpmn.specs.mixins.subworkflow_task import SubWorkflowTask 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | class WorkflowConverter(BpmnWorkflowConverter): 14 | 15 | def to_dict(self, workflow): 16 | dct = super(BpmnWorkflowConverter, self).to_dict(workflow) 17 | dct['bpmn_events'] = self.registry.convert(workflow.bpmn_events) 18 | dct['subprocesses'] = {} 19 | dct['tasks'] = list(dct['tasks'].values()) 20 | return dct 21 | 22 | class SubworkflowConverter(BpmnSubWorkflowConverter): 23 | 24 | def to_dict(self, workflow): 25 | dct = super().to_dict(workflow) 26 | dct['tasks'] = list(dct['tasks'].values()) 27 | return dct 28 | 29 | class WorkflowSpecConverter(BpmnProcessSpecConverter): 30 | 31 | def to_dict(self, spec): 32 | dct = super().to_dict(spec) 33 | dct['task_specs'] = list(dct['task_specs'].values()) 34 | return dct 35 | 36 | 37 | class SqliteSerializer(BpmnWorkflowSerializer): 38 | 39 | @staticmethod 40 | def initialize(db): 41 | with open(os.path.join(os.path.dirname(__file__), 'schema-sqlite.sql')) as fh: 42 | db.executescript(fh.read()) 43 | db.commit() 44 | 45 | def __init__(self, dbname, **kwargs): 46 | super().__init__(**kwargs) 47 | self.dbname = dbname 48 | 49 | def create_workflow_spec(self, spec, dependencies): 50 | spec_id, new = self.execute(self._create_workflow_spec, spec) 51 | if new and len(dependencies) > 0: 52 | pairs = self.get_spec_dependencies(spec_id, spec, dependencies) 53 | # This handles the case where the participant requires an event to be kicked off 54 | added = list(map(lambda p: p[1], pairs)) 55 | for name, child in dependencies.items(): 56 | child_id, new_child = self.execute(self._create_workflow_spec, child) 57 | if new_child: 58 | pairs |= self.get_spec_dependencies(child_id, child, dependencies) 59 | pairs.add((spec_id, child_id)) 60 | self.execute(self._set_spec_dependencies, pairs) 61 | return spec_id 62 | 63 | def get_spec_dependencies(self, parent_id, parent, dependencies): 64 | # There ought to be an option in the parser to do this 65 | pairs = set() 66 | for task_spec in filter(lambda ts: isinstance(ts, SubWorkflowTask), parent.task_specs.values()): 67 | child = dependencies.get(task_spec.spec) 68 | child_id, new = self.execute(self._create_workflow_spec, child) 69 | pairs.add((parent_id, child_id)) 70 | if new: 71 | pairs |= self.get_spec_dependencies(child_id, child, dependencies) 72 | return pairs 73 | 74 | def get_workflow_spec(self, spec_id, include_dependencies=True): 75 | return self.execute(self._get_workflow_spec, spec_id, include_dependencies) 76 | 77 | def list_specs(self): 78 | return self.execute(self._list_specs) 79 | 80 | def delete_workflow_spec(self, spec_id): 81 | return self.execute(self._delete_workflow_spec, spec_id) 82 | 83 | def create_workflow(self, workflow, spec_id): 84 | return self.execute(self._create_workflow, workflow, spec_id) 85 | 86 | def get_workflow(self, wf_id, include_dependencies=True): 87 | return self.execute(self._get_workflow, wf_id, include_dependencies) 88 | 89 | def update_workflow(self, workflow, wf_id): 90 | return self.execute(self._update_workflow, workflow, wf_id) 91 | 92 | def list_workflows(self, include_completed=False): 93 | return self.execute(self._list_workflows, include_completed) 94 | 95 | def delete_workflow(self, wf_id): 96 | return self.execute(self._delete_workflow, wf_id) 97 | 98 | def _create_workflow_spec(self, cursor, spec): 99 | cursor.execute( 100 | "select id, false from workflow_spec where serialization->>'file'=? and serialization->>'name'=?", 101 | (spec.file, spec.name) 102 | ) 103 | row = cursor.fetchone() 104 | if row is None: 105 | dct = self.to_dict(spec) 106 | spec_id = uuid4() 107 | cursor.execute("insert into workflow_spec (id, serialization) values (?, ?)", (spec_id, dct)) 108 | return spec_id, True 109 | else: 110 | return row 111 | 112 | def _set_spec_dependencies(self, cursor, values): 113 | cursor.executemany("insert into _spec_dependency (parent_id, child_id) values (?, ?)", values) 114 | 115 | def _get_workflow_spec(self, cursor, spec_id, include_dependencies): 116 | cursor.execute("select serialization as 'serialization [json]' from workflow_spec where id=?", (spec_id, )) 117 | spec = self.from_dict(cursor.fetchone()[0]) 118 | subprocess_specs = {} 119 | if include_dependencies: 120 | subprocess_specs = self._get_subprocess_specs(cursor, spec_id) 121 | return spec, subprocess_specs 122 | 123 | def _get_subprocess_specs(self, cursor, spec_id): 124 | subprocess_specs = {} 125 | cursor.execute( 126 | "select serialization->>'name', serialization as 'serialization [json]' from spec_dependency where root=?", 127 | (spec_id, ) 128 | ) 129 | for name, serialization in cursor: 130 | subprocess_specs[name] = self.from_dict(serialization) 131 | return subprocess_specs 132 | 133 | def _list_specs(self, cursor): 134 | cursor.execute("select id, name, filename from spec_library") 135 | return cursor.fetchall() 136 | 137 | def _delete_workflow_spec(self, cursor, spec_id): 138 | try: 139 | cursor.execute("delete from workflow_spec where id=?", (spec_id, )) 140 | except sqlite3.IntegrityError: 141 | logger.warning(f'Unable to delete spec {spec_id} because it is used by existing workflows') 142 | 143 | def _create_workflow(self, cursor, workflow, spec_id): 144 | dct = super().to_dict(workflow) 145 | wf_id = uuid4() 146 | stmt = "insert into workflow (id, workflow_spec_id, serialization) values (?, ?, ?)" 147 | cursor.execute(stmt, (wf_id, spec_id, dct)) 148 | if len(workflow.subprocesses) > 0: 149 | cursor.execute("select serialization->>'name', descendant from spec_dependency where root=?", (spec_id, )) 150 | dependencies = dict((name, id) for name, id in cursor) 151 | for sp_id, sp in workflow.subprocesses.items(): 152 | cursor.execute(stmt, (sp_id, dependencies[sp.spec.name], self.to_dict(sp))) 153 | return wf_id 154 | 155 | def _get_workflow(self, cursor, wf_id, include_dependencies): 156 | cursor.execute("select workflow_spec_id, serialization as 'serialization [json]' from workflow where id=?", (wf_id, )) 157 | row = cursor.fetchone() 158 | spec_id, workflow = row[0], self.from_dict(row[1]) 159 | if include_dependencies: 160 | workflow.subprocess_specs = self._get_subprocess_specs(cursor, spec_id) 161 | cursor.execute( 162 | "select descendant as 'id [uuid]', serialization as 'serialization [json]' from workflow_dependency where root=? order by depth", 163 | (wf_id, ) 164 | ) 165 | for sp_id, sp in cursor: 166 | task = workflow.get_task_from_id(sp_id) 167 | workflow.subprocesses[sp_id] = self.from_dict(sp, task=task, top_workflow=workflow) 168 | return workflow 169 | 170 | def _update_workflow(self, cursor, workflow, wf_id): 171 | dct = self.to_dict(workflow) 172 | cursor.execute("select descendant as 'id [uuid]' from workflow_dependency where root=?", (wf_id, )) 173 | dependencies = [row[0] for row in cursor] 174 | cursor.execute( 175 | "select serialization->>'name', descendant as 'id [uuid]' from spec_dependency where root=(select workflow_spec_id from _workflow where id=?)", 176 | (wf_id, ) 177 | ) 178 | spec_dependencies = dict((name, spec_id) for name, spec_id in cursor) 179 | stmt = "update workflow set serialization=? where id=?" 180 | cursor.execute(stmt, (dct, wf_id)) 181 | for sp_id, sp in workflow.subprocesses.items(): 182 | sp_dct = self.to_dict(sp) 183 | if sp_id in dependencies: 184 | cursor.execute(stmt, (sp_dct, sp_id)) 185 | else: 186 | cursor.execute( 187 | "insert into workflow (id, workflow_spec_id, serialization) values (?, ?, ?)", 188 | (sp_id, spec_dependencies[sp.spec.name], sp_dct) 189 | ) 190 | 191 | def _list_workflows(self, cursor, include_completed): 192 | if include_completed: 193 | query = "select id, spec_name, active_tasks, started, updated, ended from instance" 194 | else: 195 | query = "select id, spec_name, active_tasks, started, updated, ended from instance where ended is null" 196 | cursor.execute(query) 197 | return cursor.fetchall() 198 | 199 | def _delete_workflow(self, cursor, wf_id): 200 | cursor.execute("select descendant as 'id [uuid]' from workflow_dependency where root=?", (wf_id, )) 201 | for sp_id in [row[0] for row in cursor]: 202 | cursor.execute("delete from workflow where id=?", (sp_id, )) 203 | cursor.execute("delete from workflow where id=?", (wf_id, )) 204 | 205 | def execute(self, func, *args, **kwargs): 206 | 207 | conn = sqlite3.connect(self.dbname, detect_types=sqlite3.PARSE_DECLTYPES|sqlite3.PARSE_COLNAMES) 208 | conn.execute("pragma foreign_keys=on") 209 | sqlite3.register_adapter(UUID, lambda v: str(v)) 210 | sqlite3.register_converter("uuid", lambda s: UUID(s.decode('utf-8'))) 211 | sqlite3.register_adapter(dict, lambda v: json.dumps(v)) 212 | sqlite3.register_converter("json", lambda s: json.loads(s)) 213 | 214 | cursor = conn.cursor() 215 | try: 216 | rv = func(cursor, *args, **kwargs) 217 | conn.commit() 218 | except Exception as exc: 219 | logger.error(str(exc), exc_info=True) 220 | conn.rollback() 221 | finally: 222 | cursor.close() 223 | conn.close() 224 | return rv 225 | -------------------------------------------------------------------------------- /spiff_example/spiff/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sartography/spiff-example-cli/9876974e9acfaae61f40f3c29ab37da18bb152f5/spiff_example/spiff/__init__.py -------------------------------------------------------------------------------- /spiff_example/spiff/curses_handlers.py: -------------------------------------------------------------------------------- 1 | import os, json 2 | import logging 3 | 4 | from jinja2 import Template 5 | 6 | from ..curses_ui.user_input import SimpleField, Option, JsonField 7 | from ..curses_ui.human_task_handler import TaskHandler 8 | 9 | forms_dir = 'bpmn/tutorial/forms' 10 | 11 | class SpiffTaskHandler(TaskHandler): 12 | 13 | def set_instructions(self, task): 14 | user_input = self.ui._states['user_input'] 15 | user_input.instructions = f'{self.task.task_spec.bpmn_name}\n\n' 16 | text = self.task.task_spec.extensions.get('instructionsForEndUser') 17 | if text is not None: 18 | template = Template(text) 19 | user_input.instructions += template.render(self.task.data) 20 | user_input.instructions += '\n\n' 21 | 22 | class ManualTaskHandler(SpiffTaskHandler): 23 | pass 24 | 25 | class UserTaskHandler(SpiffTaskHandler): 26 | 27 | def set_fields(self, task): 28 | 29 | filename = task.task_spec.extensions['properties']['formJsonSchemaFilename'] 30 | schema = json.load(open(os.path.join(forms_dir, filename))) 31 | user_input = self.ui._states['user_input'] 32 | for name, config in schema['properties'].items(): 33 | if 'oneOf' in config: 34 | options = dict([ (v['title'], v['const']) for v in config['oneOf'] ]) 35 | label = f'{config["title"]} ' + '(' + ', '.join(options) + ')' 36 | field = Option(options, name, label, '') 37 | elif config['type'] == 'string': 38 | field = SimpleField(str, name, config['title'], None) 39 | elif config['type'] == 'integer': 40 | field = SimpleField(int, name, config['title'], None) 41 | elif config['type'] == 'number': 42 | field = SimpleField(float, name, config['title'], None) 43 | elif config['type'] == 'boolean': 44 | field = SimpleField(bool, name, config['title'], None) 45 | else: 46 | field = JsonField(name, config['title'], None) 47 | user_input.fields.append(field) 48 | 49 | -------------------------------------------------------------------------------- /spiff_example/spiff/custom_exec.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import json 3 | import os, subprocess 4 | 5 | from SpiffWorkflow.spiff.parser import SpiffBpmnParser 6 | from SpiffWorkflow.spiff.specs.defaults import UserTask, ManualTask 7 | from SpiffWorkflow.spiff.serializer.config import SPIFF_CONFIG 8 | from SpiffWorkflow.bpmn.specs.mixins.none_task import NoneTask 9 | from SpiffWorkflow.bpmn.script_engine.python_environment import BasePythonScriptEngineEnvironment 10 | from SpiffWorkflow.util.deep_merge import DeepMerge 11 | 12 | from ..serializer.file import FileSerializer 13 | from ..engine import BpmnEngine 14 | from .curses_handlers import UserTaskHandler, ManualTaskHandler 15 | 16 | from .product_info import ( 17 | ProductInfo, 18 | lookup_product_info, 19 | lookup_shipping_cost, 20 | product_info_to_dict, 21 | product_info_from_dict, 22 | ) 23 | 24 | logger = logging.getLogger('spiff_engine') 25 | logger.setLevel(logging.INFO) 26 | 27 | spiff_logger = logging.getLogger('spiff') 28 | spiff_logger.setLevel(logging.INFO) 29 | 30 | dirname = 'wfdata' 31 | FileSerializer.initialize(dirname) 32 | 33 | registry = FileSerializer.configure(SPIFF_CONFIG) 34 | registry.register(ProductInfo, product_info_to_dict, product_info_from_dict) 35 | serializer = FileSerializer(dirname, registry=registry) 36 | 37 | parser = SpiffBpmnParser() 38 | 39 | handlers = { 40 | UserTask: UserTaskHandler, 41 | ManualTask: ManualTaskHandler, 42 | NoneTask: ManualTaskHandler, 43 | } 44 | 45 | class SubprocessScriptingEnvironment(BasePythonScriptEngineEnvironment): 46 | 47 | def __init__(self, executable, serializer, **kwargs): 48 | super().__init__(**kwargs) 49 | self.executable = executable 50 | self.serializer = serializer 51 | 52 | def evaluate(self, expression, context, external_context=None): 53 | output = self.run(['eval', expression], context, external_context) 54 | return self.parse_output(output) 55 | 56 | def execute(self, script, context, external_context=None): 57 | output = self.run(['exec', script], context, external_context) 58 | DeepMerge.merge(context, self.parse_output(output)) 59 | return True 60 | 61 | def run(self, args, context, external_context): 62 | cmd = ['python', '-m', self.executable] + args + ['-c', json.dumps(registry.convert(context))] 63 | if external_context is not None: 64 | cmd.extend(['-x', json.dumps(registry.convert(external_context))]) 65 | return subprocess.run(cmd, capture_output=True) 66 | 67 | def parse_output(self, output): 68 | if output.stderr: 69 | raise Exception(output.stderr.decode('utf-8')) 70 | return registry.restore(json.loads(output.stdout)) 71 | 72 | executable = 'spiff_example.spiff.subprocess_engine' 73 | script_env = SubprocessScriptingEnvironment(executable, serializer) 74 | 75 | engine = BpmnEngine(parser, serializer, script_env) 76 | 77 | -------------------------------------------------------------------------------- /spiff_example/spiff/custom_object.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import datetime 3 | 4 | from SpiffWorkflow.spiff.parser.process import SpiffBpmnParser 5 | from SpiffWorkflow.spiff.specs.defaults import UserTask, ManualTask 6 | from SpiffWorkflow.spiff.serializer.config import SPIFF_CONFIG 7 | from SpiffWorkflow.bpmn.workflow import BpmnWorkflow, BpmnSubWorkflow 8 | from SpiffWorkflow.bpmn.specs.mixins.none_task import NoneTask 9 | from SpiffWorkflow.bpmn.script_engine import TaskDataEnvironment 10 | 11 | from ..serializer.file import FileSerializer 12 | from ..engine import BpmnEngine 13 | from .curses_handlers import UserTaskHandler, ManualTaskHandler 14 | 15 | from .product_info import ( 16 | ProductInfo, 17 | product_info_to_dict, 18 | product_info_from_dict, 19 | lookup_product_info, 20 | lookup_shipping_cost, 21 | ) 22 | 23 | logger = logging.getLogger('spiff_engine') 24 | logger.setLevel(logging.INFO) 25 | 26 | spiff_logger = logging.getLogger('spiff') 27 | spiff_logger.setLevel(logging.INFO) 28 | 29 | dirname = 'wfdata' 30 | FileSerializer.initialize(dirname) 31 | 32 | registry = FileSerializer.configure(SPIFF_CONFIG) 33 | registry.register(ProductInfo, product_info_to_dict, product_info_from_dict) 34 | 35 | serializer = FileSerializer(dirname, registry=registry) 36 | 37 | parser = SpiffBpmnParser() 38 | 39 | handlers = { 40 | UserTask: UserTaskHandler, 41 | ManualTask: ManualTaskHandler, 42 | NoneTask: ManualTaskHandler, 43 | } 44 | 45 | script_env = TaskDataEnvironment({ 46 | 'datetime': datetime, 47 | 'lookup_product_info': lookup_product_info, 48 | 'lookup_shipping_cost': lookup_shipping_cost, 49 | }) 50 | engine = BpmnEngine(parser, serializer, script_env) 51 | -------------------------------------------------------------------------------- /spiff_example/spiff/diffs.py: -------------------------------------------------------------------------------- 1 | import sqlite3 2 | import logging 3 | import datetime 4 | 5 | from SpiffWorkflow.spiff.parser.process import SpiffBpmnParser 6 | from SpiffWorkflow.spiff.specs.defaults import UserTask, ManualTask 7 | from SpiffWorkflow.spiff.serializer.config import SPIFF_CONFIG 8 | from SpiffWorkflow.bpmn.workflow import BpmnWorkflow, BpmnSubWorkflow 9 | from SpiffWorkflow.bpmn.specs import BpmnProcessSpec 10 | from SpiffWorkflow.bpmn.specs.mixins.none_task import NoneTask 11 | from SpiffWorkflow.bpmn.script_engine import TaskDataEnvironment 12 | 13 | from ..serializer.sqlite import ( 14 | SqliteSerializer, 15 | WorkflowConverter, 16 | SubworkflowConverter, 17 | WorkflowSpecConverter 18 | ) 19 | from ..engine import BpmnEngine 20 | from .curses_handlers import UserTaskHandler, ManualTaskHandler 21 | 22 | from .product_info import ( 23 | ProductInfo, 24 | product_info_to_dict, 25 | product_info_from_dict, 26 | lookup_product_info, 27 | lookup_shipping_cost, 28 | ) 29 | 30 | logger = logging.getLogger('spiff_engine') 31 | logger.setLevel(logging.INFO) 32 | 33 | spiff_logger = logging.getLogger('spiff') 34 | spiff_logger.setLevel(logging.INFO) 35 | 36 | SPIFF_CONFIG[BpmnWorkflow] = WorkflowConverter 37 | SPIFF_CONFIG[BpmnSubWorkflow] = SubworkflowConverter 38 | SPIFF_CONFIG[BpmnProcessSpec] = WorkflowSpecConverter 39 | 40 | dbname = 'spiff.db' 41 | with sqlite3.connect(dbname) as db: 42 | SqliteSerializer.initialize(db) 43 | 44 | registry = SqliteSerializer.configure(SPIFF_CONFIG) 45 | registry.register(ProductInfo, product_info_to_dict, product_info_from_dict) 46 | 47 | serializer = SqliteSerializer(dbname, registry=registry) 48 | 49 | parser = SpiffBpmnParser() 50 | 51 | handlers = { 52 | UserTask: UserTaskHandler, 53 | ManualTask: ManualTaskHandler, 54 | NoneTask: ManualTaskHandler, 55 | } 56 | 57 | script_env = TaskDataEnvironment({ 58 | 'datetime': datetime, 59 | 'lookup_product_info': lookup_product_info, 60 | 'lookup_shipping_cost': lookup_shipping_cost, 61 | }) 62 | engine = BpmnEngine(parser, serializer, script_env) 63 | -------------------------------------------------------------------------------- /spiff_example/spiff/file.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import datetime 3 | 4 | from SpiffWorkflow.spiff.parser import SpiffBpmnParser 5 | from SpiffWorkflow.spiff.specs.defaults import UserTask, ManualTask 6 | from SpiffWorkflow.spiff.serializer.config import SPIFF_CONFIG 7 | from SpiffWorkflow.bpmn.specs.mixins.none_task import NoneTask 8 | from SpiffWorkflow.bpmn.script_engine import TaskDataEnvironment 9 | 10 | from ..serializer.file import FileSerializer 11 | from ..engine import BpmnEngine 12 | from .curses_handlers import UserTaskHandler, ManualTaskHandler 13 | 14 | logger = logging.getLogger('spiff_engine') 15 | logger.setLevel(logging.INFO) 16 | 17 | spiff_logger = logging.getLogger('spiff') 18 | spiff_logger.setLevel(logging.INFO) 19 | 20 | dirname = 'wfdata' 21 | FileSerializer.initialize(dirname) 22 | 23 | registry = FileSerializer.configure(SPIFF_CONFIG) 24 | serializer = FileSerializer(dirname, registry=registry) 25 | 26 | parser = SpiffBpmnParser() 27 | 28 | handlers = { 29 | UserTask: UserTaskHandler, 30 | ManualTask: ManualTaskHandler, 31 | NoneTask: ManualTaskHandler, 32 | } 33 | 34 | script_env = TaskDataEnvironment({'datetime': datetime }) 35 | engine = BpmnEngine(parser, serializer, script_env) 36 | -------------------------------------------------------------------------------- /spiff_example/spiff/product_info.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | 3 | ProductInfo = namedtuple('ProductInfo', ['color', 'size', 'style', 'price']) 4 | INVENTORY = { 5 | 'product_a': ProductInfo(False, False, False, 15.00), 6 | 'product_b': ProductInfo(False, False, False, 15.00), 7 | 'product_c': ProductInfo(True, False, False, 25.00), 8 | 'product_d': ProductInfo(True, True, False, 20.00), 9 | 'product_e': ProductInfo(True, True, True, 25.00), 10 | 'product_f': ProductInfo(True, True, True, 30.00), 11 | 'product_g': ProductInfo(False, False, True, 25.00), 12 | } 13 | 14 | def lookup_product_info(product_name): 15 | return INVENTORY[product_name] 16 | 17 | def lookup_shipping_cost(shipping_method): 18 | return 25.00 if shipping_method == 'Overnight' else 5.00 19 | 20 | def product_info_to_dict(obj): 21 | return { 22 | 'color': obj.color, 23 | 'size': obj.size, 24 | 'style': obj.style, 25 | 'price': obj.price, 26 | } 27 | 28 | def product_info_from_dict(dct): 29 | return ProductInfo(**dct) 30 | 31 | 32 | -------------------------------------------------------------------------------- /spiff_example/spiff/service_task.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import datetime 4 | 5 | from SpiffWorkflow.spiff.parser import SpiffBpmnParser 6 | from SpiffWorkflow.spiff.specs.defaults import UserTask, ManualTask 7 | from SpiffWorkflow.spiff.serializer.config import SPIFF_CONFIG 8 | from SpiffWorkflow.bpmn.specs.mixins.none_task import NoneTask 9 | from SpiffWorkflow.bpmn.script_engine import TaskDataEnvironment 10 | 11 | from ..serializer.file import FileSerializer 12 | from ..engine import BpmnEngine 13 | from .curses_handlers import UserTaskHandler, ManualTaskHandler 14 | 15 | from .product_info import ( 16 | ProductInfo, 17 | product_info_to_dict, 18 | product_info_from_dict, 19 | lookup_product_info, 20 | lookup_shipping_cost, 21 | ) 22 | logger = logging.getLogger('spiff_engine') 23 | logger.setLevel(logging.INFO) 24 | 25 | spiff_logger = logging.getLogger('spiff_engine') 26 | spiff_logger.setLevel(logging.INFO) 27 | 28 | dirname = 'wfdata' 29 | FileSerializer.initialize(dirname) 30 | 31 | registry = FileSerializer.configure(SPIFF_CONFIG) 32 | registry.register(ProductInfo, product_info_to_dict, product_info_from_dict) 33 | serializer = FileSerializer(dirname, registry=registry) 34 | 35 | parser = SpiffBpmnParser() 36 | 37 | handlers = { 38 | UserTask: UserTaskHandler, 39 | ManualTask: ManualTaskHandler, 40 | NoneTask: ManualTaskHandler, 41 | } 42 | 43 | class ServiceTaskEnvironment(TaskDataEnvironment): 44 | 45 | def __init__(self): 46 | super().__init__({ 47 | 'product_info_from_dict': product_info_from_dict, 48 | 'datetime': datetime, 49 | }) 50 | 51 | def call_service(self, task_data, operation_name, operation_params): 52 | if operation_name == 'lookup_product_info': 53 | product_info = lookup_product_info(operation_params['product_name']['value']) 54 | result = product_info_to_dict(product_info) 55 | elif operation_name == 'lookup_shipping_cost': 56 | result = lookup_shipping_cost(operation_params['shipping_method']['value']) 57 | else: 58 | raise Exception("Unknown Service!") 59 | return json.dumps(result) 60 | 61 | script_env = ServiceTaskEnvironment() 62 | 63 | engine = BpmnEngine(parser, serializer, script_env) 64 | -------------------------------------------------------------------------------- /spiff_example/spiff/sqlite.py: -------------------------------------------------------------------------------- 1 | import sqlite3 2 | import logging 3 | import datetime 4 | 5 | from SpiffWorkflow.spiff.parser import SpiffBpmnParser 6 | from SpiffWorkflow.spiff.specs.defaults import UserTask, ManualTask 7 | from SpiffWorkflow.spiff.serializer import DEFAULT_CONFIG 8 | from SpiffWorkflow.bpmn import BpmnWorkflow 9 | from SpiffWorkflow.bpmn.util.subworkflow import BpmnSubWorkflow 10 | from SpiffWorkflow.bpmn.specs import BpmnProcessSpec 11 | from SpiffWorkflow.bpmn.specs.mixins import NoneTaskMixin as NoneTask 12 | from SpiffWorkflow.bpmn.script_engine import TaskDataEnvironment 13 | 14 | from ..serializer.sqlite import ( 15 | SqliteSerializer, 16 | WorkflowConverter, 17 | SubworkflowConverter, 18 | WorkflowSpecConverter 19 | ) 20 | from ..engine import BpmnEngine 21 | from .curses_handlers import UserTaskHandler, ManualTaskHandler 22 | 23 | logger = logging.getLogger('spiff_engine') 24 | logger.setLevel(logging.INFO) 25 | 26 | spiff_logger = logging.getLogger('spiff') 27 | spiff_logger.setLevel(logging.INFO) 28 | 29 | DEFAULT_CONFIG[BpmnWorkflow] = WorkflowConverter 30 | DEFAULT_CONFIG[BpmnSubWorkflow] = SubworkflowConverter 31 | DEFAULT_CONFIG[BpmnProcessSpec] = WorkflowSpecConverter 32 | 33 | dbname = 'spiff.db' 34 | 35 | with sqlite3.connect(dbname) as db: 36 | SqliteSerializer.initialize(db) 37 | 38 | registry = SqliteSerializer.configure(DEFAULT_CONFIG) 39 | serializer = SqliteSerializer(dbname, registry=registry) 40 | 41 | parser = SpiffBpmnParser() 42 | 43 | handlers = { 44 | UserTask: UserTaskHandler, 45 | ManualTask: ManualTaskHandler, 46 | NoneTask: ManualTaskHandler, 47 | } 48 | 49 | script_env = TaskDataEnvironment({'datetime': datetime }) 50 | 51 | engine = BpmnEngine(parser, serializer, script_env) 52 | -------------------------------------------------------------------------------- /spiff_example/spiff/subprocess_engine.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import argparse 4 | import json 5 | 6 | from .custom_exec import ( 7 | lookup_product_info, 8 | lookup_shipping_cost, 9 | registry, 10 | ) 11 | 12 | if __name__ == '__main__': 13 | 14 | parent = argparse.ArgumentParser() 15 | subparsers = parent.add_subparsers(dest='method') 16 | 17 | shared = argparse.ArgumentParser('Context', add_help=False) 18 | shared.add_argument('-c', '--context', dest='context', required=True) 19 | shared.add_argument('-x', '--external-context', dest='external') 20 | 21 | eval_args = subparsers.add_parser('eval', parents=[shared]) 22 | eval_args.add_argument('expr', type=str) 23 | 24 | exec_args = subparsers.add_parser('exec', parents=[shared]) 25 | exec_args.add_argument('script', type=str) 26 | 27 | args = parent.parse_args() 28 | local_ctx = registry.restore(json.loads(args.context)) 29 | global_ctx = globals() 30 | global_ctx.update(local_ctx) 31 | if args.external is not None: 32 | global_ctx.update(registry.restore(json.loads(args.external))) 33 | if args.method == 'eval': 34 | result = eval(args.expr, global_ctx, local_ctx) 35 | elif args.method == 'exec': 36 | exec(args.script, global_ctx, local_ctx) 37 | result = local_ctx 38 | print(json.dumps(registry.convert(result))) 39 | --------------------------------------------------------------------------------