ddeutil-workflow 0.0.37__py3-none-any.whl → 0.0.38__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 +4 -1
- ddeutil/workflow/api/routes/job.py +3 -1
- ddeutil/workflow/api/routes/logs.py +12 -4
- ddeutil/workflow/caller.py +6 -2
- ddeutil/workflow/context.py +59 -0
- ddeutil/workflow/exceptions.py +14 -1
- ddeutil/workflow/job.py +100 -121
- ddeutil/workflow/logs.py +6 -1
- ddeutil/workflow/result.py +1 -1
- ddeutil/workflow/stages.py +364 -111
- ddeutil/workflow/utils.py +1 -44
- ddeutil/workflow/workflow.py +137 -72
- {ddeutil_workflow-0.0.37.dist-info → ddeutil_workflow-0.0.38.dist-info}/METADATA +8 -2
- ddeutil_workflow-0.0.38.dist-info/RECORD +33 -0
- {ddeutil_workflow-0.0.37.dist-info → ddeutil_workflow-0.0.38.dist-info}/WHEEL +1 -1
- ddeutil_workflow-0.0.37.dist-info/RECORD +0 -32
- {ddeutil_workflow-0.0.37.dist-info → ddeutil_workflow-0.0.38.dist-info/licenses}/LICENSE +0 -0
- {ddeutil_workflow-0.0.37.dist-info → ddeutil_workflow-0.0.38.dist-info}/top_level.txt +0 -0
ddeutil/workflow/stages.py
CHANGED
@@ -23,6 +23,7 @@ template searching.
|
|
23
23
|
"""
|
24
24
|
from __future__ import annotations
|
25
25
|
|
26
|
+
import asyncio
|
26
27
|
import contextlib
|
27
28
|
import inspect
|
28
29
|
import subprocess
|
@@ -31,6 +32,11 @@ import time
|
|
31
32
|
import uuid
|
32
33
|
from abc import ABC, abstractmethod
|
33
34
|
from collections.abc import Iterator
|
35
|
+
from concurrent.futures import (
|
36
|
+
Future,
|
37
|
+
ThreadPoolExecutor,
|
38
|
+
as_completed,
|
39
|
+
)
|
34
40
|
from inspect import Parameter
|
35
41
|
from pathlib import Path
|
36
42
|
from subprocess import CompletedProcess
|
@@ -43,25 +49,23 @@ from typing_extensions import Self
|
|
43
49
|
|
44
50
|
from .__types import DictData, DictStr, TupleStr
|
45
51
|
from .caller import TagFunc, extract_call
|
46
|
-
from .conf import config
|
47
|
-
from .exceptions import StageException
|
52
|
+
from .conf import config
|
53
|
+
from .exceptions import StageException, to_dict
|
48
54
|
from .result import Result, Status
|
49
55
|
from .templates import not_in_template, param2template
|
50
56
|
from .utils import (
|
51
|
-
cut_id,
|
52
57
|
gen_id,
|
53
58
|
make_exec,
|
54
59
|
)
|
55
60
|
|
56
|
-
logger = get_logger("ddeutil.workflow")
|
57
|
-
|
58
|
-
|
59
61
|
__all__: TupleStr = (
|
60
62
|
"EmptyStage",
|
61
63
|
"BashStage",
|
62
64
|
"PyStage",
|
63
65
|
"CallStage",
|
64
66
|
"TriggerStage",
|
67
|
+
"ForEachStage",
|
68
|
+
"ParallelStage",
|
65
69
|
"Stage",
|
66
70
|
)
|
67
71
|
|
@@ -127,13 +131,14 @@ class BaseStage(BaseModel, ABC):
|
|
127
131
|
"""Execute abstraction method that action something by sub-model class.
|
128
132
|
This is important method that make this class is able to be the stage.
|
129
133
|
|
130
|
-
:param params: A parameter data that want to use in this
|
134
|
+
:param params: (DictData) A parameter data that want to use in this
|
135
|
+
execution.
|
131
136
|
:param result: (Result) A result object for keeping context and status
|
132
137
|
data.
|
133
138
|
|
134
139
|
:rtype: Result
|
135
140
|
"""
|
136
|
-
raise NotImplementedError("Stage should implement
|
141
|
+
raise NotImplementedError("Stage should implement `execute` method.")
|
137
142
|
|
138
143
|
def handler_execute(
|
139
144
|
self,
|
@@ -142,8 +147,10 @@ class BaseStage(BaseModel, ABC):
|
|
142
147
|
run_id: str | None = None,
|
143
148
|
parent_run_id: str | None = None,
|
144
149
|
result: Result | None = None,
|
150
|
+
raise_error: bool = False,
|
151
|
+
to: DictData | None = None,
|
145
152
|
) -> Result:
|
146
|
-
"""Handler execution result from the stage `execute` method.
|
153
|
+
"""Handler stage execution result from the stage `execute` method.
|
147
154
|
|
148
155
|
This stage exception handler still use ok-error concept, but it
|
149
156
|
allows you force catching an output result with error message by
|
@@ -151,26 +158,31 @@ class BaseStage(BaseModel, ABC):
|
|
151
158
|
|
152
159
|
Execution --> Ok --> Result
|
153
160
|
|-status: Status.SUCCESS
|
154
|
-
|
155
|
-
|
161
|
+
╰-context:
|
162
|
+
╰-outputs: ...
|
156
163
|
|
157
164
|
--> Error --> Result (if env var was set)
|
158
165
|
|-status: Status.FAILED
|
159
|
-
|
166
|
+
╰-errors:
|
160
167
|
|-class: ...
|
161
168
|
|-name: ...
|
162
|
-
|
169
|
+
╰-message: ...
|
163
170
|
|
164
171
|
--> Error --> Raise StageException(...)
|
165
172
|
|
166
173
|
On the last step, it will set the running ID on a return result object
|
167
174
|
from current stage ID before release the final result.
|
168
175
|
|
169
|
-
:param params: A
|
176
|
+
:param params: (DictData) A parameterize value data that use in this
|
177
|
+
stage execution.
|
170
178
|
:param run_id: (str) A running stage ID for this execution.
|
171
|
-
:param parent_run_id: A parent workflow running ID for this
|
179
|
+
:param parent_run_id: (str) A parent workflow running ID for this
|
180
|
+
execution.
|
172
181
|
:param result: (Result) A result object for keeping context and status
|
173
|
-
data.
|
182
|
+
data before execution.
|
183
|
+
:param raise_error: (bool) A flag that all this method raise error
|
184
|
+
:param to: (DictData) A target object for auto set the return output
|
185
|
+
after execution.
|
174
186
|
|
175
187
|
:rtype: Result
|
176
188
|
"""
|
@@ -178,18 +190,18 @@ class BaseStage(BaseModel, ABC):
|
|
178
190
|
result,
|
179
191
|
run_id=run_id,
|
180
192
|
parent_run_id=parent_run_id,
|
181
|
-
id_logic=
|
193
|
+
id_logic=self.iden,
|
182
194
|
)
|
183
195
|
|
184
196
|
try:
|
185
|
-
|
197
|
+
rs: Result = self.execute(params, result=result)
|
198
|
+
if to is not None:
|
199
|
+
return self.set_outputs(rs.context, to=to)
|
200
|
+
return rs
|
186
201
|
except Exception as err:
|
187
202
|
result.trace.error(f"[STAGE]: {err.__class__.__name__}: {err}")
|
188
203
|
|
189
|
-
if config.stage_raise_error:
|
190
|
-
# NOTE: If error that raise from stage execution course by
|
191
|
-
# itself, it will return that error with previous
|
192
|
-
# dependency.
|
204
|
+
if raise_error or config.stage_raise_error:
|
193
205
|
if isinstance(err, StageException):
|
194
206
|
raise
|
195
207
|
|
@@ -198,22 +210,15 @@ class BaseStage(BaseModel, ABC):
|
|
198
210
|
f"{err.__class__.__name__}: {err}"
|
199
211
|
) from None
|
200
212
|
|
201
|
-
|
202
|
-
|
203
|
-
|
204
|
-
|
205
|
-
|
206
|
-
"errors": {
|
207
|
-
"class": err,
|
208
|
-
"name": err.__class__.__name__,
|
209
|
-
"message": f"{err.__class__.__name__}: {err}",
|
210
|
-
},
|
211
|
-
},
|
212
|
-
)
|
213
|
+
errors: DictData = {"errors": to_dict(err)}
|
214
|
+
if to is not None:
|
215
|
+
return self.set_outputs(errors, to=to)
|
216
|
+
|
217
|
+
return result.catch(status=Status.FAILED, context=errors)
|
213
218
|
|
214
219
|
def set_outputs(self, output: DictData, to: DictData) -> DictData:
|
215
220
|
"""Set an outputs from execution process to the received context. The
|
216
|
-
result from execution will pass to value of
|
221
|
+
result from execution will pass to value of `outputs` key.
|
217
222
|
|
218
223
|
For example of setting output method, If you receive execute output
|
219
224
|
and want to set on the `to` like;
|
@@ -221,7 +226,7 @@ class BaseStage(BaseModel, ABC):
|
|
221
226
|
... (i) output: {'foo': bar}
|
222
227
|
... (ii) to: {}
|
223
228
|
|
224
|
-
|
229
|
+
The result of the `to` argument will be;
|
225
230
|
|
226
231
|
... (iii) to: {
|
227
232
|
'stages': {
|
@@ -229,22 +234,18 @@ class BaseStage(BaseModel, ABC):
|
|
229
234
|
}
|
230
235
|
}
|
231
236
|
|
232
|
-
:param output: An output data that want to extract to an
|
233
|
-
|
237
|
+
:param output: (DictData) An output data that want to extract to an
|
238
|
+
output key.
|
239
|
+
:param to: (DictData) A context data that want to add output result.
|
240
|
+
|
234
241
|
:rtype: DictData
|
235
242
|
"""
|
236
|
-
if self.id is None and not config.stage_default_id:
|
237
|
-
logger.warning(
|
238
|
-
"Output does not set because this stage does not set ID or "
|
239
|
-
"default stage ID config flag not be True."
|
240
|
-
)
|
241
|
-
return to
|
242
|
-
|
243
|
-
# NOTE: Create stages key to receive an output from the stage execution.
|
244
243
|
if "stages" not in to:
|
245
244
|
to["stages"] = {}
|
246
245
|
|
247
|
-
|
246
|
+
if self.id is None and not config.stage_default_id:
|
247
|
+
return to
|
248
|
+
|
248
249
|
_id: str = (
|
249
250
|
param2template(self.id, params=to)
|
250
251
|
if self.id
|
@@ -255,7 +256,6 @@ class BaseStage(BaseModel, ABC):
|
|
255
256
|
{"errors": output.pop("errors", {})} if "errors" in output else {}
|
256
257
|
)
|
257
258
|
|
258
|
-
# NOTE: Set the output to that stage generated ID with ``outputs`` key.
|
259
259
|
to["stages"][_id] = {"outputs": output, **errors}
|
260
260
|
return to
|
261
261
|
|
@@ -268,10 +268,11 @@ class BaseStage(BaseModel, ABC):
|
|
268
268
|
:raise StageException: When return type of the eval condition statement
|
269
269
|
does not return with boolean type.
|
270
270
|
|
271
|
-
:param params: A parameters that want to pass to condition
|
271
|
+
:param params: (DictData) A parameters that want to pass to condition
|
272
|
+
template.
|
273
|
+
|
272
274
|
:rtype: bool
|
273
275
|
"""
|
274
|
-
# NOTE: Return false result if condition does not set.
|
275
276
|
if self.condition is None:
|
276
277
|
return False
|
277
278
|
|
@@ -299,6 +300,7 @@ class EmptyStage(BaseStage):
|
|
299
300
|
>>> stage = {
|
300
301
|
... "name": "Empty stage execution",
|
301
302
|
... "echo": "Hello World",
|
303
|
+
... "sleep": 1,
|
302
304
|
... }
|
303
305
|
"""
|
304
306
|
|
@@ -308,7 +310,7 @@ class EmptyStage(BaseStage):
|
|
308
310
|
)
|
309
311
|
sleep: float = Field(
|
310
312
|
default=0,
|
311
|
-
description="A second value to sleep before
|
313
|
+
description="A second value to sleep before start execution",
|
312
314
|
ge=0,
|
313
315
|
)
|
314
316
|
|
@@ -322,13 +324,32 @@ class EmptyStage(BaseStage):
|
|
322
324
|
The result context should be empty and do not process anything
|
323
325
|
without calling logging function.
|
324
326
|
|
325
|
-
:param params: A context data that want to add output result.
|
326
|
-
stage does not pass any output.
|
327
|
+
:param params: (DictData) A context data that want to add output result.
|
328
|
+
But this stage does not pass any output.
|
327
329
|
:param result: (Result) A result object for keeping context and status
|
328
330
|
data.
|
329
331
|
|
330
332
|
:rtype: Result
|
331
333
|
"""
|
334
|
+
result: Result = result or Result(
|
335
|
+
run_id=gen_id(self.name + (self.id or ""), unique=True)
|
336
|
+
)
|
337
|
+
|
338
|
+
result.trace.info(
|
339
|
+
f"[STAGE]: Empty-Execute: {self.name!r}: "
|
340
|
+
f"( {param2template(self.echo, params=params) or '...'} )"
|
341
|
+
)
|
342
|
+
if self.sleep > 0:
|
343
|
+
if self.sleep > 5:
|
344
|
+
result.trace.info(f"[STAGE]: ... sleep ({self.sleep} seconds)")
|
345
|
+
time.sleep(self.sleep)
|
346
|
+
|
347
|
+
return result.catch(status=Status.SUCCESS)
|
348
|
+
|
349
|
+
# TODO: Draft async execute method for the perf improvement.
|
350
|
+
async def aexecute(
|
351
|
+
self, params: DictData, *, result: Result | None = None
|
352
|
+
) -> Result: # pragma: no cov
|
332
353
|
if result is None: # pragma: no cov
|
333
354
|
result: Result = Result(
|
334
355
|
run_id=gen_id(self.name + (self.id or ""), unique=True)
|
@@ -338,11 +359,8 @@ class EmptyStage(BaseStage):
|
|
338
359
|
f"[STAGE]: Empty-Execute: {self.name!r}: "
|
339
360
|
f"( {param2template(self.echo, params=params) or '...'} )"
|
340
361
|
)
|
341
|
-
if self.sleep > 0:
|
342
|
-
if self.sleep > 30:
|
343
|
-
result.trace.info(f"[STAGE]: ... sleep ({self.sleep} seconds)")
|
344
|
-
time.sleep(self.sleep)
|
345
362
|
|
363
|
+
await asyncio.sleep(1)
|
346
364
|
return result.catch(status=Status.SUCCESS)
|
347
365
|
|
348
366
|
|
@@ -382,20 +400,18 @@ class BashStage(BaseStage):
|
|
382
400
|
step will write the `.sh` file before giving this file name to context.
|
383
401
|
After that, it will auto delete this file automatic.
|
384
402
|
|
385
|
-
:param bash: A bash statement that want to execute.
|
386
|
-
:param env: An environment variable that use on this bash
|
387
|
-
|
388
|
-
|
403
|
+
:param bash: (str) A bash statement that want to execute.
|
404
|
+
:param env: (DictStr) An environment variable that use on this bash
|
405
|
+
statement.
|
406
|
+
:param run_id: (str | None) A running stage ID that use for writing sh
|
407
|
+
file instead generate by UUID4.
|
408
|
+
|
389
409
|
:rtype: Iterator[TupleStr]
|
390
410
|
"""
|
391
411
|
run_id: str = run_id or uuid.uuid4()
|
392
412
|
f_name: str = f"{run_id}.sh"
|
393
413
|
f_shebang: str = "bash" if sys.platform.startswith("win") else "sh"
|
394
414
|
|
395
|
-
logger.debug(
|
396
|
-
f"({cut_id(run_id)}) [STAGE]: Start create `{f_name}` file."
|
397
|
-
)
|
398
|
-
|
399
415
|
with open(f"./{f_name}", mode="w", newline="\n") as f:
|
400
416
|
# NOTE: write header of `.sh` file
|
401
417
|
f.write(f"#!/bin/{f_shebang}\n\n")
|
@@ -439,9 +455,11 @@ class BashStage(BaseStage):
|
|
439
455
|
env=param2template(self.env, params),
|
440
456
|
run_id=result.run_id,
|
441
457
|
) as sh:
|
458
|
+
result.trace.debug(f"... Start create `{sh[1]}` file.")
|
442
459
|
rs: CompletedProcess = subprocess.run(
|
443
460
|
sh, shell=False, capture_output=True, text=True
|
444
461
|
)
|
462
|
+
|
445
463
|
if rs.returncode > 0:
|
446
464
|
# NOTE: Prepare stderr message that returning from subprocess.
|
447
465
|
err: str = (
|
@@ -457,8 +475,8 @@ class BashStage(BaseStage):
|
|
457
475
|
status=Status.SUCCESS,
|
458
476
|
context={
|
459
477
|
"return_code": rs.returncode,
|
460
|
-
"stdout": rs.stdout.
|
461
|
-
"stderr": rs.stderr.
|
478
|
+
"stdout": None if (out := rs.stdout.strip("\n")) == "" else out,
|
479
|
+
"stderr": None if (out := rs.stderr.strip("\n")) == "" else out,
|
462
480
|
},
|
463
481
|
)
|
464
482
|
|
@@ -515,8 +533,9 @@ class PyStage(BaseStage):
|
|
515
533
|
"""Override set an outputs method for the Python execution process that
|
516
534
|
extract output from all the locals values.
|
517
535
|
|
518
|
-
:param output: An output data that want to extract to an
|
519
|
-
|
536
|
+
:param output: (DictData) An output data that want to extract to an
|
537
|
+
output key.
|
538
|
+
:param to: (DictData) A context data that want to add output result.
|
520
539
|
|
521
540
|
:rtype: DictData
|
522
541
|
"""
|
@@ -525,7 +544,7 @@ class PyStage(BaseStage):
|
|
525
544
|
super().set_outputs(
|
526
545
|
(
|
527
546
|
{k: lc[k] for k in self.filter_locals(lc)}
|
528
|
-
| {
|
547
|
+
| ({"errors": output["errors"]} if "errors" in output else {})
|
529
548
|
),
|
530
549
|
to=to,
|
531
550
|
)
|
@@ -558,13 +577,22 @@ class PyStage(BaseStage):
|
|
558
577
|
|
559
578
|
# NOTE: create custom globals value that will pass to exec function.
|
560
579
|
_globals: DictData = (
|
561
|
-
globals()
|
580
|
+
globals()
|
581
|
+
| params
|
582
|
+
| param2template(self.vars, params)
|
583
|
+
| {"result": result}
|
562
584
|
)
|
563
585
|
lc: DictData = {}
|
564
586
|
|
565
587
|
# NOTE: Start exec the run statement.
|
566
588
|
result.trace.info(f"[STAGE]: Py-Execute: {self.name}")
|
589
|
+
result.trace.warning(
|
590
|
+
"[STAGE]: This stage allow use `eval` function, so, please "
|
591
|
+
"check your statement be safe before execute."
|
592
|
+
)
|
567
593
|
|
594
|
+
# TODO: Add Python systax wrapper for checking dangerous code before run
|
595
|
+
# this statement.
|
568
596
|
# WARNING: The exec build-in function is very dangerous. So, it
|
569
597
|
# should use the re module to validate exec-string before running.
|
570
598
|
exec(run, _globals, lc)
|
@@ -583,11 +611,16 @@ class CallStage(BaseStage):
|
|
583
611
|
statement. So, you can create your function complexly that you can for your
|
584
612
|
objective to invoked by this stage object.
|
585
613
|
|
614
|
+
This stage is the usefull stage for run every job by a custom requirement
|
615
|
+
that you want by creating the Python function and adding it to the task
|
616
|
+
registry by importer syntax like `module.tasks.registry` not path style like
|
617
|
+
`module/tasks/registry`.
|
618
|
+
|
586
619
|
Data Validate:
|
587
620
|
>>> stage = {
|
588
621
|
... "name": "Task stage execution",
|
589
622
|
... "uses": "tasks/function-name@tag-name",
|
590
|
-
... "args": {"
|
623
|
+
... "args": {"arg01": "BAR", "kwarg01": 10},
|
591
624
|
... }
|
592
625
|
"""
|
593
626
|
|
@@ -631,15 +664,26 @@ class CallStage(BaseStage):
|
|
631
664
|
# calling.
|
632
665
|
args: DictData = {"result": result} | param2template(self.args, params)
|
633
666
|
ips = inspect.signature(t_func)
|
667
|
+
necessary_params: list[str] = [
|
668
|
+
k
|
669
|
+
for k in ips.parameters
|
670
|
+
if (
|
671
|
+
(v := ips.parameters[k]).default == Parameter.empty
|
672
|
+
and (
|
673
|
+
v.kind != Parameter.VAR_KEYWORD
|
674
|
+
or v.kind != Parameter.VAR_POSITIONAL
|
675
|
+
)
|
676
|
+
)
|
677
|
+
]
|
634
678
|
if any(
|
635
679
|
(k.removeprefix("_") not in args and k not in args)
|
636
|
-
for k in
|
637
|
-
if ips.parameters[k].default == Parameter.empty
|
680
|
+
for k in necessary_params
|
638
681
|
):
|
639
682
|
raise ValueError(
|
640
|
-
f"Necessary params, ({', '.join(
|
683
|
+
f"Necessary params, ({', '.join(necessary_params)}, ), "
|
641
684
|
f"does not set to args"
|
642
685
|
)
|
686
|
+
|
643
687
|
# NOTE: add '_' prefix if it wants to use.
|
644
688
|
for k in ips.parameters:
|
645
689
|
if k.removeprefix("_") in args:
|
@@ -649,7 +693,13 @@ class CallStage(BaseStage):
|
|
649
693
|
args.pop("result")
|
650
694
|
|
651
695
|
result.trace.info(f"[STAGE]: Call-Execute: {t_func.name}@{t_func.tag}")
|
652
|
-
|
696
|
+
if inspect.iscoroutinefunction(t_func): # pragma: no cov
|
697
|
+
loop = asyncio.get_event_loop()
|
698
|
+
rs: DictData = loop.run_until_complete(
|
699
|
+
t_func(**param2template(args, params))
|
700
|
+
)
|
701
|
+
else:
|
702
|
+
rs: DictData = t_func(**param2template(args, params))
|
653
703
|
|
654
704
|
# VALIDATE:
|
655
705
|
# Check the result type from call function, it should be dict.
|
@@ -717,54 +767,133 @@ class TriggerStage(BaseStage):
|
|
717
767
|
)
|
718
768
|
|
719
769
|
|
720
|
-
# NOTE:
|
721
|
-
# An order of parsing stage model on the Job model with ``stages`` field.
|
722
|
-
# From the current build-in stages, they do not have stage that have the same
|
723
|
-
# fields that because of parsing on the Job's stages key.
|
724
|
-
#
|
725
|
-
Stage = Union[
|
726
|
-
PyStage,
|
727
|
-
BashStage,
|
728
|
-
CallStage,
|
729
|
-
TriggerStage,
|
730
|
-
EmptyStage,
|
731
|
-
]
|
732
|
-
|
733
|
-
|
734
|
-
# TODO: Not implement this stages yet
|
735
770
|
class ParallelStage(BaseStage): # pragma: no cov
|
736
771
|
"""Parallel execution stage that execute child stages with parallel.
|
737
772
|
|
773
|
+
This stage is not the low-level stage model because it runs muti-stages
|
774
|
+
in this stage execution.
|
775
|
+
|
738
776
|
Data Validate:
|
739
777
|
>>> stage = {
|
740
778
|
... "name": "Parallel stage execution.",
|
741
|
-
... "parallel":
|
742
|
-
...
|
743
|
-
...
|
744
|
-
...
|
745
|
-
...
|
746
|
-
...
|
747
|
-
...
|
748
|
-
...
|
749
|
-
...
|
750
|
-
...
|
751
|
-
...
|
752
|
-
...
|
779
|
+
... "parallel": {
|
780
|
+
... "branch01": [
|
781
|
+
... {
|
782
|
+
... "name": "Echo first stage",
|
783
|
+
... "echo": "Start run with branch 1",
|
784
|
+
... "sleep": 3,
|
785
|
+
... },
|
786
|
+
... ],
|
787
|
+
... "branch02": [
|
788
|
+
... {
|
789
|
+
... "name": "Echo second stage",
|
790
|
+
... "echo": "Start run with branch 2",
|
791
|
+
... "sleep": 1,
|
792
|
+
... },
|
793
|
+
... ],
|
794
|
+
... }
|
753
795
|
... }
|
754
796
|
"""
|
755
797
|
|
756
|
-
parallel: list[Stage]
|
798
|
+
parallel: dict[str, list[Stage]] = Field()
|
757
799
|
max_parallel_core: int = Field(default=2)
|
758
800
|
|
801
|
+
@staticmethod
|
802
|
+
def task(
|
803
|
+
branch: str,
|
804
|
+
params: DictData,
|
805
|
+
result: Result,
|
806
|
+
stages: list[Stage],
|
807
|
+
) -> DictData:
|
808
|
+
"""Task execution method for passing a branch to each thread.
|
809
|
+
|
810
|
+
:param branch:
|
811
|
+
:param params:
|
812
|
+
:param result:
|
813
|
+
:param stages:
|
814
|
+
|
815
|
+
:rtype: DictData
|
816
|
+
"""
|
817
|
+
context = {"branch": branch, "stages": {}}
|
818
|
+
result.trace.debug(f"[STAGE]: Execute parallel branch: {branch!r}")
|
819
|
+
for stage in stages:
|
820
|
+
try:
|
821
|
+
stage.set_outputs(
|
822
|
+
stage.handler_execute(
|
823
|
+
params=params,
|
824
|
+
run_id=result.run_id,
|
825
|
+
parent_run_id=result.parent_run_id,
|
826
|
+
).context,
|
827
|
+
to=context,
|
828
|
+
)
|
829
|
+
except StageException as err: # pragma: no cov
|
830
|
+
result.trace.error(
|
831
|
+
f"[STAGE]: Catch:\n\t{err.__class__.__name__}:" f"\n\t{err}"
|
832
|
+
)
|
833
|
+
context.update(
|
834
|
+
{
|
835
|
+
"errors": {
|
836
|
+
"class": err,
|
837
|
+
"name": err.__class__.__name__,
|
838
|
+
"message": f"{err.__class__.__name__}: {err}",
|
839
|
+
},
|
840
|
+
},
|
841
|
+
)
|
842
|
+
return context
|
843
|
+
|
759
844
|
def execute(
|
760
845
|
self, params: DictData, *, result: Result | None = None
|
761
|
-
) -> Result:
|
846
|
+
) -> Result:
|
847
|
+
"""Execute the stages that parallel each branch via multi-threading mode
|
848
|
+
or async mode by changing `async_mode` flag.
|
849
|
+
|
850
|
+
:param params: A parameter that want to pass before run any statement.
|
851
|
+
:param result: (Result) A result object for keeping context and status
|
852
|
+
data.
|
762
853
|
|
854
|
+
:rtype: Result
|
855
|
+
"""
|
856
|
+
if result is None: # pragma: no cov
|
857
|
+
result: Result = Result(
|
858
|
+
run_id=gen_id(self.name + (self.id or ""), unique=True)
|
859
|
+
)
|
763
860
|
|
764
|
-
|
765
|
-
|
766
|
-
|
767
|
-
|
861
|
+
rs: DictData = {"parallel": {}}
|
862
|
+
status = Status.SUCCESS
|
863
|
+
with ThreadPoolExecutor(
|
864
|
+
max_workers=self.max_parallel_core,
|
865
|
+
thread_name_prefix="parallel_stage_exec_",
|
866
|
+
) as executor:
|
867
|
+
|
868
|
+
futures: list[Future] = []
|
869
|
+
for branch in self.parallel:
|
870
|
+
futures.append(
|
871
|
+
executor.submit(
|
872
|
+
self.task,
|
873
|
+
branch=branch,
|
874
|
+
params=params,
|
875
|
+
result=result,
|
876
|
+
stages=self.parallel[branch],
|
877
|
+
)
|
878
|
+
)
|
879
|
+
|
880
|
+
done = as_completed(futures, timeout=1800)
|
881
|
+
for future in done:
|
882
|
+
context: DictData = future.result()
|
883
|
+
rs["parallel"][context.pop("branch")] = context
|
884
|
+
|
885
|
+
if "errors" in context:
|
886
|
+
status = Status.FAILED
|
887
|
+
|
888
|
+
return result.catch(status=status, context=rs)
|
889
|
+
|
890
|
+
|
891
|
+
class ForEachStage(BaseStage):
|
892
|
+
"""For-Each execution stage that execute child stages with an item in list
|
893
|
+
of item values.
|
894
|
+
|
895
|
+
This stage is not the low-level stage model because it runs muti-stages
|
896
|
+
in this stage execution.
|
768
897
|
|
769
898
|
Data Validate:
|
770
899
|
>>> stage = {
|
@@ -779,8 +908,108 @@ class ForEachStage(BaseStage): # pragma: no cov
|
|
779
908
|
... }
|
780
909
|
"""
|
781
910
|
|
782
|
-
foreach: list[str]
|
783
|
-
|
911
|
+
foreach: Union[list[str], list[int]] = Field(
|
912
|
+
description=(
|
913
|
+
"A items for passing to each stages via ${{ item }} template."
|
914
|
+
),
|
915
|
+
)
|
916
|
+
stages: list[Stage] = Field(
|
917
|
+
description=(
|
918
|
+
"A list of stage that will run with each item in the foreach field."
|
919
|
+
),
|
920
|
+
)
|
921
|
+
|
922
|
+
def execute(
|
923
|
+
self, params: DictData, *, result: Result | None = None
|
924
|
+
) -> Result:
|
925
|
+
"""Execute the stages that pass each item form the foreach field.
|
926
|
+
|
927
|
+
:param params: A parameter that want to pass before run any statement.
|
928
|
+
:param result: (Result) A result object for keeping context and status
|
929
|
+
data.
|
930
|
+
|
931
|
+
:rtype: Result
|
932
|
+
"""
|
933
|
+
if result is None: # pragma: no cov
|
934
|
+
result: Result = Result(
|
935
|
+
run_id=gen_id(self.name + (self.id or ""), unique=True)
|
936
|
+
)
|
937
|
+
|
938
|
+
rs: DictData = {"items": self.foreach, "foreach": {}}
|
939
|
+
status = Status.SUCCESS
|
940
|
+
for item in self.foreach:
|
941
|
+
result.trace.debug(f"[STAGE]: Execute foreach item: {item!r}")
|
942
|
+
params["item"] = item
|
943
|
+
context = {"stages": {}}
|
944
|
+
|
945
|
+
for stage in self.stages:
|
946
|
+
try:
|
947
|
+
stage.set_outputs(
|
948
|
+
stage.handler_execute(
|
949
|
+
params=params,
|
950
|
+
run_id=result.run_id,
|
951
|
+
parent_run_id=result.parent_run_id,
|
952
|
+
).context,
|
953
|
+
to=context,
|
954
|
+
)
|
955
|
+
except StageException as err: # pragma: no cov
|
956
|
+
status = Status.FAILED
|
957
|
+
result.trace.error(
|
958
|
+
f"[STAGE]: Catch:\n\t{err.__class__.__name__}:"
|
959
|
+
f"\n\t{err}"
|
960
|
+
)
|
961
|
+
context.update(
|
962
|
+
{
|
963
|
+
"errors": {
|
964
|
+
"class": err,
|
965
|
+
"name": err.__class__.__name__,
|
966
|
+
"message": f"{err.__class__.__name__}: {err}",
|
967
|
+
},
|
968
|
+
},
|
969
|
+
)
|
970
|
+
|
971
|
+
rs["foreach"][item] = context
|
972
|
+
|
973
|
+
return result.catch(status=status, context=rs)
|
974
|
+
|
975
|
+
|
976
|
+
# TODO: Not implement this stages yet
|
977
|
+
class IfStage(BaseStage): # pragma: no cov
|
978
|
+
"""If execution stage.
|
979
|
+
|
980
|
+
Data Validate:
|
981
|
+
>>> stage = {
|
982
|
+
... "name": "If stage execution.",
|
983
|
+
... "case": "${{ param.test }}",
|
984
|
+
... "match": [
|
985
|
+
... {
|
986
|
+
... "case": "1",
|
987
|
+
... "stage": {
|
988
|
+
... "name": "Stage case 1",
|
989
|
+
... "eche": "Hello case 1",
|
990
|
+
... },
|
991
|
+
... },
|
992
|
+
... {
|
993
|
+
... "case": "2",
|
994
|
+
... "stage": {
|
995
|
+
... "name": "Stage case 2",
|
996
|
+
... "eche": "Hello case 2",
|
997
|
+
... },
|
998
|
+
... },
|
999
|
+
... {
|
1000
|
+
... "case": "_",
|
1001
|
+
... "stage": {
|
1002
|
+
... "name": "Stage else",
|
1003
|
+
... "eche": "Hello case else",
|
1004
|
+
... },
|
1005
|
+
... },
|
1006
|
+
... ],
|
1007
|
+
... }
|
1008
|
+
|
1009
|
+
"""
|
1010
|
+
|
1011
|
+
case: str
|
1012
|
+
match: list[dict[str, Union[str, Stage]]]
|
784
1013
|
|
785
1014
|
def execute(
|
786
1015
|
self, params: DictData, *, result: Result | None = None
|
@@ -820,3 +1049,27 @@ class VirtualPyStage(PyStage): # pragma: no cov
|
|
820
1049
|
vars: DictData
|
821
1050
|
|
822
1051
|
def create_py_file(self, py: str, run_id: str | None): ...
|
1052
|
+
|
1053
|
+
|
1054
|
+
# TODO: Not implement this stages yet
|
1055
|
+
class SensorStage(BaseStage): # pragma: no cov
|
1056
|
+
|
1057
|
+
def execute(
|
1058
|
+
self, params: DictData, *, result: Result | None = None
|
1059
|
+
) -> Result: ...
|
1060
|
+
|
1061
|
+
|
1062
|
+
# NOTE:
|
1063
|
+
# An order of parsing stage model on the Job model with ``stages`` field.
|
1064
|
+
# From the current build-in stages, they do not have stage that have the same
|
1065
|
+
# fields that because of parsing on the Job's stages key.
|
1066
|
+
#
|
1067
|
+
Stage = Union[
|
1068
|
+
EmptyStage,
|
1069
|
+
BashStage,
|
1070
|
+
CallStage,
|
1071
|
+
TriggerStage,
|
1072
|
+
ForEachStage,
|
1073
|
+
ParallelStage,
|
1074
|
+
PyStage,
|
1075
|
+
]
|