ddeutil-workflow 0.0.47__py3-none-any.whl → 0.0.49__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.
@@ -38,13 +38,12 @@ from concurrent.futures import (
38
38
  ThreadPoolExecutor,
39
39
  as_completed,
40
40
  )
41
- from dataclasses import is_dataclass
42
41
  from inspect import Parameter
43
42
  from pathlib import Path
44
43
  from subprocess import CompletedProcess
45
44
  from textwrap import dedent
46
45
  from threading import Event
47
- from typing import Annotated, Any, Optional, Union, get_type_hints
46
+ from typing import Annotated, Any, Optional, TypeVar, Union, get_type_hints
48
47
 
49
48
  from pydantic import BaseModel, Field
50
49
  from pydantic.functional_validators import model_validator
@@ -60,17 +59,7 @@ from .utils import (
60
59
  make_exec,
61
60
  )
62
61
 
63
- __all__: TupleStr = (
64
- "EmptyStage",
65
- "BashStage",
66
- "PyStage",
67
- "CallStage",
68
- "TriggerStage",
69
- "ForEachStage",
70
- "ParallelStage",
71
- "RaiseStage",
72
- "Stage",
73
- )
62
+ T = TypeVar("T")
74
63
 
75
64
 
76
65
  class BaseStage(BaseModel, ABC):
@@ -206,6 +195,7 @@ class BaseStage(BaseModel, ABC):
206
195
  run_id=run_id,
207
196
  parent_run_id=parent_run_id,
208
197
  id_logic=self.iden,
198
+ extras=self.extras,
209
199
  )
210
200
 
211
201
  try:
@@ -284,6 +274,25 @@ class BaseStage(BaseModel, ABC):
284
274
  to["stages"][_id] = {"outputs": output, **skipping, **errors}
285
275
  return to
286
276
 
277
+ def get_outputs(self, outputs: DictData) -> DictData:
278
+ """Get the outputs from stages data.
279
+
280
+ :rtype: DictData
281
+ """
282
+ if self.id is None and not dynamic(
283
+ "stage_default_id", extras=self.extras
284
+ ):
285
+ return {}
286
+
287
+ _id: str = (
288
+ param2template(self.id, params=outputs, extras=self.extras)
289
+ if self.id
290
+ else gen_id(
291
+ param2template(self.name, params=outputs, extras=self.extras)
292
+ )
293
+ )
294
+ return outputs.get("stages", {}).get(_id, {})
295
+
287
296
  def is_skipped(self, params: DictData | None = None) -> bool:
