ddeutil-workflow 0.0.48__py3-none-any.whl → 0.0.50__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,16 +35,19 @@ 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
44
48
  from textwrap import dedent
45
49
  from threading import Event
46
- from typing import Annotated, Any, Optional, Union, get_type_hints
50
+ from typing import Annotated, Any, Optional, TypeVar, Union, get_type_hints
47
51
 
48
52
  from pydantic import BaseModel, Field
49
53
  from pydantic.functional_validators import model_validator
@@ -51,25 +55,16 @@ 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 FAILED, SUCCESS, WAIT, Result, Status
56
60
  from .reusables import TagFunc, extract_call, not_in_template, param2template
57
61
  from .utils import (
62
+ filter_func,
58
63
  gen_id,
59
64
  make_exec,
60
65
  )
61
66
 
62
- __all__: TupleStr = (
63
- "EmptyStage",
64
- "BashStage",
65
- "PyStage",
66
- "CallStage",
67
- "TriggerStage",
68
- "ForEachStage",
69
- "ParallelStage",
70
- "RaiseStage",
71
- "Stage",
72
- )
67
+ T = TypeVar("T")
73
68
 
74
69
 
75
70
  class BaseStage(BaseModel, ABC):
@@ -160,9 +155,8 @@ class BaseStage(BaseModel, ABC):
160
155
  parent_run_id: str | None = None,
161
156
  result: Result | None = None,
162
157
  raise_error: bool | None = None,
163
- to: DictData | None = None,
164
158
  event: Event | None = None,
165
- ) -> Result:
159
+ ) -> Result | DictData:
166
160
  """Handler stage execution result from the stage `execute` method.
167
161
 
168
162
  This stage exception handler still use ok-error concept, but it
@@ -194,8 +188,6 @@ class BaseStage(BaseModel, ABC):
194
188
  :param result: (Result) A result object for keeping context and status
195
189
  data before execution.
196
190
  :param raise_error: (bool) A flag that all this method raise error
197
- :param to: (DictData) A target object for auto set the return output
198
- after execution.
199
191
  :param event: (Event) An event manager that pass to the stage execution.
200
192
 
201
193
  :rtype: Result
@@ -210,7 +202,7 @@ class BaseStage(BaseModel, ABC):
210
202
 
211
203
  try:
212
204
  rs: Result = self.execute(params, result=result, event=event)
213
- return self.set_outputs(rs.context, to=to) if to is not None else rs
205
+ return rs
214
206
  except Exception as e:
215
207
  result.trace.error(f"[STAGE]: {e.__class__.__name__}: {e}")
216
208
 
@@ -224,11 +216,7 @@ class BaseStage(BaseModel, ABC):
224
216
  ) from e
225
217
 
226
218
  errors: DictData = {"errors": to_dict(e)}
227
- return (
228
- self.set_outputs(errors, to=to)
229
- if to is not None
230
- else result.catch(status=FAILED, context=errors)
231
- )
219
+ return result.catch(status=FAILED, context=errors)
232
220
 
233
221
  def set_outputs(self, output: DictData, to: DictData) -> DictData:
234
222
  """Set an outputs from execution process to the received context. The
@@ -284,6 +272,28 @@ class BaseStage(BaseModel, ABC):
284
272
  to["stages"][_id] = {"outputs": output, **skipping, **errors}
285
273
  return to
286
274
 
275
+ def get_outputs(self, outputs: DictData) -> DictData:
276
+ """Get the outputs from stages data. It will get this stage ID from
277
+ the stage outputs mapping.
278
+
279
+ :param outputs: (DictData) A stage outputs that want to get by stage ID.
280
+
281
+ :rtype: DictData
282
+ """
283
+ if self.id is None and not dynamic(
284
+ "stage_default_id", extras=self.extras
285
+ ):
286
+ return {}
287
+
288
+ _id: str = (
289
+ param2template(self.id, params=outputs, extras=self.extras)
290
+ if self.id
291
+ else gen_id(
292
+ param2template(self.name, params=outputs, extras=self.extras)
293
+ )
294
+ )
295
+ return outputs.get("stages", {}).get(_id, {})
296
+
287
297
  def is_skipped(self, params: DictData | None = None) -> bool:
288
298
  """Return true if condition of this stage do not correct. This process
289
299
  use build-in eval function to execute the if-condition.
@@ -365,7 +375,6 @@ class BaseAsyncStage(BaseStage):
365
375
  parent_run_id: str | None = None,
366
376
  result: Result | None = None,
367
377
  raise_error: bool | None = None,
368
- to: DictData | None = None,
369
378
  event: Event | None = None,
370
379
  ) -> Result:
371
380
  """Async Handler stage execution result from the stage `execute` method.
