ddeutil-workflow 0.0.37__py3-none-any.whl → 0.0.39__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/__types.py +2 -0
- ddeutil/workflow/api/routes/job.py +3 -1
- ddeutil/workflow/api/routes/logs.py +12 -4
- ddeutil/workflow/audit.py +7 -5
- ddeutil/workflow/caller.py +17 -12
- ddeutil/workflow/context.py +61 -0
- ddeutil/workflow/exceptions.py +14 -1
- ddeutil/workflow/job.py +224 -135
- ddeutil/workflow/logs.py +6 -1
- ddeutil/workflow/result.py +1 -1
- ddeutil/workflow/stages.py +403 -133
- ddeutil/workflow/templates.py +39 -20
- ddeutil/workflow/utils.py +1 -44
- ddeutil/workflow/workflow.py +168 -84
- {ddeutil_workflow-0.0.37.dist-info → ddeutil_workflow-0.0.39.dist-info}/METADATA +9 -3
- ddeutil_workflow-0.0.39.dist-info/RECORD +33 -0
- {ddeutil_workflow-0.0.37.dist-info → ddeutil_workflow-0.0.39.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.39.dist-info/licenses}/LICENSE +0 -0
- {ddeutil_workflow-0.0.37.dist-info → ddeutil_workflow-0.0.39.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,11 +32,16 @@ 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
|
37
43
|
from textwrap import dedent
|
38
|
-
from typing import Optional, Union
|
44
|
+
from typing import Annotated, Optional, Union
|
39
45
|
|
40
46
|
from pydantic import BaseModel, Field
|
41
47
|
from pydantic.functional_validators import model_validator
|
@@ -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,30 +226,29 @@ 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': {
|
228
|
-
'<stage-id>': {
|
233
|
+
'<stage-id>': {
|
234
|
+
'outputs': {'foo': 'bar'},
|
235
|
+
'skipped': False
|
236
|
+
}
|
229
237
|
}
|
230
238
|
}
|
231
239
|
|
232
|
-
:param output: An output data that want to extract to an
|
233
|
-
|
240
|
+
:param output: (DictData) An output data that want to extract to an
|
241
|
+
output key.
|
242
|
+
:param to: (DictData) A context data that want to add output result.
|
243
|
+
|
234
244
|
:rtype: DictData
|
235
245
|
"""
|
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
246
|
if "stages" not in to:
|
245
247
|
to["stages"] = {}
|
246
248
|
|
247
|
-
|
249
|
+
if self.id is None and not config.stage_default_id:
|
250
|
+
return to
|
251
|
+
|
248
252
|
_id: str = (
|
249
253
|
param2template(self.id, params=to)
|
250
254
|
if self.id
|
@@ -254,9 +258,12 @@ class BaseStage(BaseModel, ABC):
|
|
254
258
|
errors: DictData = (
|
255
259
|
{"errors": output.pop("errors", {})} if "errors" in output else {}
|
256
260
|
)
|
257
|
-
|
258
|
-
|
259
|
-
|
261
|
+
skipping: dict[str, bool] = (
|
262
|
+
{"skipped": output.pop("skipped", False)}
|
263
|
+
if "skipped" in output
|
264
|
+
else {}
|
265
|
+
)
|
266
|
+
to["stages"][_id] = {"outputs": output, **skipping, **errors}
|
260
267
|
return to
|
261
268
|
|
262
269
|
def is_skipped(self, params: DictData | None = None) -> bool:
|
@@ -268,10 +275,11 @@ class BaseStage(BaseModel, ABC):
|
|
268
275
|
:raise StageException: When return type of the eval condition statement
|
269
276
|
does not return with boolean type.
|
270
277
|
|
271
|
-
:param params: A parameters that want to pass to condition
|
278
|
+
:param params: (DictData) A parameters that want to pass to condition
|
279
|
+
template.
|
280
|
+
|
272
281
|
:rtype: bool
|
273
282
|
"""
|
274
|
-
# NOTE: Return false result if condition does not set.
|
275
283
|
if self.condition is None:
|
276
284
|
return False
|
277
285
|
|
@@ -299,6 +307,7 @@ class EmptyStage(BaseStage):
|
|
299
307
|
>>> stage = {
|
300
308
|
... "name": "Empty stage execution",
|
301
309
|
... "echo": "Hello World",
|
310
|
+
... "sleep": 1,
|
302
311
|
... }
|
303
312
|
"""
|
304
313
|
|
@@ -308,7 +317,7 @@ class EmptyStage(BaseStage):
|
|
308
317
|
)
|
309
318
|
sleep: float = Field(
|
310
319
|
default=0,
|
311
|
-
description="A second value to sleep before
|
320
|
+
description="A second value to sleep before start execution",
|
312
321
|
ge=0,
|
313
322
|
)
|
314
323
|
|
@@ -322,13 +331,32 @@ class EmptyStage(BaseStage):
|
|
322
331
|
The result context should be empty and do not process anything
|
323
332
|
without calling logging function.
|
324
333
|
|
325
|
-
:param params: A context data that want to add output result.
|
326
|
-
stage does not pass any output.
|
334
|
+
:param params: (DictData) A context data that want to add output result.
|
335
|
+
But this stage does not pass any output.
|
327
336
|
:param result: (Result) A result object for keeping context and status
|
328
337
|
data.
|
329
338
|
|
330
339
|
:rtype: Result
|
331
340
|
"""
|
341
|
+
result: Result = result or Result(
|
342
|
+
run_id=gen_id(self.name + (self.id or ""), unique=True)
|
343
|
+
)
|
344
|
+
|
345
|
+
result.trace.info(
|
346
|
+
f"[STAGE]: Empty-Execute: {self.name!r}: "
|
347
|
+
f"( {param2template(self.echo, params=params) or '...'} )"
|
348
|
+
)
|
349
|
+
if self.sleep > 0:
|
350
|
+
if self.sleep > 5:
|
351
|
+
result.trace.info(f"[STAGE]: ... sleep ({self.sleep} seconds)")
|
352
|
+
time.sleep(self.sleep)
|
353
|
+
|
354
|
+
return result.catch(status=Status.SUCCESS)
|
355
|
+
|
356
|
+
# TODO: Draft async execute method for the perf improvement.
|
357
|
+
async def aexecute(
|
358
|
+
self, params: DictData, *, result: Result | None = None
|
359
|
+
) -> Result: # pragma: no cov
|
332
360
|
if result is None: # pragma: no cov
|
333
361
|
result: Result = Result(
|
334
362
|
run_id=gen_id(self.name + (self.id or ""), unique=True)
|
@@ -338,11 +366,8 @@ class EmptyStage(BaseStage):
|
|
338
366
|
f"[STAGE]: Empty-Execute: {self.name!r}: "
|
339
367
|
f"( {param2template(self.echo, params=params) or '...'} )"
|
340
368
|
)
|
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
369
|
|
370
|
+
await asyncio.sleep(1)
|
346
371
|
return result.catch(status=Status.SUCCESS)
|
347
372
|
|
348
373
|
|
@@ -382,20 +407,18 @@ class BashStage(BaseStage):
|
|
382
407
|
step will write the `.sh` file before giving this file name to context.
|
383
408
|
After that, it will auto delete this file automatic.
|
384
409
|
|
385
|
-
:param bash: A bash statement that want to execute.
|
386
|
-
:param env: An environment variable that use on this bash
|
387
|
-
|
388
|
-
|
410
|
+
:param bash: (str) A bash statement that want to execute.
|
411
|
+
:param env: (DictStr) An environment variable that use on this bash
|
412
|
+
statement.
|
413
|
+
:param run_id: (str | None) A running stage ID that use for writing sh
|
414
|
+
file instead generate by UUID4.
|
415
|
+
|
389
416
|
:rtype: Iterator[TupleStr]
|
390
417
|
"""
|
391
418
|
run_id: str = run_id or uuid.uuid4()
|
392
419
|
f_name: str = f"{run_id}.sh"
|
393
420
|
f_shebang: str = "bash" if sys.platform.startswith("win") else "sh"
|
394
421
|
|
395
|
-
logger.debug(
|
396
|
-
f"({cut_id(run_id)}) [STAGE]: Start create `{f_name}` file."
|
397
|
-
)
|
398
|
-
|
399
422
|
with open(f"./{f_name}", mode="w", newline="\n") as f:
|
400
423
|
# NOTE: write header of `.sh` file
|
401
424
|
f.write(f"#!/bin/{f_shebang}\n\n")
|
@@ -439,9 +462,11 @@ class BashStage(BaseStage):
|
|
439
462
|
env=param2template(self.env, params),
|
440
463
|
run_id=result.run_id,
|
441
464
|
) as sh:
|
465
|
+
result.trace.debug(f"... Start create `{sh[1]}` file.")
|
442
466
|
rs: CompletedProcess = subprocess.run(
|
443
467
|
sh, shell=False, capture_output=True, text=True
|
444
468
|
)
|
469
|
+
|
445
470
|
if rs.returncode > 0:
|
446
471
|
# NOTE: Prepare stderr message that returning from subprocess.
|
447
472
|
err: str = (
|
@@ -457,8 +482,8 @@ class BashStage(BaseStage):
|
|
457
482
|
status=Status.SUCCESS,
|
458
483
|
context={
|
459
484
|
"return_code": rs.returncode,
|
460
|
-
"stdout": rs.stdout.
|
461
|
-
"stderr": rs.stderr.
|
485
|
+
"stdout": None if (out := rs.stdout.strip("\n")) == "" else out,
|
486
|
+
"stderr": None if (out := rs.stderr.strip("\n")) == "" else out,
|
462
487
|
},
|
463
488
|
)
|
464
489
|
|
@@ -515,24 +540,17 @@ class PyStage(BaseStage):
|
|
515
540
|
"""Override set an outputs method for the Python execution process that
|
516
541
|
extract output from all the locals values.
|
517
542
|
|
518
|
-
:param output: An output data that want to extract to an
|
519
|
-
|
543
|
+
:param output: (DictData) An output data that want to extract to an
|
544
|
+
output key.
|
545
|
+
:param to: (DictData) A context data that want to add output result.
|
520
546
|
|
521
547
|
:rtype: DictData
|
522
548
|
"""
|
523
|
-
|
524
|
-
|
549
|
+
lc: DictData = output.pop("locals", {})
|
550
|
+
gb: DictData = output.pop("globals", {})
|
525
551
|
super().set_outputs(
|
526
|
-
(
|
527
|
-
{k: lc[k] for k in self.filter_locals(lc)}
|
528
|
-
| {k: output[k] for k in output if k.startswith("error")}
|
529
|
-
),
|
530
|
-
to=to,
|
552
|
+
{k: lc[k] for k in self.filter_locals(lc)} | output, to=to
|
531
553
|
)
|
532
|
-
|
533
|
-
# NOTE: Override value that changing from the globals that pass via the
|
534
|
-
# exec function.
|
535
|
-
gb: DictData = output.get("globals", {})
|
536
554
|
to.update({k: gb[k] for k in to if k in gb})
|
537
555
|
return to
|
538
556
|
|
@@ -553,24 +571,27 @@ class PyStage(BaseStage):
|
|
553
571
|
run_id=gen_id(self.name + (self.id or ""), unique=True)
|
554
572
|
)
|
555
573
|
|
556
|
-
# NOTE: Replace the run statement that has templating value.
|
557
|
-
run: str = param2template(dedent(self.run), params)
|
558
|
-
|
559
|
-
# NOTE: create custom globals value that will pass to exec function.
|
560
|
-
_globals: DictData = (
|
561
|
-
globals() | params | param2template(self.vars, params)
|
562
|
-
)
|
563
574
|
lc: DictData = {}
|
575
|
+
gb: DictData = (
|
576
|
+
globals()
|
577
|
+
| params
|
578
|
+
| param2template(self.vars, params)
|
579
|
+
| {"result": result}
|
580
|
+
)
|
564
581
|
|
565
582
|
# NOTE: Start exec the run statement.
|
566
583
|
result.trace.info(f"[STAGE]: Py-Execute: {self.name}")
|
584
|
+
result.trace.warning(
|
585
|
+
"[STAGE]: This stage allow use `eval` function, so, please "
|
586
|
+
"check your statement be safe before execute."
|
587
|
+
)
|
567
588
|
|
568
589
|
# WARNING: The exec build-in function is very dangerous. So, it
|
569
590
|
# should use the re module to validate exec-string before running.
|
570
|
-
exec(run,
|
591
|
+
exec(param2template(dedent(self.run), params), gb, lc)
|
571
592
|
|
572
593
|
return result.catch(
|
573
|
-
status=Status.SUCCESS, context={"locals": lc, "globals":
|
594
|
+
status=Status.SUCCESS, context={"locals": lc, "globals": gb}
|
574
595
|
)
|
575
596
|
|
576
597
|
|
@@ -583,11 +604,16 @@ class CallStage(BaseStage):
|
|
583
604
|
statement. So, you can create your function complexly that you can for your
|
584
605
|
objective to invoked by this stage object.
|
585
606
|
|
607
|
+
This stage is the usefull stage for run every job by a custom requirement
|
608
|
+
that you want by creating the Python function and adding it to the task
|
609
|
+
registry by importer syntax like `module.tasks.registry` not path style like
|
610
|
+
`module/tasks/registry`.
|
611
|
+
|
586
612
|
Data Validate:
|
587
613
|
>>> stage = {
|
588
614
|
... "name": "Task stage execution",
|
589
615
|
... "uses": "tasks/function-name@tag-name",
|
590
|
-
... "args": {"
|
616
|
+
... "args": {"arg01": "BAR", "kwarg01": 10},
|
591
617
|
... }
|
592
618
|
"""
|
593
619
|
|
@@ -631,15 +657,26 @@ class CallStage(BaseStage):
|
|
631
657
|
# calling.
|
632
658
|
args: DictData = {"result": result} | param2template(self.args, params)
|
633
659
|
ips = inspect.signature(t_func)
|
660
|
+
necessary_params: list[str] = [
|
661
|
+
k
|
662
|
+
for k in ips.parameters
|
663
|
+
if (
|
664
|
+
(v := ips.parameters[k]).default == Parameter.empty
|
665
|
+
and (
|
666
|
+
v.kind != Parameter.VAR_KEYWORD
|
667
|
+
or v.kind != Parameter.VAR_POSITIONAL
|
668
|
+
)
|
669
|
+
)
|
670
|
+
]
|
634
671
|
if any(
|
635
672
|
(k.removeprefix("_") not in args and k not in args)
|
636
|
-
for k in
|
637
|
-
if ips.parameters[k].default == Parameter.empty
|
673
|
+
for k in necessary_params
|
638
674
|
):
|
639
675
|
raise ValueError(
|
640
|
-
f"Necessary params, ({', '.join(
|
676
|
+
f"Necessary params, ({', '.join(necessary_params)}, ), "
|
641
677
|
f"does not set to args"
|
642
678
|
)
|
679
|
+
|
643
680
|
# NOTE: add '_' prefix if it wants to use.
|
644
681
|
for k in ips.parameters:
|
645
682
|
if k.removeprefix("_") in args:
|
@@ -649,7 +686,13 @@ class CallStage(BaseStage):
|
|
649
686
|
args.pop("result")
|
650
687
|
|
651
688
|
result.trace.info(f"[STAGE]: Call-Execute: {t_func.name}@{t_func.tag}")
|
652
|
-
|
689
|
+
if inspect.iscoroutinefunction(t_func): # pragma: no cov
|
690
|
+
loop = asyncio.get_event_loop()
|
691
|
+
rs: DictData = loop.run_until_complete(
|
692
|
+
t_func(**param2template(args, params))
|
693
|
+
)
|
694
|
+
else:
|
695
|
+
rs: DictData = t_func(**param2template(args, params))
|
653
696
|
|
654
697
|
# VALIDATE:
|
655
698
|
# Check the result type from call function, it should be dict.
|
@@ -717,54 +760,136 @@ class TriggerStage(BaseStage):
|
|
717
760
|
)
|
718
761
|
|
719
762
|
|
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
763
|
class ParallelStage(BaseStage): # pragma: no cov
|
736
764
|
"""Parallel execution stage that execute child stages with parallel.
|
737
765
|
|
766
|
+
This stage is not the low-level stage model because it runs muti-stages
|
767
|
+
in this stage execution.
|
768
|
+
|
738
769
|
Data Validate:
|
739
770
|
>>> stage = {
|
740
771
|
... "name": "Parallel stage execution.",
|
741
|
-
... "parallel":
|
742
|
-
...
|
743
|
-
...
|
744
|
-
...
|
745
|
-
...
|
746
|
-
...
|
747
|
-
...
|
748
|
-
...
|
749
|
-
...
|
750
|
-
...
|
751
|
-
...
|
752
|
-
...
|
772
|
+
... "parallel": {
|
773
|
+
... "branch01": [
|
774
|
+
... {
|
775
|
+
... "name": "Echo first stage",
|
776
|
+
... "echo": "Start run with branch 1",
|
777
|
+
... "sleep": 3,
|
778
|
+
... },
|
779
|
+
... ],
|
780
|
+
... "branch02": [
|
781
|
+
... {
|
782
|
+
... "name": "Echo second stage",
|
783
|
+
... "echo": "Start run with branch 2",
|
784
|
+
... "sleep": 1,
|
785
|
+
... },
|
786
|
+
... ],
|
787
|
+
... }
|
753
788
|
... }
|
754
789
|
"""
|
755
790
|
|
756
|
-
parallel: list[Stage]
|
791
|
+
parallel: dict[str, list[Stage]] = Field(
|
792
|
+
description="A mapping of parallel branch ID.",
|
793
|
+
)
|
757
794
|
max_parallel_core: int = Field(default=2)
|
758
795
|
|
796
|
+
@staticmethod
|
797
|
+
def task(
|
798
|
+
branch: str,
|
799
|
+
params: DictData,
|
800
|
+
result: Result,
|
801
|
+
stages: list[Stage],
|
802
|
+
) -> DictData:
|
803
|
+
"""Task execution method for passing a branch to each thread.
|
804
|
+
|
805
|
+
:param branch: A branch ID.
|
806
|
+
:param params: A parameter data that want to use in this execution.
|
807
|
+
:param result: (Result) A result object for keeping context and status
|
808
|
+
data.
|
809
|
+
:param stages:
|
810
|
+
|
811
|
+
:rtype: DictData
|
812
|
+
"""
|
813
|
+
context = {"branch": branch, "stages": {}}
|
814
|
+
result.trace.debug(f"[STAGE]: Execute parallel branch: {branch!r}")
|
815
|
+
for stage in stages:
|
816
|
+
try:
|
817
|
+
stage.set_outputs(
|
818
|
+
stage.handler_execute(
|
819
|
+
params=params,
|
820
|
+
run_id=result.run_id,
|
821
|
+
parent_run_id=result.parent_run_id,
|
822
|
+
).context,
|
823
|
+
to=context,
|
824
|
+
)
|
825
|
+
except StageException as err: # pragma: no cov
|
826
|
+
result.trace.error(
|
827
|
+
f"[STAGE]: Catch:\n\t{err.__class__.__name__}:" f"\n\t{err}"
|
828
|
+
)
|
829
|
+
context.update(
|
830
|
+
{
|
831
|
+
"errors": {
|
832
|
+
"class": err,
|
833
|
+
"name": err.__class__.__name__,
|
834
|
+
"message": f"{err.__class__.__name__}: {err}",
|
835
|
+
},
|
836
|
+
},
|
837
|
+
)
|
838
|
+
return context
|
839
|
+
|
759
840
|
def execute(
|
760
841
|
self, params: DictData, *, result: Result | None = None
|
761
|
-
) -> Result:
|
842
|
+
) -> Result:
|
843
|
+
"""Execute the stages that parallel each branch via multi-threading mode
|
844
|
+
or async mode by changing `async_mode` flag.
|
762
845
|
|
846
|
+
:param params: A parameter that want to pass before run any statement.
|
847
|
+
:param result: (Result) A result object for keeping context and status
|
848
|
+
data.
|
763
849
|
|
764
|
-
|
765
|
-
|
766
|
-
|
767
|
-
|
850
|
+
:rtype: Result
|
851
|
+
"""
|
852
|
+
if result is None: # pragma: no cov
|
853
|
+
result: Result = Result(
|
854
|
+
run_id=gen_id(self.name + (self.id or ""), unique=True)
|
855
|
+
)
|
856
|
+
|
857
|
+
rs: DictData = {"parallel": {}}
|
858
|
+
status = Status.SUCCESS
|
859
|
+
with ThreadPoolExecutor(
|
860
|
+
max_workers=self.max_parallel_core,
|
861
|
+
thread_name_prefix="parallel_stage_exec_",
|
862
|
+
) as executor:
|
863
|
+
|
864
|
+
futures: list[Future] = []
|
865
|
+
for branch in self.parallel:
|
866
|
+
futures.append(
|
867
|
+
executor.submit(
|
868
|
+
self.task,
|
869
|
+
branch=branch,
|
870
|
+
params=params,
|
871
|
+
result=result,
|
872
|
+
stages=self.parallel[branch],
|
873
|
+
)
|
874
|
+
)
|
875
|
+
|
876
|
+
done = as_completed(futures, timeout=1800)
|
877
|
+
for future in done:
|
878
|
+
context: DictData = future.result()
|
879
|
+
rs["parallel"][context.pop("branch")] = context
|
880
|
+
|
881
|
+
if "errors" in context:
|
882
|
+
status = Status.FAILED
|
883
|
+
|
884
|
+
return result.catch(status=status, context=rs)
|
885
|
+
|
886
|
+
|
887
|
+
class ForEachStage(BaseStage):
|
888
|
+
"""For-Each execution stage that execute child stages with an item in list
|
889
|
+
of item values.
|
890
|
+
|
891
|
+
This stage is not the low-level stage model because it runs muti-stages
|
892
|
+
in this stage execution.
|
768
893
|
|
769
894
|
Data Validate:
|
770
895
|
>>> stage = {
|
@@ -779,14 +904,126 @@ class ForEachStage(BaseStage): # pragma: no cov
|
|
779
904
|
... }
|
780
905
|
"""
|
781
906
|
|
782
|
-
foreach: list[str]
|
783
|
-
|
907
|
+
foreach: Union[list[str], list[int]] = Field(
|
908
|
+
description=(
|
909
|
+
"A items for passing to each stages via ${{ item }} template."
|
910
|
+
),
|
911
|
+
)
|
912
|
+
stages: list[Stage] = Field(
|
913
|
+
description=(
|
914
|
+
"A list of stage that will run with each item in the foreach field."
|
915
|
+
),
|
916
|
+
)
|
917
|
+
|
918
|
+
def execute(
|
919
|
+
self, params: DictData, *, result: Result | None = None
|
920
|
+
) -> Result:
|
921
|
+
"""Execute the stages that pass each item form the foreach field.
|
922
|
+
|
923
|
+
:param params: A parameter that want to pass before run any statement.
|
924
|
+
:param result: (Result) A result object for keeping context and status
|
925
|
+
data.
|
926
|
+
|
927
|
+
:rtype: Result
|
928
|
+
"""
|
929
|
+
if result is None: # pragma: no cov
|
930
|
+
result: Result = Result(
|
931
|
+
run_id=gen_id(self.name + (self.id or ""), unique=True)
|
932
|
+
)
|
933
|
+
|
934
|
+
rs: DictData = {"items": self.foreach, "foreach": {}}
|
935
|
+
status = Status.SUCCESS
|
936
|
+
for item in self.foreach:
|
937
|
+
result.trace.debug(f"[STAGE]: Execute foreach item: {item!r}")
|
938
|
+
params["item"] = item
|
939
|
+
context = {"stages": {}}
|
940
|
+
|
941
|
+
for stage in self.stages:
|
942
|
+
try:
|
943
|
+
stage.set_outputs(
|
944
|
+
stage.handler_execute(
|
945
|
+
params=params,
|
946
|
+
run_id=result.run_id,
|
947
|
+
parent_run_id=result.parent_run_id,
|
948
|
+
).context,
|
949
|
+
to=context,
|
950
|
+
)
|
951
|
+
except StageException as err: # pragma: no cov
|
952
|
+
status = Status.FAILED
|
953
|
+
result.trace.error(
|
954
|
+
f"[STAGE]: Catch:\n\t{err.__class__.__name__}:"
|
955
|
+
f"\n\t{err}"
|
956
|
+
)
|
957
|
+
context.update(
|
958
|
+
{
|
959
|
+
"errors": {
|
960
|
+
"class": err,
|
961
|
+
"name": err.__class__.__name__,
|
962
|
+
"message": f"{err.__class__.__name__}: {err}",
|
963
|
+
},
|
964
|
+
},
|
965
|
+
)
|
966
|
+
|
967
|
+
rs["foreach"][item] = context
|
968
|
+
|
969
|
+
return result.catch(status=status, context=rs)
|
970
|
+
|
971
|
+
|
972
|
+
# TODO: Not implement this stages yet
|
973
|
+
class IfStage(BaseStage): # pragma: no cov
|
974
|
+
"""If execution stage.
|
975
|
+
|
976
|
+
Data Validate:
|
977
|
+
>>> stage = {
|
978
|
+
... "name": "If stage execution.",
|
979
|
+
... "case": "${{ param.test }}",
|
980
|
+
... "match": [
|
981
|
+
... {
|
982
|
+
... "case": "1",
|
983
|
+
... "stage": {
|
984
|
+
... "name": "Stage case 1",
|
985
|
+
... "eche": "Hello case 1",
|
986
|
+
... },
|
987
|
+
... },
|
988
|
+
... {
|
989
|
+
... "case": "2",
|
990
|
+
... "stage": {
|
991
|
+
... "name": "Stage case 2",
|
992
|
+
... "eche": "Hello case 2",
|
993
|
+
... },
|
994
|
+
... },
|
995
|
+
... {
|
996
|
+
... "case": "_",
|
997
|
+
... "stage": {
|
998
|
+
... "name": "Stage else",
|
999
|
+
... "eche": "Hello case else",
|
1000
|
+
... },
|
1001
|
+
... },
|
1002
|
+
... ],
|
1003
|
+
... }
|
1004
|
+
|
1005
|
+
"""
|
1006
|
+
|
1007
|
+
case: str = Field(description="A case condition for routing.")
|
1008
|
+
match: list[dict[str, Union[str, Stage]]]
|
784
1009
|
|
785
1010
|
def execute(
|
786
1011
|
self, params: DictData, *, result: Result | None = None
|
787
1012
|
) -> Result: ...
|
788
1013
|
|
789
1014
|
|
1015
|
+
class RaiseStage(BaseStage): # pragma: no cov
|
1016
|
+
message: str = Field(
|
1017
|
+
description="An error message that want to raise",
|
1018
|
+
alias="raise",
|
1019
|
+
)
|
1020
|
+
|
1021
|
+
def execute(
|
1022
|
+
self, params: DictData, *, result: Result | None = None
|
1023
|
+
) -> Result:
|
1024
|
+
raise StageException(self.message)
|
1025
|
+
|
1026
|
+
|
790
1027
|
# TODO: Not implement this stages yet
|
791
1028
|
class HookStage(BaseStage): # pragma: no cov
|
792
1029
|
hook: str
|
@@ -820,3 +1057,36 @@ class VirtualPyStage(PyStage): # pragma: no cov
|
|
820
1057
|
vars: DictData
|
821
1058
|
|
822
1059
|
def create_py_file(self, py: str, run_id: str | None): ...
|
1060
|
+
|
1061
|
+
def execute(
|
1062
|
+
self, params: DictData, *, result: Result | None = None
|
1063
|
+
) -> Result:
|
1064
|
+
return super().execute(params, result=result)
|
1065
|
+
|
1066
|
+
|
1067
|
+
# TODO: Not implement this stages yet
|
1068
|
+
class SensorStage(BaseStage): # pragma: no cov
|
1069
|
+
|
1070
|
+
def execute(
|
1071
|
+
self, params: DictData, *, result: Result | None = None
|
1072
|
+
) -> Result: ...
|
1073
|
+
|
1074
|
+
|
1075
|
+
# NOTE:
|
1076
|
+
# An order of parsing stage model on the Job model with ``stages`` field.
|
1077
|
+
# From the current build-in stages, they do not have stage that have the same
|
1078
|
+
# fields that because of parsing on the Job's stages key.
|
1079
|
+
#
|
1080
|
+
Stage = Annotated[
|
1081
|
+
Union[
|
1082
|
+
EmptyStage,
|
1083
|
+
BashStage,
|
1084
|
+
CallStage,
|
1085
|
+
TriggerStage,
|
1086
|
+
ForEachStage,
|
1087
|
+
ParallelStage,
|
1088
|
+
PyStage,
|
1089
|
+
RaiseStage,
|
1090
|
+
],
|
1091
|
+
Field(union_mode="smart"),
|
1092
|
+
]
|