288
297
  """Return true if condition of this stage do not correct. This process
289
298
  use build-in eval function to execute the if-condition.
@@ -389,14 +398,15 @@ class BaseAsyncStage(BaseStage):
389
398
  run_id=run_id,
390
399
  parent_run_id=parent_run_id,
391
400
  id_logic=self.iden,
401
+ extras=self.extras,
392
402
  )
393
403
 
394
404
  try:
395
405
  rs: Result = await self.axecute(params, result=result, event=event)
396
- if to is not None:
406
+ if to is not None: # pragma: no cov
397
407
  return self.set_outputs(rs.context, to=to)
398
408
  return rs
399
- except Exception as e:
409
+ except Exception as e: # pragma: no cov
400
410
  await result.trace.aerror(f"[STAGE]: {e.__class__.__name__}: {e}")
401
411
 
402
412
  if dynamic("stage_raise_error", f=raise_error, extras=self.extras):
@@ -818,7 +828,7 @@ class CallStage(BaseStage):
818
828
  has_keyword: bool = False
819
829
  call_func: TagFunc = extract_call(
820
830
  param2template(self.uses, params, extras=self.extras),
821
- registries=self.extras.get("regis_call"),
831
+ registries=self.extras.get("registry_caller"),
822
832
  )()
823
833
 
824
834
  # VALIDATE: check input task caller parameters that exists before
@@ -856,6 +866,7 @@ class CallStage(BaseStage):
856
866
  )
857
867
 
858
868
  args = self.parse_model_args(call_func, args, result)
869
+
859
870
  if inspect.iscoroutinefunction(call_func):
860
871
  loop = asyncio.get_event_loop()
861
872
  rs: DictData = loop.run_until_complete(
@@ -904,8 +915,11 @@ class CallStage(BaseStage):
904
915
  args[arg] = args.pop(arg.removeprefix("_"))
905
916
 
906
917
  t: Any = type_hints[arg]
907
- if is_dataclass(t) and t.__name__ == "Result" and arg not in args:
908
- args[arg] = result
918
+
919
+ # NOTE: Check Result argument was passed to this caller function.
920
+ #
921
+ # if is_dataclass(t) and t.__name__ == "Result" and arg not in args:
922
+ # args[arg] = result
909
923
 
910
924
  if issubclass(t, BaseModel) and arg in args:
911
925
  args[arg] = t.model_validate(obj=args[arg])
@@ -1141,13 +1155,59 @@ class ForEachStage(BaseStage):
1141
1155
  )
1142
1156
  concurrent: int = Field(
1143
1157
  default=1,
1144
- gt=0,
1158
+ ge=1,
1159
+ lt=10,
1145
1160
  description=(
1146
1161
  "A concurrent value allow to run each item at the same time. It "
1147
1162
  "will be sequential mode if this value equal 1."
1148
1163
  ),
1149
1164
  )
1150
1165
 
1166
+ def execute_item(
1167
+ self,
1168
+ item: Union[str, int],
1169
+ params: DictData,
1170
+ context: DictData,
1171
+ result: Result,
1172
+ ) -> tuple[Status, DictData]:
1173
+ """Execute foreach item from list of item.
1174
+
1175
+ :param item: (str | int) An item that want to execution.
1176
+ :param params: (DictData) A parameter that want to pass to stage
1177
+ execution.
1178
+ :param context: (DictData)
1179
+ :param result: (Result)
1180
+
1181
+ :rtype: tuple[Status, DictData]
1182
+ """
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
+ for stage in self.stages:
1188
+
1189
+ if self.extras:
1190
+ stage.extras = self.extras
1191
+
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,
1200
+ )
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
+ )
1206
+ to.update({"errors": e.to_dict()})
1207
+
1208
+ context["foreach"][item] = to
1209
+ return status, context
1210
+
1151
1211
  def execute(
1152
1212
  self,
1153
1213
  params: DictData,
@@ -1181,41 +1241,26 @@ class ForEachStage(BaseStage):
1181
1241
  )
1182
1242
 
1183
1243
  result.trace.info(f"[STAGE]: Foreach-Execute: {foreach!r}.")
1184
- rs: DictData = {"items": foreach, "foreach": {}}
1185
- status: Status = SUCCESS
1186
- # TODO: Implement concurrent more than 1.
1187
- for item in foreach:
1188
- result.trace.debug(f"[STAGE]: Execute foreach item: {item!r}")
1189
- params["item"] = item
1190
- context = {"stages": {}}
1191
-
1192
- for stage in self.stages:
1193
-
1194
- if self.extras:
1195
- stage.extras = self.extras
1196
-
1197
- try:
1198
- stage.set_outputs(
1199
- stage.handler_execute(
1200
- params=params,
1201
- run_id=result.run_id,
1202
- parent_run_id=result.parent_run_id,
1203
- ).context,
1204
- to=context,
1205
- )
1206
- except StageException as e: # pragma: no cov
1207
- status = FAILED
1208
- result.trace.error(
1209
- f"[STAGE]: Catch:\n\t{e.__class__.__name__}:" f"\n\t{e}"
1210
- )
1211
- context.update({"errors": e.to_dict()})
1212
-
1213
- rs["foreach"][item] = context
1244
+ context: DictData = {"items": foreach, "foreach": {}}
1245
+ statuses: list[Union[SUCCESS, FAILED]] = []
1246
+ with ThreadPoolExecutor(max_workers=self.concurrent) as executor:
1247
+ futures: list[Future] = [
1248
+ executor.submit(
1249
+ self.execute_item,
1250
+ item=item,
1251
+ params=params.copy(),
1252
+ context=context,
1253
+ result=result,
1254
+ )
1255
+ for item in foreach
1256
+ ]
1257
+ for future in as_completed(futures):
1258
+ status, context = future.result()
1259
+ statuses.append(status)
1214
1260
 
1215
- return result.catch(status=status, context=rs)
1261
+ return result.catch(status=max(statuses), context=context)
1216
1262
 
1217
1263
 
1218
- # TODO: Not implement this stages yet
1219
1264
  class UntilStage(BaseStage): # pragma: no cov
1220
1265
  """Until execution stage.
