ddeutil-workflow 0.0.49__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,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,11 @@ 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
  )
@@ -150,9 +155,8 @@ class BaseStage(BaseModel, ABC):
150
155
  parent_run_id: str | None = None,
151
156
  result: Result | None = None,
152
157
  raise_error: bool | None = None,
153
- to: DictData | None = None,
154
158
  event: Event | None = None,
155
- ) -> Result:
159
+ ) -> Result | DictData:
156
160
  """Handler stage execution result from the stage `execute` method.
157
161
 
158
162
  This stage exception handler still use ok-error concept, but it
@@ -184,8 +188,6 @@ class BaseStage(BaseModel, ABC):
184
188
  :param result: (Result) A result object for keeping context and status
185
189
  data before execution.
186
190
  :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
191
  :param event: (Event) An event manager that pass to the stage execution.
190
192
 
191
193
  :rtype: Result
@@ -200,7 +202,7 @@ class BaseStage(BaseModel, ABC):
200
202
 
201
203
  try:
202
204
  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
205
+ return rs
204
206
  except Exception as e:
205
207
  result.trace.error(f"[STAGE]: {e.__class__.__name__}: {e}")
206
208
 
@@ -214,11 +216,7 @@ class BaseStage(BaseModel, ABC):
214
216
  ) from e
215
217
 
216
218
  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
- )
219
+ return result.catch(status=FAILED, context=errors)
222
220
 
223
221
  def set_outputs(self, output: DictData, to: DictData) -> DictData:
224
222
  """Set an outputs from execution process to the received context. The
@@ -275,7 +273,10 @@ class BaseStage(BaseModel, ABC):
275
273
  return to
276
274
 
277
275
  def get_outputs(self, outputs: DictData) -> DictData:
278
- """Get the outputs from stages data.
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.
279
280
 
280
281
  :rtype: DictData
281
282
  """
@@ -374,7 +375,6 @@ class BaseAsyncStage(BaseStage):
374
375
  parent_run_id: str | None = None,
375
376
  result: Result | None = None,
376
377
  raise_error: bool | None = None,
377
- to: DictData | None = None,
378
378
  event: Event | None = None,
379
379
  ) -> Result:
380
380
  """Async Handler stage execution result from the stage `execute` method.
@@ -387,8 +387,6 @@ class BaseAsyncStage(BaseStage):
387
387
  :param result: (Result) A result object for keeping context and status
388
388
  data before execution.
389
389
  :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
390
  :param event: (Event) An event manager that pass to the stage execution.
393
391
 
394
392
  :rtype: Result
@@ -403,8 +401,6 @@ class BaseAsyncStage(BaseStage):
403
401
 
404
402
  try:
405
403
  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
404
  return rs
409
405
  except Exception as e: # pragma: no cov
410
406
  await result.trace.aerror(f"[STAGE]: {e.__class__.__name__}: {e}")
@@ -419,9 +415,6 @@ class BaseAsyncStage(BaseStage):
419
415
  ) from None
420
416
 
421
417
  errors: DictData = {"errors": to_dict(e)}
422
- if to is not None:
423
- return self.set_outputs(errors, to=to)
424
-
425
418
  return result.catch(status=FAILED, context=errors)
426
419
 
427
420
 
@@ -880,7 +873,7 @@ class CallStage(BaseStage):
880
873
  # VALIDATE:
881
874
  # Check the result type from call function, it should be dict.
882
875
  if isinstance(rs, BaseModel):
883
- rs: DictData = rs.model_dump()
876
+ rs: DictData = rs.model_dump(by_alias=True)
884
877
  elif not isinstance(rs, dict):
885
878
  raise TypeError(
886
879
  f"Return type: '{call_func.name}@{call_func.tag}' does not "
@@ -896,6 +889,10 @@ class CallStage(BaseStage):
896
889
  ) -> DictData:
897
890
  """Parse Pydantic model from any dict data before parsing to target
898
891
  caller function.
