ddeutil-workflow 0.0.56__py3-none-any.whl → 0.0.57__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.
@@ -3,7 +3,6 @@
3
3
  # Licensed under the MIT License. See LICENSE in the project root for
4
4
  # license information.
5
5
  # ------------------------------------------------------------------------------
6
- # [x] Use dynamic config
7
6
  """Stages module include all stage model that use be the minimum execution layer
8
7
  of this workflow engine. The stage handle the minimize task that run in some
9
8
  thread (same thread at its job owner) that mean it is the lowest executor that
@@ -16,11 +15,11 @@ have a lot of use-case, and it should does not worry about it error output.
16
15
  So, I will create `handler_execute` for any exception class that raise from
17
16
  the stage execution method.
18
17
 
19
- Execution --> Ok ---( handler )--> Result with `SUCCESS` or `CANCEL`
20
-
21
- --> Error ┬--( handler )-> Result with `FAILED` (Set `raise_error` flag)
18
+ Execution --> Ok ┬--( handler )--> Result with `SUCCESS` or `CANCEL`
22
19
  |
23
- ╰--( handler )-> Raise StageException(...)
20
+ ╰--( handler )--> Result with `FAILED` (Set `raise_error` flag)
21
+
22
+ --> Error ---( handler )--> Raise StageException(...)
24
23
 
25
24
  On the context I/O that pass to a stage object at execute process. The
26
25
  execute method receives a `params={"params": {...}}` value for passing template
@@ -41,6 +40,7 @@ from abc import ABC, abstractmethod
41
40
  from collections.abc import AsyncIterator, Iterator
42
41
  from concurrent.futures import (
43
42
  FIRST_EXCEPTION,
43
+ CancelledError,
44
44
  Future,
45
45
  ThreadPoolExecutor,
46
46
  as_completed,
@@ -58,13 +58,12 @@ from pydantic import BaseModel, Field
58
58
  from pydantic.functional_validators import model_validator
59
59
  from typing_extensions import Self
60
60
 
61
- from .__types import DictData, DictStr, TupleStr
61
+ from .__types import DictData, DictStr, StrOrInt, TupleStr
62
62
  from .conf import dynamic
63
- from .exceptions import StageException, UtilException, to_dict
63
+ from .exceptions import StageException, to_dict
64
64
  from .result import CANCEL, FAILED, SUCCESS, WAIT, Result, Status
65
65
  from .reusables import TagFunc, extract_call, not_in_template, param2template
66
66
  from .utils import (
67
- NEWLINE,
68
67
  delay,
69
68
  filter_func,
70
69
  gen_id,
@@ -72,7 +71,6 @@ from .utils import (
72
71
  )
73
72
 
74
73
  T = TypeVar("T")
75
- StrOrInt = Union[str, int]
76
74
 
77
75
 
78
76
  class BaseStage(BaseModel, ABC):
@@ -174,17 +172,23 @@ class BaseStage(BaseModel, ABC):
174
172
 
175
173
  This stage exception handler still use ok-error concept, but it
176
174
  allows you force catching an output result with error message by
177
- specific environment variable,`WORKFLOW_CORE_STAGE_RAISE_ERROR`.
175
+ specific environment variable,`WORKFLOW_CORE_STAGE_RAISE_ERROR` or set
176
+ `raise_error` parameter to True.
178
177
 
179
178
  Execution --> Ok --> Result
180
179
  |-status: SUCCESS
181
180
  ╰-context:
182
181
  ╰-outputs: ...
183
182
 
184
- --> Error --> Result (if `raise_error` was set)
183
+ --> Ok --> Result
184
+ |-status: CANCEL
185
+ ╰-errors:
186
+ |-name: ...
187
+ ╰-message: ...
188
+
189
+ --> Ok --> Result (if `raise_error` was set)
185
190
  |-status: FAILED
186
191
  ╰-errors:
187
- |-class: ...
188
192
  |-name: ...
189
193
  ╰-message: ...
190
194
 
@@ -201,6 +205,9 @@ class BaseStage(BaseModel, ABC):
201
205
  :param event: (Event) An event manager that pass to the stage execution.
202
206
  :param raise_error: (bool) A flag that all this method raise error
203
207
 
208
+ :raise StageException: If the raise_error was set and the execution
209
+ raise any error.
210
+
204
211
  :rtype: Result
205
212
  """
206
213
  result: Result = Result.construct_with_rs_or_id(
@@ -210,20 +217,17 @@ class BaseStage(BaseModel, ABC):
210
217
  id_logic=self.iden,
211
218
  extras=self.extras,
212
219
  )
213
-
214
220
  try:
215
221
  return self.execute(params, result=result, event=event)
216
222
  except Exception as e:
217
223
  e_name: str = e.__class__.__name__
218
- result.trace.error(f"[STAGE]: Handler:{NEWLINE}{e_name}: {e}")
224
+ result.trace.error(f"[STAGE]: Error Handler:||{e_name}:||{e}")
219
225
  if dynamic("stage_raise_error", f=raise_error, extras=self.extras):
220
226
  if isinstance(e, StageException):
221
227
  raise
222
-
223
228
  raise StageException(
224
- f"{self.__class__.__name__}: {NEWLINE}{e_name}: {e}"
229
+ f"{self.__class__.__name__}: {e_name}: {e}"
225
230
  ) from e
226
-
227
231
  return result.catch(status=FAILED, context={"errors": to_dict(e)})
228
232
 
229
233
  def set_outputs(self, output: DictData, to: DictData) -> DictData:
@@ -273,13 +277,6 @@ class BaseStage(BaseModel, ABC):
273
277
  ):
274
278
  return to
275
279
 
276
- _id: str = (
277
- param2template(self.id, params=to, extras=self.extras)
278
- if self.id
279
- else gen_id(
280
- param2template(self.name, params=to, extras=self.extras)
281
- )
282
- )
283
280
  output: DictData = output.copy()