1221
1266
 
@@ -1242,27 +1287,144 @@ class UntilStage(BaseStage): # pragma: no cov
1242
1287
  "correct."
1243
1288
  ),
1244
1289
  )
1245
- max_until_not_change: int = Field(
1246
- default=3,
1247
- description="The maximum value of loop if condition not change.",
1290
+ max_until_loop: int = Field(
1291
+ default=10,
1292
+ ge=1,
1293
+ lt=100,
1294
+ description="The maximum value of loop for this until stage.",
1248
1295
  )
1249
1296
 
1297
+ def execute_item(
1298
+ self,
1299
+ item: T,
1300
+ loop: int,
1301
+ params: DictData,
1302
+ context: DictData,
1303
+ result: Result,
1304
+ ) -> tuple[Status, DictData, T]:
1305
+ """Execute until item set item by some stage or by default loop
1306
+ variable.
1307
+
1308
+ :param item: (T) An item that want to execution.
1309
+ :param loop: (int) A number of loop.
1310
+ :param params: (DictData) A parameter that want to pass to stage
1311
+ execution.
1312
+ :param context: (DictData)
1313
+ :param result: (Result)
1314
+
1315
+ :rtype: tuple[Status, DictData, T]
1316
+ """
1317
+ result.trace.debug(f"[STAGE]: Execute until item: {item!r}")
1318
+ params["item"] = item
1319
+ to: DictData = {"item": item, "stages": {}}
1320
+ status: Status = SUCCESS
1321
+ next_item: T = None
1322
+ for stage in self.stages:
1323
+
1324
+ if self.extras:
1325
+ stage.extras = self.extras
1326
+
1327
+ 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,
1335
+ )
1336
+ if "item" in (
1337
+ outputs := stage.get_outputs(to).get("outputs", {})
1338
+ ):
1339
+ 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()})
1346
+
1347
+ context["until"][loop] = to
1348
+ return status, context, next_item
1349
+
1250
1350
  def execute(
1251
1351
  self,
1252
1352
  params: DictData,
1253
1353
  *,
1254
1354
  result: Result | None = None,
1255
1355
  event: Event | None = None,
1256
- ) -> Result: ...
1356
+ ) -> Result:
1357
+ """Execute the stages that pass item from until condition field and
1358
+ setter step.
1359
+
1360
+ :param params: A parameter that want to pass before run any statement.
1361
+ :param result: (Result) A result object for keeping context and status
1362
+ data.
1363
+ :param event: (Event) An event manager that use to track parent execute
1364
+ was not force stopped.
1365
+
1366
+ :rtype: Result
1367
+ """
1368
+ if result is None: # pragma: no cov
1369
+ result: Result = Result(
1370
+ run_id=gen_id(self.name + (self.id or ""), unique=True)
1371
+ )
1372
+
1373
+ item: Union[str, int, bool] = param2template(
1374
+ self.item, params, extras=self.extras
1375
+ )
1376
+ result.trace.info(f"[STAGE]: Until-Execution: {self.until}")
1377
+ track: bool = True
1378
+ exceed_loop: bool = False
1379
+ loop: int = 1
1380
+ context: DictData = {"until": {}}
1381
+ statuses: list[Union[SUCCESS, FAILED]] = []
1382
+ while track and not (exceed_loop := loop >= self.max_until_loop):
1383
+ status, context, item = self.execute_item(
1384
+ item=item,
1385
+ loop=loop,
1386
+ params=params.copy(),
1387
+ context=context,
1388
+ result=result,
1389
+ )
1390
+
1391
+ loop += 1
1392
+ if item is None:
1393
+ result.trace.warning(
1394
+ "... Does not have set item stage. It will use loop by "
1395
+ "default."
1396
+ )
1397
+ item = loop
1398
+
1399
+ next_track: bool = eval(
1400
+ param2template(
1401
+ self.until, params | {"item": item}, extras=self.extras
1402
+ ),
1403
+ globals() | params | {"item": item},
1404
+ {},
1405
+ )
1406
+ if not isinstance(next_track, bool):
1407
+ raise TypeError(
1408
+ "Return type of until condition does not be boolean, it"
1409
+ f"return: {next_track!r}"
1410
+ )
1411
+ track = not next_track
1412
+ statuses.append(status)
1413
+
1414
+ if exceed_loop:
1415
+ raise StageException(
1416
+ f"The until loop was exceed {self.max_until_loop} loops"
1417
+ )
1418
+ return result.catch(status=max(statuses), context=context)
1257
1419
 