@@ -378,8 +387,6 @@ class BaseAsyncStage(BaseStage):
378
387
  :param result: (Result) A result object for keeping context and status
379
388
  data before execution.
380
389
  :param raise_error: (bool) A flag that all this method raise error
381
- :param to: (DictData) A target object for auto set the return output
382
- after execution.
383
390
  :param event: (Event) An event manager that pass to the stage execution.
384
391
 
385
392
  :rtype: Result
@@ -394,8 +401,6 @@ class BaseAsyncStage(BaseStage):
394
401
 
395
402
  try:
396
403
  rs: Result = await self.axecute(params, result=result, event=event)
397
- if to is not None: # pragma: no cov
398
- return self.set_outputs(rs.context, to=to)
399
404
  return rs
400
405
  except Exception as e: # pragma: no cov
401
406
  await result.trace.aerror(f"[STAGE]: {e.__class__.__name__}: {e}")
@@ -410,9 +415,6 @@ class BaseAsyncStage(BaseStage):
410
415
  ) from None
411
416
 
412
417
  errors: DictData = {"errors": to_dict(e)}
413
- if to is not None:
414
- return self.set_outputs(errors, to=to)
415
-
416
418
  return result.catch(status=FAILED, context=errors)
417
419
 
418
420
 
@@ -819,7 +821,7 @@ class CallStage(BaseStage):
819
821
  has_keyword: bool = False
820
822
  call_func: TagFunc = extract_call(
821
823
  param2template(self.uses, params, extras=self.extras),
822
- registries=self.extras.get("regis_call"),
824
+ registries=self.extras.get("registry_caller"),
823
825
  )()
824
826
 
825
827
  # VALIDATE: check input task caller parameters that exists before
@@ -871,7 +873,7 @@ class CallStage(BaseStage):
871
873
  # VALIDATE:
872
874
  # Check the result type from call function, it should be dict.
873
875
  if isinstance(rs, BaseModel):
874
- rs: DictData = rs.model_dump()
876
+ rs: DictData = rs.model_dump(by_alias=True)
875
877
  elif not isinstance(rs, dict):
876
878
  raise TypeError(
877
879
  f"Return type: '{call_func.name}@{call_func.tag}' does not "
@@ -887,6 +889,10 @@ class CallStage(BaseStage):
887
889
  ) -> DictData:
888
890
  """Parse Pydantic model from any dict data before parsing to target
889
891
  caller function.
892
+
893
+ :param func:
894
+ :param args:
895
+ :param result: (Result)
890
896
  """
891
897
  try:
892
898
  type_hints: dict[str, Any] = get_type_hints(func)
@@ -933,7 +939,7 @@ class TriggerStage(BaseStage):
933
939
 
934
940
  trigger: str = Field(
935
941
  description=(
936
- "A trigger workflow name that should already exist on the config."
942
+ "A trigger workflow name that should exist on the config path."
937
943
  ),
938
944
  )
