ddeutil-workflow 0.0.48__py3-none-any.whl → 0.0.50__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.
- ddeutil/workflow/__about__.py +1 -1
- ddeutil/workflow/__init__.py +8 -1
- ddeutil/workflow/api/routes/logs.py +6 -5
- ddeutil/workflow/conf.py +40 -40
- ddeutil/workflow/exceptions.py +3 -3
- ddeutil/workflow/job.py +132 -76
- ddeutil/workflow/logs.py +145 -81
- ddeutil/workflow/result.py +20 -10
- ddeutil/workflow/reusables.py +3 -3
- ddeutil/workflow/scheduler.py +54 -44
- ddeutil/workflow/stages.py +514 -114
- ddeutil/workflow/utils.py +44 -40
- ddeutil/workflow/workflow.py +125 -112
- {ddeutil_workflow-0.0.48.dist-info → ddeutil_workflow-0.0.50.dist-info}/METADATA +5 -6
- ddeutil_workflow-0.0.50.dist-info/RECORD +31 -0
- ddeutil_workflow-0.0.48.dist-info/RECORD +0 -31
- {ddeutil_workflow-0.0.48.dist-info → ddeutil_workflow-0.0.50.dist-info}/WHEEL +0 -0
- {ddeutil_workflow-0.0.48.dist-info → ddeutil_workflow-0.0.50.dist-info}/licenses/LICENSE +0 -0
- {ddeutil_workflow-0.0.48.dist-info → ddeutil_workflow-0.0.50.dist-info}/top_level.txt +0 -0
ddeutil/workflow/stages.py
CHANGED
@@ -26,6 +26,7 @@ from __future__ import annotations
|
|
26
26
|
|
27
27
|
import asyncio
|
28
28
|
import contextlib
|
29
|
+
import copy
|
29
30
|
import inspect
|
30
31
|
import subprocess
|
31
32
|
import sys
|
@@ -34,16 +35,19 @@ import uuid
|
|
34
35
|
from abc import ABC, abstractmethod
|
35
36
|
from collections.abc import Iterator
|
36
37
|
from concurrent.futures import (
|
38
|
+
FIRST_EXCEPTION,
|
37
39
|
Future,
|
38
40
|
ThreadPoolExecutor,
|
39
41
|
as_completed,
|
42
|
+
wait,
|
40
43
|
)
|
44
|
+
from datetime import datetime
|
41
45
|
from inspect import Parameter
|
42
46
|
from pathlib import Path
|
43
47
|
from subprocess import CompletedProcess
|
44
48
|
from textwrap import dedent
|
45
49
|
from threading import Event
|
46
|
-
from typing import Annotated, Any, Optional, Union, get_type_hints
|
50
|
+
from typing import Annotated, Any, Optional, TypeVar, Union, get_type_hints
|
47
51
|
|
48
52
|
from pydantic import BaseModel, Field
|
49
53
|
from pydantic.functional_validators import model_validator
|
@@ -51,25 +55,16 @@ from typing_extensions import Self
|
|
51
55
|
|
52
56
|
from .__types import DictData, DictStr, TupleStr
|
53
57
|
from .conf import dynamic
|
54
|
-
from .exceptions import StageException, to_dict
|
55
|
-
from .result import FAILED, SUCCESS, Result, Status
|
58
|
+
from .exceptions import StageException, UtilException, to_dict
|
59
|
+
from .result import FAILED, SUCCESS, WAIT, Result, Status
|
56
60
|
from .reusables import TagFunc, extract_call, not_in_template, param2template
|
57
61
|
from .utils import (
|
62
|
+
filter_func,
|
58
63
|
gen_id,
|
59
64
|
make_exec,
|
60
65
|
)
|
61
66
|
|
62
|
-
|
63
|
-
"EmptyStage",
|
64
|
-
"BashStage",
|
65
|
-
"PyStage",
|
66
|
-
"CallStage",
|
67
|
-
"TriggerStage",
|
68
|
-
"ForEachStage",
|
69
|
-
"ParallelStage",
|
70
|
-
"RaiseStage",
|
71
|
-
"Stage",
|
72
|
-
)
|
67
|
+
T = TypeVar("T")
|
73
68
|
|
74
69
|
|
75
70
|
class BaseStage(BaseModel, ABC):
|
@@ -160,9 +155,8 @@ class BaseStage(BaseModel, ABC):
|
|
160
155
|
parent_run_id: str | None = None,
|
161
156
|
result: Result | None = None,
|
162
157
|
raise_error: bool | None = None,
|
163
|
-
to: DictData | None = None,
|
164
158
|
event: Event | None = None,
|
165
|
-
) -> Result:
|
159
|
+
) -> Result | DictData:
|
166
160
|
"""Handler stage execution result from the stage `execute` method.
|
167
161
|
|
168
162
|
This stage exception handler still use ok-error concept, but it
|
@@ -194,8 +188,6 @@ class BaseStage(BaseModel, ABC):
|
|
194
188
|
:param result: (Result) A result object for keeping context and status
|
195
189
|
data before execution.
|
196
190
|
:param raise_error: (bool) A flag that all this method raise error
|
197
|
-
:param to: (DictData) A target object for auto set the return output
|
198
|
-
after execution.
|
199
191
|
:param event: (Event) An event manager that pass to the stage execution.
|
200
192
|
|
201
193
|
:rtype: Result
|
@@ -210,7 +202,7 @@ class BaseStage(BaseModel, ABC):
|
|
210
202
|
|
211
203
|
try:
|
212
204
|
rs: Result = self.execute(params, result=result, event=event)
|
213
|
-
return
|
205
|
+
return rs
|
214
206
|
except Exception as e:
|
215
207
|
result.trace.error(f"[STAGE]: {e.__class__.__name__}: {e}")
|
216
208
|
|
@@ -224,11 +216,7 @@ class BaseStage(BaseModel, ABC):
|
|
224
216
|
) from e
|
225
217
|
|
226
218
|
errors: DictData = {"errors": to_dict(e)}
|
227
|
-
return (
|
228
|
-
self.set_outputs(errors, to=to)
|
229
|
-
if to is not None
|
230
|
-
else result.catch(status=FAILED, context=errors)
|
231
|
-
)
|
219
|
+
return result.catch(status=FAILED, context=errors)
|
232
220
|
|
233
221
|
def set_outputs(self, output: DictData, to: DictData) -> DictData:
|
234
222
|
"""Set an outputs from execution process to the received context. The
|
@@ -284,6 +272,28 @@ class BaseStage(BaseModel, ABC):
|
|
284
272
|
to["stages"][_id] = {"outputs": output, **skipping, **errors}
|
285
273
|
return to
|
286
274
|
|
275
|
+
def get_outputs(self, outputs: DictData) -> DictData:
|
276
|
+
"""Get the outputs from stages data. It will get this stage ID from
|
277
|
+
the stage outputs mapping.
|
278
|
+
|
279
|
+
:param outputs: (DictData) A stage outputs that want to get by stage ID.
|
280
|
+
|
281
|
+
:rtype: DictData
|
282
|
+
"""
|
283
|
+
if self.id is None and not dynamic(
|
284
|
+
"stage_default_id", extras=self.extras
|
285
|
+
):
|
286
|
+
return {}
|
287
|
+
|
288
|
+
_id: str = (
|
289
|
+
param2template(self.id, params=outputs, extras=self.extras)
|
290
|
+
if self.id
|
291
|
+
else gen_id(
|
292
|
+
param2template(self.name, params=outputs, extras=self.extras)
|
293
|
+
)
|
294
|
+
)
|
295
|
+
return outputs.get("stages", {}).get(_id, {})
|
296
|
+
|
287
297
|
def is_skipped(self, params: DictData | None = None) -> bool:
|
288
298
|
"""Return true if condition of this stage do not correct. This process
|
289
299
|
use build-in eval function to execute the if-condition.
|
@@ -365,7 +375,6 @@ class BaseAsyncStage(BaseStage):
|
|
365
375
|
parent_run_id: str | None = None,
|
366
376
|
result: Result | None = None,
|
367
377
|
raise_error: bool | None = None,
|
368
|
-
to: DictData | None = None,
|
369
378
|
event: Event | None = None,
|
370
379
|
) -> Result:
|
371
380
|
"""Async Handler stage execution result from the stage `execute` method.
|
@@ -378,8 +387,6 @@ class BaseAsyncStage(BaseStage):
|
|
378
387
|
:param result: (Result) A result object for keeping context and status
|
379
388
|
data before execution.
|
380
389
|
:param raise_error: (bool) A flag that all this method raise error
|
381
|
-
:param to: (DictData) A target object for auto set the return output
|
382
|
-
after execution.
|
383
390
|
:param event: (Event) An event manager that pass to the stage execution.
|
384
391
|
|
385
392
|
:rtype: Result
|
@@ -394,8 +401,6 @@ class BaseAsyncStage(BaseStage):
|
|
394
401
|
|
395
402
|
try:
|
396
403
|
rs: Result = await self.axecute(params, result=result, event=event)
|
397
|
-
if to is not None: # pragma: no cov
|
398
|
-
return self.set_outputs(rs.context, to=to)
|
399
404
|
return rs
|
400
405
|
except Exception as e: # pragma: no cov
|
401
406
|
await result.trace.aerror(f"[STAGE]: {e.__class__.__name__}: {e}")
|
@@ -410,9 +415,6 @@ class BaseAsyncStage(BaseStage):
|
|
410
415
|
) from None
|
411
416
|
|
412
417
|
errors: DictData = {"errors": to_dict(e)}
|
413
|
-
if to is not None:
|
414
|
-
return self.set_outputs(errors, to=to)
|
415
|
-
|
416
418
|
return result.catch(status=FAILED, context=errors)
|
417
419
|
|
418
420
|
|
@@ -819,7 +821,7 @@ class CallStage(BaseStage):
|
|
819
821
|
has_keyword: bool = False
|
820
822
|
call_func: TagFunc = extract_call(
|
821
823
|
param2template(self.uses, params, extras=self.extras),
|
822
|
-
registries=self.extras.get("
|
824
|
+
registries=self.extras.get("registry_caller"),
|
823
825
|
)()
|
824
826
|
|
825
827
|
# VALIDATE: check input task caller parameters that exists before
|
@@ -871,7 +873,7 @@ class CallStage(BaseStage):
|
|
871
873
|
# VALIDATE:
|
872
874
|
# Check the result type from call function, it should be dict.
|
873
875
|
if isinstance(rs, BaseModel):
|
874
|
-
rs: DictData = rs.model_dump()
|
876
|
+
rs: DictData = rs.model_dump(by_alias=True)
|
875
877
|
elif not isinstance(rs, dict):
|
876
878
|
raise TypeError(
|
877
879
|
f"Return type: '{call_func.name}@{call_func.tag}' does not "
|
@@ -887,6 +889,10 @@ class CallStage(BaseStage):
|
|
887
889
|
) -> DictData:
|
888
890
|
"""Parse Pydantic model from any dict data before parsing to target
|
889
891
|
caller function.
|
892
|
+
|
893
|
+
:param func:
|
894
|
+
:param args:
|
895
|
+
:param result: (Result)
|
890
896
|
"""
|
891
897
|
try:
|
892
898
|
type_hints: dict[str, Any] = get_type_hints(func)
|
@@ -933,7 +939,7 @@ class TriggerStage(BaseStage):
|
|
933
939
|
|
934
940
|
trigger: str = Field(
|
935
941
|
description=(
|
936
|
-
"A trigger workflow name that should
|
942
|
+
"A trigger workflow name that should exist on the config path."
|
937
943
|
),
|
938
944
|
)
|
939
945
|
params: DictData = Field(
|
@@ -959,26 +965,30 @@ class TriggerStage(BaseStage):
|
|
959
965
|
|
960
966
|
:rtype: Result
|
961
967
|
"""
|
962
|
-
|
963
|
-
from . import Workflow
|
968
|
+
from .workflow import Workflow
|
964
969
|
|
965
|
-
if result is None:
|
970
|
+
if result is None:
|
966
971
|
result: Result = Result(
|
967
972
|
run_id=gen_id(self.name + (self.id or ""), unique=True)
|
968
973
|
)
|
969
974
|
|
970
|
-
# NOTE: Loading workflow object from trigger name.
|
971
975
|
_trigger: str = param2template(self.trigger, params, extras=self.extras)
|
972
|
-
|
973
|
-
# NOTE: Set running workflow ID from running stage ID to external
|
974
|
-
# params on Loader object.
|
975
|
-
workflow: Workflow = Workflow.from_conf(_trigger, extras=self.extras)
|
976
976
|
result.trace.info(f"[STAGE]: Trigger-Execute: {_trigger!r}")
|
977
|
-
|
977
|
+
rs: Result = Workflow.from_conf(
|
978
|
+
_trigger, extras=self.extras | {"stage_raise_error": True}
|
979
|
+
).execute(
|
978
980
|
params=param2template(self.params, params, extras=self.extras),
|
979
981
|
result=result,
|
980
982
|
event=event,
|
981
983
|
)
|
984
|
+
if rs.status == FAILED:
|
985
|
+
err_msg: str | None = (
|
986
|
+
f" with:\n{msg}"
|
987
|
+
if (msg := rs.context.get("errors", {}).get("message"))
|
988
|
+
else ""
|
989
|
+
)
|
990
|
+
raise StageException(f"Trigger workflow was failed{err_msg}.")
|
991
|
+
return rs
|
982
992
|
|
983
993
|
|
984
994
|
class ParallelStage(BaseStage): # pragma: no cov
|
@@ -1115,10 +1125,10 @@ class ParallelStage(BaseStage): # pragma: no cov
|
|
1115
1125
|
|
1116
1126
|
class ForEachStage(BaseStage):
|
1117
1127
|
"""For-Each execution stage that execute child stages with an item in list
|
1118
|
-
of item values.
|
1128
|
+
of item values. This stage is not the low-level stage model because it runs
|
1129
|
+
muti-stages in this stage execution.
|
1119
1130
|
|
1120
|
-
|
1121
|
-
in this stage execution.
|
1131
|
+
The concept of this stage use the same logic of the Job execution.
|
1122
1132
|
|
1123
1133
|
Data Validate:
|
1124
1134
|
>>> stage = {
|
@@ -1146,13 +1156,104 @@ class ForEachStage(BaseStage):
|
|
1146
1156
|
)
|
1147
1157
|
concurrent: int = Field(
|
1148
1158
|
default=1,
|
1149
|
-
|
1159
|
+
ge=1,
|
1160
|
+
lt=10,
|
1150
1161
|
description=(
|
1151
1162
|
"A concurrent value allow to run each item at the same time. It "
|
1152
1163
|
"will be sequential mode if this value equal 1."
|
1153
1164
|
),
|
1154
1165
|
)
|
1155
1166
|
|
1167
|
+
def execute_item(
|
1168
|
+
self,
|
1169
|
+
item: Union[str, int],
|
1170
|
+
params: DictData,
|
1171
|
+
result: Result,
|
1172
|
+
*,
|
1173
|
+
event: Event | None = None,
|
1174
|
+
) -> Result:
|
1175
|
+
"""Execute foreach item from list of item.
|
1176
|
+
|
1177
|
+
:param item: (str | int) An item that want to execution.
|
1178
|
+
:param params: (DictData) A parameter that want to pass to stage
|
1179
|
+
execution.
|
1180
|
+
:param result: (Result) A result object for keeping context and status
|
1181
|
+
data.
|
1182
|
+
:param event: (Event) An event manager that use to track parent execute
|
1183
|
+
was not force stopped.
|
1184
|
+
|
1185
|
+
:rtype: Result
|
1186
|
+
"""
|
1187
|
+
result.trace.debug(f"[STAGE]: Execute item: {item!r}")
|
1188
|
+
context: DictData = copy.deepcopy(params)
|
1189
|
+
context.update({"item": item, "stages": {}})
|
1190
|
+
for stage in self.stages:
|
1191
|
+
|
1192
|
+
if self.extras:
|
1193
|
+
stage.extras = self.extras
|
1194
|
+
|
1195
|
+
if stage.is_skipped(params=context):
|
1196
|
+
result.trace.info(f"[STAGE]: Skip stage: {stage.iden!r}")
|
1197
|
+
stage.set_outputs(output={"skipped": True}, to=context)
|
1198
|
+
continue
|
1199
|
+
|
1200
|
+
if event and event.is_set():
|
1201
|
+
error_msg: str = (
|
1202
|
+
"Item-Stage was canceled from event that had set before "
|
1203
|
+
"stage item execution."
|
1204
|
+
)
|
1205
|
+
return result.catch(
|
1206
|
+
status=FAILED,
|
1207
|
+
foreach={
|
1208
|
+
item: {
|
1209
|
+
"item": item,
|
1210
|
+
"stages": filter_func(context.pop("stages", {})),
|
1211
|
+
"errors": StageException(error_msg).to_dict(),
|
1212
|
+
}
|
1213
|
+
},
|
1214
|
+
)
|
1215
|
+
|
1216
|
+
try:
|
1217
|
+
rs: Result = stage.handler_execute(
|
1218
|
+
params=context,
|
1219
|
+
run_id=result.run_id,
|
1220
|
+
parent_run_id=result.parent_run_id,
|
1221
|
+
raise_error=True,
|
1222
|
+
)
|
1223
|
+
stage.set_outputs(rs.context, to=context)
|
1224
|
+
if rs.status == FAILED:
|
1225
|
+
error_msg: str = (
|
1226
|
+
f"Item-Stage was break because it has a sub stage, "
|
1227
|
+
f"{stage.iden}, failed without raise error."
|
1228
|
+
)
|
1229
|
+
return result.catch(
|
1230
|
+
status=FAILED,
|
1231
|
+
foreach={
|
1232
|
+
item: {
|
1233
|
+
"item": item,
|
1234
|
+
"stages": filter_func(
|
1235
|
+
context.pop("stages", {})
|
1236
|
+
),
|
1237
|
+
"errors": StageException(error_msg).to_dict(),
|
1238
|
+
},
|
1239
|
+
},
|
1240
|
+
)
|
1241
|
+
except (StageException, UtilException) as e:
|
1242
|
+
result.trace.error(f"[STAGE]: {e.__class__.__name__}: {e}")
|
1243
|
+
raise StageException(
|
1244
|
+
f"Sub-Stage execution error: {e.__class__.__name__}: {e}"
|
1245
|
+
) from None
|
1246
|
+
|
1247
|
+
return result.catch(
|
1248
|
+
status=SUCCESS,
|
1249
|
+
foreach={
|
1250
|
+
item: {
|
1251
|
+
"item": item,
|
1252
|
+
"stages": filter_func(context.pop("stages", {})),
|
1253
|
+
},
|
1254
|
+
},
|
1255
|
+
)
|
1256
|
+
|
1156
1257
|
def execute(
|
1157
1258
|
self,
|
1158
1259
|
params: DictData,
|
@@ -1172,9 +1273,10 @@ class ForEachStage(BaseStage):
|
|
1172
1273
|
"""
|
1173
1274
|
if result is None: # pragma: no cov
|
1174
1275
|
result: Result = Result(
|
1175
|
-
run_id=gen_id(self.name + (self.id or ""), unique=True)
|
1276
|
+
run_id=gen_id(self.name + (self.id or ""), unique=True),
|
1277
|
+
extras=self.extras,
|
1176
1278
|
)
|
1177
|
-
|
1279
|
+
event: Event = Event() if event is None else event
|
1178
1280
|
foreach: Union[list[str], list[int]] = (
|
1179
1281
|
param2template(self.foreach, params, extras=self.extras)
|
1180
1282
|
if isinstance(self.foreach, str)
|
@@ -1186,41 +1288,59 @@ class ForEachStage(BaseStage):
|
|
1186
1288
|
)
|
1187
1289
|
|
1188
1290
|
result.trace.info(f"[STAGE]: Foreach-Execute: {foreach!r}.")
|
1189
|
-
|
1190
|
-
|
1191
|
-
|
1192
|
-
|
1193
|
-
|
1194
|
-
|
1195
|
-
|
1291
|
+
result.catch(status=WAIT, context={"items": foreach, "foreach": {}})
|
1292
|
+
if event and event.is_set(): # pragma: no cov
|
1293
|
+
return result.catch(
|
1294
|
+
status=FAILED,
|
1295
|
+
context={
|
1296
|
+
"errors": StageException(
|
1297
|
+
"Stage was canceled from event that had set "
|
1298
|
+
"before stage foreach execution."
|
1299
|
+
).to_dict()
|
1300
|
+
},
|
1301
|
+
)
|
1302
|
+
|
1303
|
+
with ThreadPoolExecutor(
|
1304
|
+
max_workers=self.concurrent, thread_name_prefix="stage_foreach_"
|
1305
|
+
) as executor:
|
1306
|
+
|
1307
|
+
futures: list[Future] = [
|
1308
|
+
executor.submit(
|
1309
|
+
self.execute_item,
|
1310
|
+
item=item,
|
1311
|
+
params=params,
|
1312
|
+
result=result,
|
1313
|
+
)
|
1314
|
+
for item in foreach
|
1315
|
+
]
|
1316
|
+
context: DictData = {}
|
1317
|
+
status: Status = SUCCESS
|
1196
1318
|
|
1197
|
-
|
1319
|
+
done, not_done = wait(
|
1320
|
+
futures, timeout=1800, return_when=FIRST_EXCEPTION
|
1321
|
+
)
|
1198
1322
|
|
1199
|
-
|
1200
|
-
|
1323
|
+
if len(done) != len(futures):
|
1324
|
+
result.trace.warning(
|
1325
|
+
"[STAGE]: Set the event for stop running stage."
|
1326
|
+
)
|
1327
|
+
event.set()
|
1328
|
+
for future in not_done:
|
1329
|
+
future.cancel()
|
1201
1330
|
|
1331
|
+
for future in done:
|
1202
1332
|
try:
|
1203
|
-
|
1204
|
-
|
1205
|
-
params=params,
|
1206
|
-
run_id=result.run_id,
|
1207
|
-
parent_run_id=result.parent_run_id,
|
1208
|
-
).context,
|
1209
|
-
to=context,
|
1210
|
-
)
|
1211
|
-
except StageException as e: # pragma: no cov
|
1333
|
+
future.result()
|
1334
|
+
except StageException as e:
|
1212
1335
|
status = FAILED
|
1213
1336
|
result.trace.error(
|
1214
|
-
f"[STAGE]: Catch:\n\t{e.__class__.__name__}
|
1337
|
+
f"[STAGE]: Catch:\n\t{e.__class__.__name__}:\n\t{e}"
|
1215
1338
|
)
|
1216
1339
|
context.update({"errors": e.to_dict()})
|
1217
1340
|
|
1218
|
-
|
1219
|
-
|
1220
|
-
return result.catch(status=status, context=rs)
|
1341
|
+
return result.catch(status=status, context=context)
|
1221
1342
|
|
1222
1343
|
|
1223
|
-
# TODO: Not implement this stages yet
|
1224
1344
|
class UntilStage(BaseStage): # pragma: no cov
|
1225
1345
|
"""Until execution stage.
|
1226
1346
|
|
@@ -1247,27 +1367,191 @@ class UntilStage(BaseStage): # pragma: no cov
|
|
1247
1367
|
"correct."
|
1248
1368
|
),
|
1249
1369
|
)
|
1250
|
-
|
1251
|
-
default=
|
1252
|
-
|
1370
|
+
max_until_loop: int = Field(
|
1371
|
+
default=10,
|
1372
|
+
ge=1,
|
1373
|
+
lt=100,
|
1374
|
+
description="The maximum value of loop for this until stage.",
|
1253
1375
|
)
|
1254
1376
|
|
1377
|
+
def execute_item(
|
1378
|
+
self,
|
1379
|
+
item: T,
|
1380
|
+
loop: int,
|
1381
|
+
params: DictData,
|
1382
|
+
result: Result,
|
1383
|
+
event: Event | None = None,
|
1384
|
+
) -> tuple[Result, T]:
|
1385
|
+
"""Execute until item set item by some stage or by default loop
|
1386
|
+
variable.
|
1387
|
+
|
1388
|
+
:param item: (T) An item that want to execution.
|
1389
|
+
:param loop: (int) A number of loop.
|
1390
|
+
:param params: (DictData) A parameter that want to pass to stage
|
1391
|
+
execution.
|
1392
|
+
:param result: (Result) A result object for keeping context and status
|
1393
|
+
data.
|
1394
|
+
:param event: (Event) An event manager that use to track parent execute
|
1395
|
+
was not force stopped.
|
1396
|
+
|
1397
|
+
:rtype: tuple[Result, T]
|
1398
|
+
"""
|
1399
|
+
result.trace.debug(f"[STAGE]: Execute until item: {item!r}")
|
1400
|
+
context: DictData = copy.deepcopy(params)
|
1401
|
+
context.update({"loop": loop, "item": item, "stages": {}})
|
1402
|
+
next_item: T = None
|
1403
|
+
for stage in self.stages:
|
1404
|
+
|
1405
|
+
if self.extras:
|
1406
|
+
stage.extras = self.extras
|
1407
|
+
|
1408
|
+
if stage.is_skipped(params=context):
|
1409
|
+
result.trace.info(f"[STAGE]: Skip stage: {stage.iden!r}")
|
1410
|
+
stage.set_outputs(output={"skipped": True}, to=context)
|
1411
|
+
continue
|
1412
|
+
|
1413
|
+
if event and event.is_set():
|
1414
|
+
error_msg: str = (
|
1415
|
+
"Item-Stage was canceled from event that had set before "
|
1416
|
+
"stage item execution."
|
1417
|
+
)
|
1418
|
+
return (
|
1419
|
+
result.catch(
|
1420
|
+
status=FAILED,
|
1421
|
+
until={
|
1422
|
+
loop: {
|
1423
|
+
"loop": loop,
|
1424
|
+
"item": item,
|
1425
|
+
"stages": filter_func(
|
1426
|
+
context.pop("stages", {})
|
1427
|
+
),
|
1428
|
+
"errors": StageException(error_msg).to_dict(),
|
1429
|
+
}
|
1430
|
+
},
|
1431
|
+
),
|
1432
|
+
next_item,
|
1433
|
+
)
|
1434
|
+
|
1435
|
+
try:
|
1436
|
+
rs: Result = stage.handler_execute(
|
1437
|
+
params=context,
|
1438
|
+
run_id=result.run_id,
|
1439
|
+
parent_run_id=result.parent_run_id,
|
1440
|
+
raise_error=True,
|
1441
|
+
)
|
1442
|
+
stage.set_outputs(rs.context, to=context)
|
1443
|
+
if "item" in (
|
1444
|
+
outputs := stage.get_outputs(context).get("outputs", {})
|
1445
|
+
):
|
1446
|
+
next_item = outputs["item"]
|
1447
|
+
except (StageException, UtilException) as e:
|
1448
|
+
result.trace.error(f"[STAGE]: {e.__class__.__name__}: {e}")
|
1449
|
+
raise StageException(
|
1450
|
+
f"Sub-Stage execution error: {e.__class__.__name__}: {e}"
|
1451
|
+
) from None
|
1452
|
+
|
1453
|
+
return (
|
1454
|
+
result.catch(
|
1455
|
+
status=SUCCESS,
|
1456
|
+
until={
|
1457
|
+
loop: {
|
1458
|
+
"loop": loop,
|
1459
|
+
"item": item,
|
1460
|
+
"stages": filter_func(context.pop("stages", {})),
|
1461
|
+
}
|
1462
|
+
},
|
1463
|
+
),
|
1464
|
+
next_item,
|
1465
|
+
)
|
1466
|
+
|
1255
1467
|
def execute(
|
1256
1468
|
self,
|
1257
1469
|
params: DictData,
|
1258
1470
|
*,
|
1259
1471
|
result: Result | None = None,
|
1260
1472
|
event: Event | None = None,
|
1261
|
-
) -> Result:
|
1473
|
+
) -> Result:
|
1474
|
+
"""Execute the stages that pass item from until condition field and
|
1475
|
+
setter step.
|
1476
|
+
|
1477
|
+
:param params: A parameter that want to pass before run any statement.
|
1478
|
+
:param result: (Result) A result object for keeping context and status
|
1479
|
+
data.
|
1480
|
+
:param event: (Event) An event manager that use to track parent execute
|
1481
|
+
was not force stopped.
|
1482
|
+
|
1483
|
+
:rtype: Result
|
1484
|
+
"""
|
1485
|
+
if result is None: # pragma: no cov
|
1486
|
+
result: Result = Result(
|
1487
|
+
run_id=gen_id(self.name + (self.id or ""), unique=True)
|
1488
|
+
)
|
1489
|
+
result.trace.info(f"[STAGE]: Until-Execution: {self.until}")
|
1490
|
+
item: Union[str, int, bool] = param2template(
|
1491
|
+
self.item, params, extras=self.extras
|
1492
|
+
)
|
1493
|
+
loop: int = 1
|
1494
|
+
track: bool = True
|
1495
|
+
exceed_loop: bool = False
|
1496
|
+
result.catch(status=WAIT, context={"until": {}})
|
1497
|
+
while track and not (exceed_loop := loop >= self.max_until_loop):
|
1498
|
+
|
1499
|
+
if event and event.is_set():
|
1500
|
+
return result.catch(
|
1501
|
+
status=FAILED,
|
1502
|
+
context={
|
1503
|
+
"errors": StageException(
|
1504
|
+
"Stage was canceled from event that had set "
|
1505
|
+
"before stage until execution."
|
1506
|
+
).to_dict()
|
1507
|
+
},
|
1508
|
+
)
|
1509
|
+
|
1510
|
+
result, item = self.execute_item(
|
1511
|
+
item=item,
|
1512
|
+
loop=loop,
|
1513
|
+
params=params,
|
1514
|
+
result=result,
|
1515
|
+
)
|
1516
|
+
|
1517
|
+
loop += 1
|
1518
|
+
if item is None:
|
1519
|
+
result.trace.warning(
|
1520
|
+
"... Does not have set item stage. It will use loop by "
|
1521
|
+
"default."
|
1522
|
+
)
|
1523
|
+
item = loop
|
1524
|
+
|
1525
|
+
next_track: bool = eval(
|
1526
|
+
param2template(
|
1527
|
+
self.until,
|
1528
|
+
params | {"item": item, "loop": loop},
|
1529
|
+
extras=self.extras,
|
1530
|
+
),
|
1531
|
+
globals() | params | {"item": item},
|
1532
|
+
{},
|
1533
|
+
)
|
1534
|
+
if not isinstance(next_track, bool):
|
1535
|
+
raise StageException(
|
1536
|
+
"Return type of until condition does not be boolean, it"
|
1537
|
+
f"return: {next_track!r}"
|
1538
|
+
)
|
1539
|
+
track = not next_track
|
1540
|
+
|
1541
|
+
if exceed_loop:
|
1542
|
+
raise StageException(
|
1543
|
+
f"The until loop was exceed {self.max_until_loop} loops"
|
1544
|
+
)
|
1545
|
+
return result.catch(status=SUCCESS)
|
1262
1546
|
|
1263
1547
|
|
1264
|
-
# TODO: Not implement this stages yet
|
1265
1548
|
class Match(BaseModel):
|
1266
|
-
|
1267
|
-
|
1549
|
+
"""Match model for the Case Stage."""
|
1550
|
+
|
1551
|
+
case: Union[str, int] = Field(description="A match case.")
|
1552
|
+
stage: Stage = Field(description="A stage to execution for this case.")
|
1268
1553
|
|
1269
1554
|
|
1270
|
-
# TODO: Not implement this stages yet
|
1271
1555
|
class CaseStage(BaseStage): # pragma: no cov
|
1272
1556
|
"""Case execution stage.
|
1273
1557
|
|
@@ -1303,7 +1587,9 @@ class CaseStage(BaseStage): # pragma: no cov
|
|
1303
1587
|
"""
|
1304
1588
|
|
1305
1589
|
case: str = Field(description="A case condition for routing.")
|
1306
|
-
match: list[Match]
|
1590
|
+
match: list[Match] = Field(
|
1591
|
+
description="A list of Match model that should not be an empty list.",
|
1592
|
+
)
|
1307
1593
|
|
1308
1594
|
def execute(
|
1309
1595
|
self,
|
@@ -1326,10 +1612,14 @@ class CaseStage(BaseStage): # pragma: no cov
|
|
1326
1612
|
result: Result = Result(
|
1327
1613
|
run_id=gen_id(self.name + (self.id or ""), unique=True)
|
1328
1614
|
)
|
1329
|
-
|
1615
|
+
|
1330
1616
|
_case = param2template(self.case, params, extras=self.extras)
|
1617
|
+
|
1618
|
+
result.trace.info(f"[STAGE]: Case-Execute: {_case!r}.")
|
1331
1619
|
_else = None
|
1620
|
+
stage: Optional[Stage] = None
|
1332
1621
|
context = {}
|
1622
|
+
status = SUCCESS
|
1333
1623
|
for match in self.match:
|
1334
1624
|
if (c := match.case) != "_":
|
1335
1625
|
_condition = param2template(c, params, extras=self.extras)
|
@@ -1337,33 +1627,41 @@ class CaseStage(BaseStage): # pragma: no cov
|
|
1337
1627
|
_else = match
|
1338
1628
|
continue
|
1339
1629
|
|
1340
|
-
if
|
1630
|
+
if stage is None and _case == _condition:
|
1341
1631
|
stage: Stage = match.stage
|
1342
|
-
if self.extras:
|
1343
|
-
stage.extras = self.extras
|
1344
1632
|
|
1345
|
-
|
1346
|
-
|
1347
|
-
|
1348
|
-
|
1349
|
-
|
1350
|
-
|
1351
|
-
).context,
|
1352
|
-
to=context,
|
1353
|
-
)
|
1354
|
-
except StageException as e: # pragma: no cov
|
1355
|
-
status = FAILED
|
1356
|
-
result.trace.error(
|
1357
|
-
f"[STAGE]: Catch:\n\t{e.__class__.__name__}:" f"\n\t{e}"
|
1358
|
-
)
|
1359
|
-
context.update({"errors": e.to_dict()})
|
1633
|
+
if stage is None:
|
1634
|
+
if _else is None:
|
1635
|
+
raise StageException(
|
1636
|
+
"This stage does not set else for support not match "
|
1637
|
+
"any case."
|
1638
|
+
)
|
1360
1639
|
|
1640
|
+
stage: Stage = _else.stage
|
1641
|
+
|
1642
|
+
if self.extras:
|
1643
|
+
stage.extras = self.extras
|
1644
|
+
|
1645
|
+
try:
|
1646
|
+
context.update(
|
1647
|
+
stage.handler_execute(
|
1648
|
+
params=params,
|
1649
|
+
run_id=result.run_id,
|
1650
|
+
parent_run_id=result.parent_run_id,
|
1651
|
+
).context
|
1652
|
+
)
|
1653
|
+
except StageException as e: # pragma: no cov
|
1654
|
+
status = FAILED
|
1655
|
+
result.trace.error(
|
1656
|
+
f"[STAGE]: Catch:\n\t{e.__class__.__name__}:" f"\n\t{e}"
|
1657
|
+
)
|
1658
|
+
context.update({"errors": e.to_dict()})
|
1361
1659
|
return result.catch(status=status, context=context)
|
1362
1660
|
|
1363
1661
|
|
1364
1662
|
class RaiseStage(BaseStage): # pragma: no cov
|
1365
|
-
"""Raise error stage that raise StageException that use a message
|
1366
|
-
making error message before raise.
|
1663
|
+
"""Raise error stage execution that raise StageException that use a message
|
1664
|
+
field for making error message before raise.
|
1367
1665
|
|
1368
1666
|
Data Validate:
|
1369
1667
|
>>> stage = {
|
@@ -1385,13 +1683,21 @@ class RaiseStage(BaseStage): # pragma: no cov
|
|
1385
1683
|
result: Result | None = None,
|
1386
1684
|
event: Event | None = None,
|
1387
1685
|
) -> Result:
|
1388
|
-
"""Raise the
|
1686
|
+
"""Raise the StageException object with the message field execution.
|
1687
|
+
|
1688
|
+
:param params: A parameter that want to pass before run any statement.
|
1689
|
+
:param result: (Result) A result object for keeping context and status
|
1690
|
+
data.
|
1691
|
+
:param event: (Event) An event manager that use to track parent execute
|
1692
|
+
was not force stopped.
|
1693
|
+
"""
|
1389
1694
|
if result is None: # pragma: no cov
|
1390
1695
|
result: Result = Result(
|
1391
1696
|
run_id=gen_id(self.name + (self.id or ""), unique=True)
|
1392
1697
|
)
|
1393
|
-
|
1394
|
-
|
1698
|
+
message: str = param2template(self.message, params, extras=self.extras)
|
1699
|
+
result.trace.info(f"[STAGE]: Raise-Execute: {message!r}.")
|
1700
|
+
raise StageException(message)
|
1395
1701
|
|
1396
1702
|
|
1397
1703
|
# TODO: Not implement this stages yet
|
@@ -1399,7 +1705,7 @@ class HookStage(BaseStage): # pragma: no cov
|
|
1399
1705
|
"""Hook stage execution."""
|
1400
1706
|
|
1401
1707
|
hook: str
|
1402
|
-
args: DictData
|
1708
|
+
args: DictData = Field(default_factory=dict)
|
1403
1709
|
callback: str
|
1404
1710
|
|
1405
1711
|
def execute(
|
@@ -1408,7 +1714,8 @@ class HookStage(BaseStage): # pragma: no cov
|
|
1408
1714
|
*,
|
1409
1715
|
result: Result | None = None,
|
1410
1716
|
event: Event | None = None,
|
1411
|
-
) -> Result:
|
1717
|
+
) -> Result:
|
1718
|
+
raise NotImplementedError("Hook Stage does not implement yet.")
|
1412
1719
|
|
1413
1720
|
|
1414
1721
|
# TODO: Not implement this stages yet
|
@@ -1431,9 +1738,69 @@ class DockerStage(BaseStage): # pragma: no cov
|
|
1431
1738
|
image: str = Field(
|
1432
1739
|
description="A Docker image url with tag that want to run.",
|
1433
1740
|
)
|
1741
|
+
tag: str = Field(default="latest", description="An Docker image tag.")
|
1434
1742
|
env: DictData = Field(default_factory=dict)
|
1435
1743
|
volume: DictData = Field(default_factory=dict)
|
1436
|
-
auth: DictData = Field(
|
1744
|
+
auth: DictData = Field(
|
1745
|
+
default_factory=dict,
|
1746
|
+
description=(
|
1747
|
+
"An authentication of the Docker registry that use in pulling step."
|
1748
|
+
),
|
1749
|
+
)
|
1750
|
+
|
1751
|
+
def execute_task(
|
1752
|
+
self,
|
1753
|
+
result: Result,
|
1754
|
+
):
|
1755
|
+
from docker import DockerClient
|
1756
|
+
from docker.errors import ContainerError
|
1757
|
+
|
1758
|
+
client = DockerClient(
|
1759
|
+
base_url="unix://var/run/docker.sock", version="auto"
|
1760
|
+
)
|
1761
|
+
|
1762
|
+
resp = client.api.pull(
|
1763
|
+
repository=f"{self.image}",
|
1764
|
+
tag=self.tag,
|
1765
|
+
# NOTE: For pulling from GCS.
|
1766
|
+
# {
|
1767
|
+
# "username": "_json_key",
|
1768
|
+
# "password": credential-json-string,
|
1769
|
+
# }
|
1770
|
+
auth_config=self.auth,
|
1771
|
+
stream=True,
|
1772
|
+
decode=True,
|
1773
|
+
)
|
1774
|
+
for line in resp:
|
1775
|
+
result.trace.info(f"... {line}")
|
1776
|
+
|
1777
|
+
unique_image_name: str = f"{self.image}_{datetime.now():%Y%m%d%H%M%S}"
|
1778
|
+
container = client.containers.run(
|
1779
|
+
image=f"{self.image}:{self.tag}",
|
1780
|
+
name=unique_image_name,
|
1781
|
+
environment=self.env,
|
1782
|
+
volumes=(
|
1783
|
+
{Path.cwd() / ".docker.logs": {"bind": "/logs", "mode": "rw"}}
|
1784
|
+
| self.volume
|
1785
|
+
),
|
1786
|
+
detach=True,
|
1787
|
+
)
|
1788
|
+
|
1789
|
+
for line in container.logs(stream=True, timestamps=True):
|
1790
|
+
result.trace.info(f"... {line.strip().decode()}")
|
1791
|
+
|
1792
|
+
# NOTE: This code copy from the docker package.
|
1793
|
+
exit_status: int = container.wait()["StatusCode"]
|
1794
|
+
if exit_status != 0:
|
1795
|
+
out = container.logs(stdout=False, stderr=True)
|
1796
|
+
container.remove()
|
1797
|
+
raise ContainerError(
|
1798
|
+
container,
|
1799
|
+
exit_status,
|
1800
|
+
None,
|
1801
|
+
f"{self.image}:{self.tag}",
|
1802
|
+
out,
|
1803
|
+
)
|
1437
1804
|
|
1438
1805
|
def execute(
|
1439
1806
|
self,
|
@@ -1441,15 +1808,20 @@ class DockerStage(BaseStage): # pragma: no cov
|
|
1441
1808
|
*,
|
1442
1809
|
result: Result | None = None,
|
1443
1810
|
event: Event | None = None,
|
1444
|
-
) -> Result:
|
1811
|
+
) -> Result:
|
1812
|
+
raise NotImplementedError("Docker Stage does not implement yet.")
|
1445
1813
|
|
1446
1814
|
|
1447
1815
|
# TODO: Not implement this stages yet
|
1448
1816
|
class VirtualPyStage(PyStage): # pragma: no cov
|
1449
1817
|
"""Python Virtual Environment stage execution."""
|
1450
1818
|
|
1451
|
-
|
1452
|
-
|
1819
|
+
deps: list[str] = Field(
|
1820
|
+
description=(
|
1821
|
+
"list of Python dependency that want to install before execution "
|
1822
|
+
"stage."
|
1823
|
+
),
|
1824
|
+
)
|
1453
1825
|
|
1454
1826
|
def create_py_file(self, py: str, run_id: str | None): ...
|
1455
1827
|
|
@@ -1460,7 +1832,30 @@ class VirtualPyStage(PyStage): # pragma: no cov
|
|
1460
1832
|
result: Result | None = None,
|
1461
1833
|
event: Event | None = None,
|
1462
1834
|
) -> Result:
|
1463
|
-
|
1835
|
+
"""Execute the Python statement via Python virtual environment.
|
1836
|
+
|
1837
|
+
Steps:
|
1838
|
+
- Create python file.
|
1839
|
+
- Create `.venv` and install necessary Python deps.
|
1840
|
+
- Execution python file with uv and specific `.venv`.
|
1841
|
+
|
1842
|
+
:param params: A parameter that want to pass before run any statement.
|
1843
|
+
:param result: (Result) A result object for keeping context and status
|
1844
|
+
data.
|
1845
|
+
:param event: (Event) An event manager that use to track parent execute
|
1846
|
+
was not force stopped.
|
1847
|
+
|
1848
|
+
:rtype: Result
|
1849
|
+
"""
|
1850
|
+
if result is None: # pragma: no cov
|
1851
|
+
result: Result = Result(
|
1852
|
+
run_id=gen_id(self.name + (self.id or ""), unique=True)
|
1853
|
+
)
|
1854
|
+
|
1855
|
+
result.trace.info(f"[STAGE]: Py-Virtual-Execute: {self.name}")
|
1856
|
+
raise NotImplementedError(
|
1857
|
+
"Python Virtual Stage does not implement yet."
|
1858
|
+
)
|
1464
1859
|
|
1465
1860
|
|
1466
1861
|
# NOTE:
|
@@ -1470,11 +1865,16 @@ class VirtualPyStage(PyStage): # pragma: no cov
|
|
1470
1865
|
#
|
1471
1866
|
Stage = Annotated[
|
1472
1867
|
Union[
|
1868
|
+
DockerStage,
|
1473
1869
|
BashStage,
|
1474
1870
|
CallStage,
|
1871
|
+
HookStage,
|
1475
1872
|
TriggerStage,
|
1476
1873
|
ForEachStage,
|
1874
|
+
UntilStage,
|
1477
1875
|
ParallelStage,
|
1876
|
+
CaseStage,
|
1877
|
+
VirtualPyStage,
|
1478
1878
|
PyStage,
|
1479
1879
|
RaiseStage,
|
1480
1880
|
EmptyStage,
|