1258
1420
 
1259
- # TODO: Not implement this stages yet
1260
1421
  class Match(BaseModel):
1422
+ """Match model for the Case Stage."""
1423
+
1261
1424
  case: Union[str, int]
1262
1425
  stage: Stage
1263
1426
 
1264
1427
 
1265
- # TODO: Not implement this stages yet
1266
1428
  class CaseStage(BaseStage): # pragma: no cov
1267
1429
  """Case execution stage.
1268
1430
 
@@ -1298,7 +1460,9 @@ class CaseStage(BaseStage): # pragma: no cov
1298
1460
  """
1299
1461
 
1300
1462
  case: str = Field(description="A case condition for routing.")
1301
- match: list[Match]
1463
+ match: list[Match] = Field(
1464
+ description="A list of Match model that should not be an empty list.",
1465
+ )
1302
1466
 
1303
1467
  def execute(
1304
1468
  self,
@@ -1321,10 +1485,14 @@ class CaseStage(BaseStage): # pragma: no cov
1321
1485
  result: Result = Result(
1322
1486
  run_id=gen_id(self.name + (self.id or ""), unique=True)
1323
1487
  )
1324
- status = SUCCESS
1488
+
1325
1489
  _case = param2template(self.case, params, extras=self.extras)
1490
+
1491
+ result.trace.info(f"[STAGE]: Case-Execute: {_case!r}.")
1326
1492
  _else = None
1493
+ stage: Optional[Stage] = None
1327
1494
  context = {}
1495
+ status = SUCCESS
1328
1496
  for match in self.match:
1329
1497
  if (c := match.case) != "_":
1330
1498
  _condition = param2template(c, params, extras=self.extras)
@@ -1332,27 +1500,35 @@ class CaseStage(BaseStage): # pragma: no cov
1332
1500
  _else = match
1333
1501
  continue
1334
1502
 
1335
- if match == _condition:
1503
+ if stage is None and _case == _condition:
1336
1504
  stage: Stage = match.stage
1337
- if self.extras:
1338
- stage.extras = self.extras
1339
-
1340
- try:
1341
- stage.set_outputs(
1342
- stage.handler_execute(
1343
- params=params,
1344
- run_id=result.run_id,
1345
- parent_run_id=result.parent_run_id,
1346
- ).context,
1347
- to=context,
1348
- )
1349
- except StageException as e: # pragma: no cov
1350
- status = FAILED
1351
- result.trace.error(
1352
- f"[STAGE]: Catch:\n\t{e.__class__.__name__}:" f"\n\t{e}"
1353
- )
1354
- context.update({"errors": e.to_dict()})
1355
1505
 
1506
+ if stage is None:
1507
+ if _else is None:
1508
+ raise StageException(
1509
+ "This stage does not set else for support not match "
1510
+ "any case."
1511
+ )
1512
+
1513
+ stage: Stage = _else.stage
1514
+
1515
+ if self.extras:
1516
+ stage.extras = self.extras
1517
+
1518
+ try:
1519
+ context.update(
1520
+ stage.handler_execute(
1521
+ params=params,
1522
+ run_id=result.run_id,
1523
+ parent_run_id=result.parent_run_id,
1524
+ ).context
1525
+ )
1526
+ except StageException as e: # pragma: no cov
1527
+ status = FAILED
1528
+ result.trace.error(
1529
+ f"[STAGE]: Catch:\n\t{e.__class__.__name__}:" f"\n\t{e}"
1530
+ )
1531
+ context.update({"errors": e.to_dict()})
1356
1532
  return result.catch(status=status, context=context)
1357
1533
 
1358
1534
 
@@ -1403,7 +1579,8 @@ class HookStage(BaseStage): # pragma: no cov
1403
1579
  *,
1404
1580
  result: Result | None = None,
1405
1581
  event: Event | None = None,
1406
- ) -> Result: ...
1582
+ ) -> Result:
1583
+ raise NotImplementedError("Hook Stage does not implement yet.")
1407
1584
 
