├── .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 |
--------------------------------------------------------------------------------