ddeutil-workflow 0.0.49__py3-none-any.whl → 0.0.51__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.
@@ -26,6 +26,7 @@ from __future__ import annotations
26
26
 
27
27
  import asyncio
28
28
  import contextlib
29
+ import copy
29
30
  import inspect
30
31
  import subprocess
31
32
  import sys
@@ -34,10 +35,13 @@ import uuid
34
35
  from abc import ABC, abstractmethod
35
36
  from collections.abc import Iterator
36
37
  from concurrent.futures import (
38
+ FIRST_EXCEPTION,
37
39
  Future,
38
40
  ThreadPoolExecutor,
39
41
  as_completed,
42
+ wait,
40
43
  )
44
+ from datetime import datetime
41
45
  from inspect import Parameter
42
46
  from pathlib import Path
43
47
  from subprocess import CompletedProcess
@@ -51,10 +55,12 @@ from typing_extensions import Self
51
55
 
52
56
  from .__types import DictData, DictStr, TupleStr
53
57
  from .conf import dynamic
54
- from .exceptions import StageException, to_dict
55
- from .result import FAILED, SUCCESS, Result, Status
58
+ from .exceptions import StageException, UtilException, to_dict
59
+ from .result import CANCEL, FAILED, SUCCESS, WAIT, Result, Status
56
60
  from .reusables import TagFunc, extract_call, not_in_template, param2template
57
61
  from .utils import (
62
+ delay,
63
+ filter_func,
58
64
  gen_id,
59
65
  make_exec,
60
66
  )
@@ -67,7 +73,8 @@ class BaseStage(BaseModel, ABC):
67
73
  metadata. If you want to implement any custom stage, you can use this class
68
74
  to parent and implement ``self.execute()`` method only.
69
75
 
70
- This class is the abstraction class for any stage class.
76
+ This class is the abstraction class for any stage model that want to
77
+ implement to workflow model.
71
78
  """
72
79
 
73
80
  extras: DictData = Field(
@@ -150,9 +157,8 @@ class BaseStage(BaseModel, ABC):
150
157
  parent_run_id: str | None = None,
151
158
  result: Result | None = None,
152
159
  raise_error: bool | None = None,
153
- to: DictData | None = None,
154
160
  event: Event | None = None,
155
- ) -> Result:
161
+ ) -> Result | DictData:
156
162
  """Handler stage execution result from the stage `execute` method.
157
163
 
158
164
  This stage exception handler still use ok-error concept, but it
@@ -184,8 +190,6 @@ class BaseStage(BaseModel, ABC):
184
190
  :param result: (Result) A result object for keeping context and status
185
191
  data before execution.
186
192
  :param raise_error: (bool) A flag that all this method raise error
187
- :param to: (DictData) A target object for auto set the return output
188
- after execution.
189
193
  :param event: (Event) An event manager that pass to the stage execution.
190
194
 
191
195
  :rtype: Result
@@ -200,7 +204,7 @@ class BaseStage(BaseModel, ABC):
200
204
 
201
205
  try:
202
206
  rs: Result = self.execute(params, result=result, event=event)
203
- return self.set_outputs(rs.context, to=to) if to is not None else rs
207
+ return rs
204
208
  except Exception as e:
205
209
  result.trace.error(f"[STAGE]: {e.__class__.__name__}: {e}")
206
210
 
@@ -214,11 +218,7 @@ class BaseStage(BaseModel, ABC):
214
218
  ) from e
215
219
 
216
220
  errors: DictData = {"errors": to_dict(e)}
217
- return (
218
- self.set_outputs(errors, to=to)
219
- if to is not None
220
- else result.catch(status=FAILED, context=errors)
221
- )
221
+ return result.catch(status=FAILED, context=errors)
222
222
 
223
223
  def set_outputs(self, output: DictData, to: DictData) -> DictData:
224
224
  """Set an outputs from execution process to the received context. The
@@ -228,7 +228,7 @@ class BaseStage(BaseModel, ABC):
228
228
  and want to set on the `to` like;
229
229
 
230
230
  ... (i) output: {'foo': bar}
231
- ... (ii) to: {}
231
+ ... (ii) to: {'stages': {}}
232
232
 
233
233
  The result of the `to` argument will be;
234
234
 
@@ -236,11 +236,15 @@ class BaseStage(BaseModel, ABC):
236
236
  'stages': {
237
237
  '<stage-id>': {
238
238
  'outputs': {'foo': 'bar'},
239
- 'skipped': False
239
+ 'skipped': False,
240
240
  }
241
241
  }
242
242
  }
243
243
 
244
+ Important:
245
+ This method is use for reconstruct the result context and transfer
246
+ to the `to` argument.
247
+
244
248
  :param output: (DictData) An output data that want to extract to an
245
249
  output key.
246
250
  :param to: (DictData) A context data that want to add output result.
@@ -271,11 +275,18 @@ class BaseStage(BaseModel, ABC):
271
275
  if "skipped" in output
272
276
  else {}
273
277
  )
274
- to["stages"][_id] = {"outputs": output, **skipping, **errors}
278
+ to["stages"][_id] = {
279
+ "outputs": copy.deepcopy(output),
280
+ **skipping,
281
+ **errors,
282
+ }
275
283
  return to
276
284
 
277
285
  def get_outputs(self, outputs: DictData) -> DictData:
278
- """Get the outputs from stages data.
286
+ """Get the outputs from stages data. It will get this stage ID from
287
+ the stage outputs mapping.
288
+
289
+ :param outputs: (DictData) A stage outputs that want to get by stage ID.
279
290
 
280
291
  :rtype: DictData
281
292
  """
@@ -329,6 +340,7 @@ class BaseStage(BaseModel, ABC):
329
340
 
330
341
 
331
342
  class BaseAsyncStage(BaseStage):
343
+ """Base Async Stage model."""
332
344
 
333
345
  @abstractmethod
334
346
  def execute(
@@ -374,7 +386,6 @@ class BaseAsyncStage(BaseStage):
374
386
  parent_run_id: str | None = None,
375
387
  result: Result | None = None,
376
388
  raise_error: bool | None = None,
377
- to: DictData | None = None,
378
389
  event: Event | None = None,
379
390
  ) -> Result:
380
391
  """Async Handler stage execution result from the stage `execute` method.
@@ -387,8 +398,6 @@ class BaseAsyncStage(BaseStage):
387
398
  :param result: (Result) A result object for keeping context and status
388
399
  data before execution.
389
400
  :param raise_error: (bool) A flag that all this method raise error
390
- :param to: (DictData) A target object for auto set the return output
391
- after execution.
392
401
  :param event: (Event) An event manager that pass to the stage execution.
393
402
 
394
403
  :rtype: Result
@@ -403,8 +412,6 @@ class BaseAsyncStage(BaseStage):
403
412
 
404
413
  try:
405
414
  rs: Result = await self.axecute(params, result=result, event=event)
406
- if to is not None: # pragma: no cov
407
- return self.set_outputs(rs.context, to=to)
408
415
  return rs
409
416
  except Exception as e: # pragma: no cov
410
417
  await result.trace.aerror(f"[STAGE]: {e.__class__.__name__}: {e}")
@@ -419,9 +426,6 @@ class BaseAsyncStage(BaseStage):
419
426
  ) from None
420
427
 
421
428
  errors: DictData = {"errors": to_dict(e)}
422
- if to is not None:
423
- return self.set_outputs(errors, to=to)
424
-
425
429
  return result.catch(status=FAILED, context=errors)
426
430
 
427
431
 
@@ -439,12 +443,13 @@ class EmptyStage(BaseAsyncStage):
439
443
 
440
444
  echo: Optional[str] = Field(
441
445
  default=None,
442
- description="A string statement that want to logging",
446
+ description="A string message that want to show on the stdout.",
443
447
  )
444
448
  sleep: float = Field(
445
449
  default=0,
446
- description="A second value to sleep before start execution",
450
+ description="A second value to sleep before start execution.",
447
451
  ge=0,
452
+ lt=1800,
448
453
  )
449
454
 
450
455
  def execute(
@@ -471,12 +476,23 @@ class EmptyStage(BaseAsyncStage):
471
476
  :rtype: Result
472
477
  """
473
478
  result: Result = result or Result(
474
- run_id=gen_id(self.name + (self.id or ""), unique=True)
479
+ run_id=gen_id(self.name + (self.id or ""), unique=True),
480
+ extras=self.extras,
475
481
  )
476
482
 
483
+ if not self.echo:
484
+ message: str = "..."
485
+ else:
486
+ message: str = param2template(
487
+ dedent(self.echo), params, extras=self.extras
488
+ )
489
+ if "\n" in self.echo:
490
+ message: str = "\n\t" + message.replace("\n", "\n\t").strip(
491
+ "\n"
492
+ )
493
+
477
494
  result.trace.info(
478
- f"[STAGE]: Empty-Execute: {self.name!r}: "
479
- f"( {param2template(self.echo, params, extras=self.extras) or '...'} )"
495
+ f"[STAGE]: Empty-Execute: {self.name!r}: ( {message} )"
480
496
  )
481
497
  if self.sleep > 0:
482
498
  if self.sleep > 5:
@@ -504,9 +520,10 @@ class EmptyStage(BaseAsyncStage):
504
520
 
505
521
  :rtype: Result
506
522
  """
507
- if result is None: # pragma: no cov
523
+ if result is None:
508
524
  result: Result = Result(
509
- run_id=gen_id(self.name + (self.id or ""), unique=True)
525
+ run_id=gen_id(self.name + (self.id or ""), unique=True),
526
+ extras=self.extras,
510
527
  )
511
528
 
512
529
  await result.trace.ainfo(
@@ -547,8 +564,8 @@ class BashStage(BaseStage):
547
564
  env: DictStr = Field(
548
565
  default_factory=dict,
549
566
  description=(
550
- "An environment variable mapping that want to set before execute "
551
- "this shell statement."
567
+ "An environment variables that set before start execute by adding "
568
+ "on the header of the `.sh` file."
552
569
  ),
553
570
  )
554
571
 
@@ -610,7 +627,8 @@ class BashStage(BaseStage):
610
627
  """
611
628
  if result is None: # pragma: no cov
612
629
  result: Result = Result(
613
- run_id=gen_id(self.name + (self.id or ""), unique=True)
630
+ run_id=gen_id(self.name + (self.id or ""), unique=True),
631
+ extras=self.extras,
614
632
  )
615
633
 
616
634
  bash: str = param2template(
@@ -623,7 +641,7 @@ class BashStage(BaseStage):
623
641
  env=param2template(self.env, params, extras=self.extras),
624
642
  run_id=result.run_id,
625
643
  ) as sh:
626
- result.trace.debug(f"... Start create `{sh[1]}` file.")
644
+ result.trace.debug(f"... Create `{sh[1]}` file.")
627
645
  rs: CompletedProcess = subprocess.run(
628
646
  sh, shell=False, capture_output=True, text=True
629
647
  )
@@ -667,18 +685,20 @@ class PyStage(BaseStage):
667
685
  """
668
686
 
669
687
  run: str = Field(
670
- description="A Python string statement that want to run with exec.",
688
+ description="A Python string statement that want to run with `exec`.",
671
689
  )
672
690
  vars: DictData = Field(
673
691
  default_factory=dict,
674
692
  description=(
675
- "A mapping to variable that want to pass to globals in exec."
693
+ "A variable mapping that want to pass to globals parameter in the "
694
+ "`exec` func."
676
695
  ),
677
696
  )
678
697
 
679
698
  @staticmethod
680
699
  def filter_locals(values: DictData) -> Iterator[str]:
681
- """Filter a locals input values.
700
+ """Filter a locals mapping values that be module, class, or
701
+ __annotations__.
682
702
 
683
703
  :param values: (DictData) A locals values that want to filter.
684
704
 
@@ -735,7 +755,8 @@ class PyStage(BaseStage):
735
755
  """
736
756
  if result is None: # pragma: no cov
737
757
  result: Result = Result(
738
- run_id=gen_id(self.name + (self.id or ""), unique=True)
758
+ run_id=gen_id(self.name + (self.id or ""), unique=True),
759
+ extras=self.extras,
739
760
  )
740
761
 
741
762
  lc: DictData = {}
@@ -748,11 +769,11 @@ class PyStage(BaseStage):
748
769
 
749
770
  # NOTE: Start exec the run statement.
750
771
  result.trace.info(f"[STAGE]: Py-Execute: {self.name}")
751
- result.trace.warning(
752
- "[STAGE]: This stage allow use `eval` function, so, please "
753
- "check your statement be safe before execute."
754
- )
755
-
772
+ # result.trace.warning(
773
+ # "[STAGE]: This stage allow use `eval` function, so, please "
774
+ # "check your statement be safe before execute."
775
+ # )
776
+ #
756
777
  # WARNING: The exec build-in function is very dangerous. So, it
757
778
  # should use the re module to validate exec-string before running.
