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.
@@ -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, get_logger
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 execution.
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 ``execute`` method.")
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
- |-context:
155
- |-outputs: ...
161
+ ╰-context:
162
+ ╰-outputs: ...
156
163
 
157
164
  --> Error --> Result (if env var was set)
158
165
  |-status: Status.FAILED
159
- |-errors:
166
+ ╰-errors:
160
167
  |-class: ...
161
168
  |-name: ...
162
- |-message: ...
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 parameter data that want to use in this execution.
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 release.
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=(self.name + (self.id or "")),
193
+ id_logic=self.iden,
182
194
  )
183
195
 
184
196
  try:
185
- return self.execute(params, result=result)
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
- # NOTE: Catching exception error object to result with
202
- # error_message and error keys.
203
- return result.catch(
204
- status=Status.FAILED,
205
- context={
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 ``outputs`` key.
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
- The result of the `to` variable will be;
229
+ The result of the `to` argument will be;
225
230
 
226
231
  ... (iii) to: {
227
232
  'stages': {
228
- '<stage-id>': {'outputs': {'foo': 'bar'}}
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 output key.
233
- :param to: A context data that want to add output result.
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
- # NOTE: If the stage ID did not set, it will use its name instead.
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
- # NOTE: Set the output to that stage generated ID with ``outputs`` key.
259
- to["stages"][_id] = {"outputs": output, **errors}
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 template.
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 finish execution",
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. But this
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 statement.
387
- :param run_id: A running stage ID that use for writing sh file instead
388
- generate by UUID4.
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.rstrip("\n") or None,
461
- "stderr": rs.stderr.rstrip("\n") or None,
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 output key.
519
- :param to: A context data that want to add output result.
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
- # NOTE: The output will fileter unnecessary keys from locals.
524
- lc: DictData = output.get("locals", {})
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, _globals, lc)
591
+ exec(param2template(dedent(self.run), params), gb, lc)
571
592
 
572
593
  return result.catch(
573
- status=Status.SUCCESS, context={"locals": lc, "globals": _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": {"FOO": "BAR"},
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 ips.parameters
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(ips.parameters.keys())}, ), "
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
- rs: DictData = t_func(**param2template(args, params))
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
- ... "name": "Echo first stage",
744
- ... "echo": "Start run with branch 1",
745
- ... "sleep": 3,
746
- ... },
747
- ... {
748
- ... "name": "Echo second stage",
749
- ... "echo": "Start run with branch 2",
750
- ... "sleep": 1,
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
- # TODO: Not implement this stages yet
765
- class ForEachStage(BaseStage): # pragma: no cov
766
- """For-Each execution stage that execute child stages with an item in list of
767
- item values.
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
- stages: list[Stage]
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
+ ]