939
945
  params: DictData = Field(
@@ -959,26 +965,30 @@ class TriggerStage(BaseStage):
959
965
 
960
966
  :rtype: Result
961
967
  """
962
- # NOTE: Lazy import this workflow object.
963
- from . import Workflow
968
+ from .workflow import Workflow
964
969
 
965
- if result is None: # pragma: no cov
970
+ if result is None:
966
971
  result: Result = Result(
967
972
  run_id=gen_id(self.name + (self.id or ""), unique=True)
968
973
  )
969
974
 
970
- # NOTE: Loading workflow object from trigger name.
971
975
  _trigger: str = param2template(self.trigger, params, extras=self.extras)
972
-
973
- # NOTE: Set running workflow ID from running stage ID to external
974
- # params on Loader object.
975
- workflow: Workflow = Workflow.from_conf(_trigger, extras=self.extras)
976
976
  result.trace.info(f"[STAGE]: Trigger-Execute: {_trigger!r}")
977
- return workflow.execute(
977
+ rs: Result = Workflow.from_conf(
978
+ _trigger, extras=self.extras | {"stage_raise_error": True}
979
+ ).execute(
978
980
  params=param2template(self.params, params, extras=self.extras),
979
981
  result=result,
980
982
  event=event,
981
983
  )
984
+ if rs.status == FAILED:
985
+ err_msg: str | None = (
986
+ f" with:\n{msg}"
987
+ if (msg := rs.context.get("errors", {}).get("message"))
988
+ else ""
989
+ )
990
+ raise StageException(f"Trigger workflow was failed{err_msg}.")
991
+ return rs
982
992
 
983
993
 
984
994
  class ParallelStage(BaseStage): # pragma: no cov
@@ -1115,10 +1125,10 @@ class ParallelStage(BaseStage): # pragma: no cov
1115
1125
 
1116
1126
  class ForEachStage(BaseStage):
1117
1127
  """For-Each execution stage that execute child stages with an item in list
1118
- of item values.
1128
+ of item values. This stage is not the low-level stage model because it runs
1129
+ muti-stages in this stage execution.
1119
1130
 
1120
- This stage is not the low-level stage model because it runs muti-stages
1121
- in this stage execution.
1131
+ The concept of this stage use the same logic of the Job execution.
1122
1132
 
1123
1133
  Data Validate:
1124
1134
  >>> stage = {
@@ -1146,13 +1156,104 @@ class ForEachStage(BaseStage):
1146
1156
  )
1147
1157
  concurrent: int = Field(
1148
1158
  default=1,
1149
- gt=0,
1159
+ ge=1,
1160
+ lt=10,
1150
1161
  description=(
1151
1162
  "A concurrent value allow to run each item at the same time. It "
1152
1163
  "will be sequential mode if this value equal 1."
1153
1164
  ),
1154
1165
  )
1155
1166
 
1167
+ def execute_item(
1168
+ self,
1169
+ item: Union[str, int],
1170
+ params: DictData,
1171
+ result: Result,
1172
+ *,
1173
+ event: Event | None = None,
1174
+ ) -> Result:
1175
+ """Execute foreach item from list of item.
1176
+
1177
+ :param item: (str | int) An item that want to execution.
1178
+ :param params: (DictData) A parameter that want to pass to stage
1179
+ execution.
1180
+ :param result: (Result) A result object for keeping context and status
1181
+ data.
1182
+ :param event: (Event) An event manager that use to track parent execute
1183
+ was not force stopped.
1184
+
1185
+ :rtype: Result
1186
+ """
1187
+ result.trace.debug(f"[STAGE]: Execute item: {item!r}")
1188
+ context: DictData = copy.deepcopy(params)
1189
+ context.update({"item": item, "stages": {}})
1190
+ for stage in self.stages:
1191
+
1192
+ if self.extras:
1193
+ stage.extras = self.extras
1194
+
1195
+ if stage.is_skipped(params=context):
1196
+ result.trace.info(f"[STAGE]: Skip stage: {stage.iden!r}")
1197
+ stage.set_outputs(output={"skipped": True}, to=context)
1198
+ continue
1199
+
1200
+ if event and event.is_set():
1201
+ error_msg: str = (
1202
+ "Item-Stage was canceled from event that had set before "
1203
+ "stage item execution."
1204
+ )
1205
+ return result.catch(
1206
+ status=FAILED,
1207
+ foreach={
1208
+ item: {
1209
+ "item": item,
1210
+ "stages": filter_func(context.pop("stages", {})),
1211
+ "errors": StageException(error_msg).to_dict(),
1212
+ }
1213
+ },
1214
+ )
1215
+
1216
+ try:
1217
+ rs: Result = stage.handler_execute(
1218
+ params=context,
1219
+ run_id=result.run_id,
1220
+ parent_run_id=result.parent_run_id,
1221
+ raise_error=True,
1222
+ )
1223
+ stage.set_outputs(rs.context, to=context)
1224
+ if rs.status == FAILED:
1225
+ error_msg: str = (
1226
+ f"Item-Stage was break because it has a sub stage, "
1227
+ f"{stage.iden}, failed without raise error."
1228
+ )
1229
+ return result.catch(
1230
+ status=FAILED,
1231
+ foreach={
1232
+ item: {
1233
+ "item": item,
1234
+ "stages": filter_func(
1235
+ context.pop("stages", {})
1236
+ ),
1237
+ "errors": StageException(error_msg).to_dict(),
1238
+ },
1239
+ },
1240
+ )
1241
+ except (StageException, UtilException) as e:
1242
+ result.trace.error(f"[STAGE]: {e.__class__.__name__}: {e}")
1243
+ raise StageException(
1244
+ f"Sub-Stage execution error: {e.__class__.__name__}: {e}"
1245
+ ) from None
1246
+
1247
+ return result.catch(
1248
+ status=SUCCESS,
1249
+ foreach={
1250
+ item: {
1251
+ "item": item,
1252
+ "stages": filter_func(context.pop("stages", {})),
1253
+ },
1254
+ },
1255
+ )
1256
+
1156
1257
  def execute(
1157
1258
  self,
1158
1259
  params: DictData,
@@ -1172,9 +1273,10 @@ class ForEachStage(BaseStage):
1172
1273
  """
1173
1274
  if result is None: # pragma: no cov
1174
1275
  result: Result = Result(
1175
- run_id=gen_id(self.name + (self.id or ""), unique=True)
1276
+ run_id=gen_id(self.name + (self.id or ""), unique=True),
1277
+ extras=self.extras,
1176
1278
  )
1177
-
1279
+ event: Event = Event() if event is None else event
1178
1280
  foreach: Union[list[str], list[int]] = (
1179
1281
  param2template(self.foreach, params, extras=self.extras)
1180
1282
  if isinstance(self.foreach, str)
@@ -1186,41 +1288,59 @@ class ForEachStage(BaseStage):
1186
1288
  )
1187
1289
 
1188
1290
  result.trace.info(f"[STAGE]: Foreach-Execute: {foreach!r}.")
1189
- rs: DictData = {"items": foreach, "foreach": {}}
1190
- status: Status = SUCCESS
1191
- # TODO: Implement concurrent more than 1.
1192
- for item in foreach:
1193
- result.trace.debug(f"[STAGE]: Execute foreach item: {item!r}")
1194
- params["item"] = item
1195
- context = {"stages": {}}
1291
+ result.catch(status=WAIT, context={"items": foreach, "foreach": {}})
1292
+ if event and event.is_set(): # pragma: no cov
1293
+ return result.catch(
1294
+ status=FAILED,
1295
+ context={
1296
+ "errors": StageException(
1297
+ "Stage was canceled from event that had set "
1298
+ "before stage foreach execution."
1299
+ ).to_dict()
1300
+ },
1301
+ )
1302
+
1303
+ with ThreadPoolExecutor(
1304
+ max_workers=self.concurrent, thread_name_prefix="stage_foreach_"
1305
+ ) as executor:
1306
+
1307
+ futures: list[Future] = [
1308
+ executor.submit(
1309
+ self.execute_item,
1310
+ item=item,
1311
+ params=params,
1312
+ result=result,
1313
+ )
1314
+ for item in foreach
1315
+ ]
1316
+ context: DictData = {}
1317
+ status: Status = SUCCESS
1196
1318
 
1197
- for stage in self.stages:
1319
+ done, not_done = wait(
1320
+ futures, timeout=1800, return_when=FIRST_EXCEPTION
1321
+ )
1198
1322
 
1199
- if self.extras:
1200
- stage.extras = self.extras
1323
+ if len(done) != len(futures):
1324
+ result.trace.warning(
1325
+ "[STAGE]: Set the event for stop running stage."
1326
+ )
1327
+ event.set()
1328
+ for future in not_done:
1329
+ future.cancel()
1201
1330
 
1331
+ for future in done:
1202
1332
  try:
1203
- stage.set_outputs(
1204
- stage.handler_execute(
1205
- params=params,
1206
- run_id=result.run_id,
1207
- parent_run_id=result.parent_run_id,
1208
- ).context,
1209
- to=context,
1210
- )
1211
- except StageException as e: # pragma: no cov
1333
+ future.result()
1334
+ except StageException as e:
1212
1335
  status = FAILED
1213
1336
  result.trace.error(
1214
- f"[STAGE]: Catch:\n\t{e.__class__.__name__}:" f"\n\t{e}"
1337
+ f"[STAGE]: Catch:\n\t{e.__class__.__name__}:\n\t{e}"
1215
1338
  )
1216
1339
  context.update({"errors": e.to_dict()})
1217
1340
 
1218
- rs["foreach"][item] = context
1219
-
1220
- return result.catch(status=status, context=rs)
1341
+ return result.catch(status=status, context=context)
1221
1342
 
1222
1343
 
1223
- # TODO: Not implement this stages yet
1224
1344
  class UntilStage(BaseStage): # pragma: no cov
1225
1345
  """Until execution stage.
1226
1346
 
@@ -1247,27 +1367,191 @@ class UntilStage(BaseStage): # pragma: no cov
1247
1367
  "correct."
1248
1368
  ),
1249
1369
  )
