ddeutil-workflow 0.0.8__py3-none-any.whl → 0.0.9__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 +3 -14
- ddeutil/workflow/api.py +44 -75
- ddeutil/workflow/cli.py +51 -0
- ddeutil/workflow/cron.py +713 -0
- ddeutil/workflow/loader.py +65 -13
- ddeutil/workflow/log.py +147 -49
- ddeutil/workflow/on.py +18 -15
- ddeutil/workflow/pipeline.py +389 -140
- ddeutil/workflow/repeat.py +9 -5
- ddeutil/workflow/route.py +30 -37
- ddeutil/workflow/scheduler.py +398 -659
- ddeutil/workflow/stage.py +145 -73
- ddeutil/workflow/utils.py +133 -42
- ddeutil_workflow-0.0.9.dist-info/METADATA +273 -0
- ddeutil_workflow-0.0.9.dist-info/RECORD +22 -0
- {ddeutil_workflow-0.0.8.dist-info → ddeutil_workflow-0.0.9.dist-info}/WHEEL +1 -1
- ddeutil_workflow-0.0.9.dist-info/entry_points.txt +2 -0
- ddeutil/workflow/app.py +0 -45
- ddeutil_workflow-0.0.8.dist-info/METADATA +0 -266
- ddeutil_workflow-0.0.8.dist-info/RECORD +0 -20
- {ddeutil_workflow-0.0.8.dist-info → ddeutil_workflow-0.0.9.dist-info}/LICENSE +0 -0
- {ddeutil_workflow-0.0.8.dist-info → ddeutil_workflow-0.0.9.dist-info}/top_level.txt +0 -0
ddeutil/workflow/stage.py
CHANGED
@@ -31,11 +31,18 @@ from functools import wraps
|
|
31
31
|
from inspect import Parameter
|
32
32
|
from pathlib import Path
|
33
33
|
from subprocess import CompletedProcess
|
34
|
+
from textwrap import dedent
|
34
35
|
from typing import Callable, Optional, Union
|
35
36
|
|
37
|
+
try:
|
38
|
+
from typing import ParamSpec
|
39
|
+
except ImportError:
|
40
|
+
from typing_extensions import ParamSpec
|
41
|
+
|
36
42
|
from ddeutil.core import str2bool
|
37
43
|
from pydantic import BaseModel, Field
|
38
44
|
from pydantic.functional_validators import model_validator
|
45
|
+
from typing_extensions import Self
|
39
46
|
|
40
47
|
from .__types import DictData, DictStr, Re, TupleStr
|
41
48
|
from .exceptions import StageException
|
@@ -46,33 +53,63 @@ from .utils import (
|
|
46
53
|
gen_id,
|
47
54
|
make_exec,
|
48
55
|
make_registry,
|
56
|
+
not_in_template,
|
49
57
|
param2template,
|
50
58
|
)
|
51
59
|
|
60
|
+
P = ParamSpec("P")
|
61
|
+
__all__: TupleStr = (
|
62
|
+
"Stage",
|
63
|
+
"EmptyStage",
|
64
|
+
"BashStage",
|
65
|
+
"PyStage",
|
66
|
+
"HookStage",
|
67
|
+
"TriggerStage",
|
68
|
+
"handler_result",
|
69
|
+
)
|
70
|
+
|
52
71
|
|
53
|
-
def handler_result(message: str | None = None):
|
54
|
-
"""Decorator function for handler result from the stage execution.
|
72
|
+
def handler_result(message: str | None = None) -> Callable[P, Result]:
|
73
|
+
"""Decorator function for handler result from the stage execution. This
|
74
|
+
function should to use with execution method only.
|
75
|
+
|
76
|
+
:param message: A message that want to add at prefix of exception statement.
|
77
|
+
"""
|
55
78
|
message: str = message or ""
|
56
79
|
|
57
|
-
def decorator(func):
|
80
|
+
def decorator(func: Callable[P, Result]) -> Callable[P, Result]:
|
58
81
|
|
59
82
|
@wraps(func)
|
60
|
-
def wrapped(self:
|
83
|
+
def wrapped(self: Stage, *args, **kwargs):
|
61
84
|
try:
|
62
|
-
|
63
|
-
return
|
85
|
+
# NOTE: Start calling origin function with a passing args.
|
86
|
+
return func(self, *args, **kwargs).set_run_id(self.run_id)
|
64
87
|
except Exception as err:
|
88
|
+
# NOTE: Start catching error from the stage execution.
|
65
89
|
logging.error(
|
66
90
|
f"({self.run_id}) [STAGE]: {err.__class__.__name__}: {err}"
|
67
91
|
)
|
68
|
-
if
|
92
|
+
if str2bool(
|
93
|
+
os.getenv("WORKFLOW_CORE_STAGE_RAISE_ERROR", "true")
|
94
|
+
):
|
95
|
+
# NOTE: If error that raise from stage execution course by
|
96
|
+
# itself, it will return that error with previous
|
97
|
+
# dependency.
|
98
|
+
if isinstance(err, StageException):
|
99
|
+
raise StageException(
|
100
|
+
f"{self.__class__.__name__}: {message}\n\t{err}"
|
101
|
+
) from err
|
69
102
|
raise StageException(
|
70
|
-
f"{self.__class__.__name__}: {message}\n
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
103
|
+
f"{self.__class__.__name__}: {message}\n\t"
|
104
|
+
f"{err.__class__.__name__}: {err}"
|
105
|
+
) from None
|
106
|
+
rs: Result = Result(
|
107
|
+
status=1,
|
108
|
+
context={
|
109
|
+
"error_message": f"{err.__class__.__name__}: {err}",
|
110
|
+
},
|
111
|
+
)
|
112
|
+
return rs.set_run_id(self.run_id)
|
76
113
|
|
77
114
|
return wrapped
|
78
115
|
|
@@ -93,10 +130,11 @@ class BaseStage(BaseModel, ABC):
|
|
93
130
|
),
|
94
131
|
)
|
95
132
|
name: str = Field(
|
96
|
-
description="A stage name that want to logging when start execution."
|
133
|
+
description="A stage name that want to logging when start execution.",
|
97
134
|
)
|
98
135
|
condition: Optional[str] = Field(
|
99
136
|
default=None,
|
137
|
+
description="A stage condition statement to allow stage executable.",
|
100
138
|
alias="if",
|
101
139
|
)
|
102
140
|
run_id: Optional[str] = Field(
|
@@ -107,10 +145,31 @@ class BaseStage(BaseModel, ABC):
|
|
107
145
|
|
108
146
|
@model_validator(mode="after")
|
109
147
|
def __prepare_running_id(self):
|
148
|
+
"""Prepare stage running ID that use default value of field and this
|
149
|
+
method will validate name and id fields should not contain any template
|
150
|
+
parameter (exclude matrix template).
|
151
|
+
"""
|
110
152
|
if self.run_id is None:
|
111
153
|
self.run_id = gen_id(self.name + (self.id or ""), unique=True)
|
154
|
+
|
155
|
+
# VALIDATE: Validate stage id and name should not dynamic with params
|
156
|
+
# template. (allow only matrix)
|
157
|
+
if not_in_template(self.id) or not_in_template(self.name):
|
158
|
+
raise ValueError(
|
159
|
+
"Stage name and ID should only template with matrix."
|
160
|
+
)
|
161
|
+
|
112
162
|
return self
|
113
163
|
|
164
|
+
def get_running_id(self, run_id: str) -> Self:
|
165
|
+
"""Return Stage model object that changing stage running ID with an
|
166
|
+
input running ID.
|
167
|
+
|
168
|
+
:param run_id: A replace stage running ID.
|
169
|
+
:rtype: Self
|
170
|
+
"""
|
171
|
+
return self.model_copy(update={"run_id": run_id})
|
172
|
+
|
114
173
|
@abstractmethod
|
115
174
|
def execute(self, params: DictData) -> Result:
|
116
175
|
"""Execute abstraction method that action something by sub-model class.
|
@@ -121,41 +180,39 @@ class BaseStage(BaseModel, ABC):
|
|
121
180
|
"""
|
122
181
|
raise NotImplementedError("Stage should implement ``execute`` method.")
|
123
182
|
|
124
|
-
def set_outputs(self, output: DictData,
|
183
|
+
def set_outputs(self, output: DictData, to: DictData) -> DictData:
|
125
184
|
"""Set an outputs from execution process to an input params.
|
126
185
|
|
127
186
|
:param output: A output data that want to extract to an output key.
|
128
|
-
:param
|
187
|
+
:param to: A context data that want to add output result.
|
129
188
|
:rtype: DictData
|
130
189
|
"""
|
131
190
|
if not (
|
132
191
|
self.id
|
133
|
-
or str2bool(os.getenv("
|
192
|
+
or str2bool(os.getenv("WORKFLOW_CORE_STAGE_DEFAULT_ID", "false"))
|
134
193
|
):
|
135
194
|
logging.debug(
|
136
195
|
f"({self.run_id}) [STAGE]: Output does not set because this "
|
137
196
|
f"stage does not set ID or default stage ID config flag not be "
|
138
197
|
f"True."
|
139
198
|
)
|
140
|
-
return
|
199
|
+
return to
|
141
200
|
|
142
201
|
# NOTE: Create stages key to receive an output from the stage execution.
|
143
|
-
if "stages" not in
|
144
|
-
|
202
|
+
if "stages" not in to:
|
203
|
+
to["stages"] = {}
|
145
204
|
|
146
|
-
# TODO: Validate stage id and name should not dynamic with params
|
147
|
-
# template. (allow only matrix)
|
148
205
|
if self.id:
|
149
|
-
_id: str = param2template(self.id, params=
|
206
|
+
_id: str = param2template(self.id, params=to)
|
150
207
|
else:
|
151
|
-
_id: str = gen_id(param2template(self.name, params=
|
208
|
+
_id: str = gen_id(param2template(self.name, params=to))
|
152
209
|
|
153
210
|
# NOTE: Set the output to that stage generated ID.
|
154
|
-
params["stages"][_id] = {"outputs": output}
|
155
211
|
logging.debug(
|
156
212
|
f"({self.run_id}) [STAGE]: Set output complete with stage ID: {_id}"
|
157
213
|
)
|
158
|
-
|
214
|
+
to["stages"][_id] = {"outputs": output}
|
215
|
+
return to
|
159
216
|
|
160
217
|
def is_skipped(self, params: DictData | None = None) -> bool:
|
161
218
|
"""Return true if condition of this stage do not correct.
|
@@ -175,7 +232,7 @@ class BaseStage(BaseModel, ABC):
|
|
175
232
|
return not rs
|
176
233
|
except Exception as err:
|
177
234
|
logging.error(f"({self.run_id}) [STAGE]: {err}")
|
178
|
-
raise StageException(
|
235
|
+
raise StageException(f"{err.__class__.__name__}: {err}") from err
|
179
236
|
|
180
237
|
|
181
238
|
class EmptyStage(BaseStage):
|
@@ -246,15 +303,17 @@ class BashStage(BaseStage):
|
|
246
303
|
f_shebang: str = "bash" if sys.platform.startswith("win") else "sh"
|
247
304
|
with open(f"./{f_name}", mode="w", newline="\n") as f:
|
248
305
|
# NOTE: write header of `.sh` file
|
249
|
-
f.write(f"#!/bin/{f_shebang}\n")
|
306
|
+
f.write(f"#!/bin/{f_shebang}\n\n")
|
250
307
|
|
251
308
|
# NOTE: add setting environment variable before bash skip statement.
|
252
309
|
f.writelines([f"{k}='{env[k]}';\n" for k in env])
|
253
310
|
|
254
311
|
# NOTE: make sure that shell script file does not have `\r` char.
|
255
|
-
f.write(bash.replace("\r\n", "\n"))
|
312
|
+
f.write("\n" + bash.replace("\r\n", "\n"))
|
256
313
|
|
314
|
+
# NOTE: Make this .sh file able to executable.
|
257
315
|
make_exec(f"./{f_name}")
|
316
|
+
|
258
317
|
logging.debug(
|
259
318
|
f"({self.run_id}) [STAGE]: Start create `.sh` file and running a "
|
260
319
|
f"bash statement."
|
@@ -262,17 +321,18 @@ class BashStage(BaseStage):
|
|
262
321
|
|
263
322
|
yield [f_shebang, f_name]
|
264
323
|
|
324
|
+
# Note: Remove .sh file that use to run bash.
|
265
325
|
Path(f"./{f_name}").unlink()
|
266
326
|
|
267
327
|
@handler_result()
|
268
|
-
def execute(self, params: DictData) ->
|
328
|
+
def execute(self, params: DictData) -> Result:
|
269
329
|
"""Execute the Bash statement with the Python build-in ``subprocess``
|
270
330
|
package.
|
271
331
|
|
272
332
|
:param params: A parameter data that want to use in this execution.
|
273
333
|
:rtype: Result
|
274
334
|
"""
|
275
|
-
bash: str = param2template(self.bash, params)
|
335
|
+
bash: str = param2template(dedent(self.bash), params)
|
276
336
|
with self.__prepare_bash(
|
277
337
|
bash=bash, env=param2template(self.env, params)
|
278
338
|
) as sh:
|
@@ -288,19 +348,19 @@ class BashStage(BaseStage):
|
|
288
348
|
rs.stderr.encode("utf-8").decode("utf-16")
|
289
349
|
if "\\x00" in rs.stderr
|
290
350
|
else rs.stderr
|
291
|
-
)
|
292
|
-
logging.error(
|
293
|
-
f"({self.run_id}) [STAGE]: {err}\n\n```bash\n{bash}```"
|
294
|
-
)
|
351
|
+
).removesuffix("\n")
|
295
352
|
raise StageException(
|
296
|
-
f"
|
297
|
-
f"
|
353
|
+
f"Subprocess: {err}\nRunning Statement:\n---\n"
|
354
|
+
f"```bash\n{bash}\n```"
|
298
355
|
)
|
299
|
-
return
|
300
|
-
|
301
|
-
|
302
|
-
|
303
|
-
|
356
|
+
return Result(
|
357
|
+
status=0,
|
358
|
+
context={
|
359
|
+
"return_code": rs.returncode,
|
360
|
+
"stdout": rs.stdout.rstrip("\n"),
|
361
|
+
"stderr": rs.stderr.rstrip("\n"),
|
362
|
+
},
|
363
|
+
)
|
304
364
|
|
305
365
|
|
306
366
|
class PyStage(BaseStage):
|
@@ -327,48 +387,56 @@ class PyStage(BaseStage):
|
|
327
387
|
),
|
328
388
|
)
|
329
389
|
|
330
|
-
def set_outputs(self, output: DictData,
|
390
|
+
def set_outputs(self, output: DictData, to: DictData) -> DictData:
|
331
391
|
"""Set an outputs from the Python execution process to an input params.
|
332
392
|
|
333
393
|
:param output: A output data that want to extract to an output key.
|
334
|
-
:param
|
394
|
+
:param to: A context data that want to add output result.
|
335
395
|
:rtype: DictData
|
336
396
|
"""
|
337
397
|
# NOTE: The output will fileter unnecessary keys from locals.
|
338
398
|
_locals: DictData = output["locals"]
|
339
399
|
super().set_outputs(
|
340
|
-
{k: _locals[k] for k in _locals if k != "__annotations__"},
|
341
|
-
params=params,
|
400
|
+
{k: _locals[k] for k in _locals if k != "__annotations__"}, to=to
|
342
401
|
)
|
343
402
|
|
344
403
|
# NOTE:
|
345
404
|
# Override value that changing from the globals that pass via exec.
|
346
405
|
_globals: DictData = output["globals"]
|
347
|
-
|
348
|
-
return
|
406
|
+
to.update({k: _globals[k] for k in to if k in _globals})
|
407
|
+
return to
|
349
408
|
|
350
409
|
@handler_result()
|
351
|
-
def execute(self, params: DictData) ->
|
410
|
+
def execute(self, params: DictData) -> Result:
|
352
411
|
"""Execute the Python statement that pass all globals and input params
|
353
412
|
to globals argument on ``exec`` build-in function.
|
354
413
|
|
355
414
|
:param params: A parameter that want to pass before run any statement.
|
356
415
|
:rtype: Result
|
357
416
|
"""
|
417
|
+
# NOTE: Replace the run statement that has templating value.
|
418
|
+
run: str = param2template(dedent(self.run), params)
|
419
|
+
|
358
420
|
# NOTE: create custom globals value that will pass to exec function.
|
359
421
|
_globals: DictData = (
|
360
422
|
globals() | params | param2template(self.vars, params)
|
361
423
|
)
|
362
424
|
_locals: DictData = {}
|
363
|
-
|
364
|
-
|
425
|
+
|
426
|
+
# NOTE: Start exec the run statement.
|
427
|
+
logging.info(f"({self.run_id}) [STAGE]: Py-Execute: {self.name}")
|
365
428
|
exec(run, _globals, _locals)
|
366
|
-
|
429
|
+
|
430
|
+
return Result(
|
431
|
+
status=0, context={"locals": _locals, "globals": _globals}
|
432
|
+
)
|
367
433
|
|
368
434
|
|
369
435
|
@dataclass
|
370
436
|
class HookSearch:
|
371
|
-
"""Hook Search dataclass
|
437
|
+
"""Hook Search dataclass that use for receive regular expression grouping
|
438
|
+
dict from searching hook string value.
|
439
|
+
"""
|
372
440
|
|
373
441
|
path: str
|
374
442
|
func: str
|
@@ -376,13 +444,16 @@ class HookSearch:
|
|
376
444
|
|
377
445
|
|
378
446
|
def extract_hook(hook: str) -> Callable[[], TagFunc]:
|
379
|
-
"""Extract Hook string value to hook function
|
447
|
+
"""Extract Hook function from string value to hook partial function that
|
448
|
+
does run it at runtime.
|
380
449
|
|
381
450
|
:param hook: A hook value that able to match with Task regex.
|
382
451
|
:rtype: Callable[[], TagFunc]
|
383
452
|
"""
|
384
453
|
if not (found := Re.RE_TASK_FMT.search(hook)):
|
385
|
-
raise ValueError(
|
454
|
+
raise ValueError(
|
455
|
+
f"Hook {hook!r} does not match with hook format regex."
|
456
|
+
)
|
386
457
|
|
387
458
|
# NOTE: Pass the searching hook string to `path`, `func`, and `tag`.
|
388
459
|
hook: HookSearch = HookSearch(**found.groupdict())
|
@@ -415,7 +486,7 @@ class HookStage(BaseStage):
|
|
415
486
|
Data Validate:
|
416
487
|
>>> stage = {
|
417
488
|
... "name": "Task stage execution",
|
418
|
-
... "
|
489
|
+
... "uses": "tasks/function-name@tag-name",
|
419
490
|
... "args": {
|
420
491
|
... "FOO": "BAR",
|
421
492
|
... },
|
@@ -426,12 +497,13 @@ class HookStage(BaseStage):
|
|
426
497
|
description="A pointer that want to load function from registry.",
|
427
498
|
)
|
428
499
|
args: DictData = Field(
|
500
|
+
default_factory=dict,
|
429
501
|
description="An arguments that want to pass to the hook function.",
|
430
502
|
alias="with",
|
431
503
|
)
|
432
504
|
|
433
505
|
@handler_result()
|
434
|
-
def execute(self, params: DictData) ->
|
506
|
+
def execute(self, params: DictData) -> Result:
|
435
507
|
"""Execute the Hook function that already in the hook registry.
|
436
508
|
|
437
509
|
:param params: A parameter that want to pass before run any statement.
|
@@ -440,10 +512,7 @@ class HookStage(BaseStage):
|
|
440
512
|
"""
|
441
513
|
t_func_hook: str = param2template(self.uses, params)
|
442
514
|
t_func: TagFunc = extract_hook(t_func_hook)()
|
443
|
-
|
444
|
-
raise ImportError(
|
445
|
-
f"Hook caller {t_func_hook!r} function does not callable."
|
446
|
-
)
|
515
|
+
|
447
516
|
# VALIDATE: check input task caller parameters that exists before
|
448
517
|
# calling.
|
449
518
|
args: DictData = param2template(self.args, params)
|
@@ -454,7 +523,7 @@ class HookStage(BaseStage):
|
|
454
523
|
if ips.parameters[k].default == Parameter.empty
|
455
524
|
):
|
456
525
|
raise ValueError(
|
457
|
-
f"Necessary params, ({', '.join(ips.parameters.keys())}), "
|
526
|
+
f"Necessary params, ({', '.join(ips.parameters.keys())}, ), "
|
458
527
|
f"does not set to args"
|
459
528
|
)
|
460
529
|
# NOTE: add '_' prefix if it want to use.
|
@@ -463,8 +532,7 @@ class HookStage(BaseStage):
|
|
463
532
|
args[k] = args.pop(k.removeprefix("_"))
|
464
533
|
|
465
534
|
logging.info(
|
466
|
-
f"({self.run_id}) [STAGE]: Hook-Execute: "
|
467
|
-
f"{t_func.name}@{t_func.tag}"
|
535
|
+
f"({self.run_id}) [STAGE]: Hook-Execute: {t_func.name}@{t_func.tag}"
|
468
536
|
)
|
469
537
|
rs: DictData = t_func(**param2template(args, params))
|
470
538
|
|
@@ -472,11 +540,10 @@ class HookStage(BaseStage):
|
|
472
540
|
# Check the result type from hook function, it should be dict.
|
473
541
|
if not isinstance(rs, dict):
|
474
542
|
raise TypeError(
|
475
|
-
f"Return
|
476
|
-
f"
|
477
|
-
f"`dict` type."
|
543
|
+
f"Return type: '{t_func.name}@{t_func.tag}' does not serialize "
|
544
|
+
f"to result model, you change return type to `dict`."
|
478
545
|
)
|
479
|
-
return rs
|
546
|
+
return Result(status=0, context=rs)
|
480
547
|
|
481
548
|
|
482
549
|
class TriggerStage(BaseStage):
|
@@ -499,8 +566,8 @@ class TriggerStage(BaseStage):
|
|
499
566
|
description="A parameter that want to pass to pipeline execution.",
|
500
567
|
)
|
501
568
|
|
502
|
-
@handler_result("Raise from
|
503
|
-
def execute(self, params: DictData) ->
|
569
|
+
@handler_result("Raise from TriggerStage")
|
570
|
+
def execute(self, params: DictData) -> Result:
|
504
571
|
"""Trigger pipeline execution.
|
505
572
|
|
506
573
|
:param params: A parameter data that want to use in this execution.
|
@@ -510,9 +577,14 @@ class TriggerStage(BaseStage):
|
|
510
577
|
|
511
578
|
# NOTE: Loading pipeline object from trigger name.
|
512
579
|
_trigger: str = param2template(self.trigger, params=params)
|
513
|
-
|
514
|
-
|
515
|
-
|
580
|
+
|
581
|
+
# NOTE: Set running pipeline ID from running stage ID to external
|
582
|
+
# params on Loader object.
|
583
|
+
pipe: Pipeline = Pipeline.from_loader(
|
584
|
+
name=_trigger, externals={"run_id": self.run_id}
|
585
|
+
)
|
586
|
+
logging.info(f"({self.run_id}) [STAGE]: Trigger-Execute: {_trigger!r}")
|
587
|
+
return pipe.execute(params=param2template(self.params, params))
|
516
588
|
|
517
589
|
|
518
590
|
# NOTE: Order of parsing stage data
|