operaton-external-task-client-python3 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- examples/__init__.py +0 -0
- examples/bpmn_error_example.py +75 -0
- examples/correlate_message.py +11 -0
- examples/event_subprocess_example.py +50 -0
- examples/examples_auth_basic/__init__.py +0 -0
- examples/examples_auth_basic/fetch_and_execute.py +31 -0
- examples/examples_auth_basic/get_process_instance.py +12 -0
- examples/examples_auth_basic/start_process.py +15 -0
- examples/examples_auth_basic/task_handler_example.py +44 -0
- examples/fetch_and_execute.py +30 -0
- examples/get_process_instance.py +12 -0
- examples/retry_task_example.py +58 -0
- examples/start_process.py +14 -0
- examples/task_handler_example.py +44 -0
- examples/tasks_example.py +36 -0
- operaton/__init__.py +0 -0
- operaton/client/__init__.py +0 -0
- operaton/client/async_external_task_client.py +171 -0
- operaton/client/engine_client.py +180 -0
- operaton/client/external_task_client.py +166 -0
- operaton/client/tests/__init__.py +0 -0
- operaton/client/tests/test_async_external_task_client.py +128 -0
- operaton/client/tests/test_async_external_task_client_auth.py +42 -0
- operaton/client/tests/test_async_external_task_client_bearer.py +43 -0
- operaton/client/tests/test_engine_client.py +228 -0
- operaton/client/tests/test_engine_client_auth.py +231 -0
- operaton/client/tests/test_engine_client_bearer.py +237 -0
- operaton/client/tests/test_external_task_client.py +17 -0
- operaton/client/tests/test_external_task_client_auth.py +19 -0
- operaton/client/tests/test_external_task_client_bearer.py +24 -0
- operaton/external_task/__init__.py +0 -0
- operaton/external_task/async_external_task_executor.py +91 -0
- operaton/external_task/async_external_task_worker.py +181 -0
- operaton/external_task/external_task.py +173 -0
- operaton/external_task/external_task_executor.py +88 -0
- operaton/external_task/external_task_worker.py +92 -0
- operaton/external_task/tests/__init__.py +0 -0
- operaton/external_task/tests/test_async_external_task_executor.py +139 -0
- operaton/external_task/tests/test_async_external_task_worker.py +129 -0
- operaton/external_task/tests/test_external_task.py +106 -0
- operaton/external_task/tests/test_external_task_executor.py +200 -0
- operaton/external_task/tests/test_external_task_worker.py +147 -0
- operaton/process_definition/__init__.py +0 -0
- operaton/process_definition/process_definition_client.py +123 -0
- operaton/process_definition/tests/__init__.py +0 -0
- operaton/process_definition/tests/test_process_definition_client.py +181 -0
- operaton/utils/__init__.py +0 -0
- operaton/utils/auth_basic.py +28 -0
- operaton/utils/auth_bearer.py +28 -0
- operaton/utils/log_utils.py +31 -0
- operaton/utils/response_utils.py +35 -0
- operaton/utils/tests/test_auth_basic.py +30 -0
- operaton/utils/tests/test_auth_bearer.py +27 -0
- operaton/utils/tests/test_response_utils.py +43 -0
- operaton/utils/tests/test_utils.py +21 -0
- operaton/utils/utils.py +14 -0
- operaton/variables/__init__.py +0 -0
- operaton/variables/properties.py +27 -0
- operaton/variables/tests/test_properties.py +20 -0
- operaton/variables/tests/test_variables.py +60 -0
- operaton/variables/variables.py +45 -0
- operaton_external_task_client_python3-1.0.0.dist-info/METADATA +258 -0
- operaton_external_task_client_python3-1.0.0.dist-info/RECORD +66 -0
- operaton_external_task_client_python3-1.0.0.dist-info/WHEEL +5 -0
- operaton_external_task_client_python3-1.0.0.dist-info/licenses/LICENSE +201 -0
- operaton_external_task_client_python3-1.0.0.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
from http import HTTPStatus
|
|
3
|
+
from unittest import TestCase
|
|
4
|
+
from unittest.mock import patch
|
|
5
|
+
|
|
6
|
+
import responses
|
|
7
|
+
|
|
8
|
+
from operaton.client.engine_client import EngineClient, ENGINE_LOCAL_BASE_URL
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class EngineClientAuthTest(TestCase):
|
|
12
|
+
tenant_id = "6172cdf0-7b32-4460-9da0-ded5107aa977"
|
|
13
|
+
process_key = "PARALLEL_STEPS_EXAMPLE"
|
|
14
|
+
|
|
15
|
+
def setUp(self):
|
|
16
|
+
token = ('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJkZW1vIn0'
|
|
17
|
+
'.NbMsjy8QQ5nrjGTXqdTrJ6g0dqawRvZAqp4XvNt437M')
|
|
18
|
+
self.client = EngineClient(
|
|
19
|
+
config={"auth_bearer": {"access_token": token}})
|
|
20
|
+
|
|
21
|
+
@responses.activate
|
|
22
|
+
def test_auth_basic_start_process_success(self):
|
|
23
|
+
resp_payload = {
|
|
24
|
+
"links": [
|
|
25
|
+
{
|
|
26
|
+
"method": "GET",
|
|
27
|
+
"href": "http://localhost:8080/engine-rest/process-instance/cb678be8-9b93-11ea-bad9-0242ac110002",
|
|
28
|
+
"rel": "self"
|
|
29
|
+
}
|
|
30
|
+
],
|
|
31
|
+
"id": "cb678be8-9b93-11ea-bad9-0242ac110002",
|
|
32
|
+
"definitionId": "PARALLEL_STEPS_EXAMPLE:1:9b72da83-9b91-11ea-bad9-0242ac110002",
|
|
33
|
+
"businessKey": "123456",
|
|
34
|
+
"caseInstanceId": None,
|
|
35
|
+
"ended": False,
|
|
36
|
+
"suspended": False,
|
|
37
|
+
"tenantId": None
|
|
38
|
+
}
|
|
39
|
+
responses.add(responses.POST, self.client.get_start_process_instance_url(self.process_key, self.tenant_id),
|
|
40
|
+
json=resp_payload, status=HTTPStatus.OK)
|
|
41
|
+
actual_resp_payload = self.client.start_process(self.process_key, {}, self.tenant_id, "123456")
|
|
42
|
+
self.assertDictEqual(resp_payload, actual_resp_payload)
|
|
43
|
+
|
|
44
|
+
@responses.activate
|
|
45
|
+
def test_auth_basic_start_process_not_found_raises_exception(self):
|
|
46
|
+
resp_payload = {
|
|
47
|
+
"type": "RestException",
|
|
48
|
+
"message": "No matching process definition with key: PROCESS_KEY_NOT_EXISTS and tenant-id: tenant_123"
|
|
49
|
+
}
|
|
50
|
+
responses.add(responses.POST,
|
|
51
|
+
self.client.get_start_process_instance_url("PROCESS_KEY_NOT_EXISTS", self.tenant_id),
|
|
52
|
+
status=HTTPStatus.NOT_FOUND, json=resp_payload)
|
|
53
|
+
with self.assertRaises(Exception) as exception_ctx:
|
|
54
|
+
self.client.start_process("PROCESS_KEY_NOT_EXISTS", {}, self.tenant_id)
|
|
55
|
+
|
|
56
|
+
self.assertEqual("received 404 : RestException : "
|
|
57
|
+
"No matching process definition with key: PROCESS_KEY_NOT_EXISTS and tenant-id: tenant_123",
|
|
58
|
+
str(exception_ctx.exception))
|
|
59
|
+
|
|
60
|
+
@responses.activate
|
|
61
|
+
def test_auth_basic_start_process_bad_request_raises_exception(self):
|
|
62
|
+
client = EngineClient()
|
|
63
|
+
expected_message = "Cannot instantiate process definition " \
|
|
64
|
+
"PARALLEL_STEPS_EXAMPLE:1:9b72da83-9b91-11ea-bad9-0242ac110002: " \
|
|
65
|
+
"Cannot convert value '1aa2345' of type 'Integer' to java type java.lang.Integer"
|
|
66
|
+
resp_payload = {
|
|
67
|
+
"type": "InvalidRequestException",
|
|
68
|
+
"message": expected_message
|
|
69
|
+
}
|
|
70
|
+
responses.add(responses.POST, client.get_start_process_instance_url(self.process_key, self.tenant_id),
|
|
71
|
+
status=HTTPStatus.BAD_REQUEST, json=resp_payload)
|
|
72
|
+
with self.assertRaises(Exception) as exception_ctx:
|
|
73
|
+
client.start_process(self.process_key, {"int_var": "1aa2345"}, self.tenant_id)
|
|
74
|
+
|
|
75
|
+
self.assertEqual(f"received 400 : InvalidRequestException : {expected_message}", str(exception_ctx.exception))
|
|
76
|
+
|
|
77
|
+
@responses.activate
|
|
78
|
+
def test_auth_basic_start_process_server_error_raises_exception(self):
|
|
79
|
+
responses.add(responses.POST, self.client.get_start_process_instance_url(self.process_key, self.tenant_id),
|
|
80
|
+
status=HTTPStatus.INTERNAL_SERVER_ERROR)
|
|
81
|
+
with self.assertRaises(Exception) as exception_ctx:
|
|
82
|
+
self.client.start_process(self.process_key, {"int_var": "1aa2345"}, self.tenant_id)
|
|
83
|
+
|
|
84
|
+
self.assertEqual(HTTPStatus.INTERNAL_SERVER_ERROR, exception_ctx.exception.response.status_code)
|
|
85
|
+
self.assertIn("Server Error: Internal Server Error", str(exception_ctx.exception))
|
|
86
|
+
|
|
87
|
+
@responses.activate
|
|
88
|
+
def test_auth_basic_get_process_instance_success(self):
|
|
89
|
+
resp_payload = [
|
|
90
|
+
{
|
|
91
|
+
"links": [],
|
|
92
|
+
"id": "c2c68785-9f42-11ea-a841-0242ac1c0004",
|
|
93
|
+
"definitionId": "PARALLEL_STEPS_EXAMPLE:1:88613042-9f42-11ea-a841-0242ac1c0004",
|
|
94
|
+
"businessKey": None,
|
|
95
|
+
"caseInstanceId": None,
|
|
96
|
+
"ended": False,
|
|
97
|
+
"suspended": False,
|
|
98
|
+
"tenantId": self.tenant_id
|
|
99
|
+
}
|
|
100
|
+
]
|
|
101
|
+
get_process_instance_url = f"{ENGINE_LOCAL_BASE_URL}/process-instance" \
|
|
102
|
+
f"?processDefinitionKey={self.process_key}" \
|
|
103
|
+
f"&tenantIdIn={self.tenant_id}" \
|
|
104
|
+
f"&variables=intVar_eq_1,strVar_eq_hello"
|
|
105
|
+
responses.add(responses.GET, get_process_instance_url, status=HTTPStatus.OK, json=resp_payload)
|
|
106
|
+
actual_resp_payload = self.client.get_process_instance(process_key=self.process_key,
|
|
107
|
+
variables=["intVar_eq_1", "strVar_eq_hello"],
|
|
108
|
+
tenant_ids=[self.tenant_id])
|
|
109
|
+
self.assertListEqual(resp_payload, actual_resp_payload)
|
|
110
|
+
|
|
111
|
+
@responses.activate
|
|
112
|
+
def test_auth_basic_get_process_instance_bad_request_raises_exception(self):
|
|
113
|
+
expected_message = "Invalid variable comparator specified: XXX"
|
|
114
|
+
resp_payload = {
|
|
115
|
+
"type": "InvalidRequestException",
|
|
116
|
+
"message": expected_message
|
|
117
|
+
}
|
|
118
|
+
get_process_instance_url = f"{ENGINE_LOCAL_BASE_URL}/process-instance" \
|
|
119
|
+
f"?processDefinitionKey={self.process_key}" \
|
|
120
|
+
f"&tenantIdIn={self.tenant_id}" \
|
|
121
|
+
f"&variables=intVar_XXX_1,strVar_eq_hello"
|
|
122
|
+
responses.add(responses.GET, get_process_instance_url, status=HTTPStatus.BAD_REQUEST, json=resp_payload)
|
|
123
|
+
with self.assertRaises(Exception) as exception_ctx:
|
|
124
|
+
self.client.get_process_instance(process_key=self.process_key,
|
|
125
|
+
variables=["intVar_XXX_1", "strVar_eq_hello"],
|
|
126
|
+
tenant_ids=[self.tenant_id])
|
|
127
|
+
|
|
128
|
+
self.assertEqual(f"received 400 : InvalidRequestException : {expected_message}", str(exception_ctx.exception))
|
|
129
|
+
|
|
130
|
+
@responses.activate
|
|
131
|
+
def test_auth_basic_get_process_instance_server_error_raises_exception(self):
|
|
132
|
+
get_process_instance_url = f"{ENGINE_LOCAL_BASE_URL}/process-instance" \
|
|
133
|
+
f"?processDefinitionKey={self.process_key}" \
|
|
134
|
+
f"&tenantIdIn={self.tenant_id}" \
|
|
135
|
+
f"&variables=intVar_XXX_1,strVar_eq_hello"
|
|
136
|
+
responses.add(responses.GET, get_process_instance_url, status=HTTPStatus.INTERNAL_SERVER_ERROR)
|
|
137
|
+
with self.assertRaises(Exception) as exception_ctx:
|
|
138
|
+
self.client.get_process_instance(process_key=self.process_key,
|
|
139
|
+
variables=["intVar_XXX_1", "strVar_eq_hello"],
|
|
140
|
+
tenant_ids=[self.tenant_id])
|
|
141
|
+
|
|
142
|
+
self.assertEqual(HTTPStatus.INTERNAL_SERVER_ERROR, exception_ctx.exception.response.status_code)
|
|
143
|
+
self.assertIn("Server Error: Internal Server Error", str(exception_ctx.exception))
|
|
144
|
+
|
|
145
|
+
@patch('requests.post')
|
|
146
|
+
def test_auth_basic_correlate_message_with_only_message_name(self, mock_post):
|
|
147
|
+
expected_request_payload = {
|
|
148
|
+
"messageName": "CANCEL_MESSAGE",
|
|
149
|
+
"withoutTenantId": True,
|
|
150
|
+
"resultEnabled": True
|
|
151
|
+
}
|
|
152
|
+
token = ('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJkZW1vIn0'
|
|
153
|
+
'.NbMsjy8QQ5nrjGTXqdTrJ6g0dqawRvZAqp4XvNt437M')
|
|
154
|
+
self.client.correlate_message("CANCEL_MESSAGE")
|
|
155
|
+
mock_post.assert_called_with(ENGINE_LOCAL_BASE_URL + "/message",
|
|
156
|
+
json=expected_request_payload,
|
|
157
|
+
headers={'Content-Type': 'application/json',
|
|
158
|
+
'Authorization': f'Bearer {token}'})
|
|
159
|
+
|
|
160
|
+
@patch('requests.post')
|
|
161
|
+
def test_auth_basic_correlate_message_with_business_key(self, mock_post):
|
|
162
|
+
expected_request_payload = {
|
|
163
|
+
"messageName": "CANCEL_MESSAGE",
|
|
164
|
+
"withoutTenantId": True,
|
|
165
|
+
"businessKey": "123456",
|
|
166
|
+
"resultEnabled": True
|
|
167
|
+
}
|
|
168
|
+
token = ('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJkZW1vIn0'
|
|
169
|
+
'.NbMsjy8QQ5nrjGTXqdTrJ6g0dqawRvZAqp4XvNt437M')
|
|
170
|
+
self.client.correlate_message("CANCEL_MESSAGE", business_key="123456")
|
|
171
|
+
mock_post.assert_called_with(ENGINE_LOCAL_BASE_URL + "/message",
|
|
172
|
+
json=expected_request_payload,
|
|
173
|
+
headers={'Content-Type': 'application/json',
|
|
174
|
+
'Authorization': f'Bearer {token}'})
|
|
175
|
+
|
|
176
|
+
@patch('requests.post')
|
|
177
|
+
def test_auth_basic_correlate_message_with_tenant_id(self, mock_post):
|
|
178
|
+
expected_request_payload = {
|
|
179
|
+
"messageName": "CANCEL_MESSAGE",
|
|
180
|
+
"withoutTenantId": False,
|
|
181
|
+
"tenantId": "123456",
|
|
182
|
+
"resultEnabled": True
|
|
183
|
+
}
|
|
184
|
+
token = ('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJkZW1vIn0'
|
|
185
|
+
'.NbMsjy8QQ5nrjGTXqdTrJ6g0dqawRvZAqp4XvNt437M')
|
|
186
|
+
self.client.correlate_message("CANCEL_MESSAGE", tenant_id="123456")
|
|
187
|
+
mock_post.assert_called_with(ENGINE_LOCAL_BASE_URL + "/message",
|
|
188
|
+
json=expected_request_payload,
|
|
189
|
+
headers={'Content-Type': 'application/json',
|
|
190
|
+
'Authorization': f'Bearer {token}'})
|
|
191
|
+
|
|
192
|
+
@responses.activate
|
|
193
|
+
def test_auth_basic_correlate_message_invalid_message_name_raises_exception(self):
|
|
194
|
+
expected_message = "org.operaton.bpm.engine.MismatchingMessageCorrelationException: " \
|
|
195
|
+
"Cannot correlate message 'XXX': No process definition or execution matches the parameters"
|
|
196
|
+
resp_payload = {
|
|
197
|
+
"type": "RestException",
|
|
198
|
+
"message": expected_message
|
|
199
|
+
}
|
|
200
|
+
correlate_msg_url = f"{ENGINE_LOCAL_BASE_URL}/message"
|
|
201
|
+
responses.add(responses.POST, correlate_msg_url, status=HTTPStatus.BAD_REQUEST, json=resp_payload)
|
|
202
|
+
with self.assertRaises(Exception) as exception_ctx:
|
|
203
|
+
self.client.correlate_message(message_name="XXX")
|
|
204
|
+
|
|
205
|
+
self.assertEqual(f"received 400 : RestException : {expected_message}", str(exception_ctx.exception))
|
|
206
|
+
|
|
207
|
+
@responses.activate
|
|
208
|
+
def test_auth_basic_get_process_instance_variable_without_meta(self):
|
|
209
|
+
process_instance_id = "c2c68785-9f42-11ea-a841-0242ac1c0004"
|
|
210
|
+
variable_name = "var1"
|
|
211
|
+
process_instance_var_url = \
|
|
212
|
+
f"{ENGINE_LOCAL_BASE_URL}/process-instance/{process_instance_id}/variables/{variable_name}"
|
|
213
|
+
resp_frame_payload = {"value": None, "valueInfo": {}, "type": ""}
|
|
214
|
+
resp_data_payload = base64.decodebytes(b"operaton")
|
|
215
|
+
process_instance_var_data_url = f"{process_instance_var_url}/data"
|
|
216
|
+
|
|
217
|
+
responses.add(responses.GET, process_instance_var_url, status=HTTPStatus.OK, json=resp_frame_payload)
|
|
218
|
+
responses.add(responses.GET, process_instance_var_data_url, status=HTTPStatus.OK, body=resp_data_payload)
|
|
219
|
+
|
|
220
|
+
resp = self.client.get_process_instance_variable(process_instance_id, variable_name)
|
|
221
|
+
self.assertEqual("operaton\n", resp)
|
|
222
|
+
|
|
223
|
+
@responses.activate
|
|
224
|
+
def test_auth_basic_get_process_instance_variable_with_meta(self):
|
|
225
|
+
process_instance_id = "c2c68785-9f42-11ea-a841-0242ac1c0004"
|
|
226
|
+
variable_name = "var1"
|
|
227
|
+
process_instance_var_url = \
|
|
228
|
+
f"{ENGINE_LOCAL_BASE_URL}/process-instance/{process_instance_id}/variables/{variable_name}"
|
|
229
|
+
resp_frame_payload = {"value": None, "valueInfo": {}, "type": ""}
|
|
230
|
+
resp_data_payload = base64.decodebytes(b"operaton")
|
|
231
|
+
process_instance_var_data_url = f"{process_instance_var_url}/data"
|
|
232
|
+
|
|
233
|
+
responses.add(responses.GET, process_instance_var_url, status=HTTPStatus.OK, json=resp_frame_payload)
|
|
234
|
+
responses.add(responses.GET, process_instance_var_data_url, status=HTTPStatus.OK, body=resp_data_payload)
|
|
235
|
+
|
|
236
|
+
resp = self.client.get_process_instance_variable(process_instance_id, variable_name, True)
|
|
237
|
+
self.assertEqual({"value": "operaton\n", "valueInfo": {}, "type": ""}, resp)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from unittest import TestCase
|
|
2
|
+
|
|
3
|
+
from operaton.client.engine_client import ENGINE_LOCAL_BASE_URL
|
|
4
|
+
from operaton.client.external_task_client import ExternalTaskClient
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ExternalTaskClientTest(TestCase):
|
|
8
|
+
|
|
9
|
+
def test_creation_with_no_debug_config(self):
|
|
10
|
+
client = ExternalTaskClient(1, ENGINE_LOCAL_BASE_URL, {})
|
|
11
|
+
self.assertFalse(client.is_debug)
|
|
12
|
+
self.assertFalse(client.config.get("isDebug"))
|
|
13
|
+
|
|
14
|
+
def test_creation_with_debug_config(self):
|
|
15
|
+
client = ExternalTaskClient(1, ENGINE_LOCAL_BASE_URL, {"isDebug": True})
|
|
16
|
+
self.assertTrue(client.is_debug)
|
|
17
|
+
self.assertTrue(client.config.get("isDebug"))
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from unittest import TestCase
|
|
2
|
+
|
|
3
|
+
from operaton.client.engine_client import ENGINE_LOCAL_BASE_URL
|
|
4
|
+
from operaton.client.external_task_client import ExternalTaskClient
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ExternalTaskClientTest(TestCase):
|
|
8
|
+
|
|
9
|
+
def test_auth_basic_creation_with_no_debug_config(self):
|
|
10
|
+
client = ExternalTaskClient(
|
|
11
|
+
1, ENGINE_LOCAL_BASE_URL, {"auth_basic": {"username": "demo", "password": "demo"}})
|
|
12
|
+
self.assertFalse(client.is_debug)
|
|
13
|
+
self.assertFalse(client.config.get("isDebug"))
|
|
14
|
+
|
|
15
|
+
def test_auth_basic_creation_with_debug_config(self):
|
|
16
|
+
client = ExternalTaskClient(
|
|
17
|
+
1, ENGINE_LOCAL_BASE_URL,{"auth_basic": {"username": "demo", "password": "demo"}, "isDebug": True})
|
|
18
|
+
self.assertTrue(client.is_debug)
|
|
19
|
+
self.assertTrue(client.config.get("isDebug"))
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from unittest import TestCase
|
|
2
|
+
|
|
3
|
+
from operaton.client.engine_client import ENGINE_LOCAL_BASE_URL
|
|
4
|
+
from operaton.client.external_task_client import ExternalTaskClient
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ExternalTaskClientTest(TestCase):
|
|
8
|
+
|
|
9
|
+
def test_auth_bearer_creation_with_no_debug_config(self):
|
|
10
|
+
token = ('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJkZW1vIn0'
|
|
11
|
+
'.NbMsjy8QQ5nrjGTXqdTrJ6g0dqawRvZAqp4XvNt437M')
|
|
12
|
+
client = ExternalTaskClient(
|
|
13
|
+
1, ENGINE_LOCAL_BASE_URL, {"auth_bearer": {"access_token": token}})
|
|
14
|
+
self.assertFalse(client.is_debug)
|
|
15
|
+
self.assertFalse(client.config.get("isDebug"))
|
|
16
|
+
|
|
17
|
+
def test_auth_bearer_creation_with_debug_config(self):
|
|
18
|
+
token = ('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJkZW1vIn0'
|
|
19
|
+
'.NbMsjy8QQ5nrjGTXqdTrJ6g0dqawRvZAqp4XvNt437M')
|
|
20
|
+
client = ExternalTaskClient(
|
|
21
|
+
1, ENGINE_LOCAL_BASE_URL,
|
|
22
|
+
{"auth_bearer": {"access_token": token}, "isDebug": True})
|
|
23
|
+
self.assertTrue(client.is_debug)
|
|
24
|
+
self.assertTrue(client.config.get("isDebug"))
|
|
File without changes
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
from operaton.client.async_external_task_client import AsyncExternalTaskClient
|
|
4
|
+
from operaton.utils.log_utils import log_with_context
|
|
5
|
+
|
|
6
|
+
logger = logging.getLogger(__name__)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AsyncExternalTaskExecutor:
|
|
10
|
+
|
|
11
|
+
def __init__(self, worker_id: str, external_task_client: AsyncExternalTaskClient):
|
|
12
|
+
self.worker_id = worker_id
|
|
13
|
+
self.external_task_client = external_task_client
|
|
14
|
+
|
|
15
|
+
async def execute_task(self, task, action):
|
|
16
|
+
topic = task.get_topic_name()
|
|
17
|
+
task_id = task.get_task_id()
|
|
18
|
+
self._log_with_context(f"Executing external task for Topic: {topic}", task_id=task_id)
|
|
19
|
+
task_result = await action(task)
|
|
20
|
+
# in case task result is not set inside action function, set it in task here
|
|
21
|
+
task.set_task_result(task_result)
|
|
22
|
+
await self._handle_task_result(task_result)
|
|
23
|
+
return task_result
|
|
24
|
+
|
|
25
|
+
async def _handle_task_result(self, task_result):
|
|
26
|
+
task = task_result.get_task()
|
|
27
|
+
topic = task.get_topic_name()
|
|
28
|
+
task_id = task.get_task_id()
|
|
29
|
+
if task_result.is_success():
|
|
30
|
+
await self._handle_task_success(task_id, task_result, topic)
|
|
31
|
+
elif task_result.is_bpmn_error():
|
|
32
|
+
await self._handle_task_bpmn_error(task_id, task_result, topic)
|
|
33
|
+
elif task_result.is_failure():
|
|
34
|
+
await self._handle_task_failure(task_id, task_result, topic)
|
|
35
|
+
else:
|
|
36
|
+
err_msg = f"task result for task_id={task_id} must be either complete/failure/BPMNError"
|
|
37
|
+
self._log_with_context(err_msg, task_id=task_id, log_level='warning')
|
|
38
|
+
raise Exception(err_msg)
|
|
39
|
+
|
|
40
|
+
def _strip_long_variables(self, variables):
|
|
41
|
+
"""remove value of complex variables for the dict"""
|
|
42
|
+
if not variables:
|
|
43
|
+
return variables
|
|
44
|
+
cleaned = {}
|
|
45
|
+
for k, v in variables.items():
|
|
46
|
+
if isinstance(v, dict) and v.get("type", "") in ("File", "Bytes"):
|
|
47
|
+
cleaned[k] = {**v, "value": "..."}
|
|
48
|
+
else:
|
|
49
|
+
cleaned[k] = v
|
|
50
|
+
return cleaned
|
|
51
|
+
|
|
52
|
+
async def _handle_task_success(self, task_id, task_result, topic):
|
|
53
|
+
self._log_with_context(f"Marking task complete for Topic: {topic}", task_id)
|
|
54
|
+
if await self.external_task_client.complete(task_id, task_result.global_variables, task_result.local_variables):
|
|
55
|
+
self._log_with_context(f"Marked task completed - Topic: {topic} "
|
|
56
|
+
f"global_variables: {self._strip_long_variables(task_result.global_variables)} "
|
|
57
|
+
f"local_variables: {self._strip_long_variables(task_result.local_variables)}",
|
|
58
|
+
task_id, log_level='debug')
|
|
59
|
+
else:
|
|
60
|
+
self._log_with_context(f"Not able to mark task completed - Topic: {topic} "
|
|
61
|
+
f"global_variables: {self._strip_long_variables(task_result.global_variables)} "
|
|
62
|
+
f"local_variables: {self._strip_long_variables(task_result.local_variables)}",
|
|
63
|
+
task_id, log_level='error')
|
|
64
|
+
raise Exception(f"Not able to mark complete for task_id={task_id} "
|
|
65
|
+
f"for topic={topic}, worker_id={self.worker_id}")
|
|
66
|
+
|
|
67
|
+
async def _handle_task_failure(self, task_id, task_result, topic):
|
|
68
|
+
self._log_with_context(f"Marking task failed - Topic: {topic} task_result: {task_result}", task_id)
|
|
69
|
+
if await self.external_task_client.failure(task_id, task_result.error_message, task_result.error_details,
|
|
70
|
+
task_result.retries, task_result.retry_timeout):
|
|
71
|
+
self._log_with_context(f"Marked task failed - Topic: {topic} task_result: {task_result}", task_id)
|
|
72
|
+
else:
|
|
73
|
+
self._log_with_context(f"Not able to mark task failure - Topic: {topic}", task_id=task_id)
|
|
74
|
+
raise Exception(f"Not able to mark failure for task_id={task_id} "
|
|
75
|
+
f"for topic={topic}, worker_id={self.worker_id}")
|
|
76
|
+
|
|
77
|
+
async def _handle_task_bpmn_error(self, task_id, task_result, topic):
|
|
78
|
+
bpmn_error_handled = await self.external_task_client.bpmn_failure(task_id, task_result.bpmn_error_code,
|
|
79
|
+
task_result.error_message,
|
|
80
|
+
task_result.global_variables)
|
|
81
|
+
if bpmn_error_handled:
|
|
82
|
+
self._log_with_context(f"BPMN Error Handled: {bpmn_error_handled} "
|
|
83
|
+
f"Topic: {topic} task_result: {task_result}")
|
|
84
|
+
else:
|
|
85
|
+
self._log_with_context(f"Not able to mark BPMN error - Topic: {topic}", task_id=task_id)
|
|
86
|
+
raise Exception(f"Not able to mark BPMN Error for task_id={task_id} "
|
|
87
|
+
f"for topic={topic}, worker_id={self.worker_id}")
|
|
88
|
+
|
|
89
|
+
def _log_with_context(self, msg, task_id=None, log_level='info', **kwargs):
|
|
90
|
+
context = {"WORKER_ID": self.worker_id, "TASK_ID": task_id}
|
|
91
|
+
log_with_context(msg, context=context, log_level=log_level, **kwargs)
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
3
|
+
|
|
4
|
+
from operaton.client.async_external_task_client import AsyncExternalTaskClient
|
|
5
|
+
from operaton.client.external_task_client import ENGINE_LOCAL_BASE_URL
|
|
6
|
+
from operaton.external_task.async_external_task_executor import AsyncExternalTaskExecutor
|
|
7
|
+
from operaton.external_task.external_task import ExternalTask
|
|
8
|
+
from operaton.utils.auth_basic import obfuscate_password
|
|
9
|
+
from operaton.utils.log_utils import log_with_context
|
|
10
|
+
from operaton.utils.utils import get_exception_detail
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AsyncExternalTaskWorker:
|
|
14
|
+
DEFAULT_SLEEP_SECONDS = 1 # Sleep duration when no tasks are fetched
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
worker_id: str,
|
|
19
|
+
base_url: str = ENGINE_LOCAL_BASE_URL,
|
|
20
|
+
config: Optional[Dict[str, Any]] = None,
|
|
21
|
+
):
|
|
22
|
+
self.config = config or {}
|
|
23
|
+
self.worker_id = worker_id
|
|
24
|
+
self.client = AsyncExternalTaskClient(self.worker_id, base_url, self.config)
|
|
25
|
+
self.executor = AsyncExternalTaskExecutor(self.worker_id, self.client)
|
|
26
|
+
self.subscriptions: List[asyncio.Task] = []
|
|
27
|
+
max_concurrent_tasks = self.config.get('maxConcurrentTasks', 10)
|
|
28
|
+
self.semaphore = asyncio.Semaphore(max_concurrent_tasks)
|
|
29
|
+
self.running_tasks = set()
|
|
30
|
+
self._log_with_context(
|
|
31
|
+
f"Created new External Task Worker with config: {obfuscate_password(self.config)}"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
async def subscribe(
|
|
35
|
+
self,
|
|
36
|
+
topic_handlers: Dict[str, Callable[[ExternalTask], Any]],
|
|
37
|
+
process_variables: Optional[Dict[str, Any]] = None,
|
|
38
|
+
variables: Optional[List[str]] = None,
|
|
39
|
+
):
|
|
40
|
+
self.subscriptions = [
|
|
41
|
+
asyncio.create_task(
|
|
42
|
+
self._fetch_and_execute_safe(topic, action, process_variables, variables)
|
|
43
|
+
)
|
|
44
|
+
for topic, action in topic_handlers.items()
|
|
45
|
+
]
|
|
46
|
+
await asyncio.gather(*self.subscriptions)
|
|
47
|
+
|
|
48
|
+
async def _fetch_and_execute_safe(
|
|
49
|
+
self,
|
|
50
|
+
topic_name: str,
|
|
51
|
+
action: Callable[[ExternalTask], Any],
|
|
52
|
+
process_variables: Optional[Dict[str, Any]] = None,
|
|
53
|
+
variables: Optional[List[str]] = None,
|
|
54
|
+
):
|
|
55
|
+
sleep_seconds = self._get_sleep_seconds()
|
|
56
|
+
while True:
|
|
57
|
+
try:
|
|
58
|
+
await self.semaphore.acquire()
|
|
59
|
+
tasks_processed = await self.fetch_and_execute(topic_name, action, process_variables, variables)
|
|
60
|
+
if not tasks_processed:
|
|
61
|
+
# Release semaphore if no tasks were fetched
|
|
62
|
+
self.semaphore.release()
|
|
63
|
+
await asyncio.sleep(sleep_seconds)
|
|
64
|
+
else:
|
|
65
|
+
await asyncio.sleep(0) # Yield control to the event loop
|
|
66
|
+
except asyncio.CancelledError:
|
|
67
|
+
self._log_with_context(f"Task for topic {topic_name} was cancelled.")
|
|
68
|
+
break
|
|
69
|
+
except Exception as e:
|
|
70
|
+
self._log_with_context(
|
|
71
|
+
f"Error fetching and executing tasks: {get_exception_detail(e)} "
|
|
72
|
+
f"for topic={topic_name} with Process variables: {process_variables}. "
|
|
73
|
+
f"Retrying after {sleep_seconds} seconds",
|
|
74
|
+
exc_info=True,
|
|
75
|
+
log_level="error"
|
|
76
|
+
)
|
|
77
|
+
self.semaphore.release()
|
|
78
|
+
await asyncio.sleep(sleep_seconds)
|
|
79
|
+
|
|
80
|
+
async def fetch_and_execute(
|
|
81
|
+
self,
|
|
82
|
+
topic_name: str,
|
|
83
|
+
action: Callable[[ExternalTask], Any],
|
|
84
|
+
process_variables: Optional[Dict[str, Any]] = None,
|
|
85
|
+
variables: Optional[List[str]] = None,
|
|
86
|
+
):
|
|
87
|
+
self._log_with_context(
|
|
88
|
+
f"Fetching and executing external tasks for Topic: {topic_name} "
|
|
89
|
+
f"with Process variables: {process_variables}",
|
|
90
|
+
log_level="debug"
|
|
91
|
+
)
|
|
92
|
+
resp_json = await self.client.fetch_and_lock([topic_name], process_variables, variables)
|
|
93
|
+
tasks = self._parse_response(resp_json, topic_name, process_variables)
|
|
94
|
+
if not tasks:
|
|
95
|
+
return False
|
|
96
|
+
|
|
97
|
+
for task in tasks:
|
|
98
|
+
# Start processing the task in the background
|
|
99
|
+
running_task = asyncio.create_task(self._execute_task(task, action))
|
|
100
|
+
self.running_tasks.add(running_task)
|
|
101
|
+
# Release semaphore when task is done
|
|
102
|
+
running_task.add_done_callback(lambda t: self.semaphore.release())
|
|
103
|
+
# Remove from running_tasks when done
|
|
104
|
+
running_task.add_done_callback(self.running_tasks.discard)
|
|
105
|
+
return True
|
|
106
|
+
|
|
107
|
+
def _parse_response(
|
|
108
|
+
self,
|
|
109
|
+
resp_json: List[Dict[str, Any]],
|
|
110
|
+
topic_name: str,
|
|
111
|
+
process_variables: Optional[Dict[str, Any]],
|
|
112
|
+
) -> List[ExternalTask]:
|
|
113
|
+
tasks = [ExternalTask(context) for context in resp_json or []]
|
|
114
|
+
tasks_count = len(tasks)
|
|
115
|
+
self._log_with_context(
|
|
116
|
+
f"{tasks_count} external task(s) found for "
|
|
117
|
+
f"Topic: {topic_name}, Process variables: {process_variables}",
|
|
118
|
+
log_level="debug"
|
|
119
|
+
)
|
|
120
|
+
return tasks
|
|
121
|
+
|
|
122
|
+
async def _execute_task(self, task: ExternalTask, action: Callable[[ExternalTask], Any]):
|
|
123
|
+
try:
|
|
124
|
+
await self.executor.execute_task(task, action)
|
|
125
|
+
except asyncio.CancelledError:
|
|
126
|
+
task_result = task.failure(
|
|
127
|
+
error_message='Task execution cancelled',
|
|
128
|
+
error_details='Task was cancelled by the user or system',
|
|
129
|
+
max_retries=self.config.get('retries', AsyncExternalTaskClient.default_config['retries']),
|
|
130
|
+
retry_timeout=self.config.get('retryTimeout', AsyncExternalTaskClient.default_config['retryTimeout'])
|
|
131
|
+
)
|
|
132
|
+
await self.executor._handle_task_result(task_result)
|
|
133
|
+
self._log_with_context(
|
|
134
|
+
f"Task execution cancelled for task_id: {task.get_task_id()}",
|
|
135
|
+
topic=task.get_topic_name(),
|
|
136
|
+
task_id=task.get_task_id(),
|
|
137
|
+
log_level="info"
|
|
138
|
+
)
|
|
139
|
+
return task_result
|
|
140
|
+
except Exception as e:
|
|
141
|
+
task_result = task.failure(
|
|
142
|
+
error_message='Task execution failed',
|
|
143
|
+
error_details='An unexpected error occurred while executing the task',
|
|
144
|
+
max_retries=self.config.get('retries', AsyncExternalTaskClient.default_config['retries']),
|
|
145
|
+
retry_timeout=self.config.get('retryTimeout', AsyncExternalTaskClient.default_config['retryTimeout'])
|
|
146
|
+
)
|
|
147
|
+
await self.executor._handle_task_result(task_result)
|
|
148
|
+
self._log_with_context(
|
|
149
|
+
f"Error when executing task: {get_exception_detail(e)}. "
|
|
150
|
+
f"Task execution cancelled for task_id: {task.get_task_id()}.",
|
|
151
|
+
topic=task.get_topic_name(),
|
|
152
|
+
task_id=task.get_task_id(),
|
|
153
|
+
log_level="error",
|
|
154
|
+
exc_info=True
|
|
155
|
+
)
|
|
156
|
+
return task_result
|
|
157
|
+
|
|
158
|
+
def _log_with_context(
|
|
159
|
+
self,
|
|
160
|
+
msg: str,
|
|
161
|
+
topic: Optional[str] = None,
|
|
162
|
+
task_id: Optional[str] = None,
|
|
163
|
+
log_level: str = "info",
|
|
164
|
+
**kwargs: Any,
|
|
165
|
+
):
|
|
166
|
+
context = {"WORKER_ID": str(self.worker_id), "TOPIC": topic, "TASK_ID": task_id}
|
|
167
|
+
log_with_context(msg, context=context, log_level=log_level, **kwargs)
|
|
168
|
+
|
|
169
|
+
def _get_sleep_seconds(self) -> int:
|
|
170
|
+
return self.config.get("sleepSeconds", self.DEFAULT_SLEEP_SECONDS)
|
|
171
|
+
|
|
172
|
+
async def stop(self):
|
|
173
|
+
# First, cancel running tasks
|
|
174
|
+
for task in self.running_tasks:
|
|
175
|
+
task.cancel()
|
|
176
|
+
await asyncio.gather(*self.running_tasks, return_exceptions=True)
|
|
177
|
+
|
|
178
|
+
# Then, cancel the fetch loops (subscriptions)
|
|
179
|
+
for task in self.subscriptions:
|
|
180
|
+
task.cancel()
|
|
181
|
+
await asyncio.gather(*self.subscriptions, return_exceptions=True)
|