1250
- max_until_not_change: int = Field(
1251
- default=3,
1252
- description="The maximum value of loop if condition not change.",
1370
+ max_until_loop: int = Field(
1371
+ default=10,
1372
+ ge=1,
1373
+ lt=100,
1374
+ description="The maximum value of loop for this until stage.",
1253
1375
  )
1254
1376
 
1377
+ def execute_item(
1378
+ self,
1379
+ item: T,
1380
+ loop: int,
1381
+ params: DictData,
1382
+ result: Result,
1383
+ event: Event | None = None,
1384
+ ) -> tuple[Result, T]:
1385
+ """Execute until item set item by some stage or by default loop
1386
+ variable.
1387
+
1388
+ :param item: (T) An item that want to execution.
1389
+ :param loop: (int) A number of loop.
1390
+ :param params: (DictData) A parameter that want to pass to stage
1391
+ execution.
1392
+ :param result: (Result) A result object for keeping context and status
1393
+ data.
1394
+ :param event: (Event) An event manager that use to track parent execute
1395
+ was not force stopped.
1396
+
1397
+ :rtype: tuple[Result, T]
1398
+ """
1399
+ result.trace.debug(f"[STAGE]: Execute until item: {item!r}")
1400
+ context: DictData = copy.deepcopy(params)
1401
+ context.update({"loop": loop, "item": item, "stages": {}})
1402
+ next_item: T = None
1403
+ for stage in self.stages:
1404
+
1405
+ if self.extras:
1406
+ stage.extras = self.extras
1407
+
1408
+ if stage.is_skipped(params=context):
1409
+ result.trace.info(f"[STAGE]: Skip stage: {stage.iden!r}")
1410
+ stage.set_outputs(output={"skipped": True}, to=context)
1411
+ continue
1412
+
1413
+ if event and event.is_set():
1414
+ error_msg: str = (
1415
+ "Item-Stage was canceled from event that had set before "
1416
+ "stage item execution."
1417
+ )
1418
+ return (
1419
+ result.catch(
1420
+ status=FAILED,
1421
+ until={
1422
+ loop: {
1423
+ "loop": loop,
1424
+ "item": item,
1425
+ "stages": filter_func(
1426
+ context.pop("stages", {})
1427
+ ),
1428
+ "errors": StageException(error_msg).to_dict(),
1429
+ }
1430
+ },
1431
+ ),
1432
+ next_item,
1433
+ )
1434
+
1435
+ try:
1436
+ rs: Result = stage.handler_execute(
1437
+ params=context,
1438
+ run_id=result.run_id,
1439
+ parent_run_id=result.parent_run_id,
1440
+ raise_error=True,
1441
+ )
1442
+ stage.set_outputs(rs.context, to=context)
1443
+ if "item" in (
1444
+ outputs := stage.get_outputs(context).get("outputs", {})
1445
+ ):
1446
+ next_item = outputs["item"]
1447
+ except (StageException, UtilException) as e:
1448
+ result.trace.error(f"[STAGE]: {e.__class__.__name__}: {e}")
1449
+ raise StageException(
1450
+ f"Sub-Stage execution error: {e.__class__.__name__}: {e}"
1451
+ ) from None
1452
+
1453
+ return (
1454
+ result.catch(
1455
+ status=SUCCESS,
1456
+ until={
1457
+ loop: {
1458
+ "loop": loop,
1459
+ "item": item,
1460
+ "stages": filter_func(context.pop("stages", {})),
1461
+ }
1462
+ },
1463
+ ),
1464
+ next_item,
1465
+ )
1466
+
1255
1467
  def execute(
1256
1468
  self,
1257
1469
  params: DictData,
1258
1470
  *,
1259
1471
  result: Result | None = None,
1260
1472
  event: Event | None = None,
1261
- ) -> Result: ...
1473
+ ) -> Result:
1474
+ """Execute the stages that pass item from until condition field and
1475
+ setter step.
1476
+
1477
+ :param params: A parameter that want to pass before run any statement.
1478
+ :param result: (Result) A result object for keeping context and status
1479
+ data.
1480
+ :param event: (Event) An event manager that use to track parent execute
1481
+ was not force stopped.
1482
+
1483
+ :rtype: Result
1484
+ """
1485
+ if result is None: # pragma: no cov
1486
+ result: Result = Result(
1487
+ run_id=gen_id(self.name + (self.id or ""), unique=True)
1488
+ )
1489
+ result.trace.info(f"[STAGE]: Until-Execution: {self.until}")
1490
+ item: Union[str, int, bool] = param2template(
1491
+ self.item, params, extras=self.extras
1492
+ )
1493
+ loop: int = 1
1494
+ track: bool = True
1495
+ exceed_loop: bool = False
1496
+ result.catch(status=WAIT, context={"until": {}})
1497
+ while track and not (exceed_loop := loop >= self.max_until_loop):
1498
+
1499
+ if event and event.is_set():
1500
+ return result.catch(
1501
+ status=FAILED,
1502
+ context={
1503
+ "errors": StageException(
1504
+ "Stage was canceled from event that had set "
1505
+ "before stage until execution."
1506
+ ).to_dict()
1507
+ },
1508
+ )
1509
+
1510
+ result, item = self.execute_item(
1511
+ item=item,
1512
+ loop=loop,
1513
+ params=params,
1514
+ result=result,
1515
+ )
1516
+
1517
+ loop += 1
1518
+ if item is None:
1519
+ result.trace.warning(
1520
+ "... Does not have set item stage. It will use loop by "
1521
+ "default."
1522
+ )
1523
+ item = loop
1524
+
1525
+ next_track: bool = eval(
1526
+ param2template(
1527
+ self.until,
1528
+ params | {"item": item, "loop": loop},
1529
+ extras=self.extras,
1530
+ ),
1531
+ globals() | params | {"item": item},
1532
+ {},
1533
+ )
1534
+ if not isinstance(next_track, bool):
1535
+ raise StageException(
1536
+ "Return type of until condition does not be boolean, it"
1537
+ f"return: {next_track!r}"
1538
+ )
1539
+ track = not next_track
1540
+
1541
+ if exceed_loop:
1542
+ raise StageException(
1543
+ f"The until loop was exceed {self.max_until_loop} loops"
1544
+ )
1545
+ return result.catch(status=SUCCESS)
1262
1546
 