892
+
893
+ :param func:
894
+ :param args:
895
+ :param result: (Result)
899
896
  """
900
897
  try:
901
898
  type_hints: dict[str, Any] = get_type_hints(func)
@@ -942,7 +939,7 @@ class TriggerStage(BaseStage):
942
939
 
943
940
  trigger: str = Field(
944
941
  description=(
945
- "A trigger workflow name that should already exist on the config."
942
+ "A trigger workflow name that should exist on the config path."
946
943
  ),
947
944
  )
948
945
  params: DictData = Field(
@@ -968,26 +965,30 @@ class TriggerStage(BaseStage):
968
965
 
969
966
  :rtype: Result
970
967
  """
971
- # NOTE: Lazy import this workflow object.
972
- from . import Workflow
968
+ from .workflow import Workflow
973
969
 
974
- if result is None: # pragma: no cov
970
+ if result is None:
975
971
  result: Result = Result(
976
972
  run_id=gen_id(self.name + (self.id or ""), unique=True)
977
973
  )
978
974
 
979
- # NOTE: Loading workflow object from trigger name.
980
975
  _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
976
  result.trace.info(f"[STAGE]: Trigger-Execute: {_trigger!r}")
986
- return workflow.execute(
977
+ rs: Result = Workflow.from_conf(
978
+ _trigger, extras=self.extras | {"stage_raise_error": True}
979
+ ).execute(
987
980
  params=param2template(self.params, params, extras=self.extras),
988
981
  result=result,
989
982
  event=event,
990
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
991
992
 
992
993
 
993
994
  class ParallelStage(BaseStage): # pragma: no cov
@@ -1124,10 +1125,10 @@ class ParallelStage(BaseStage): # pragma: no cov
1124
1125
 
1125
1126
  class ForEachStage(BaseStage):
1126
1127
  """For-Each execution stage that execute child stages with an item in list
1127
- 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.
1128
1130
 
1129
- This stage is not the low-level stage model because it runs muti-stages
1130
- in this stage execution.
1131
+ The concept of this stage use the same logic of the Job execution.
1131
1132
 
1132
1133
  Data Validate:
1133
1134
  >>> stage = {
@@ -1167,46 +1168,91 @@ class ForEachStage(BaseStage):
1167
1168
  self,
1168
1169
  item: Union[str, int],
1169
1170
  params: DictData,
1170
- context: DictData,
1171
1171
  result: Result,
1172
- ) -> tuple[Status, DictData]:
1172
+ *,
1173
+ event: Event | None = None,
1174
+ ) -> Result:
1173
1175
  """Execute foreach item from list of item.
1174
1176
 
1175
1177
  :param item: (str | int) An item that want to execution.
1176
1178
  :param params: (DictData) A parameter that want to pass to stage
1177
1179
  execution.
1178
- :param context: (DictData)
1179
- :param result: (Result)
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.
1180
1184
 
1181
- :rtype: tuple[Status, DictData]
1185
+ :rtype: Result
1182
1186
  """
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
1187
+ result.trace.debug(f"[STAGE]: Execute item: {item!r}")
1188
+ context: DictData = copy.deepcopy(params)
1189
+ context.update({"item": item, "stages": {}})
1187
1190
  for stage in self.stages:
1188
1191
 
1189
1192
  if self.extras:
1190
1193
  stage.extras = self.extras
1191
1194
 
1192
- 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,
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."
1200
1204
  )
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}"
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,
1205
1222
  )
1206
- to.update({"errors": e.to_dict()})
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
1207
1246
 
1208
- context["foreach"][item] = to
1209
- return status, context
1247
+ return result.catch(
1248
+ status=SUCCESS,
1249
+ foreach={
1250
+ item: {
1251
+ "item": item,
1252
+ "stages": filter_func(context.pop("stages", {})),
1253
+ },
1254
+ },
1255
+ )
1210
1256
 
1211
1257
  def execute(
1212
1258
  self,
@@ -1227,9 +1273,10 @@ class ForEachStage(BaseStage):
1227
1273
  """
1228
1274
  if result is None: # pragma: no cov
1229
1275
  result: Result = Result(
1230
- 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,
1231
1278
  )
1232
-
1279
+ event: Event = Event() if event is None else event
1233
1280
  foreach: Union[list[str], list[int]] = (
1234
1281
  param2template(self.foreach, params, extras=self.extras)
1235
1282
  if isinstance(self.foreach, str)
@@ -1241,24 +1288,57 @@ class ForEachStage(BaseStage):
1241
1288
  )
1242
1289
 
1243
1290
  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:
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
+
1247
1307
  futures: list[Future] = [
1248
1308
  executor.submit(
1249
1309
  self.execute_item,
1250
1310
  item=item,
1251
- params=params.copy(),
1252
- context=context,
1311
+ params=params,
1253
1312
  result=result,
1254
1313
  )
1255
1314
  for item in foreach
1256
1315
  ]
1257
- for future in as_completed(futures):
1258
- status, context = future.result()
1259
- statuses.append(status)
1316
+ context: DictData = {}
1317
+ status: Status = SUCCESS
1260
1318
 
1261
- return result.catch(status=max(statuses), context=context)
1319
+ done, not_done = wait(
1320
+ futures, timeout=1800, return_when=FIRST_EXCEPTION
1321
+ )
1322
+
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()
1330
+
1331
+ for future in done:
1332
+ try:
1333
+ future.result()
1334
+ except StageException as e:
1335
+ status = FAILED
1336
+ result.trace.error(
1337
+ f"[STAGE]: Catch:\n\t{e.__class__.__name__}:\n\t{e}"
1338
+ )
1339
+ context.update({"errors": e.to_dict()})
1340
+
1341
+ return result.catch(status=status, context=context)
1262
1342
 
1263
1343
 
1264
1344
  class UntilStage(BaseStage): # pragma: no cov
@@ -1299,9 +1379,9 @@ class UntilStage(BaseStage): # pragma: no cov
1299
1379
  item: T,
1300
1380
  loop: int,
1301
1381
  params: DictData,
1302
- context: DictData,
1303
1382
  result: Result,
1304
- ) -> tuple[Status, DictData, T]:
1383
+ event: Event | None = None,
1384
+ ) -> tuple[Result, T]:
1305
1385
  """Execute until item set item by some stage or by default loop
1306
1386
  variable.
1307
1387
 
@@ -1309,43 +1389,80 @@ class UntilStage(BaseStage): # pragma: no cov
1309
1389
  :param loop: (int) A number of loop.
1310
1390
  :param params: (DictData) A parameter that want to pass to stage
1311
1391
  execution.
1312
- :param context: (DictData)
1313
- :param result: (Result)
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.
1314
1396
 
1315
- :rtype: tuple[Status, DictData, T]
1397
+ :rtype: tuple[Result, T]
1316
1398
  """
