ddeutil-workflow 0.0.85__py3-none-any.whl → 0.0.86__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/api/routes/job.py +3 -2
- ddeutil/workflow/conf.py +5 -3
- ddeutil/workflow/errors.py +3 -0
- ddeutil/workflow/job.py +66 -42
- ddeutil/workflow/result.py +46 -55
- ddeutil/workflow/stages.py +157 -165
- ddeutil/workflow/traces.py +147 -89
- ddeutil/workflow/workflow.py +300 -360
- {ddeutil_workflow-0.0.85.dist-info → ddeutil_workflow-0.0.86.dist-info}/METADATA +2 -2
- {ddeutil_workflow-0.0.85.dist-info → ddeutil_workflow-0.0.86.dist-info}/RECORD +15 -15
- {ddeutil_workflow-0.0.85.dist-info → ddeutil_workflow-0.0.86.dist-info}/WHEEL +0 -0
- {ddeutil_workflow-0.0.85.dist-info → ddeutil_workflow-0.0.86.dist-info}/entry_points.txt +0 -0
- {ddeutil_workflow-0.0.85.dist-info → ddeutil_workflow-0.0.86.dist-info}/licenses/LICENSE +0 -0
- {ddeutil_workflow-0.0.85.dist-info → ddeutil_workflow-0.0.86.dist-info}/top_level.txt +0 -0
ddeutil/workflow/__about__.py
CHANGED
@@ -1,2 +1,2 @@
|
|
1
|
-
__version__: str = "0.0.
|
1
|
+
__version__: str = "0.0.86"
|
2
2
|
__python_version__: str = "3.9"
|
@@ -10,6 +10,7 @@ from typing import Any, Optional
|
|
10
10
|
|
11
11
|
from fastapi import APIRouter, Body
|
12
12
|
from fastapi import status as st
|
13
|
+
from fastapi.encoders import jsonable_encoder
|
13
14
|
from fastapi.responses import UJSONResponse
|
14
15
|
|
15
16
|
from ...__types import DictData
|
@@ -66,7 +67,7 @@ async def job_execute(
|
|
66
67
|
exclude_unset=True,
|
67
68
|
),
|
68
69
|
"params": params,
|
69
|
-
"context": context,
|
70
|
+
"context": jsonable_encoder(context),
|
70
71
|
},
|
71
72
|
status_code=st.HTTP_500_INTERNAL_SERVER_ERROR,
|
72
73
|
)
|
@@ -81,7 +82,7 @@ async def job_execute(
|
|
81
82
|
exclude_unset=True,
|
82
83
|
),
|
83
84
|
"params": params,
|
84
|
-
"context": context,
|
85
|
+
"context": jsonable_encoder(context),
|
85
86
|
},
|
86
87
|
status_code=st.HTTP_200_OK,
|
87
88
|
)
|
ddeutil/workflow/conf.py
CHANGED
@@ -117,7 +117,7 @@ class Config: # pragma: no cov
|
|
117
117
|
def debug(self) -> bool:
|
118
118
|
"""Debug flag for echo log that use DEBUG mode.
|
119
119
|
|
120
|
-
:
|
120
|
+
Returns: bool
|
121
121
|
"""
|
122
122
|
return str2bool(env("LOG_DEBUG_MODE", "true"))
|
123
123
|
|
@@ -472,9 +472,11 @@ def dynamic(
|
|
472
472
|
def pass_env(value: T) -> T: # pragma: no cov
|
473
473
|
"""Passing environment variable to an input value.
|
474
474
|
|
475
|
-
:
|
475
|
+
Args:
|
476
|
+
value (Any): A value that want to pass env var searching.
|
476
477
|
|
477
|
-
:
|
478
|
+
Returns:
|
479
|
+
Any: An any value that have passed environment variable.
|
478
480
|
"""
|
479
481
|
if isinstance(value, dict):
|
480
482
|
return {k: pass_env(value[k]) for k in value}
|
ddeutil/workflow/errors.py
CHANGED
ddeutil/workflow/job.py
CHANGED
@@ -75,7 +75,7 @@ from .result import (
|
|
75
75
|
from .reusables import has_template, param2template
|
76
76
|
from .stages import Stage
|
77
77
|
from .traces import Trace, get_trace
|
78
|
-
from .utils import cross_product, extract_id, filter_func, gen_id
|
78
|
+
from .utils import cross_product, extract_id, filter_func, gen_id, get_dt_now
|
79
79
|
|
80
80
|
MatrixFilter = list[dict[str, Union[str, int]]]
|
81
81
|
|
@@ -824,9 +824,14 @@ class Job(BaseModel):
|
|
824
824
|
status: dict[str, Status] = (
|
825
825
|
{"status": output.pop("status")} if "status" in output else {}
|
826
826
|
)
|
827
|
+
info: DictData = (
|
828
|
+
{"info": output.pop("info")} if "info" in output else {}
|
829
|
+
)
|
827
830
|
kwargs: DictData = kwargs or {}
|
828
831
|
if self.strategy.is_set():
|
829
|
-
to["jobs"][_id] =
|
832
|
+
to["jobs"][_id] = (
|
833
|
+
{"strategies": output} | errors | status | info | kwargs
|
834
|
+
)
|
830
835
|
elif len(k := output.keys()) > 1: # pragma: no cov
|
831
836
|
raise JobError(
|
832
837
|
"Strategy output from execution return more than one ID while "
|
@@ -835,7 +840,7 @@ class Job(BaseModel):
|
|
835
840
|
else:
|
836
841
|
_output: DictData = {} if len(k) == 0 else output[list(k)[0]]
|
837
842
|
_output.pop("matrix", {})
|
838
|
-
to["jobs"][_id] = _output | errors | status | kwargs
|
843
|
+
to["jobs"][_id] = _output | errors | status | info | kwargs
|
839
844
|
return to
|
840
845
|
|
841
846
|
def get_outputs(
|
@@ -851,7 +856,8 @@ class Job(BaseModel):
|
|
851
856
|
output (DictData): A job outputs data that want to extract
|
852
857
|
job_id (StrOrNone): A job ID if the `id` field does not set.
|
853
858
|
|
854
|
-
:
|
859
|
+
Returns:
|
860
|
+
DictData: An output data.
|
855
861
|
"""
|
856
862
|
_id: str = self.id or job_id
|
857
863
|
if self.strategy.is_set():
|
@@ -878,17 +884,31 @@ class Job(BaseModel):
|
|
878
884
|
params: DictData,
|
879
885
|
run_id: str,
|
880
886
|
context: DictData,
|
887
|
+
*,
|
881
888
|
parent_run_id: Optional[str] = None,
|
882
889
|
event: Optional[Event] = None,
|
883
890
|
) -> Result:
|
884
891
|
"""Process routing method that will route the provider function depend
|
885
892
|
on runs-on value.
|
893
|
+
|
894
|
+
Args:
|
895
|
+
params (DictData): A parameter data that want to use in this
|
896
|
+
execution.
|
897
|
+
run_id (str): A running stage ID.
|
898
|
+
context (DictData): A context data that was passed from handler
|
899
|
+
method.
|
900
|
+
parent_run_id (str, default None): A parent running ID.
|
901
|
+
event (Event, default None): An event manager that use to track
|
902
|
+
parent process was not force stopped.
|
903
|
+
|
904
|
+
Returns:
|
905
|
+
Result: The execution result with status and context data.
|
886
906
|
"""
|
887
907
|
trace: Trace = get_trace(
|
888
908
|
run_id, parent_run_id=parent_run_id, extras=self.extras
|
889
909
|
)
|
890
910
|
trace.info(
|
891
|
-
f"[JOB]: Routing
|
911
|
+
f"[JOB]: Routing "
|
892
912
|
f"{''.join(self.runs_on.type.value.split('_')).title()}: "
|
893
913
|
f"{self.id!r}"
|
894
914
|
)
|
@@ -946,6 +966,7 @@ class Job(BaseModel):
|
|
946
966
|
run_id=parent_run_id,
|
947
967
|
event=event,
|
948
968
|
)
|
969
|
+
|
949
970
|
if rs is None:
|
950
971
|
trace.error(
|
951
972
|
f"[JOB]: Execution not support runs-on: {self.runs_on.type.value!r} "
|
@@ -964,20 +985,20 @@ class Job(BaseModel):
|
|
964
985
|
},
|
965
986
|
extras=self.extras,
|
966
987
|
)
|
967
|
-
|
968
|
-
|
969
|
-
|
970
|
-
|
988
|
+
|
989
|
+
if rs.status == SKIP:
|
990
|
+
raise JobSkipError("Job got skipped status.")
|
991
|
+
elif rs.status == CANCEL:
|
992
|
+
raise JobCancelError("Job got canceled status.")
|
971
993
|
elif rs.status == FAILED:
|
972
|
-
raise JobError("
|
994
|
+
raise JobError("Job process error")
|
973
995
|
return rs
|
974
996
|
|
975
997
|
def _execute(
|
976
998
|
self,
|
977
999
|
params: DictData,
|
978
|
-
run_id: str,
|
979
1000
|
context: DictData,
|
980
|
-
|
1001
|
+
trace: Trace,
|
981
1002
|
event: Optional[Event] = None,
|
982
1003
|
) -> Result:
|
983
1004
|
"""Wrapped the route execute method before returning to handler
|
@@ -988,45 +1009,40 @@ class Job(BaseModel):
|
|
988
1009
|
|
989
1010
|
Args:
|
990
1011
|
params: A parameter data that want to use in this execution
|
991
|
-
run_id:
|
992
1012
|
context:
|
993
|
-
|
994
|
-
event:
|
1013
|
+
trace (Trace):
|
1014
|
+
event (Event, default None):
|
995
1015
|
|
996
1016
|
Returns:
|
997
1017
|
Result: The wrapped execution result.
|
998
1018
|
"""
|
999
1019
|
current_retry: int = 0
|
1020
|
+
maximum_retry: int = self.retry + 1
|
1000
1021
|
exception: Exception
|
1001
1022
|
catch(context, status=WAIT)
|
1002
|
-
trace: Trace = get_trace(
|
1003
|
-
run_id, parent_run_id=parent_run_id, extras=self.extras
|
1004
|
-
)
|
1005
1023
|
try:
|
1006
1024
|
return self.process(
|
1007
1025
|
params,
|
1008
|
-
run_id,
|
1026
|
+
run_id=trace.run_id,
|
1009
1027
|
context=context,
|
1010
|
-
parent_run_id=parent_run_id,
|
1028
|
+
parent_run_id=trace.parent_run_id,
|
1011
1029
|
event=event,
|
1012
1030
|
)
|
1013
1031
|
except (JobCancelError, JobSkipError):
|
1014
1032
|
trace.debug("[JOB]: process raise skip or cancel error.")
|
1015
1033
|
raise
|
1016
1034
|
except Exception as e:
|
1035
|
+
if self.retry == 0:
|
1036
|
+
raise
|
1037
|
+
|
1017
1038
|
current_retry += 1
|
1018
1039
|
exception = e
|
1019
|
-
finally:
|
1020
|
-
trace.debug("[JOB]: Failed at the first execution.")
|
1021
|
-
|
1022
|
-
if self.retry == 0:
|
1023
|
-
raise exception
|
1024
1040
|
|
1025
1041
|
trace.warning(
|
1026
|
-
f"[JOB]: Retry count: {current_retry} ... "
|
1042
|
+
f"[JOB]: Retry count: {current_retry}/{maximum_retry} ... "
|
1027
1043
|
f"( {exception.__class__.__name__} )"
|
1028
1044
|
)
|
1029
|
-
while current_retry <
|
1045
|
+
while current_retry < maximum_retry:
|
1030
1046
|
try:
|
1031
1047
|
catch(
|
1032
1048
|
context=context,
|
@@ -1035,18 +1051,18 @@ class Job(BaseModel):
|
|
1035
1051
|
)
|
1036
1052
|
return self.process(
|
1037
1053
|
params,
|
1038
|
-
run_id,
|
1054
|
+
run_id=trace.run_id,
|
1039
1055
|
context=context,
|
1040
|
-
parent_run_id=parent_run_id,
|
1056
|
+
parent_run_id=trace.parent_run_id,
|
1041
1057
|
event=event,
|
1042
1058
|
)
|
1043
1059
|
except (JobCancelError, JobSkipError):
|
1044
1060
|
trace.debug("[JOB]: process raise skip or cancel error.")
|
1045
1061
|
raise
|
1046
|
-
except
|
1062
|
+
except Exception as e:
|
1047
1063
|
current_retry += 1
|
1048
1064
|
trace.warning(
|
1049
|
-
f"[JOB]: Retry count: {current_retry} ... "
|
1065
|
+
f"[JOB]: Retry count: {current_retry}/{maximum_retry} ... "
|
1050
1066
|
f"( {e.__class__.__name__} )"
|
1051
1067
|
)
|
1052
1068
|
exception = e
|
@@ -1083,32 +1099,40 @@ class Job(BaseModel):
|
|
1083
1099
|
parent_run_id, run_id = extract_id(
|
1084
1100
|
(self.id or "EMPTY"), run_id=run_id, extras=self.extras
|
1085
1101
|
)
|
1102
|
+
context: DictData = {
|
1103
|
+
"status": WAIT,
|
1104
|
+
"info": {"exec_start": get_dt_now()},
|
1105
|
+
}
|
1086
1106
|
trace: Trace = get_trace(
|
1087
1107
|
run_id, parent_run_id=parent_run_id, extras=self.extras
|
1088
1108
|
)
|
1089
|
-
context: DictData = {"status": WAIT}
|
1090
1109
|
try:
|
1091
1110
|
trace.info(
|
1092
1111
|
f"[JOB]: Handler {self.runs_on.type.name}: "
|
1093
1112
|
f"{(self.id or 'EMPTY')!r}."
|
1094
1113
|
)
|
1095
|
-
|
1114
|
+
result: Result = self._execute(
|
1096
1115
|
params,
|
1097
|
-
run_id=run_id,
|
1098
1116
|
context=context,
|
1099
|
-
|
1117
|
+
trace=trace,
|
1100
1118
|
event=event,
|
1101
1119
|
)
|
1102
|
-
return
|
1103
|
-
|
1104
|
-
)
|
1105
|
-
|
1120
|
+
return result
|
1121
|
+
except JobError as e: # pragma: no cov
|
1122
|
+
if isinstance(e, JobSkipError):
|
1123
|
+
trace.error(f"[JOB]: ⏭️ Skip: {e}")
|
1124
|
+
|
1125
|
+
st: Status = get_status_from_error(e)
|
1106
1126
|
return Result.from_trace(trace).catch(
|
1107
|
-
status=
|
1108
|
-
context=catch(context, status=FAILED),
|
1109
|
-
info={"execution_time": time.monotonic() - ts},
|
1127
|
+
status=st, context=catch(context, status=st)
|
1110
1128
|
)
|
1111
1129
|
finally:
|
1130
|
+
context["info"].update(
|
1131
|
+
{
|
1132
|
+
"exec_end": get_dt_now(),
|
1133
|
+
"exec_latency": round(time.monotonic() - ts, 6),
|
1134
|
+
}
|
1135
|
+
)
|
1112
1136
|
trace.debug("[JOB]: End Handler job execution.")
|
1113
1137
|
|
1114
1138
|
|
ddeutil/workflow/result.py
CHANGED
@@ -12,30 +12,29 @@ states and the Result dataclass for context transfer between workflow components
|
|
12
12
|
from __future__ import annotations
|
13
13
|
|
14
14
|
from dataclasses import field
|
15
|
+
from datetime import datetime
|
15
16
|
from enum import Enum
|
16
17
|
from typing import Any, Optional, TypedDict, Union
|
17
18
|
|
18
19
|
from pydantic import ConfigDict
|
19
20
|
from pydantic.dataclasses import dataclass
|
20
|
-
from pydantic.functional_validators import model_validator
|
21
21
|
from typing_extensions import NotRequired, Self
|
22
22
|
|
23
|
-
from . import
|
23
|
+
from .__types import DictData
|
24
|
+
from .errors import (
|
25
|
+
BaseError,
|
26
|
+
ErrorData,
|
24
27
|
JobCancelError,
|
25
|
-
JobError,
|
26
28
|
JobSkipError,
|
29
|
+
ResultError,
|
27
30
|
StageCancelError,
|
28
|
-
StageError,
|
29
31
|
StageNestedCancelError,
|
30
|
-
StageNestedError,
|
31
32
|
StageNestedSkipError,
|
32
33
|
StageSkipError,
|
33
34
|
WorkflowCancelError,
|
34
|
-
|
35
|
+
WorkflowSkipError,
|
35
36
|
)
|
36
|
-
from .
|
37
|
-
from .audits import Trace, get_trace
|
38
|
-
from .errors import ErrorData, ResultError
|
37
|
+
from .traces import Trace, get_trace
|
39
38
|
from .utils import default_gen_id
|
40
39
|
|
41
40
|
|
@@ -110,11 +109,11 @@ def validate_statuses(statuses: list[Status]) -> Status:
|
|
110
109
|
Status: Final consolidated status based on workflow logic
|
111
110
|
|
112
111
|
Example:
|
113
|
-
|
112
|
+
Case: Mixed statuses - FAILED takes priority
|
114
113
|
>>> validate_statuses([SUCCESS, FAILED, SUCCESS])
|
115
114
|
>>> # Returns: FAILED
|
116
115
|
|
117
|
-
|
116
|
+
Case: All same status
|
118
117
|
>>> validate_statuses([SUCCESS, SUCCESS, SUCCESS])
|
119
118
|
>>> # Returns: SUCCESS
|
120
119
|
"""
|
@@ -131,21 +130,7 @@ def validate_statuses(statuses: list[Status]) -> Status:
|
|
131
130
|
|
132
131
|
|
133
132
|
def get_status_from_error(
|
134
|
-
error: Union[
|
135
|
-
StageError,
|
136
|
-
StageCancelError,
|
137
|
-
StageSkipError,
|
138
|
-
StageNestedCancelError,
|
139
|
-
StageNestedError,
|
140
|
-
StageNestedSkipError,
|
141
|
-
JobError,
|
142
|
-
JobCancelError,
|
143
|
-
JobSkipError,
|
144
|
-
WorkflowError,
|
145
|
-
WorkflowCancelError,
|
146
|
-
Exception,
|
147
|
-
BaseException,
|
148
|
-
]
|
133
|
+
error: Union[BaseError, Exception, BaseException]
|
149
134
|
) -> Status:
|
150
135
|
"""Get the Status from the error object.
|
151
136
|
|
@@ -155,7 +140,10 @@ def get_status_from_error(
|
|
155
140
|
Returns:
|
156
141
|
Status: The status from the specific exception class.
|
157
142
|
"""
|
158
|
-
if isinstance(
|
143
|
+
if isinstance(
|
144
|
+
error,
|
145
|
+
(StageNestedSkipError, StageSkipError, JobSkipError, WorkflowSkipError),
|
146
|
+
):
|
159
147
|
return SKIP
|
160
148
|
elif isinstance(
|
161
149
|
error,
|
@@ -189,25 +177,8 @@ class Result:
|
|
189
177
|
extras: DictData = field(default_factory=dict, compare=False, repr=False)
|
190
178
|
status: Status = field(default=WAIT)
|
191
179
|
context: Optional[DictData] = field(default=None)
|
192
|
-
info: DictData = field(default_factory=dict)
|
193
180
|
run_id: str = field(default_factory=default_gen_id)
|
194
181
|
parent_run_id: Optional[str] = field(default=None)
|
195
|
-
trace: Optional[Trace] = field(default=None, compare=False, repr=False)
|
196
|
-
|
197
|
-
@model_validator(mode="after")
|
198
|
-
def __prepare_trace(self) -> Self:
|
199
|
-
"""Prepare trace field that want to pass after its initialize step.
|
200
|
-
|
201
|
-
:rtype: Self
|
202
|
-
"""
|
203
|
-
if self.trace is None: # pragma: no cov
|
204
|
-
self.trace: Trace = get_trace(
|
205
|
-
self.run_id,
|
206
|
-
parent_run_id=self.parent_run_id,
|
207
|
-
extras=self.extras,
|
208
|
-
)
|
209
|
-
|
210
|
-
return self
|
211
182
|
|
212
183
|
@classmethod
|
213
184
|
def from_trace(cls, trace: Trace):
|
@@ -216,7 +187,13 @@ class Result:
|
|
216
187
|
run_id=trace.run_id,
|
217
188
|
parent_run_id=trace.parent_run_id,
|
218
189
|
extras=trace.extras,
|
219
|
-
|
190
|
+
)
|
191
|
+
|
192
|
+
def gen_trace(self) -> Trace:
|
193
|
+
return get_trace(
|
194
|
+
self.run_id,
|
195
|
+
parent_run_id=self.parent_run_id,
|
196
|
+
extras=self.extras,
|
220
197
|
)
|
221
198
|
|
222
199
|
def catch(
|
@@ -251,18 +228,16 @@ class Result:
|
|
251
228
|
self.__dict__["context"][k].update(kwargs[k])
|
252
229
|
# NOTE: Exclude the `info` key for update information data.
|
253
230
|
elif k == "info":
|
254
|
-
|
231
|
+
if "info" in self.__dict__["context"]:
|
232
|
+
self.__dict__["context"].update(kwargs[k])
|
233
|
+
else:
|
234
|
+
self.__dict__["context"]["info"] = kwargs[k]
|
255
235
|
else:
|
256
236
|
raise ResultError(
|
257
237
|
f"The key {k!r} does not exists on context data."
|
258
238
|
)
|
259
239
|
return self
|
260
240
|
|
261
|
-
def make_info(self, data: DictData) -> Self:
|
262
|
-
"""Making information."""
|
263
|
-
self.__dict__["info"].update(data)
|
264
|
-
return self
|
265
|
-
|
266
241
|
|
267
242
|
def catch(
|
268
243
|
context: DictData,
|
@@ -292,19 +267,35 @@ def catch(
|
|
292
267
|
context[k].update(kwargs[k])
|
293
268
|
# NOTE: Exclude the `info` key for update information data.
|
294
269
|
elif k == "info":
|
295
|
-
|
296
|
-
|
297
|
-
|
270
|
+
if "info" in context:
|
271
|
+
context.update(kwargs[k])
|
272
|
+
else:
|
273
|
+
context["info"] = kwargs[k]
|
274
|
+
# ARCHIVE:
|
275
|
+
# else:
|
276
|
+
# raise ResultError(f"The key {k!r} does not exist on context data.")
|
298
277
|
return context
|
299
278
|
|
300
279
|
|
280
|
+
class Info(TypedDict):
|
281
|
+
exec_start: datetime
|
282
|
+
exec_end: NotRequired[datetime]
|
283
|
+
exec_latency: NotRequired[float]
|
284
|
+
|
285
|
+
|
286
|
+
class System(TypedDict):
|
287
|
+
__sys_release_dryrun_mode: NotRequired[bool]
|
288
|
+
__sys_exec_break_circle: NotRequired[str]
|
289
|
+
|
290
|
+
|
301
291
|
class Context(TypedDict):
|
302
292
|
"""Context dict typed."""
|
303
293
|
|
304
294
|
status: Status
|
295
|
+
info: Info
|
296
|
+
sys: NotRequired[System]
|
305
297
|
context: NotRequired[DictData]
|
306
298
|
errors: NotRequired[Union[list[ErrorData], ErrorData]]
|
307
|
-
info: NotRequired[DictData]
|
308
299
|
|
309
300
|
|
310
301
|
class Layer(str, Enum):
|