1263
1547
 
1264
- # TODO: Not implement this stages yet
1265
1548
  class Match(BaseModel):
1266
- case: Union[str, int]
1267
- stage: Stage
1549
+ """Match model for the Case Stage."""
1550
+
1551
+ case: Union[str, int] = Field(description="A match case.")
1552
+ stage: Stage = Field(description="A stage to execution for this case.")
1268
1553
 
1269
1554
 
1270
- # TODO: Not implement this stages yet
1271
1555
  class CaseStage(BaseStage): # pragma: no cov
1272
1556
  """Case execution stage.
1273
1557
 
@@ -1303,7 +1587,9 @@ class CaseStage(BaseStage): # pragma: no cov
1303
1587
  """
1304
1588
 
1305
1589
  case: str = Field(description="A case condition for routing.")
1306
- match: list[Match]
1590
+ match: list[Match] = Field(
1591
+ description="A list of Match model that should not be an empty list.",
1592
+ )
1307
1593
 
1308
1594
  def execute(
1309
1595
  self,
@@ -1326,10 +1612,14 @@ class CaseStage(BaseStage): # pragma: no cov
1326
1612
  result: Result = Result(
1327
1613
  run_id=gen_id(self.name + (self.id or ""), unique=True)
1328
1614
  )
1329
- status = SUCCESS
1615
+
1330
1616
  _case = param2template(self.case, params, extras=self.extras)
1617
+
1618
+ result.trace.info(f"[STAGE]: Case-Execute: {_case!r}.")
1331
1619
  _else = None
1620
+ stage: Optional[Stage] = None
1332
1621
  context = {}
1622
+ status = SUCCESS
1333
1623
  for match in self.match:
1334
1624
  if (c := match.case) != "_":
1335
1625
  _condition = param2template(c, params, extras=self.extras)
@@ -1337,33 +1627,41 @@ class CaseStage(BaseStage): # pragma: no cov
1337
1627
  _else = match
1338
1628
  continue
1339
1629
 
1340
- if match == _condition:
1630
+ if stage is None and _case == _condition:
1341
1631
  stage: Stage = match.stage
1342
- if self.extras:
1343
- stage.extras = self.extras
1344
1632
 
1345
- try:
1346
- stage.set_outputs(
1347
- stage.handler_execute(
1348
- params=params,
1349
- run_id=result.run_id,
1350
- parent_run_id=result.parent_run_id,
1351
- ).context,
1352
- to=context,
1353
- )
1354
- except StageException as e: # pragma: no cov
1355
- status = FAILED
1356
- result.trace.error(
1357
- f"[STAGE]: Catch:\n\t{e.__class__.__name__}:" f"\n\t{e}"
1358
- )
1359
- context.update({"errors": e.to_dict()})
1633
+ if stage is None:
1634
+ if _else is None:
1635
+ raise StageException(
1636
+ "This stage does not set else for support not match "
1637
+ "any case."
1638
+ )
1360
1639
 
1640
+ stage: Stage = _else.stage
1641
+
1642
+ if self.extras:
1643
+ stage.extras = self.extras
1644
+
1645
+ try:
1646
+ context.update(
1647
+ stage.handler_execute(
1648
+ params=params,
1649
+ run_id=result.run_id,
1650
+ parent_run_id=result.parent_run_id,
1651
+ ).context
1652
+ )
1653
+ except StageException as e: # pragma: no cov
1654
+ status = FAILED
1655
+ result.trace.error(
1656
+ f"[STAGE]: Catch:\n\t{e.__class__.__name__}:" f"\n\t{e}"
1657
+ )
1658
+ context.update({"errors": e.to_dict()})
1361
1659
  return result.catch(status=status, context=context)
1362
1660
 
1363
1661
 
1364
1662
  class RaiseStage(BaseStage): # pragma: no cov
1365
- """Raise error stage that raise StageException that use a message field for
1366
- making error message before raise.
1663
+ """Raise error stage execution that raise StageException that use a message
1664
+ field for making error message before raise.
1367
1665
 
1368
1666
  Data Validate:
1369
1667
  >>> stage = {
@@ -1385,13 +1683,21 @@ class RaiseStage(BaseStage): # pragma: no cov
1385
1683
  result: Result | None = None,
1386
1684
  event: Event | None = None,
1387
1685
  ) -> Result:
1388
- """Raise the stage."""
1686
+ """Raise the StageException object with the message field execution.
1687
+
1688
+ :param params: A parameter that want to pass before run any statement.
1689
+ :param result: (Result) A result object for keeping context and status
1690
+ data.
1691
+ :param event: (Event) An event manager that use to track parent execute
1692
+ was not force stopped.
1693
+ """
1389
1694
  if result is None: # pragma: no cov
1390
1695
  result: Result = Result(
1391
1696
  run_id=gen_id(self.name + (self.id or ""), unique=True)
1392
1697
  )
1393
- result.trace.info(f"[STAGE]: Raise-Execute: {self.message!r}.")
1394
- raise StageException(self.message)
1698
+ message: str = param2template(self.message, params, extras=self.extras)
1699
+ result.trace.info(f"[STAGE]: Raise-Execute: {message!r}.")
1700
+ raise StageException(message)
1395
1701
 
1396
1702
 
1397
1703
  # TODO: Not implement this stages yet
@@ -1399,7 +1705,7 @@ class HookStage(BaseStage): # pragma: no cov
1399
1705
  """Hook stage execution."""
1400
1706
 
1401
1707
  hook: str
1402
- args: DictData
1708
+ args: DictData = Field(default_factory=dict)
1403
1709
  callback: str
1404
1710
 
1405
1711
  def execute(
@@ -1408,7 +1714,8 @@ class HookStage(BaseStage): # pragma: no cov
1408
1714
  *,
1409
1715
  result: Result | None = None,
1410
1716
  event: Event | None = None,
1411
- ) -> Result: ...
1717
+ ) -> Result:
1718
+ raise NotImplementedError("Hook Stage does not implement yet.")
1412
1719
 