1317
1399
  result.trace.debug(f"[STAGE]: Execute until item: {item!r}")
1318
- params["item"] = item
1319
- to: DictData = {"item": item, "stages": {}}
1320
- status: Status = SUCCESS
1400
+ context: DictData = copy.deepcopy(params)
1401
+ context.update({"loop": loop, "item": item, "stages": {}})
1321
1402
  next_item: T = None
1322
1403
  for stage in self.stages:
1323
1404
 
1324
1405
  if self.extras:
1325
1406
  stage.extras = self.extras
1326
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
+
1327
1435
  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,
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,
1335
1441
  )
1442
+ stage.set_outputs(rs.context, to=context)
1336
1443
  if "item" in (
1337
- outputs := stage.get_outputs(to).get("outputs", {})
1444
+ outputs := stage.get_outputs(context).get("outputs", {})
1338
1445
  ):
1339
1446
  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()})
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
1346
1452
 
1347
- context["until"][loop] = to
1348
- return status, context, next_item
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
+ )
1349
1466
 
1350
1467
  def execute(
1351
1468
  self,
@@ -1369,22 +1486,31 @@ class UntilStage(BaseStage): # pragma: no cov
1369
1486
  result: Result = Result(
1370
1487
  run_id=gen_id(self.name + (self.id or ""), unique=True)
1371
1488
  )
1372
-
1489
+ result.trace.info(f"[STAGE]: Until-Execution: {self.until}")
1373
1490
  item: Union[str, int, bool] = param2template(
1374
1491
  self.item, params, extras=self.extras
1375
1492
  )
1376
- result.trace.info(f"[STAGE]: Until-Execution: {self.until}")
1493
+ loop: int = 1
1377
1494
  track: bool = True
1378
1495
  exceed_loop: bool = False
1379
- loop: int = 1
1380
- context: DictData = {"until": {}}
1381
- statuses: list[Union[SUCCESS, FAILED]] = []
1496
+ result.catch(status=WAIT, context={"until": {}})
1382
1497
  while track and not (exceed_loop := loop >= self.max_until_loop):
1383
- status, context, item = self.execute_item(
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(
1384
1511
  item=item,
1385
1512
  loop=loop,
1386
- params=params.copy(),
1387
- context=context,
1513
+ params=params,
1388
1514
  result=result,
1389
1515
  )
1390
1516
 
@@ -1398,31 +1524,32 @@ class UntilStage(BaseStage): # pragma: no cov
1398
1524
 
1399
1525
  next_track: bool = eval(
1400
1526
  param2template(
1401
- self.until, params | {"item": item}, extras=self.extras
1527
+ self.until,
1528
+ params | {"item": item, "loop": loop},
1529
+ extras=self.extras,
1402
1530
  ),
1403
1531
  globals() | params | {"item": item},
1404
1532
  {},
1405
1533
  )
1406
1534
  if not isinstance(next_track, bool):
1407
- raise TypeError(
1535
+ raise StageException(
1408
1536
  "Return type of until condition does not be boolean, it"
1409
1537
  f"return: {next_track!r}"
1410
1538
  )
1411
1539
  track = not next_track
1412
- statuses.append(status)
1413
1540
 
1414
1541
  if exceed_loop:
1415
1542
  raise StageException(
1416
1543
  f"The until loop was exceed {self.max_until_loop} loops"
1417
1544
  )
1418
- return result.catch(status=max(statuses), context=context)
1545
+ return result.catch(status=SUCCESS)
1419
1546
 
1420
1547
 
1421
1548
  class Match(BaseModel):
1422
1549
  """Match model for the Case Stage."""
1423
1550
 
1424
- case: Union[str, int]
1425
- stage: Stage
1551
+ case: Union[str, int] = Field(description="A match case.")
1552
+ stage: Stage = Field(description="A stage to execution for this case.")
1426
1553
 
1427
1554
 
1428
1555
  class CaseStage(BaseStage): # pragma: no cov
@@ -1533,8 +1660,8 @@ class CaseStage(BaseStage): # pragma: no cov
1533
1660
 
1534
1661
 
1535
1662
  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.
1663
+ """Raise error stage execution that raise StageException that use a message
1664
+ field for making error message before raise.
1538
1665
 
1539
1666
  Data Validate:
1540
1667
  >>> stage = {
@@ -1556,13 +1683,21 @@ class RaiseStage(BaseStage): # pragma: no cov
1556
1683
  result: Result | None = None,
1557
1684
  event: Event | None = None,
1558
1685
  ) -> Result:
1559
- """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
+ """
1560
1694
  if result is None: # pragma: no cov
1561
1695
  result: Result = Result(
1562
1696
  run_id=gen_id(self.name + (self.id or ""), unique=True)
1563
1697
  )
1564
- result.trace.info(f"[STAGE]: Raise-Execute: {self.message!r}.")
1565
- 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)
1566
1701
 
1567
1702
 
1568
1703
  # TODO: Not implement this stages yet
@@ -1570,7 +1705,7 @@ class HookStage(BaseStage): # pragma: no cov
1570
1705
  """Hook stage execution."""
1571
1706
 
1572
1707
  hook: str
1573
- args: DictData
1708
+ args: DictData = Field(default_factory=dict)
1574
1709
  callback: str
1575
1710
 
1576
1711
  def execute(
@@ -1603,9 +1738,69 @@ class DockerStage(BaseStage): # pragma: no cov
1603
1738
  image: str = Field(
1604
1739
  description="A Docker image url with tag that want to run.",
1605
1740
  )
1741
+ tag: str = Field(default="latest", description="An Docker image tag.")
1606
1742
  env: DictData = Field(default_factory=dict)
1607
1743
  volume: DictData = Field(default_factory=dict)
1608
- 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
+ )
1609
1804
 
1610
1805
  def execute(
1611
1806
  self,
@@ -1639,6 +1834,11 @@ class VirtualPyStage(PyStage): # pragma: no cov
1639
1834
  ) -> Result:
1640
1835
  """Execute the Python statement via Python virtual environment.
1641
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
+
1642
1842
  :param params: A parameter that want to pass before run any statement.
1643
1843
  :param result: (Result) A result object for keeping context and status
1644
1844
  data.