284
281
  errors: DictData = (
285
282
  {"errors": output.pop("errors", {})} if "errors" in output else {}
@@ -289,7 +286,7 @@ class BaseStage(BaseModel, ABC):
289
286
  if "skipped" in output
290
287
  else {}
291
288
  )
292
- to["stages"][_id] = {
289
+ to["stages"][self.gen_id(params=to)] = {
293
290
  "outputs": copy.deepcopy(output),
294
291
  **skipping,
295
292
  **errors,
@@ -309,15 +306,11 @@ class BaseStage(BaseModel, ABC):
309
306
  "stage_default_id", extras=self.extras
310
307
  ):
311
308
  return {}
312
-
313
- _id: str = (
314
- param2template(self.id, params=output, extras=self.extras)
315
- if self.id
316
- else gen_id(
317
- param2template(self.name, params=output, extras=self.extras)
318
- )
309
+ return (
310
+ output.get("stages", {})
311
+ .get(self.gen_id(params=output), {})
312
+ .get("outputs", {})
319
313
  )
320
- return output.get("stages", {}).get(_id, {}).get("outputs", {})
321
314
 
322
315
  def is_skipped(self, params: DictData) -> bool:
323
316
  """Return true if condition of this stage do not correct. This process
@@ -351,6 +344,22 @@ class BaseStage(BaseModel, ABC):
351
344
  except Exception as e:
352
345
  raise StageException(f"{e.__class__.__name__}: {e}") from e
353
346
 
347
+ def gen_id(self, params: DictData) -> str:
348
+ """Generate stage ID that dynamic use stage's name if it ID does not
349
+ set.
350
+
351
+ :param params: A parameter data.
352
+
353
+ :rtype: str
354
+ """
355
+ return (
356
+ param2template(self.id, params=params, extras=self.extras)
357
+ if self.id
358
+ else gen_id(
359
+ param2template(self.name, params=params, extras=self.extras)
360
+ )
361
+ )
362
+
354
363
 
355
364
  class BaseAsyncStage(BaseStage):
356
365
  """Base Async Stage model to make any stage model allow async execution for
@@ -431,15 +440,14 @@ class BaseAsyncStage(BaseStage):
431
440
  try:
432
441
  rs: Result = await self.axecute(params, result=result, event=event)
433
442
  return rs
434
- except Exception as e: # pragma: no cov
443
+ except Exception as e:
435
444
  e_name: str = e.__class__.__name__
436
445
  await result.trace.aerror(f"[STAGE]: Handler {e_name}: {e}")
437
446
  if dynamic("stage_raise_error", f=raise_error, extras=self.extras):
438
447
  if isinstance(e, StageException):
439
448
  raise
440
-
441
449
  raise StageException(
442
- f"{self.__class__.__name__}: {NEWLINE}{e_name}: {e}"
450
+ f"{self.__class__.__name__}: {e_name}: {e}"
443
451
  ) from None
444
452
 
445
453
  return result.catch(status=FAILED, context={"errors": to_dict(e)})
@@ -506,11 +514,9 @@ class EmptyStage(BaseAsyncStage):
506
514
  message: str = param2template(
507
515
  dedent(self.echo.strip("\n")), params, extras=self.extras
508
516
  )
509
- if "\n" in message:
510
- message: str = NEWLINE + message.replace("\n", NEWLINE)
511
517
 
512
518
  result.trace.info(
513
- f"[STAGE]: Empty-Execute: {self.name!r}: ( {message} )"
519
+ f"[STAGE]: Execute Empty-Stage: {self.name!r}: ( {message} )"
514
520
  )
515
521
  if self.sleep > 0:
516
522
  if self.sleep > 5:
@@ -546,12 +552,8 @@ class EmptyStage(BaseAsyncStage):
546
552
  message: str = param2template(
547
553
  dedent(self.echo.strip("\n")), params, extras=self.extras
548
554
  )
549
- if "\n" in message:
550
- message: str = NEWLINE + message.replace("\n", NEWLINE)
551
555
 
552
- result.trace.info(
553
- f"[STAGE]: Empty-Execute: {self.name!r}: ( {message} )"
554
- )
556
+ result.trace.info(f"[STAGE]: Empty-Stage: {self.name!r}: ( {message} )")
555
557
  if self.sleep > 0:
556
558
  if self.sleep > 5:
557
559
  await result.trace.ainfo(
@@ -561,7 +563,7 @@ class EmptyStage(BaseAsyncStage):
561
563
  return result.catch(status=SUCCESS)
562
564
 
563
565
 
564
- class BashStage(BaseStage):
566
+ class BashStage(BaseAsyncStage):
565
567
  """Bash stage executor that execute bash script on the current OS.
566
568
  If your current OS is Windows, it will run on the bash from the current WSL.
567
569
  It will use `bash` for Windows OS and use `sh` for Linux OS.
@@ -597,7 +599,16 @@ class BashStage(BaseStage):
597
599
  @contextlib.asynccontextmanager
598
600
  async def acreate_sh_file(
599
601
  self, bash: str, env: DictStr, run_id: str | None = None
600
- ) -> AsyncIterator: # pragma no cov
602
+ ) -> AsyncIterator[TupleStr]:
603
+ """Async create and write `.sh` file with the `aiofiles` package.
604
+
605
+ :param bash: (str) A bash statement.
606
+ :param env: (DictStr) An environment variable that set before run bash.
607
+ :param run_id: (str | None) A running stage ID that use for writing sh
608
+ file instead generate by UUID4.
609
+
610
+ :rtype: AsyncIterator[TupleStr]
611
+ """
601
612
  import aiofiles
602
613
 
603
614
  f_name: str = f"{run_id or uuid.uuid4()}.sh"
@@ -616,7 +627,7 @@ class BashStage(BaseStage):
616
627
  # NOTE: Make this .sh file able to executable.
617
628
  make_exec(f"./{f_name}")
618
629
 
619
- yield [f_shebang, f_name]
630
+ yield f_shebang, f_name
620
631
 
621
632
  # Note: Remove .sh file that use to run bash.
622
633
  Path(f"./{f_name}").unlink()
@@ -625,9 +636,8 @@ class BashStage(BaseStage):
625
636
  def create_sh_file(
626
637
  self, bash: str, env: DictStr, run_id: str | None = None
627
638
  ) -> Iterator[TupleStr]:
628
- """Return context of prepared bash statement that want to execute. This
629
- step will write the `.sh` file before giving this file name to context.
630
- After that, it will auto delete this file automatic.
639
+ """Create and write the `.sh` file before giving this file name to
640
+ context. After that, it will auto delete this file automatic.
631
641
 
632
642
  :param bash: (str) A bash statement.
633
643
  :param env: (DictStr) An environment variable that set before run bash.
@@ -635,6 +645,7 @@ class BashStage(BaseStage):
635
645
  file instead generate by UUID4.
636
646
 
637
647
  :rtype: Iterator[TupleStr]
648
+ :return: Return context of prepared bash statement that want to execute.
638
649
  """
639
650
  f_name: str = f"{run_id or uuid.uuid4()}.sh"
640
651
  f_shebang: str = "bash" if sys.platform.startswith("win") else "sh"
@@ -652,7 +663,7 @@ class BashStage(BaseStage):
652
663
  # NOTE: Make this .sh file able to executable.
653
664
  make_exec(f"./{f_name}")
654
665
 
655
- yield [f_shebang, f_name]
666
+ yield f_shebang, f_name
656
667
 
657
668
  # Note: Remove .sh file that use to run bash.
658
669
  Path(f"./{f_name}").unlink()
@@ -680,7 +691,7 @@ class BashStage(BaseStage):
680
691
  extras=self.extras,
681
692
  )
682
693
 
683
- result.trace.info(f"[STAGE]: Shell-Execute: {self.name}")
694
+ result.trace.info(f"[STAGE]: Execute Shell-Stage: {self.name}")
684
695
 
685
696
  bash: str = param2template(
686
697
  dedent(self.bash.strip("\n")), params, extras=self.extras
@@ -691,21 +702,74 @@ class BashStage(BaseStage):
691
702
  env=param2template(self.env, params, extras=self.extras),
692
703
  run_id=result.run_id,
693
704
  ) as sh:
694
- result.trace.debug(f"... Create `{sh[1]}` file.")
705
+ result.trace.debug(f"[STAGE]: ... Create `{sh[1]}` file.")
706
+ rs: CompletedProcess = subprocess.run(
707
+ sh,
708
+ shell=False,
709
+ check=False,
710
+ capture_output=True,
711
+ text=True,
712
+ encoding="utf-8",
713
+ )
714
+ if rs.returncode > 0:
715
+ e: str = rs.stderr.removesuffix("\n")
716
+ raise StageException(
717
+ f"Subprocess: {e}\n---( statement )---\n```bash\n{bash}\n```"
718
+ )
719
+ return result.catch(
720
+ status=SUCCESS,
721
+ context={
722
+ "return_code": rs.returncode,
723
+ "stdout": None if (out := rs.stdout.strip("\n")) == "" else out,
724
+ "stderr": None if (out := rs.stderr.strip("\n")) == "" else out,
725
+ },
726
+ )
727
+
728
+ async def axecute(
729
+ self,
730
+ params: DictData,
731
+ *,
732
+ result: Result | None = None,
733
+ event: Event | None = None,
734
+ ) -> Result:
735
+ """Async execution method for this Bash stage that only logging out to
736
+ stdout.
737
+
738
+ :param params: (DictData) A parameter data.
739
+ :param result: (Result) A Result instance for return context and status.
740
+ :param event: (Event) An Event manager instance that use to cancel this
741
+ execution if it forces stopped by parent execution.
742
+
743
+ :rtype: Result
744
+ """
745
+ result: Result = result or Result(
746
+ run_id=gen_id(self.name + (self.id or ""), unique=True),
747
+ extras=self.extras,
748
+ )
749
+ await result.trace.ainfo(f"[STAGE]: Execute Shell-Stage: {self.name}")
750
+ bash: str = param2template(
751
+ dedent(self.bash.strip("\n")), params, extras=self.extras
752
+ )
753
+
754
+ async with self.acreate_sh_file(
755
+ bash=bash,
756
+ env=param2template(self.env, params, extras=self.extras),
757
+ run_id=result.run_id,
758
+ ) as sh:
759
+ await result.trace.adebug(f"[STAGE]: ... Create `{sh[1]}` file.")
695
760
  rs: CompletedProcess = subprocess.run(
696
- sh, shell=False, capture_output=True, text=True
761
+ sh,
762
+ shell=False,
763
+ check=False,
764
+ capture_output=True,
765
+ text=True,
766
+ encoding="utf-8",
697
767
  )
698
768
 
699
769
  if rs.returncode > 0:
700
- # NOTE: Prepare stderr message that returning from subprocess.
701
- e: str = (
702
- rs.stderr.encode("utf-8").decode("utf-16")
703
- if "\\x00" in rs.stderr
704
- else rs.stderr
705
- ).removesuffix("\n")
770
+ e: str = rs.stderr.removesuffix("\n")
706
771
  raise StageException(
707
- f"Subprocess: {e}\n---( statement )---\n"
708
- f"```bash\n{bash}\n```"
772
+ f"Subprocess: {e}\n---( statement )---\n```bash\n{bash}\n```"
709
773
  )
710
774
  return result.catch(
711
775
  status=SUCCESS,
@@ -717,7 +781,7 @@ class BashStage(BaseStage):
717
781
  )
718
782
 
719
783
 
720
- class PyStage(BaseStage):
784
+ class PyStage(BaseAsyncStage):
721
785
  """Python stage that running the Python statement with the current globals
722
786
  and passing an input additional variables via `exec` built-in function.
723
787
 
@@ -819,7 +883,7 @@ class PyStage(BaseStage):
819
883
  | {"result": result}
820
884
  )
821
885
 
822
- result.trace.info(f"[STAGE]: Py-Execute: {self.name}")
886
+ result.trace.info(f"[STAGE]: Execute Py-Stage: {self.name}")
823
887
 
824
888
  # WARNING: The exec build-in function is very dangerous. So, it
825
889
  # should use the re module to validate exec-string before running.
@@ -849,15 +913,63 @@ class PyStage(BaseStage):
849
913
 
850
914
  async def axecute(
851
915
  self,
852
- ):
853
- """Async execution method.
916
+ params: DictData,
917
+ *,
918
+ result: Result | None = None,
919
+ event: Event | None = None,
920
+ ) -> Result:
921
+ """Async execution method for this Bash stage that only logging out to
922
+ stdout.
923
+
924
+ :param params: (DictData) A parameter data.
925
+ :param result: (Result) A Result instance for return context and status.
926
+ :param event: (Event) An Event manager instance that use to cancel this
927
+ execution if it forces stopped by parent execution.
854
928
 
855
929
  References:
856
930
  - https://stackoverflow.com/questions/44859165/async-exec-in-python
931
+
932
+ :rtype: Result
857
933
  """
934
+ result: Result = result or Result(
935
+ run_id=gen_id(self.name + (self.id or ""), unique=True),
936
+ extras=self.extras,
937
+ )
938
+ lc: DictData = {}
939
+ gb: DictData = (
940
+ globals()
941
+ | param2template(self.vars, params, extras=self.extras)
942
+ | {"result": result}
943
+ )
944
+ await result.trace.ainfo(f"[STAGE]: Execute Py-Stage: {self.name}")
945
+
946
+ # WARNING: The exec build-in function is very dangerous. So, it
947
+ # should use the re module to validate exec-string before running.
948
+ exec(
949
+ param2template(dedent(self.run), params, extras=self.extras),
950
+ gb,
951
+ lc,
952
+ )
953
+ return result.catch(
954
+ status=SUCCESS,
955
+ context={
956
+ "locals": {k: lc[k] for k in self.filter_locals(lc)},
957
+ "globals": {
958
+ k: gb[k]
959
+ for k in gb
960
+ if (
961
+ not k.startswith("__")
962
+ and k != "annotations"
963
+ and not ismodule(gb[k])
964
+ and not isclass(gb[k])
965
+ and not isfunction(gb[k])
966
+ )
967
+ },
968
+ },
969
+ )
858
970
 
859
971
 
860
- class CallStage(BaseStage):
972
+ class CallStage(BaseAsyncStage):
861
973
  """Call stage executor that call the Python function from registry with tag
862
974
  decorator function in `reusables` module and run it with input arguments.
863
975
 
@@ -933,7 +1045,7 @@ class CallStage(BaseStage):
933
1045
  )()
934
1046
 
935
1047
  result.trace.info(
936
- f"[STAGE]: Call-Execute: {call_func.name}@{call_func.tag}"
1048
+ f"[STAGE]: Execute Call-Stage: {call_func.name}@{call_func.tag}"
937
1049
  )
938
1050
 
939
1051
  # VALIDATE: check input task caller parameters that exists before
@@ -985,8 +1097,97 @@ class CallStage(BaseStage):
985
1097
  rs: DictData = rs.model_dump(by_alias=True)
986
1098
  elif not isinstance(rs, dict):
987
1099
  raise TypeError(
988
- f"Return type: '{call_func.name}@{call_func.tag}' does not "
989
- f"serialize to result model, you change return type to `dict`."
1100
+ f"Return type: '{call_func.name}@{call_func.tag}' can not "
1101
+ f"serialize, you must set return be `dict` or Pydantic "
1102
+ f"model."
1103
+ )
1104
+ return result.catch(status=SUCCESS, context=rs)
1105
+
1106
+ async def axecute(
1107
+ self,
1108
+ params: DictData,
1109
+ *,
1110
+ result: Result | None = None,
1111
+ event: Event | None = None,
1112
+ ) -> Result:
1113
+ """Async execution method for this Bash stage that only logging out to
1114
+ stdout.
1115
+
1116
+ :param params: (DictData) A parameter data.
1117
+ :param result: (Result) A Result instance for return context and status.
1118
+ :param event: (Event) An Event manager instance that use to cancel this
1119
+ execution if it forces stopped by parent execution.
1120
+
1121
+ References:
1122
+ - https://stackoverflow.com/questions/44859165/async-exec-in-python
1123
+
1124
+ :rtype: Result
1125
+ """
1126
+ result: Result = result or Result(
1127
+ run_id=gen_id(self.name + (self.id or ""), unique=True),
1128
+ extras=self.extras,
1129
+ )
1130
+
1131
+ call_func: TagFunc = extract_call(
1132
+ param2template(self.uses, params, extras=self.extras),
1133
+ registries=self.extras.get("registry_caller"),
1134
+ )()
1135
+
1136
+ await result.trace.ainfo(
1137
+ f"[STAGE]: Execute Call-Stage: {call_func.name}@{call_func.tag}"
1138
+ )
1139
+
1140
+ # VALIDATE: check input task caller parameters that exists before
1141
+ # calling.
1142
+ args: DictData = {"result": result} | param2template(
1143
+ self.args, params, extras=self.extras
1144
+ )
1145
+ ips = inspect.signature(call_func)
1146
+ necessary_params: list[str] = []
1147
+ has_keyword: bool = False
1148
+ for k in ips.parameters:
1149
+ if (
1150
+ v := ips.parameters[k]
1151
+ ).default == Parameter.empty and v.kind not in (
1152
+ Parameter.VAR_KEYWORD,
1153
+ Parameter.VAR_POSITIONAL,
1154
+ ):
1155
+ necessary_params.append(k)
1156
+ elif v.kind == Parameter.VAR_KEYWORD:
1157
+ has_keyword = True
1158
+
1159
+ if any(
1160
+ (k.removeprefix("_") not in args and k not in args)
1161
+ for k in necessary_params
1162
+ ):
1163
+ raise ValueError(
1164
+ f"Necessary params, ({', '.join(necessary_params)}, ), "
1165
+ f"does not set to args, {list(args.keys())}."
1166
+ )
1167
+
1168
+ if "result" not in ips.parameters and not has_keyword:
1169
+ args.pop("result")
1170
+
1171
+ args = self.parse_model_args(call_func, args, result)
1172
+
1173
+ if inspect.iscoroutinefunction(call_func):
1174
+ rs: DictData = await call_func(
1175
+ **param2template(args, params, extras=self.extras)
1176
+ )
1177
+ else:
1178
+ rs: DictData = call_func(
1179
+ **param2template(args, params, extras=self.extras)
1180
+ )
1181
+
1182
+ # VALIDATE:
1183
+ # Check the result type from call function, it should be dict.
1184
+ if isinstance(rs, BaseModel):
1185
+ rs: DictData = rs.model_dump(by_alias=True)
1186
+ elif not isinstance(rs, dict):
1187
+ raise TypeError(
1188
+ f"Return type: '{call_func.name}@{call_func.tag}' can not "
1189
+ f"serialize, you must set return be `dict` or Pydantic "
1190
+ f"model."
990
1191
  )
991
1192
  return result.catch(status=SUCCESS, context=rs)
992
1193
 
@@ -1078,7 +1279,6 @@ class TriggerStage(BaseStage):
1078
1279
 
1079
1280
  :rtype: Result
1080
1281
  """
1081
- from .exceptions import WorkflowException
1082
1282
  from .workflow import Workflow
1083
1283
 
1084
1284
  result: Result = result or Result(
@@ -1087,19 +1287,15 @@ class TriggerStage(BaseStage):
1087
1287
  )
1088
1288
 
1089
1289
  _trigger: str = param2template(self.trigger, params, extras=self.extras)
1090
- result.trace.info(f"[STAGE]: Trigger-Execute: {_trigger!r}")
1091
- try:
1092
- rs: Result = Workflow.from_conf(
1093
- name=_trigger,
1094
- extras=self.extras | {"stage_raise_error": True},
1095
- ).execute(
1096
- params=param2template(self.params, params, extras=self.extras),
1097
- parent_run_id=result.run_id,
1098
- event=event,
1099
- )
1100
- except WorkflowException as e:
1101
- raise StageException("Trigger workflow stage was failed") from e
1102
-
1290
+ result.trace.info(f"[STAGE]: Execute Trigger-Stage: {_trigger!r}")
1291
+ rs: Result = Workflow.from_conf(
1292
+ name=_trigger,
1293
+ extras=self.extras | {"stage_raise_error": True},
1294
+ ).execute(
1295
+ params=param2template(self.params, params, extras=self.extras),
1296
+ parent_run_id=result.run_id,
1297
+ event=event,
1298
+ )
1103
1299
  if rs.status == FAILED:
1104
1300
  err_msg: str | None = (
1105
1301
  f" with:\n{msg}"
@@ -1112,7 +1308,7 @@ class TriggerStage(BaseStage):
1112
1308
  return rs
1113
1309
 
1114
1310
 
1115
- class ParallelStage(BaseStage): # pragma: no cov
1311
+ class ParallelStage(BaseStage):
1116
1312
  """Parallel stage executor that execute branch stages with multithreading.
1117
1313
  This stage let you set the fix branches for running child stage inside it on
1118
1314
  multithread pool.
@@ -1193,7 +1389,7 @@ class ParallelStage(BaseStage): # pragma: no cov
1193
1389
  "Branch-Stage was canceled from event that had set before "
1194
1390
  "stage branch execution."
1195
1391
  )
1196
- return result.catch(
1392
+ result.catch(
1197
1393
  status=CANCEL,
1198
1394
  parallel={
1199
1395
  branch: {
@@ -1203,6 +1399,7 @@ class ParallelStage(BaseStage): # pragma: no cov
1203
1399
  }
1204
1400
  },
1205
1401
  )
1402
+ raise StageException(error_msg, refs=branch)
1206
1403
 
1207
1404
  try:
1208
1405
  rs: Result = stage.handler_execute(
@@ -1214,10 +1411,7 @@ class ParallelStage(BaseStage): # pragma: no cov
1214
1411
  )
1215
1412
  stage.set_outputs(rs.context, to=output)
1216
1413
  stage.set_outputs(stage.get_outputs(output), to=context)
1217
- except (StageException, UtilException) as e: # pragma: no cov
1218
- result.trace.error(
1219
- f"[STAGE]: {e.__class__.__name__}:{NEWLINE}{e}"
1220
- )
1414
+ except StageException as e:
1221
1415
  result.catch(
1222
1416
  status=FAILED,
1223
1417
  parallel={
@@ -1228,9 +1422,7 @@ class ParallelStage(BaseStage): # pragma: no cov
1228
1422
  },
1229
1423
  },
1230
1424
  )
1231
- raise StageException(
1232
- f"Sub-Stage raise: {e.__class__.__name__}: {e}"
1233
- ) from None
1425
+ raise StageException(str(e), refs=branch) from e
1234
1426
 
1235
1427
  if rs.status == FAILED:
1236
1428
  error_msg: str = (
@@ -1247,7 +1439,7 @@ class ParallelStage(BaseStage): # pragma: no cov
1247
1439
  },
1248
1440
  },
1249
1441
  )
1250
- raise StageException(error_msg)
1442
+ raise StageException(error_msg, refs=branch)
1251
1443
 
1252
1444
  return result.catch(
1253
1445
  status=SUCCESS,
@@ -1279,12 +1471,12 @@ class ParallelStage(BaseStage): # pragma: no cov
1279
1471
  run_id=gen_id(self.name + (self.id or ""), unique=True),
1280
1472
  extras=self.extras,
1281
1473
  )
1282
- event: Event = Event() if event is None else event
1474
+ event: Event = event or Event()
1283
1475
  result.trace.info(
1284
- f"[STAGE]: Parallel-Execute: {self.max_workers} workers."
1476
+ f"[STAGE]: Execute Parallel-Stage: {self.max_workers} workers."
1285
1477
  )
1286
1478
  result.catch(status=WAIT, context={"parallel": {}})
1287
- if event and event.is_set(): # pragma: no cov
1479
+ if event and event.is_set():
1288
1480
  return result.catch(
1289
1481
  status=CANCEL,
1290
1482
  context={
@@ -1319,12 +1511,14 @@ class ParallelStage(BaseStage): # pragma: no cov
1319
1511
  except StageException as e:
1320
1512
  status = FAILED
1321
1513
  result.trace.error(
1322
- f"[STAGE]: {e.__class__.__name__}:{NEWLINE}{e}"
1514
+ f"[STAGE]: Error Handler:||{e.__class__.__name__}:||{e}"
1323
1515
  )
1324
1516
  if "errors" in context:
1325
- context["errors"].append(e.to_dict())
1517
+ context["errors"][e.refs] = e.to_dict()
1326
1518
  else:
1327
- context["errors"] = [e.to_dict()]
1519
+ context["errors"] = e.to_dict(with_refs=True)
1520
+ except CancelledError:
1521
+ pass
1328
1522
  return result.catch(status=status, context=context)
1329
1523
 
1330
1524
 
@@ -1378,7 +1572,8 @@ class ForEachStage(BaseStage):
1378
1572
  *,
1379
1573
  event: Event | None = None,
1380
1574
  ) -> Result:
1381
- """Execute all stage with specific foreach item.
1575
+ """Execute all nested stage that set on this stage with specific foreach
1576
+ item parameter.
1382
1577
 
1383
1578
  :param item: (str | int) An item that want to execution.
1384
1579
  :param params: (DictData) A parameter data.
@@ -1386,7 +1581,9 @@ class ForEachStage(BaseStage):
1386
1581
  :param event: (Event) An Event manager instance that use to cancel this
1387
1582
  execution if it forces stopped by parent execution.
1388
1583
 
1389
- :raise StageException: If the stage execution raise errors.
1584
+ :raise StageException: If event was set.
1585
+ :raise StageException: If the stage execution raise any Exception error.
1586
+ :raise StageException: If the result from execution has `FAILED` status.
1390
1587
 
1391
1588
  :rtype: Result
1392
1589
  """
@@ -1404,12 +1601,11 @@ class ForEachStage(BaseStage):
1404
1601
  stage.set_outputs(output={"skipped": True}, to=output)
1405
1602
  continue
1406
1603
 
1407
- if event and event.is_set(): # pragma: no cov
1604
+ if event and event.is_set():
1408
1605
  error_msg: str = (
1409
- "Item-Stage was canceled from event that had set before "
1410
- "stage item execution."
1606
+ "Item-Stage was canceled because event was set."
1411
1607
  )
1412
- return result.catch(
1608
+ result.catch(
1413
1609
  status=CANCEL,
1414
1610
  foreach={
1415
1611
  item: {
@@ -1419,6 +1615,7 @@ class ForEachStage(BaseStage):
1419
1615
  }
1420
1616
  },
1421
1617
  )
1618
+ raise StageException(error_msg, refs=item)
1422
1619
 
1423
1620
  try:
1424
1621
  rs: Result = stage.handler_execute(
@@ -1430,10 +1627,7 @@ class ForEachStage(BaseStage):
1430
1627
  )
1431
1628
  stage.set_outputs(rs.context, to=output)
1432
1629
  stage.set_outputs(stage.get_outputs(output), to=context)
1433
- except (StageException, UtilException) as e:
1434
- result.trace.error(
1435
- f"[STAGE]: {e.__class__.__name__}:{NEWLINE}{e}"
1436
- )
1630
+ except StageException as e:
1437
1631
  result.catch(
1438
1632
  status=FAILED,
1439
1633
  foreach={
@@ -1444,9 +1638,7 @@ class ForEachStage(BaseStage):
1444
1638
  },
1445
1639
  },
1446
1640
  )
1447
- raise StageException(
1448
- f"Sub-Stage raise: {e.__class__.__name__}: {e}"
1449
- ) from None
1641
+ raise StageException(str(e), refs=item) from e
1450
1642
 
1451
1643
  if rs.status == FAILED:
1452
1644
  error_msg: str = (
@@ -1464,7 +1656,7 @@ class ForEachStage(BaseStage):
1464
1656
  },
1465
1657
  },
1466
1658
  )