1413
1720
 
1414
1721
  # TODO: Not implement this stages yet
@@ -1431,9 +1738,69 @@ class DockerStage(BaseStage): # pragma: no cov
1431
1738
  image: str = Field(
1432
1739
  description="A Docker image url with tag that want to run.",
1433
1740
  )
1741
+ tag: str = Field(default="latest", description="An Docker image tag.")
1434
1742
  env: DictData = Field(default_factory=dict)
1435
1743
  volume: DictData = Field(default_factory=dict)
1436
- auth: DictData = Field(default_factory=dict)
1744
+ auth: DictData = Field(
1745
+ default_factory=dict,
1746
+ description=(
1747
+ "An authentication of the Docker registry that use in pulling step."
1748
+ ),
1749
+ )
1750
+
1751
+ def execute_task(
1752
+ self,
1753
+ result: Result,
1754
+ ):
1755
+ from docker import DockerClient
1756
+ from docker.errors import ContainerError
1757
+
1758
+ client = DockerClient(
1759
+ base_url="unix://var/run/docker.sock", version="auto"
1760
+ )
1761
+
1762
+ resp = client.api.pull(
1763
+ repository=f"{self.image}",
1764
+ tag=self.tag,
1765
+ # NOTE: For pulling from GCS.
1766
+ # {
1767
+ # "username": "_json_key",
1768
+ # "password": credential-json-string,
1769
+ # }
1770
+ auth_config=self.auth,
1771
+ stream=True,
1772
+ decode=True,
1773
+ )
1774
+ for line in resp:
1775
+ result.trace.info(f"... {line}")
1776
+
1777
+ unique_image_name: str = f"{self.image}_{datetime.now():%Y%m%d%H%M%S}"
1778
+ container = client.containers.run(
1779
+ image=f"{self.image}:{self.tag}",
1780
+ name=unique_image_name,
1781
+ environment=self.env,
1782
+ volumes=(
1783
+ {Path.cwd() / ".docker.logs": {"bind": "/logs", "mode": "rw"}}
1784
+ | self.volume
1785
+ ),
1786
+ detach=True,
1787
+ )
1788
+
1789
+ for line in container.logs(stream=True, timestamps=True):
1790
+ result.trace.info(f"... {line.strip().decode()}")
1791
+
1792
+ # NOTE: This code copy from the docker package.
1793
+ exit_status: int = container.wait()["StatusCode"]
1794
+ if exit_status != 0:
1795
+ out = container.logs(stdout=False, stderr=True)
1796
+ container.remove()
1797
+ raise ContainerError(
1798
+ container,
1799
+ exit_status,
1800
+ None,
1801
+ f"{self.image}:{self.tag}",
1802
+ out,
1803
+ )
1437
1804
 