758
779
  exec(
@@ -818,11 +839,17 @@ class CallStage(BaseStage):
818
839
  :param event: (Event) An event manager that use to track parent execute
819
840
  was not force stopped.
820
841
 
842
+ :raise ValueError: If necessary arguments does not pass from the `args`
843
+ field.
844
+ :raise TypeError: If the result from the caller function does not by
845
+ a `dict` type.
846
+
821
847
  :rtype: Result
822
848
  """
823
849
  if result is None: # pragma: no cov
824
850
  result: Result = Result(
825
- run_id=gen_id(self.name + (self.id or ""), unique=True)
851
+ run_id=gen_id(self.name + (self.id or ""), unique=True),
852
+ extras=self.extras,
826
853
  )
827
854
 
828
855
  has_keyword: bool = False
@@ -880,7 +907,7 @@ class CallStage(BaseStage):
880
907
  # VALIDATE:
881
908
  # Check the result type from call function, it should be dict.
882
909
  if isinstance(rs, BaseModel):
883
- rs: DictData = rs.model_dump()
910
+ rs: DictData = rs.model_dump(by_alias=True)
884
911
  elif not isinstance(rs, dict):
885
912
  raise TypeError(
886
913
  f"Return type: '{call_func.name}@{call_func.tag}' does not "
@@ -896,6 +923,13 @@ class CallStage(BaseStage):
896
923
  ) -> DictData:
897
924
  """Parse Pydantic model from any dict data before parsing to target
898
925
  caller function.
926
+
927
+ :param func: A tag function that want to get typing.
928
+ :param args: An arguments before passing to this tag function.
929
+ :param result: (Result) A result object for keeping context and status
930
+ data.
931
+
932
+ :rtype: DictData
899
933
  """
900
934
  try:
901
935
  type_hints: dict[str, Any] = get_type_hints(func)
@@ -942,7 +976,7 @@ class TriggerStage(BaseStage):
942
976
 
943
977
  trigger: str = Field(
944
978
  description=(
945
- "A trigger workflow name that should already exist on the config."
979
+ "A trigger workflow name that should exist on the config path."
946
980
  ),
947
981
  )
948
982
  params: DictData = Field(
@@ -968,26 +1002,37 @@ class TriggerStage(BaseStage):
968
1002
 
969
1003
  :rtype: Result
970
1004
  """
971
- # NOTE: Lazy import this workflow object.
972
- from . import Workflow
1005
+ from .exceptions import WorkflowException
1006
+ from .workflow import Workflow
973
1007
 
974
- if result is None: # pragma: no cov
1008
+ if result is None:
975
1009
  result: Result = Result(
976
- run_id=gen_id(self.name + (self.id or ""), unique=True)
1010
+ run_id=gen_id(self.name + (self.id or ""), unique=True),
1011
+ extras=self.extras,
977
1012
  )
978
1013
 
979
- # NOTE: Loading workflow object from trigger name.
980
1014
  _trigger: str = param2template(self.trigger, params, extras=self.extras)
981
-
982
- # NOTE: Set running workflow ID from running stage ID to external
983
- # params on Loader object.
984
- workflow: Workflow = Workflow.from_conf(_trigger, extras=self.extras)
985
1015
  result.trace.info(f"[STAGE]: Trigger-Execute: {_trigger!r}")
986
- return workflow.execute(
987
- params=param2template(self.params, params, extras=self.extras),
988
- result=result,
989
- event=event,
990
- )
1016
+ try:
1017
+ rs: Result = Workflow.from_conf(
1018
+ name=_trigger,
1019
+ extras=self.extras | {"stage_raise_error": True},
1020
+ ).execute(
1021
+ params=param2template(self.params, params, extras=self.extras),
1022
+ parent_run_id=result.run_id,
1023
+ event=event,
1024
+ )
1025
+ except WorkflowException as e:
1026
+ raise StageException("Trigger workflow stage was failed") from e
1027
+
1028
+ if rs.status == FAILED:
1029
+ err_msg: str | None = (
1030
+ f" with:\n{msg}"
1031
+ if (msg := rs.context.get("errors", {}).get("message"))
1032
+ else ""
1033
+ )
1034
+ raise StageException(f"Trigger workflow was failed{err_msg}.")
1035
+ return rs
991
1036
 
992
1037
 
993
1038
  class ParallelStage(BaseStage): # pragma: no cov
@@ -1019,17 +1064,25 @@ class ParallelStage(BaseStage): # pragma: no cov
1019
1064
  """
1020
1065
 
1021
1066
  parallel: dict[str, list[Stage]] = Field(
1022
- description="A mapping of parallel branch ID.",
1067
+ description="A mapping of parallel branch name and stages.",
1068
+ )
1069
+ max_workers: int = Field(
1070
+ default=2,
1071
+ ge=1,
1072
+ lt=20,
1073
+ description=(
1074
+ "The maximum thread pool worker size for execution parallel."
1075
+ ),
1076
+ alias="max-workers",
1023
1077
  )
1024
- max_parallel_core: int = Field(default=2)
1025
1078
 
1026
- @staticmethod
1027
- def task(
1079
+ def execute_task(
1080
+ self,
1028
1081
  branch: str,
1029
1082
  params: DictData,
1030
1083
  result: Result,
1031
- stages: list[Stage],
1032
1084
  *,
1085
+ event: Event | None = None,
1033
1086
  extras: DictData | None = None,
1034
1087
  ) -> DictData:
1035
1088
  """Task execution method for passing a branch to each thread.
@@ -1038,32 +1091,82 @@ class ParallelStage(BaseStage): # pragma: no cov
1038
1091
  :param params: A parameter data that want to use in this execution.
1039
1092
  :param result: (Result) A result object for keeping context and status
1040
1093
  data.
1041
- :param stages:
1042
- :param extras
1094
+ :param event: (Event) An event manager that use to track parent execute
1095
+ was not force stopped.
1096
+ :param extras: (DictData) An extra parameters that want to override
1097
+ config values.
1043
1098
 
1044
1099
  :rtype: DictData
1045
1100
  """
1046
- context = {"branch": branch, "stages": {}}
1047
- result.trace.debug(f"[STAGE]: Execute parallel branch: {branch!r}")
1048
- for stage in stages:
1101
+ result.trace.debug(f"... Execute branch: {branch!r}")
1102
+ context: DictData = copy.deepcopy(params)
1103
+ context.update({"branch": branch, "stages": {}})
1104
+ for stage in self.parallel[branch]:
1105
+
1049
1106
  if extras:
1050
1107
  stage.extras = extras
1051
1108
 
1109
+ if stage.is_skipped(params=context):
1110
+ result.trace.info(f"... Skip stage: {stage.iden!r}")
1111
+ stage.set_outputs(output={"skipped": True}, to=context)
1112
+ continue
1113
+
1114
+ if event and event.is_set():
1115
+ error_msg: str = (
1116
+ "Branch-Stage was canceled from event that had set before "
1117
+ "stage item execution."
1118
+ )
1119
+ return result.catch(
1120
+ status=CANCEL,
1121
+ parallel={
1122
+ branch: {
1123
+ "branch": branch,
1124
+ "stages": filter_func(context.pop("stages", {})),
1125
+ "errors": StageException(error_msg).to_dict(),
1126
+ }
1127
+ },
1128
+ )
1129
+
1052
1130
  try:
1053
- stage.set_outputs(
1054
- stage.handler_execute(
1055
- params=params,
1056
- run_id=result.run_id,
1057
- parent_run_id=result.parent_run_id,
1058
- ).context,
1059
- to=context,
1131
+ rs: Result = stage.handler_execute(
1132
+ params=context,
1133
+ run_id=result.run_id,
1134
+ parent_run_id=result.parent_run_id,
1135
+ raise_error=True,
1136
+ event=event,
1060
1137
  )
1138
+ stage.set_outputs(rs.context, to=context)
1061
1139
  except StageException as e: # pragma: no cov
1062
- result.trace.error(
1063
- f"[STAGE]: Catch:\n\t{e.__class__.__name__}:" f"\n\t{e}"
1140
+ result.trace.error(f"[STAGE]: {e.__class__.__name__}: {e}")
1141
+ raise StageException(
1142
+ f"Sub-Stage execution error: {e.__class__.__name__}: {e}"
1143
+ ) from None
1144
+
1145
+ if rs.status == FAILED:
1146
+ error_msg: str = (
1147
+ f"Item-Stage was break because it has a sub stage, "
1148
+ f"{stage.iden}, failed without raise error."
1149
+ )
1150
+ return result.catch(
1151
+ status=FAILED,
1152
+ parallel={
1153
+ branch: {
1154
+ "branch": branch,
1155
+ "stages": filter_func(context.pop("stages", {})),
1156
+ "errors": StageException(error_msg).to_dict(),
1157
+ },
1158
+ },
1064
1159
  )
1065
- context.update({"errors": e.to_dict()})
1066
- return context
1160
+
1161
+ return result.catch(
1162
+ status=SUCCESS,
1163
+ parallel={
1164
+ branch: {
1165
+ "branch": branch,
1166
+ "stages": filter_func(context.pop("stages", {})),
1167
+ },
1168
+ },
1169
+ )
1067
1170
 
1068
1171
  def execute(
1069
1172
  self,
@@ -1085,49 +1188,54 @@ class ParallelStage(BaseStage): # pragma: no cov
1085
1188
  """
1086
1189
  if result is None: # pragma: no cov
1087
1190
  result: Result = Result(
1088
- run_id=gen_id(self.name + (self.id or ""), unique=True)
1191
+ run_id=gen_id(self.name + (self.id or ""), unique=True),
1192
+ extras=self.extras,
1089
1193
  )
1090
-
1194
+ event: Event = Event() if event is None else event
1091
1195
  result.trace.info(
1092
- f"[STAGE]: Parallel-Execute with {self.max_parallel_core} cores."
1196
+ f"[STAGE]: Parallel-Execute: {self.max_workers} workers."
1093
1197
  )
1094
- rs: DictData = {"parallel": {}}
1095
- status = SUCCESS
1198
+ result.catch(status=WAIT, context={"parallel": {}})
1096
1199
  with ThreadPoolExecutor(
1097
- max_workers=self.max_parallel_core,
1200
+ max_workers=self.max_workers,
1098
1201
  thread_name_prefix="parallel_stage_exec_",
1099
1202
  ) as executor:
1100
1203
 
1101
- futures: list[Future] = []
1102
- for branch in self.parallel:
1103
- futures.append(
1104
- executor.submit(
1105
- self.task,
1106
- branch=branch,
1107
- params=params,
1108
- result=result,
1109
- stages=self.parallel[branch],
1110
- extras=self.extras,
1111
- )
1204
+ context: DictData = {}
1205
+ status: Status = SUCCESS
1206
+
1207
+ futures: list[Future] = (
1208
+ executor.submit(
1209
+ self.execute_task,
1210
+ branch=branch,
1211
+ params=params,
1212
+ result=result,
1213
+ event=event,
1214
+ extras=self.extras,
1112
1215
  )
1216
+ for branch in self.parallel
1217
+ )
1113
1218
 
1114
1219
  done = as_completed(futures, timeout=1800)
1115
1220
  for future in done:
1116
- context: DictData = future.result()
1117
- rs["parallel"][context.pop("branch")] = context
1118
-
1119
- if "errors" in context:
1221
+ try:
1222
+ future.result()
1223
+ except StageException as e:
1120
1224
  status = FAILED
1225
+ result.trace.error(
1226
+ f"[STAGE]: {e.__class__.__name__}:\n\t{e}"
1227
+ )
1228
+ context.update({"errors": e.to_dict()})
1121
1229
 
1122
- return result.catch(status=status, context=rs)
1230
+ return result.catch(status=status, context=context)
1123
1231
 
1124
1232
 
1125
1233
  class ForEachStage(BaseStage):
1126
1234
  """For-Each execution stage that execute child stages with an item in list
1127
- of item values.
1235
+ of item values. This stage is not the low-level stage model because it runs
1236
+ muti-stages in this stage execution.
1128
1237
 
1129
- This stage is not the low-level stage model because it runs muti-stages
1130
- in this stage execution.
1238
+ The concept of this stage use the same logic of the Job execution.
1131
1239
 
1132
1240
  Data Validate:
1133
1241
  >>> stage = {
@@ -1167,46 +1275,93 @@ class ForEachStage(BaseStage):
1167
1275
  self,
1168
1276
  item: Union[str, int],
1169
1277
  params: DictData,
1170
- context: DictData,
1171
1278
  result: Result,
1172
- ) -> tuple[Status, DictData]:
1279
+ *,
1280
+ event: Event | None = None,
1281
+ ) -> Result:
1173
1282
  """Execute foreach item from list of item.
1174
1283
 
1175
1284
  :param item: (str | int) An item that want to execution.
1176
1285
  :param params: (DictData) A parameter that want to pass to stage
1177
1286
  execution.
1178
- :param context: (DictData)
1179
- :param result: (Result)
1287
+ :param result: (Result) A result object for keeping context and status
1288
+ data.
1289
+ :param event: (Event) An event manager that use to track parent execute
1290
+ was not force stopped.
1180
1291
 
1181
- :rtype: tuple[Status, DictData]
1292
+ :raise StageException: If the stage execution raise errors.
1293
+
1294
+ :rtype: Result
1182
1295
  """
1183
- result.trace.debug(f"[STAGE]: Execute foreach item: {item!r}")
1184
- params["item"] = item
1185
- to: DictData = {"item": item, "stages": {}}
1186
- status: Status = SUCCESS
1296
+ result.trace.debug(f"... Execute item: {item!r}")
1297
+ context: DictData = copy.deepcopy(params)
1298
+ context.update({"item": item, "stages": {}})
1187
1299
  for stage in self.stages:
1188
1300
 
1189
1301
  if self.extras:
1190
1302
  stage.extras = self.extras
1191
1303
 
1304
+ if stage.is_skipped(params=context):
1305
+ result.trace.info(f"... Skip stage: {stage.iden!r}")
1306
+ stage.set_outputs(output={"skipped": True}, to=context)
1307
+ continue
1308
+
1309
+ if event and event.is_set(): # pragma: no cov
1310
+ error_msg: str = (
1311
+ "Item-Stage was canceled from event that had set before "
1312
+ "stage item execution."
1313
+ )
1314
+ return result.catch(
1315
+ status=CANCEL,
1316
+ foreach={
1317
+ item: {
1318
+ "item": item,
1319
+ "stages": filter_func(context.pop("stages", {})),
1320
+ "errors": StageException(error_msg).to_dict(),
1321
+ }
1322
+ },
1323
+ )
1324
+
1192
1325
  try:
1193
- stage.set_outputs(
1194
- stage.handler_execute(
1195
- params=params,
1196
- run_id=result.run_id,
1197
- parent_run_id=result.parent_run_id,
1198
- ).context,
1199
- to=to,
1326
+ rs: Result = stage.handler_execute(
1327
+ params=context,
1328
+ run_id=result.run_id,
1329
+ parent_run_id=result.parent_run_id,
1330
+ raise_error=True,
1331
+ event=event,
1200
1332
  )
1201
- except StageException as e: # pragma: no cov
1202
- status = FAILED
1203
- result.trace.error(
1204
- f"[STAGE]: Catch:\n\t{e.__class__.__name__}:" f"\n\t{e}"
1333
+ stage.set_outputs(rs.context, to=context)
1334
+ except (StageException, UtilException) as e:
1335
+ result.trace.error(f"[STAGE]: {e.__class__.__name__}: {e}")
1336
+ raise StageException(
1337
+ f"Sub-Stage execution error: {e.__class__.__name__}: {e}"
1338
+ ) from None
1339
+
1340
+ if rs.status == FAILED:
1341
+ error_msg: str = (
1342
+ f"Item-Stage was break because it has a sub stage, "
1343
+ f"{stage.iden}, failed without raise error."
1344
+ )
1345
+ return result.catch(
1346
+ status=FAILED,
1347
+ foreach={
1348
+ item: {
1349
+ "item": item,
1350
+ "stages": filter_func(context.pop("stages", {})),
1351
+ "errors": StageException(error_msg).to_dict(),
1352
+ },
1353
+ },
1205
1354
  )
1206
- to.update({"errors": e.to_dict()})
1207
1355
 
1208
- context["foreach"][item] = to
1209
- return status, context
1356
+ return result.catch(
1357
+ status=SUCCESS,
1358
+ foreach={
1359
+ item: {
1360
+ "item": item,
1361
+ "stages": filter_func(context.pop("stages", {})),
1362
+ },
1363
+ },
1364
+ )
1210
1365
 
1211
1366
  def execute(
1212
1367
  self,
@@ -1227,9 +1382,10 @@ class ForEachStage(BaseStage):
1227
1382
  """
1228
1383
  if result is None: # pragma: no cov
1229
1384
  result: Result = Result(
1230
- run_id=gen_id(self.name + (self.id or ""), unique=True)
1385
+ run_id=gen_id(self.name + (self.id or ""), unique=True),
1386
+ extras=self.extras,
1231
1387
  )
1232
-
1388
+ event: Event = Event() if event is None else event
1233
1389
  foreach: Union[list[str], list[int]] = (
1234
1390
  param2template(self.foreach, params, extras=self.extras)
1235
1391
  if isinstance(self.foreach, str)
@@ -1241,24 +1397,58 @@ class ForEachStage(BaseStage):
1241
1397
  )
1242
1398
 
1243
1399
  result.trace.info(f"[STAGE]: Foreach-Execute: {foreach!r}.")
1244
- context: DictData = {"items": foreach, "foreach": {}}
1245
- statuses: list[Union[SUCCESS, FAILED]] = []
1246
- with ThreadPoolExecutor(max_workers=self.concurrent) as executor:
1400
+ result.catch(status=WAIT, context={"items": foreach, "foreach": {}})
1401
+ if event and event.is_set(): # pragma: no cov
1402
+ return result.catch(
1403
+ status=CANCEL,
1404
+ context={
1405
+ "errors": StageException(
1406
+ "Stage was canceled from event that had set "
1407
+ "before stage foreach execution."
1408
+ ).to_dict()
1409
+ },
1410
+ )
1411
+
1412
+ with ThreadPoolExecutor(
1413
+ max_workers=self.concurrent, thread_name_prefix="stage_foreach_"
1414
+ ) as executor:
1415
+
1247
1416
  futures: list[Future] = [
1248
1417
  executor.submit(
1249
1418
  self.execute_item,
1250
1419
  item=item,
1251
- params=params.copy(),
1252
- context=context,
1420
+ params=params,
1253
1421
  result=result,
1422
+ event=event,
1254
1423
  )
1255
1424
  for item in foreach
1256
1425
  ]
1257
- for future in as_completed(futures):
1258
- status, context = future.result()
1259
- statuses.append(status)
1426
+ context: DictData = {}
1427
+ status: Status = SUCCESS
1428
+
1429
+ done, not_done = wait(
1430
+ futures, timeout=1800, return_when=FIRST_EXCEPTION
1431
+ )
1432
+
1433
+ if len(done) != len(futures):
1434
+ result.trace.warning(
1435
+ "[STAGE]: Set the event for stop running stage."
1436
+ )
1437
+ event.set()
1438
+ for future in not_done:
1439
+ future.cancel()
1260
1440
 
1261
- return result.catch(status=max(statuses), context=context)
1441
+ for future in done:
1442
+ try:
1443
+ future.result()
1444
+ except StageException as e:
1445
+ status = FAILED
1446
+ result.trace.error(
1447
+ f"[STAGE]: {e.__class__.__name__}:\n\t{e}"
1448
+ )
1449
+ context.update({"errors": e.to_dict()})
1450
+
1451
+ return result.catch(status=status, context=context)
1262
1452
 
1263
1453
 
1264
1454
  class UntilStage(BaseStage): # pragma: no cov
@@ -1278,7 +1468,12 @@ class UntilStage(BaseStage): # pragma: no cov
1278
1468
  ... }
1279
1469
  """
1280
1470
 
1281
- item: Union[str, int, bool] = Field(description="An initial value.")
1471
+ item: Union[str, int, bool] = Field(
1472
+ default=0,
1473
+ description=(
1474
+ "An initial value that can be any value in str, int, or bool type."
1475
+ ),
1476
+ )
1282
1477
  until: str = Field(description="A until condition.")
1283
1478
  stages: list[Stage] = Field(
1284
1479
  default_factory=list,
@@ -1287,11 +1482,12 @@ class UntilStage(BaseStage): # pragma: no cov
1287
1482
  "correct."
1288
1483
  ),
1289
1484
  )
1290
- max_until_loop: int = Field(
1485
+ max_loop: int = Field(
1291
1486
  default=10,
1292
1487
  ge=1,
1293
1488
  lt=100,
1294
1489
  description="The maximum value of loop for this until stage.",
1490
+ alias="max-loop",
1295
1491
  )
1296
1492
 
1297
1493
  def execute_item(
@@ -1299,9 +1495,9 @@ class UntilStage(BaseStage): # pragma: no cov
1299
1495
  item: T,
1300
1496
  loop: int,
1301
1497
  params: DictData,
1302
- context: DictData,
1303
1498
  result: Result,
1304
- ) -> tuple[Status, DictData, T]:
1499
+ event: Event | None = None,
1500
+ ) -> tuple[Result, T]:
1305
1501
  """Execute until item set item by some stage or by default loop
1306
1502
  variable.
1307
1503
 
@@ -1309,43 +1505,81 @@ class UntilStage(BaseStage): # pragma: no cov
1309
1505
  :param loop: (int) A number of loop.
1310
1506
  :param params: (DictData) A parameter that want to pass to stage
1311
1507
  execution.
1312
- :param context: (DictData)
1313
- :param result: (Result)
1508
+ :param result: (Result) A result object for keeping context and status
1509
+ data.
1510
+ :param event: (Event) An event manager that use to track parent execute
1511
+ was not force stopped.
1314
1512
 
1315
- :rtype: tuple[Status, DictData, T]
1513
+ :rtype: tuple[Result, T]
1316
1514
  """
1317
- result.trace.debug(f"[STAGE]: Execute until item: {item!r}")
1318
- params["item"] = item
1319
- to: DictData = {"item": item, "stages": {}}
1320
- status: Status = SUCCESS
1515
+ result.trace.debug(f"... Execute until item: {item!r}")
1516
+ context: DictData = copy.deepcopy(params)
1517
+ context.update({"loop": loop, "item": item, "stages": {}})
1321
1518
  next_item: T = None
1322
1519
  for stage in self.stages:
1323
1520
 
1324
1521
  if self.extras:
1325
1522
  stage.extras = self.extras
1326
1523
 
1524
+ if stage.is_skipped(params=context):
1525
+ result.trace.info(f"... Skip stage: {stage.iden!r}")
1526
+ stage.set_outputs(output={"skipped": True}, to=context)
1527
+ continue
1528
+
1529
+ if event and event.is_set():
1530
+ error_msg: str = (
1531
+ "Item-Stage was canceled from event that had set before "
1532
+ "stage item execution."
1533
+ )
1534
+ return (
1535
+ result.catch(
1536
+ status=CANCEL,
1537
+ until={
1538
+ loop: {
1539
+ "loop": loop,
1540
+ "item": item,
1541
+ "stages": filter_func(
1542
+ context.pop("stages", {})
1543
+ ),
1544
+ "errors": StageException(error_msg).to_dict(),
1545
+ }
1546
+ },
1547
+ ),
1548
+ next_item,
1549
+ )
1550
+
1327
1551
  try:
1328
- stage.set_outputs(
1329
- stage.handler_execute(
1330
- params=params,
1331
- run_id=result.run_id,
1332
- parent_run_id=result.parent_run_id,
1333
- ).context,
1334
- to=to,
1552
+ rs: Result = stage.handler_execute(
1553
+ params=context,
1554
+ run_id=result.run_id,
1555
+ parent_run_id=result.parent_run_id,
1556
+ raise_error=True,
1557
+ event=event,
1335
1558
  )
1559
+ stage.set_outputs(rs.context, to=context)
1336
1560
  if "item" in (
1337
- outputs := stage.get_outputs(to).get("outputs", {})
1561
+ outputs := stage.get_outputs(context).get("outputs", {})
1338
1562
  ):
1339
1563
  next_item = outputs["item"]
1340
- except StageException as e:
1341
- status = FAILED
1342
- result.trace.error(
1343
- f"[STAGE]: Catch:\n\t{e.__class__.__name__}:" f"\n\t{e}"
1344
- )
1345
- to.update({"errors": e.to_dict()})
1564
+ except (StageException, UtilException) as e:
1565
+ result.trace.error(f"[STAGE]: {e.__class__.__name__}: {e}")
1566
+ raise StageException(
1567
+ f"Sub-Stage execution error: {e.__class__.__name__}: {e}"
1568
+ ) from None
1346
1569
 
1347
- context["until"][loop] = to
1348
- return status, context, next_item
1570
+ return (
1571
+ result.catch(
1572
+ status=SUCCESS,
1573
+ until={
1574
+ loop: {
1575
+ "loop": loop,
1576
+ "item": item,
1577
+ "stages": filter_func(context.pop("stages", {})),
1578
+ }
1579
+ },
1580
+ ),
1581
+ next_item,
1582
+ )
1349
1583
 
1350
1584
  def execute(
1351
1585
  self,
@@ -1370,22 +1604,33 @@ class UntilStage(BaseStage): # pragma: no cov
1370
1604
  run_id=gen_id(self.name + (self.id or ""), unique=True)
1371
1605
  )
1372
1606
 
1607
+ result.trace.info(f"[STAGE]: Until-Execution: {self.until}")
1373
1608
  item: Union[str, int, bool] = param2template(
1374
1609
  self.item, params, extras=self.extras
1375
1610
  )
1376
- result.trace.info(f"[STAGE]: Until-Execution: {self.until}")
1611
+ loop: int = 1
1377
1612
  track: bool = True
1378
1613
  exceed_loop: bool = False
1379
- loop: int = 1
1380
- context: DictData = {"until": {}}
1381
- statuses: list[Union[SUCCESS, FAILED]] = []
1382
- while track and not (exceed_loop := loop >= self.max_until_loop):
1383
- status, context, item = self.execute_item(
1614
+ result.catch(status=WAIT, context={"until": {}})
1615
+ while track and not (exceed_loop := loop >= self.max_loop):
1616
+
1617
+ if event and event.is_set():
1618
+ return result.catch(
1619
+ status=CANCEL,
1620
+ context={
1621
+ "errors": StageException(
1622
+ "Stage was canceled from event that had set "
1623
+ "before stage until execution."
1624
+ ).to_dict()
1625
+ },
1626
+ )
1627
+
1628
+ result, item = self.execute_item(
1384
1629
  item=item,
1385
1630
  loop=loop,
1386
- params=params.copy(),
1387
- context=context,
1631
+ params=params,
1388
1632
  result=result,
1633
+ event=event,
1389
1634
  )
1390
1635
 
1391
1636
  loop += 1
@@ -1398,34 +1643,36 @@ class UntilStage(BaseStage): # pragma: no cov
1398
1643
 
1399
1644
  next_track: bool = eval(
1400
1645
  param2template(
1401
- self.until, params | {"item": item}, extras=self.extras
1646
+ self.until,
1647
+ params | {"item": item, "loop": loop},
1648
+ extras=self.extras,
1402
1649
  ),
1403
1650
  globals() | params | {"item": item},
1404
1651
  {},
1405
1652
  )
1406
1653
  if not isinstance(next_track, bool):
1407
- raise TypeError(
1654
+ raise StageException(
1408
1655
  "Return type of until condition does not be boolean, it"
1409
1656
  f"return: {next_track!r}"
1410
1657
  )
1411
- track = not next_track
1412
- statuses.append(status)
1658
+ track: bool = not next_track
1659
+ delay(0.025)
1413
1660
 
1414
1661
  if exceed_loop:
1415
1662
  raise StageException(
1416
- f"The until loop was exceed {self.max_until_loop} loops"
1663
+ f"The until loop was exceed {self.max_loop} loops"
1417
1664
  )
1418
- return result.catch(status=max(statuses), context=context)
1665
+ return result.catch(status=SUCCESS)
1419
1666
 
1420
1667
 
1421
1668
  class Match(BaseModel):
1422
1669
  """Match model for the Case Stage."""
1423
1670
 
1424
- case: Union[str, int]
1425
- stage: Stage
1671
+ case: Union[str, int] = Field(description="A match case.")
1672
+ stage: Stage = Field(description="A stage to execution for this case.")
1426
1673
 
1427
1674
 
1428
- class CaseStage(BaseStage): # pragma: no cov
1675
+ class CaseStage(BaseStage):
1429
1676
  """Case execution stage.
1430
1677
 
1431
1678
  Data Validate:
@@ -1463,6 +1710,14 @@ class CaseStage(BaseStage): # pragma: no cov
1463
1710
  match: list[Match] = Field(
1464
1711
  description="A list of Match model that should not be an empty list.",
1465
1712
  )
1713
+ skip_not_match: bool = Field(
1714
+ default=False,
1715
+ description=(
1716
+ "A flag for making skip if it does not match and else condition "
1717
+ "does not set too."
1718
+ ),
1719
+ alias="skip-not-match",
1720
+ )
1466
1721
 
1467
1722
  def execute(
1468
1723
  self,
@@ -1483,58 +1738,78 @@ class CaseStage(BaseStage): # pragma: no cov
1483
1738
  """
1484
1739
  if result is None: # pragma: no cov
1485
1740
  result: Result = Result(
1486
- run_id=gen_id(self.name + (self.id or ""), unique=True)
1741
+ run_id=gen_id(self.name + (self.id or ""), unique=True),
1742
+ extras=self.extras,
1487
1743
  )
1488
1744
 
1489
- _case = param2template(self.case, params, extras=self.extras)
1745
+ _case: Optional[str] = param2template(
1746
+ self.case, params, extras=self.extras
1747
+ )
1490
1748
 
1491
1749
  result.trace.info(f"[STAGE]: Case-Execute: {_case!r}.")
1492
- _else = None
1750
+ _else: Optional[Match] = None
1493
1751
  stage: Optional[Stage] = None
1494
- context = {}
1495
- status = SUCCESS
1496
1752
  for match in self.match:
1497
- if (c := match.case) != "_":
1498
- _condition = param2template(c, params, extras=self.extras)
1499
- else:
1500
- _else = match
1753
+ if (c := match.case) == "_":
1754
+ _else: Match = match
1501
1755
  continue
1502
1756
 
1757
+ _condition: str = param2template(c, params, extras=self.extras)
1503
1758
  if stage is None and _case == _condition:
1504
1759
  stage: Stage = match.stage
1505
1760
 
1506
1761
  if stage is None:
1507
1762
  if _else is None:
1508
- raise StageException(
1509
- "This stage does not set else for support not match "
1510
- "any case."
1763
+ if not self.skip_not_match:
1764
+ raise StageException(
1765
+ "This stage does not set else for support not match "
1766
+ "any case."
1767
+ )
1768
+ result.trace.info(
1769
+ "... Skip this stage because it does not match."
1770
+ )
1771
+ error_msg: str = (
1772
+ "Case-Stage was canceled because it does not match any "
1773
+ "case and else condition does not set too."
1774
+ )
1775
+ return result.catch(
1776
+ status=CANCEL,
1777
+ context={"errors": StageException(error_msg).to_dict()},
1511
1778
  )
1512
-
1513
1779
  stage: Stage = _else.stage
1514
1780
 
1515
1781
  if self.extras:
1516
1782
  stage.extras = self.extras
1517
1783
 
1784
+ if event and event.is_set(): # pragma: no cov
1785
+ return result.catch(
1786
+ status=CANCEL,
1787
+ context={
1788
+ "errors": StageException(
1789
+ "Stage was canceled from event that had set before "
1790
+ "case-stage execution."
1791
+ ).to_dict()
1792
+ },
1793
+ )
1794
+
1518
1795
  try:
1519
- context.update(
1520
- stage.handler_execute(
1796
+ return result.catch(
1797
+ status=SUCCESS,
1798
+ context=stage.handler_execute(
1521
1799
  params=params,
1522
1800
  run_id=result.run_id,
1523
1801
  parent_run_id=result.parent_run_id,
1524
- ).context
1802
+ event=event,
1803
+ ).context,
1525
1804
  )
1526
1805
  except StageException as e: # pragma: no cov
1527
- status = FAILED
1528
- result.trace.error(
1529
- f"[STAGE]: Catch:\n\t{e.__class__.__name__}:" f"\n\t{e}"
1530
- )
1531
- context.update({"errors": e.to_dict()})
1532
- return result.catch(status=status, context=context)
1806
+ result.trace.error(f"[STAGE]: {e.__class__.__name__}:" f"\n\t{e}")
1807
+ return result.catch(status=FAILED, context={"errors": e.to_dict()})
1533
1808
 
1534
1809
 
1535
1810
  class RaiseStage(BaseStage): # pragma: no cov
1536
- """Raise error stage that raise StageException that use a message field for
1537
- making error message before raise.
1811
+ """Raise error stage execution that raise StageException that use a message
1812
+ field for making error message before raise.
1538
1813
 
1539
1814
  Data Validate:
1540
1815
  >>> stage = {
@@ -1545,7 +1820,9 @@ class RaiseStage(BaseStage): # pragma: no cov
1545
1820
  """
1546
1821
 
1547
1822
  message: str = Field(
1548
- description="An error message that want to raise",
1823
+ description=(
1824
+ "An error message that want to raise with StageException class"
1825
+ ),
1549
1826
  alias="raise",
1550
1827
  )
1551
1828
 
@@ -1556,13 +1833,22 @@ class RaiseStage(BaseStage): # pragma: no cov
1556
1833
  result: Result | None = None,
1557
1834
  event: Event | None = None,
1558
1835
  ) -> Result:
1559
- """Raise the stage."""
1836
+ """Raise the StageException object with the message field execution.
1837
+
1838
+ :param params: A parameter that want to pass before run any statement.
1839
+ :param result: (Result) A result object for keeping context and status
1840
+ data.
1841
+ :param event: (Event) An event manager that use to track parent execute
1842
+ was not force stopped.
1843
+ """
1560
1844
  if result is None: # pragma: no cov
1561
1845
  result: Result = Result(
1562
- run_id=gen_id(self.name + (self.id or ""), unique=True)
1846
+ run_id=gen_id(self.name + (self.id or ""), unique=True),
1847
+ extras=self.extras,
1563
1848
  )
1564
- result.trace.info(f"[STAGE]: Raise-Execute: {self.message!r}.")
1565
- raise StageException(self.message)
1849
+ message: str = param2template(self.message, params, extras=self.extras)
1850
+ result.trace.info(f"[STAGE]: Raise-Execute: {message!r}.")
1851
+ raise StageException(message)
1566
1852
 
1567
1853
 
1568
1854
  # TODO: Not implement this stages yet
@@ -1570,7 +1856,7 @@ class HookStage(BaseStage): # pragma: no cov
1570
1856
  """Hook stage execution."""
1571
1857
 
1572
1858
  hook: str
1573
- args: DictData
1859
+ args: DictData = Field(default_factory=dict)
1574
1860
  callback: str
1575
1861
 
1576
1862
  def execute(
@@ -1593,19 +1879,91 @@ class DockerStage(BaseStage): # pragma: no cov
1593
1879
  ... "image": "image-name.pkg.com",
1594
1880
  ... "env": {
1595
1881
  ... "ENV": "dev",
1882
+ ... "DEBUG": "true",
1596
1883
  ... },
1597
1884
  ... "volume": {
1598
1885
  ... "secrets": "/secrets",
1599
1886
  ... },
1887
+ ... "auth": {
1888
+ ... "username": "__json_key",
1889
+ ... "password": "${GOOGLE_CREDENTIAL_JSON_STRING}",
1890
+ ... },
1600
1891
  ... }
1601
1892
  """
1602
1893
 
1603
1894
  image: str = Field(
1604
1895
  description="A Docker image url with tag that want to run.",
1605
1896
  )
1897
+ tag: str = Field(default="latest", description="An Docker image tag.")
1606
1898
  env: DictData = Field(default_factory=dict)
1607
1899
  volume: DictData = Field(default_factory=dict)
1608
- auth: DictData = Field(default_factory=dict)
1900
+ auth: DictData = Field(
1901
+ default_factory=dict,
1902
+ description=(
1903
+ "An authentication of the Docker registry that use in pulling step."
1904
+ ),
1905
+ )
1906
+
1907
+ def execute_task(
1908
+ self,
1909
+ params: DictData,
1910
+ result: Result,
1911
+ ):
1912
+ from docker import DockerClient
1913
+ from docker.errors import ContainerError
1914
+
1915
+ client = DockerClient(
1916
+ base_url="unix://var/run/docker.sock", version="auto"
1917
+ )
1918
+
1919
+ resp = client.api.pull(
1920
+ repository=f"{self.image}",
1921
+ tag=self.tag,
1922
+ auth_config=param2template(self.auth, params, extras=self.extras),
1923
+ stream=True,
1924
+ decode=True,
1925
+ )
1926
+ for line in resp:
1927
+ result.trace.info(f"... {line}")
1928
+
1929
+ unique_image_name: str = f"{self.image}_{datetime.now():%Y%m%d%H%M%S%f}"
1930
+ container = client.containers.run(
1931
+ image=f"{self.image}:{self.tag}",
1932
+ name=unique_image_name,
1933
+ environment=self.env,
1934
+ volumes=(
1935
+ {
1936
+ Path.cwd()
1937
+ / f".docker.{result.run_id}.logs": {
1938
+ "bind": "/logs",
1939
+ "mode": "rw",
1940
+ },
1941
+ }
1942
+ | {
1943
+ Path.cwd() / source: {"bind": target, "mode": "rw"}
1944
+ for source, target in (
1945
+ volume.split(":", maxsplit=1) for volume in self.volume
1946
+ )
1947
+ }
1948
+ ),
1949
+ detach=True,
1950
+ )
1951
+
1952
+ for line in container.logs(stream=True, timestamps=True):
1953
+ result.trace.info(f"... {line.strip().decode()}")
1954
+
1955
+ # NOTE: This code copy from the docker package.
1956
+ exit_status: int = container.wait()["StatusCode"]
1957
+ if exit_status != 0:
1958
+ out = container.logs(stdout=False, stderr=True)
1959
+ container.remove()
1960
+ raise ContainerError(
1961
+ container,
1962
+ exit_status,
1963
+ None,
1964
+ f"{self.image}:{self.tag}",
1965
+ out,
1966
+ )
1609
1967
 
1610
1968
  def execute(
1611
1969
  self,
@@ -1639,6 +1997,11 @@ class VirtualPyStage(PyStage): # pragma: no cov
1639
1997
  ) -> Result:
1640
1998
  """Execute the Python statement via Python virtual environment.
1641
1999
 
2000
+ Steps:
2001
+ - Create python file.
2002
+ - Create `.venv` and install necessary Python deps.
2003
+ - Execution python file with uv and specific `.venv`.
2004
+
1642
2005
  :param params: A parameter that want to pass before run any statement.
1643
2006
  :param result: (Result) A result object for keeping context and status
1644
2007
  data.