1467
- raise StageException(error_msg)
1659
+ raise StageException(error_msg, refs=item)
1468
1660
 
1469
1661
  return result.catch(
1470
1662
  status=SUCCESS,
@@ -1498,7 +1690,7 @@ class ForEachStage(BaseStage):
1498
1690
  run_id=gen_id(self.name + (self.id or ""), unique=True),
1499
1691
  extras=self.extras,
1500
1692
  )
1501
- event: Event = Event() if event is None else event
1693
+ event: Event = event or Event()
1502
1694
  foreach: Union[list[str], list[int]] = (
1503
1695
  param2template(self.foreach, params, extras=self.extras)
1504
1696
  if isinstance(self.foreach, str)
@@ -1509,9 +1701,9 @@ class ForEachStage(BaseStage):
1509
1701
  if not isinstance(foreach, list):
1510
1702
  raise TypeError(f"Does not support foreach: {foreach!r}")
1511
1703
 
1512
- result.trace.info(f"[STAGE]: Foreach-Execute: {foreach!r}.")
1704
+ result.trace.info(f"[STAGE]: Execute Foreach-Stage: {foreach!r}.")
1513
1705
  result.catch(status=WAIT, context={"items": foreach, "foreach": {}})
1514
- if event and event.is_set(): # pragma: no cov
1706
+ if event and event.is_set():
1515
1707
  return result.catch(
1516
1708
  status=CANCEL,
1517
1709
  context={
@@ -1542,14 +1734,18 @@ class ForEachStage(BaseStage):
1542
1734
  done, not_done = wait(futures, return_when=FIRST_EXCEPTION)
1543
1735
  if len(done) != len(futures):
1544
1736
  result.trace.warning(
1545
- "[STAGE]: Set event for stop pending stage future."
1737
+ "[STAGE]: Set event for stop pending for-each stage."
1546
1738
  )
1547
1739
  event.set()
1548
1740
  for future in not_done:
1549
1741
  future.cancel()
1742
+ time.sleep(0.075)
1550
1743
 
1551
1744
  nd: str = f", item not run: {not_done}" if not_done else ""
1552
- result.trace.debug(f"... Foreach set Fail-Fast{nd}")
1745
+ result.trace.debug(
1746
+ f"[STAGE]: ... Foreach-Stage set failed event{nd}"
1747
+ )
1748
+ done: list[Future] = as_completed(futures)
1553
1749
 
1554
1750
  for future in done:
1555
1751
  try:
@@ -1557,9 +1753,14 @@ class ForEachStage(BaseStage):
1557
1753
  except StageException as e:
1558
1754
  status = FAILED
1559
1755
  result.trace.error(
1560
- f"[STAGE]: {e.__class__.__name__}:{NEWLINE}{e}"
1756
+ f"[STAGE]: Error Handler:||{e.__class__.__name__}:||{e}"
1561
1757
  )
1562
- context.update({"errors": e.to_dict()})
1758
+ if "errors" in context:
1759
+ context["errors"][e.refs] = e.to_dict()
1760
+ else:
1761
+ context["errors"] = e.to_dict(with_refs=True)
1762
+ except CancelledError:
1763
+ pass
1563
1764
  return result.catch(status=status, context=context)
1564
1765
 
1565
1766
 
@@ -1628,7 +1829,7 @@ class UntilStage(BaseStage):
1628
1829
  :rtype: tuple[Result, T]
1629
1830
  :return: Return a pair of Result and changed item.
1630
1831
  """
1631
- result.trace.debug(f"... Execute until item: {item!r}")
1832
+ result.trace.debug(f"[STAGE]: ... Execute until item: {item!r}")
1632
1833
  context: DictData = copy.deepcopy(params)
1633
1834
  context.update({"item": item})
1634
1835
  output: DictData = {"loop": loop, "item": item, "stages": {}}
@@ -1677,10 +1878,7 @@ class UntilStage(BaseStage):
1677
1878
  next_item = _output["item"]
1678
1879
 
1679
1880
  stage.set_outputs(_output, to=context)
1680
- except (StageException, UtilException) as e:
1681
- result.trace.error(
1682
- f"[STAGE]: {e.__class__.__name__}:{NEWLINE}{e}"
1683
- )
1881
+ except StageException as e:
1684
1882
  result.catch(
1685
1883
  status=FAILED,
1686
1884
  until={
@@ -1692,9 +1890,7 @@ class UntilStage(BaseStage):
1692
1890
  }
1693
1891
  },
1694
1892
  )
1695
- raise StageException(
1696
- f"Sub-Stage execution error: {e.__class__.__name__}: {e}"
1697
- ) from None
1893
+ raise
1698
1894
 
1699
1895
  if rs.status == FAILED:
1700
1896
  error_msg: str = (
@@ -1749,7 +1945,7 @@ class UntilStage(BaseStage):
1749
1945
  extras=self.extras,
1750
1946
  )
1751
1947
 
1752
- result.trace.info(f"[STAGE]: Until-Execution: {self.until}")
1948
+ result.trace.info(f"[STAGE]: Execute Until-Stage: {self.until}")
1753
1949
  item: Union[str, int, bool] = param2template(
1754
1950
  self.item, params, extras=self.extras
1755
1951
  )
@@ -1781,7 +1977,7 @@ class UntilStage(BaseStage):
1781
1977
  loop += 1
1782
1978
  if item is None:
1783
1979
  result.trace.warning(
1784
- f"... Loop-Execute not set item. It use loop: {loop} by "
1980
+ f"[STAGE]: ... Loop-Execute not set item. It use loop: {loop} by "
1785
1981
  f"default."
1786
1982
  )
1787
1983
  item: int = loop
@@ -1892,11 +2088,11 @@ class CaseStage(BaseStage):
1892
2088
  stage.extras = self.extras
1893
2089
 
1894
2090
  if stage.is_skipped(params=context):
1895
- result.trace.info(f"... Skip stage: {stage.iden!r}")
2091
+ result.trace.info(f"[STAGE]: ... Skip stage: {stage.iden!r}")
1896
2092
  stage.set_outputs(output={"skipped": True}, to=output)
1897
2093
  continue
1898
2094
 
1899
- if event and event.is_set(): # pragma: no cov
2095
+ if event and event.is_set():
1900
2096
  error_msg: str = (
1901
2097
  "Case-Stage was canceled from event that had set before "
1902
2098
  "stage case execution."
@@ -1920,8 +2116,7 @@ class CaseStage(BaseStage):
1920
2116
  )
1921
2117
  stage.set_outputs(rs.context, to=output)
1922
2118
  stage.set_outputs(stage.get_outputs(output), to=context)
1923
- except (StageException, UtilException) as e: # pragma: no cov
1924
- result.trace.error(f"[STAGE]: {e.__class__.__name__}: {e}")
2119
+ except StageException as e:
1925
2120
  return result.catch(
1926
2121
  status=FAILED,
1927
2122
  context={
@@ -1977,7 +2172,7 @@ class CaseStage(BaseStage):
1977
2172
  self.case, params, extras=self.extras
1978
2173
  )
1979
2174
 
1980
- result.trace.info(f"[STAGE]: Case-Execute: {_case!r}.")
2175
+ result.trace.info(f"[STAGE]: Execute Case-Stage: {_case!r}.")
1981
2176
  _else: Optional[Match] = None
1982
2177
  stages: Optional[list[Stage]] = None
1983
2178
  for match in self.match:
@@ -1997,7 +2192,7 @@ class CaseStage(BaseStage):
1997
2192
  "any case."
1998
2193
  )
1999
2194
  result.trace.info(
2000
- "... Skip this stage because it does not match."
2195
+ "[STAGE]: ... Skip this stage because it does not match."
2001
2196
  )
2002
2197
  error_msg: str = (
2003
2198
  "Case-Stage was canceled because it does not match any "
@@ -2010,7 +2205,7 @@ class CaseStage(BaseStage):
2010
2205
  _case: str = "_"
2011
2206
  stages: list[Stage] = _else.stages
2012
2207
 
2013
- if event and event.is_set(): # pragma: no cov
2208
+ if event and event.is_set():
2014
2209
  return result.catch(
2015
2210
  status=CANCEL,
2016
2211
  context={
@@ -2026,7 +2221,7 @@ class CaseStage(BaseStage):
2026
2221
  )
2027
2222
 
2028
2223
 
2029
- class RaiseStage(BaseStage): # pragma: no cov
2224
+ class RaiseStage(BaseAsyncStage):
2030
2225
  """Raise error stage executor that raise `StageException` that use a message
2031
2226
  field for making error message before raise.
2032
2227
 
@@ -2064,7 +2259,34 @@ class RaiseStage(BaseStage): # pragma: no cov
2064
2259
  extras=self.extras,
2065
2260
  )
2066
2261
  message: str = param2template(self.message, params, extras=self.extras)
2067
- result.trace.info(f"[STAGE]: Raise-Execute: {message!r}.")
2262
+ result.trace.info(f"[STAGE]: Execute Raise-Stage: {message!r}.")
2263
+ raise StageException(message)
2264
+
2265
+ async def axecute(
2266
+ self,
2267
+ params: DictData,
2268
+ *,
2269
+ result: Result | None = None,
2270
+ event: Event | None = None,
2271
+ ) -> Result:
2272
+ """Async execution method for this Empty stage that only logging out to
2273
+ stdout.
2274
+
2275
+ :param params: (DictData) A context data that want to add output result.
2276
+ But this stage does not pass any output.
2277
+ :param result: (Result) A result object for keeping context and status
2278
+ data.
2279
+ :param event: (Event) An event manager that use to track parent execute
2280
+ was not force stopped.
2281
+
2282
+ :rtype: Result
2283
+ """
2284
+ result: Result = result or Result(
2285
+ run_id=gen_id(self.name + (self.id or ""), unique=True),
2286
+ extras=self.extras,
2287
+ )
2288
+ message: str = param2template(self.message, params, extras=self.extras)
2289
+ await result.trace.ainfo(f"[STAGE]: Execute Raise-Stage: {message!r}.")
2068
2290
  raise StageException(message)
2069
2291
 
2070
2292
 
@@ -2142,7 +2364,7 @@ class DockerStage(BaseStage): # pragma: no cov
2142
2364
  decode=True,
2143
2365
  )
2144
2366
  for line in resp:
2145
- result.trace.info(f"... {line}")
2367
+ result.trace.info(f"[STAGE]: ... {line}")
2146
2368
 
2147
2369
  if event and event.is_set():
2148
2370
  error_msg: str = (
@@ -2178,7 +2400,7 @@ class DockerStage(BaseStage): # pragma: no cov
2178
2400
  )
2179
2401
 
2180
2402
  for line in container.logs(stream=True, timestamps=True):
2181
- result.trace.info(f"... {line.strip().decode()}")
2403
+ result.trace.info(f"[STAGE]: ... {line.strip().decode()}")
2182
2404
 
2183
2405
  # NOTE: This code copy from the docker package.
2184
2406
  exit_status: int = container.wait()["StatusCode"]
@@ -2221,8 +2443,9 @@ class DockerStage(BaseStage): # pragma: no cov
2221
2443
  extras=self.extras,
2222
2444
  )