1438
1805
  def execute(
1439
1806
  self,
@@ -1441,15 +1808,20 @@ class DockerStage(BaseStage): # pragma: no cov
1441
1808
  *,
1442
1809
  result: Result | None = None,
1443
1810
  event: Event | None = None,
1444
- ) -> Result: ...
1811
+ ) -> Result:
1812
+ raise NotImplementedError("Docker Stage does not implement yet.")
1445
1813
 
1446
1814
 
1447
1815
  # TODO: Not implement this stages yet
1448
1816
  class VirtualPyStage(PyStage): # pragma: no cov
1449
1817
  """Python Virtual Environment stage execution."""
1450
1818
 
1451
- run: str
1452
- vars: DictData
1819
+ deps: list[str] = Field(
1820
+ description=(
1821
+ "list of Python dependency that want to install before execution "
1822
+ "stage."
1823
+ ),
1824
+ )
1453
1825
 
1454
1826
  def create_py_file(self, py: str, run_id: str | None): ...
1455
1827
 
@@ -1460,7 +1832,30 @@ class VirtualPyStage(PyStage): # pragma: no cov
1460
1832
  result: Result | None = None,
1461
1833
  event: Event | None = None,
1462
1834
  ) -> Result:
1463
- return super().execute(params, result=result)
1835
+ """Execute the Python statement via Python virtual environment.
1836
+
1837
+ Steps:
1838
+ - Create python file.
1839
+ - Create `.venv` and install necessary Python deps.
1840
+ - Execution python file with uv and specific `.venv`.
1841
+
1842
+ :param params: A parameter that want to pass before run any statement.
1843
+ :param result: (Result) A result object for keeping context and status
1844
+ data.
1845
+ :param event: (Event) An event manager that use to track parent execute
1846
+ was not force stopped.
1847
+
1848
+ :rtype: Result
1849
+ """
1850
+ if result is None: # pragma: no cov
1851
+ result: Result = Result(
1852
+ run_id=gen_id(self.name + (self.id or ""), unique=True)
1853
+ )
1854
+
1855
+ result.trace.info(f"[STAGE]: Py-Virtual-Execute: {self.name}")
1856
+ raise NotImplementedError(
1857
+ "Python Virtual Stage does not implement yet."
1858
+ )
1464
1859
 
1465
1860
 
1466
1861
  # NOTE:
@@ -1470,11 +1865,16 @@ class VirtualPyStage(PyStage): # pragma: no cov
1470
1865
  #
1471
1866
  Stage = Annotated[
1472
1867
  Union[
1868
+ DockerStage,
1473
1869
  BashStage,
1474
1870
  CallStage,
1871
+ HookStage,
1475
1872
  TriggerStage,
1476
1873
  ForEachStage,
1874
+ UntilStage,
1477
1875
  ParallelStage,
1876
+ CaseStage,
1877
+ VirtualPyStage,
1478
1878
  PyStage,
1479
1879
  RaiseStage,
1480
1880
  EmptyStage,