1408
1585
 
1409
1586
  # TODO: Not implement this stages yet
@@ -1436,15 +1613,20 @@ class DockerStage(BaseStage): # pragma: no cov
1436
1613
  *,
1437
1614
  result: Result | None = None,
1438
1615
  event: Event | None = None,
1439
- ) -> Result: ...
1616
+ ) -> Result:
1617
+ raise NotImplementedError("Docker Stage does not implement yet.")
1440
1618
 
1441
1619
 
1442
1620
  # TODO: Not implement this stages yet
1443
1621
  class VirtualPyStage(PyStage): # pragma: no cov
1444
1622
  """Python Virtual Environment stage execution."""
1445
1623
 
1446
- run: str
1447
- vars: DictData
1624
+ deps: list[str] = Field(
1625
+ description=(
1626
+ "list of Python dependency that want to install before execution "
1627
+ "stage."
1628
+ ),
1629
+ )
1448
1630
 
1449
1631
  def create_py_file(self, py: str, run_id: str | None): ...
1450
1632
 
@@ -1455,7 +1637,25 @@ class VirtualPyStage(PyStage): # pragma: no cov
1455
1637
  result: Result | None = None,
1456
1638
  event: Event | None = None,
1457
1639
  ) -> Result:
1458
- return super().execute(params, result=result)
1640
+ """Execute the Python statement via Python virtual environment.
1641
+
1642
+ :param params: A parameter that want to pass before run any statement.
1643
+ :param result: (Result) A result object for keeping context and status
1644
+ data.
1645
+ :param event: (Event) An event manager that use to track parent execute
1646
+ was not force stopped.
1647
+
1648
+ :rtype: Result
1649
+ """
1650
+ if result is None: # pragma: no cov
1651
+ result: Result = Result(
1652
+ run_id=gen_id(self.name + (self.id or ""), unique=True)
1653
+ )
1654
+
1655
+ result.trace.info(f"[STAGE]: Py-Virtual-Execute: {self.name}")
1656
+ raise NotImplementedError(
1657
+ "Python Virtual Stage does not implement yet."
1658
+ )
1459
1659
 
1460
1660
 
1461
1661
  # NOTE:
@@ -1465,11 +1665,16 @@ class VirtualPyStage(PyStage): # pragma: no cov
1465
1665
  #
1466
1666
  Stage = Annotated[
1467
1667
  Union[
1668
+ DockerStage,
1468
1669
  BashStage,
1469
1670
  CallStage,
1671
+ HookStage,
1470
1672
  TriggerStage,
1471
1673
  ForEachStage,
1674
+ UntilStage,
1472
1675
  ParallelStage,
1676
+ CaseStage,
1677
+ VirtualPyStage,
1473
1678
  PyStage,
1474
1679
  RaiseStage,
1475
1680
  EmptyStage,
ddeutil/workflow/utils.py CHANGED
@@ -15,7 +15,7 @@ from inspect import isfunction
15
15
  from itertools import chain, islice, product
16
16
  from pathlib import Path
17
17
  from random import randrange
18
- from typing import Any, TypeVar
18
+ from typing import Any, Final, TypeVar
19
19
  from zoneinfo import ZoneInfo
20
20
 
21
21
  from ddeutil.core import hash_str
@@ -23,7 +23,7 @@ from ddeutil.core import hash_str
23
23
  from .__types import DictData, Matrix
24
24
 
25
25
  T = TypeVar("T")
26
- UTC = ZoneInfo("UTC")
26
+ UTC: Final[ZoneInfo] = ZoneInfo("UTC")
27
27
 
28
28
 
29
29
  def get_dt_now(
@@ -142,7 +142,7 @@ def gen_id(
142
142
  if not isinstance(value, str):
143
143
  value: str = str(value)
144
144
 
145
- if config.gen_id_simple_mode:
145
+ if config.generate_id_simple_mode:
146
146
  return (
147
147
  f"{datetime.now(tz=config.tz):%Y%m%d%H%M%S%f}T" if unique else ""
148
148
  ) + hash_str(f"{(value if sensitive else value.lower())}", n=10)