2223
2445
 
2224
- result.trace.info(f"[STAGE]: Docker-Execute: {self.image}:{self.tag}")
2225
-
2446
+ result.trace.info(
2447
+ f"[STAGE]: Execute Docker-Stage: {self.image}:{self.tag}"
2448
+ )
2226
2449
  raise NotImplementedError("Docker Stage does not implement yet.")
2227
2450
 
2228
2451
 
@@ -2316,7 +2539,7 @@ class VirtualPyStage(PyStage): # pragma: no cov
2316
2539
  extras=self.extras,
2317
2540
  )
2318
2541
 
2319
- result.trace.info(f"[STAGE]: Py-Virtual-Execute: {self.name}")
2542
+ result.trace.info(f"[STAGE]: Execute VirtualPy-Stage: {self.name}")
2320
2543
  run: str = param2template(dedent(self.run), params, extras=self.extras)
2321
2544
  with self.create_py_file(
2322
2545
  py=run,
@@ -2324,7 +2547,7 @@ class VirtualPyStage(PyStage): # pragma: no cov
2324
2547
  deps=param2template(self.deps, params, extras=self.extras),
2325
2548
  run_id=result.run_id,
2326
2549
  ) as py:
2327
- result.trace.debug(f"... Create `{py}` file.")
2550
+ result.trace.debug(f"[STAGE]: ... Create `{py}` file.")
2328
2551
  rs: CompletedProcess = subprocess.run(
2329
2552
  ["uv", "run", py, "--no-cache"],
2330
2553
  # ["uv", "run", "--python", "3.9", py],
@@ -2375,4 +2598,4 @@ Stage = Annotated[
2375
2598
  EmptyStage,
2376
2599
  ],
2377
2600
  Field(union_mode="smart"),
2378
- ]
2601
+ ] # pragma: no cov