ddeutil-workflow 0.0.59__py3-none-any.whl → 0.0.61__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.
@@ -20,6 +20,7 @@ from typing import Annotated, Any, Literal, Optional, TypeVar, Union
20
20
  from ddeutil.core import str2dict, str2list
21
21
  from pydantic import BaseModel, Field
22
22
 
23
+ from .__types import StrOrInt
23
24
  from .exceptions import ParamValueException
24
25
  from .utils import get_d_now, get_dt_now
25
26
 
@@ -159,10 +160,11 @@ class StrParam(DefaultParam):
159
160
 
160
161
  type: Literal["str"] = "str"
161
162
 
162
- def receive(self, value: Optional[str] = None) -> Optional[str]:
163
+ def receive(self, value: Optional[Any] = None) -> Optional[str]:
163
164
  """Receive value that match with str.
164
165
 
165
- :param value: A value that want to validate with string parameter type.
166
+ :param value: (Any) A value that want to validate with string parameter
167
+ type.
166
168
  :rtype: Optional[str]
167
169
  """
168
170
  if value is None:
@@ -175,7 +177,7 @@ class IntParam(DefaultParam):
175
177
 
176
178
  type: Literal["int"] = "int"
177
179
 
178
- def receive(self, value: Optional[int] = None) -> Optional[int]:
180
+ def receive(self, value: Optional[StrOrInt] = None) -> Optional[int]:
179
181
  """Receive value that match with int.
180
182
 
181
183
  :param value: A value that want to validate with integer parameter type.
@@ -200,13 +202,24 @@ class FloatParam(DefaultParam): # pragma: no cov
200
202
  precision: int = 6
201
203
 
202
204
  def rounding(self, value: float) -> float:
203
- """Rounding float value with the specific precision field."""
205
+ """Rounding float value with the specific precision field.
206
+
207
+ :param value: A float value that want to round with the precision value.
208
+
209
+ :rtype: float
210
+ """
204
211
  round_str: str = f"{{0:.{self.precision}f}}"
205
212
  return float(round_str.format(round(value, self.precision)))
206
213
 
207
- def receive(self, value: Optional[Union[float, int, str]] = None) -> float:
214
+ def receive(
215
+ self, value: Optional[Union[float, int, str]] = None
216
+ ) -> Optional[float]:
217
+ """Receive value that match with float.
208
218
 
209
- if value in None:
219
+ :param value: A value that want to validate with float parameter type.
220
+ :rtype: float | None
221
+ """
222
+ if value is None:
210
223
  return self.default
211
224
 
212
225
  if isinstance(value, float):
@@ -217,11 +230,7 @@ class FloatParam(DefaultParam): # pragma: no cov
217
230
  raise TypeError(
218
231
  "Received value type does not math with str, float, or int."
219
232
  )
220
-
221
- try:
222
- return self.rounding(float(value))
223
- except Exception:
224
- raise
233
+ return self.rounding(float(value))
225
234
 
226
235
 
227
236
  class DecimalParam(DefaultParam): # pragma: no cov
@@ -231,12 +240,28 @@ class DecimalParam(DefaultParam): # pragma: no cov
231
240
  precision: int = 6
232
241
 
233
242
  def rounding(self, value: Decimal) -> Decimal:
234
- """Rounding float value with the specific precision field."""
243
+ """Rounding float value with the specific precision field.
244
+
245
+ :param value: (Decimal) A Decimal value that want to round with the
246
+ precision value.
247
+
248
+ :rtype: Decimal
249
+ """
235
250
  return value.quantize(Decimal(10) ** -self.precision)
236
251
 
237
- def receive(self, value: float | Decimal | None = None) -> Decimal:
252
+ def receive(
253
+ self, value: Optional[Union[float, int, str, Decimal]] = None
254
+ ) -> Decimal:
255
+ """Receive value that match with decimal.
238
256
 
239
- if isinstance(value, float):
257
+ :param value: (float | Decimal) A value that want to validate with
258
+ decimal parameter type.
259
+ :rtype: Decimal | None
260
+ """
261
+ if value is None:
262
+ return self.default
263
+
264
+ if isinstance(value, (float, int)):
240
265
  return self.rounding(Decimal(value))
241
266
  elif isinstance(value, Decimal):
242
267
  return self.rounding(value)
@@ -261,11 +286,12 @@ class ChoiceParam(BaseParam):
261
286
  description="A list of choice parameters that able be str or int.",
262
287
  )
263
288
 
264
- def receive(self, value: Union[str, int] | None = None) -> Union[str, int]:
289
+ def receive(self, value: Optional[StrOrInt] = None) -> StrOrInt:
265
290
  """Receive value that match with options.
266
291
 
267
- :param value: A value that want to select from the options field.
268
- :rtype: str
292
+ :param value: (str | int) A value that want to select from the options
293
+ field.
294
+ :rtype: str | int
269
295
  """
270
296
  # NOTE:
271
297
  # Return the first value in options if it does not pass any input
@@ -279,7 +305,7 @@ class ChoiceParam(BaseParam):
279
305
  return value
280
306
 
281
307
 
282
- class MapParam(DefaultParam): # pragma: no cov
308
+ class MapParam(DefaultParam):
283
309
  """Map parameter."""
284
310
 
285
311
  type: Literal["map"] = "map"
@@ -295,6 +321,7 @@ class MapParam(DefaultParam): # pragma: no cov
295
321
  """Receive value that match with map type.
296
322
 
297
323
  :param value: A value that want to validate with map parameter type.
324
+
298
325
  :rtype: dict[Any, Any]
299
326
  """
300
327
  if value is None:
@@ -316,7 +343,7 @@ class MapParam(DefaultParam): # pragma: no cov
316
343
  return value
317
344
 
318
345
 
319
- class ArrayParam(DefaultParam): # pragma: no cov
346
+ class ArrayParam(DefaultParam):
320
347
  """Array parameter."""
321
348
 
322
349
  type: Literal["array"] = "array"
@@ -326,7 +353,7 @@ class ArrayParam(DefaultParam): # pragma: no cov
326
353
  )
327
354
 
328
355
  def receive(
329
- self, value: Optional[Union[list[T], tuple[T, ...], str]] = None
356
+ self, value: Optional[Union[list[T], tuple[T, ...], set[T], str]] = None
330
357
  ) -> list[T]:
331
358
  """Receive value that match with array type.
332
359
 
@@ -365,5 +392,11 @@ Param = Annotated[
365
392
  IntParam,
366
393
  StrParam,
367
394
  ],
368
- Field(discriminator="type"),
395
+ Field(
396
+ discriminator="type",
397
+ description=(
398
+ "A parameter models that use for validate and receive on the "
399
+ "workflow execution."
400
+ ),
401
+ ),
369
402
  ]
@@ -38,7 +38,7 @@ class Status(IntEnum):
38
38
  CANCEL = 4
39
39
 
40
40
  @property
41
- def emoji(self) -> str:
41
+ def emoji(self) -> str: # pragma: no cov
42
42
  """Return the emoji value of this status.
43
43
 
44
44
  :rtype: str
@@ -44,6 +44,8 @@ FILTERS: dict[str, Callable] = { # pragma: no cov
44
44
  "upper": lambda x: x.upper(),
45
45
  "lower": lambda x: x.lower(),
46
46
  "rstr": [str, repr],
47
+ "keys": lambda x: list(x.keys()),
48
+ "values": lambda x: list(x.values()),
47
49
  }
48
50
 
49
51
 
@@ -35,6 +35,7 @@ import json
35
35
  import subprocess
36
36
  import sys
37
37
  import time
38
+ import traceback
38
39
  import uuid
39
40
  from abc import ABC, abstractmethod
40
41
  from collections.abc import AsyncIterator, Iterator
@@ -58,19 +59,21 @@ from pydantic import BaseModel, Field
58
59
  from pydantic.functional_validators import model_validator
59
60
  from typing_extensions import Self
60
61
 
61
- from .__types import DictData, DictStr, StrOrInt, TupleStr
62
+ from .__types import DictData, DictStr, StrOrInt, StrOrNone, TupleStr
62
63
  from .conf import dynamic
63
64
  from .exceptions import StageException, to_dict
64
65
  from .result import CANCEL, FAILED, SUCCESS, WAIT, Result, Status
65
66
  from .reusables import TagFunc, extract_call, not_in_template, param2template
66
67
  from .utils import (
67
68
  delay,
69
+ dump_all,
68
70
  filter_func,
69
71
  gen_id,
70
72
  make_exec,
71
73
  )
72
74
 
73
75
  T = TypeVar("T")
76
+ DictOrModel = Union[DictData, BaseModel]
74
77
 
75
78
 
76
79
  class BaseStage(BaseModel, ABC):
@@ -87,7 +90,7 @@ class BaseStage(BaseModel, ABC):
87
90
  default_factory=dict,
88
91
  description="An extra parameter that override core config values.",
89
92
  )
90
- id: Optional[str] = Field(
93
+ id: StrOrNone = Field(
91
94
  default=None,
92
95
  description=(
93
96
  "A stage ID that use to keep execution output or getting by job "
@@ -97,7 +100,7 @@ class BaseStage(BaseModel, ABC):
97
100
  name: str = Field(
98
101
  description="A stage name that want to logging when start execution.",
99
102
  )
100
- condition: Optional[str] = Field(
103
+ condition: StrOrNone = Field(
101
104
  default=None,
102
105
  description=(
103
106
  "A stage condition statement to allow stage executable. This field "
@@ -162,8 +165,8 @@ class BaseStage(BaseModel, ABC):
162
165
  self,
163
166
  params: DictData,
164
167
  *,
165
- run_id: Optional[str] = None,
166
- parent_run_id: Optional[str] = None,
168
+ run_id: StrOrNone = None,
169
+ parent_run_id: StrOrNone = None,
167
170
  result: Optional[Result] = None,
168
171
  event: Optional[Event] = None,
169
172
  raise_error: Optional[bool] = None,
@@ -221,7 +224,10 @@ class BaseStage(BaseModel, ABC):
221
224
  return self.execute(params, result=result, event=event)
222
225
  except Exception as e:
223
226
  e_name: str = e.__class__.__name__
224
- result.trace.error(f"[STAGE]: Error Handler:||{e_name}:||{e}")
227
+ result.trace.error(
228
+ f"[STAGE]: Error Handler:||{e_name}:||{e}||"
229
+ f"{traceback.format_exc()}"
230
+ )
225
231
  if dynamic("stage_raise_error", f=raise_error, extras=self.extras):
226
232
  if isinstance(e, StageException):
227
233
  raise
@@ -411,8 +417,8 @@ class BaseAsyncStage(BaseStage):
411
417
  self,
412
418
  params: DictData,
413
419
  *,
414
- run_id: Optional[str] = None,
415
- parent_run_id: Optional[str] = None,
420
+ run_id: StrOrNone = None,
421
+ parent_run_id: StrOrNone = None,
416
422
  result: Optional[Result] = None,
417
423
  event: Optional[Event] = None,
418
424
  raise_error: Optional[bool] = None,
@@ -469,7 +475,7 @@ class EmptyStage(BaseAsyncStage):
469
475
  ... }
470
476
  """
471
477
 
472
- echo: Optional[str] = Field(
478
+ echo: StrOrNone = Field(
473
479
  default=None,
474
480
  description="A message that want to show on the stdout.",
475
481
  )
@@ -598,14 +604,14 @@ class BashStage(BaseAsyncStage):
598
604
  )
599
605
 
600
606
  @contextlib.asynccontextmanager
601
- async def acreate_sh_file(
602
- self, bash: str, env: DictStr, run_id: Optional[str] = None
607
+ async def async_create_sh_file(
608
+ self, bash: str, env: DictStr, run_id: StrOrNone = None
603
609
  ) -> AsyncIterator[TupleStr]:
604
610
  """Async create and write `.sh` file with the `aiofiles` package.
605
611
 
606
612
  :param bash: (str) A bash statement.
607
613
  :param env: (DictStr) An environment variable that set before run bash.
608
- :param run_id: (Optional[str]) A running stage ID that use for writing sh
614
+ :param run_id: (StrOrNone) A running stage ID that use for writing sh
609
615
  file instead generate by UUID4.
610
616
 
611
617
  :rtype: AsyncIterator[TupleStr]
@@ -635,14 +641,14 @@ class BashStage(BaseAsyncStage):
635
641
 
636
642
  @contextlib.contextmanager
637
643
  def create_sh_file(
638
- self, bash: str, env: DictStr, run_id: Optional[str] = None
644
+ self, bash: str, env: DictStr, run_id: StrOrNone = None
639
645
  ) -> Iterator[TupleStr]:
640
646
  """Create and write the `.sh` file before giving this file name to
641
647
  context. After that, it will auto delete this file automatic.
642
648
 
643
649
  :param bash: (str) A bash statement.
644
650
  :param env: (DictStr) An environment variable that set before run bash.
645
- :param run_id: (Optional[str]) A running stage ID that use for writing sh
651
+ :param run_id: (StrOrNone) A running stage ID that use for writing sh
646
652
  file instead generate by UUID4.
647
653
 
648
654
  :rtype: Iterator[TupleStr]
@@ -752,7 +758,7 @@ class BashStage(BaseAsyncStage):
752
758
  dedent(self.bash.strip("\n")), params, extras=self.extras
753
759
  )
754
760
 
755
- async with self.acreate_sh_file(
761
+ async with self.async_create_sh_file(
756
762
  bash=bash,
757
763
  env=param2template(self.env, params, extras=self.extras),
758
764
  run_id=result.run_id,
@@ -1170,13 +1176,12 @@ class CallStage(BaseAsyncStage):
1170
1176
  args.pop("result")
1171
1177
 
1172
1178
  args = self.parse_model_args(call_func, args, result)
1173
-
1174
1179
  if inspect.iscoroutinefunction(call_func):
1175
- rs: DictData = await call_func(
1180
+ rs: DictOrModel = await call_func(
1176
1181
  **param2template(args, params, extras=self.extras)
1177
1182
  )
1178
1183
  else:
1179
- rs: DictData = call_func(
1184
+ rs: DictOrModel = call_func(
1180
1185
  **param2template(args, params, extras=self.extras)
1181
1186
  )
1182
1187
 
@@ -1190,7 +1195,7 @@ class CallStage(BaseAsyncStage):
1190
1195
  f"serialize, you must set return be `dict` or Pydantic "
1191
1196
  f"model."
1192
1197
  )
1193
- return result.catch(status=SUCCESS, context=rs)
1198
+ return result.catch(status=SUCCESS, context=dump_all(rs, by_alias=True))
1194
1199
 
1195
1200
  @staticmethod
1196
1201
  def parse_model_args(
@@ -1294,11 +1299,12 @@ class TriggerStage(BaseStage):
1294
1299
  extras=self.extras | {"stage_raise_error": True},
1295
1300
  ).execute(
1296
1301
  params=param2template(self.params, params, extras=self.extras),
1297
- parent_run_id=result.run_id,
1302
+ run_id=None,
1303
+ parent_run_id=result.parent_run_id,
1298
1304
  event=event,
1299
1305
  )
1300
1306
  if rs.status == FAILED:
1301
- err_msg: Optional[str] = (
1307
+ err_msg: StrOrNone = (
1302
1308
  f" with:\n{msg}"
1303
1309
  if (msg := rs.context.get("errors", {}).get("message"))
1304
1310
  else "."
@@ -1826,7 +1832,10 @@ class UntilStage(BaseNestedStage):
1826
1832
  ... "stages": [
1827
1833
  ... {
1828
1834
  ... "name": "Start increase item value.",
1829
- ... "run": "item = ${{ item }}\\nitem += 1\\n"
1835
+ ... "run": (
1836
+ ... "item = ${{ item }}\\n"
1837
+ ... "item += 1\\n"
1838
+ ... )
1830
1839
  ... },
1831
1840
  ... ],
1832
1841
  ... }
@@ -2215,9 +2224,7 @@ class CaseStage(BaseNestedStage):
2215
2224
  extras=self.extras,
2216
2225
  )
2217
2226
 
2218
- _case: Optional[str] = param2template(
2219
- self.case, params, extras=self.extras
2220
- )
2227
+ _case: StrOrNone = param2template(self.case, params, extras=self.extras)
2221
2228
 
2222
2229
  result.trace.info(f"[STAGE]: Execute Case-Stage: {_case!r}.")
2223
2230
  _else: Optional[Match] = None
@@ -2396,8 +2403,14 @@ class DockerStage(BaseStage): # pragma: no cov
2396
2403
 
2397
2404
  :rtype: Result
2398
2405
  """
2399
- from docker import DockerClient
2400
- from docker.errors import ContainerError
2406
+ try:
2407
+ from docker import DockerClient
2408
+ from docker.errors import ContainerError
2409
+ except ImportError:
2410
+ raise ImportError(
2411
+ "Docker stage need the docker package, you should install it "
2412
+ "by `pip install docker` first."
2413
+ ) from None
2401
2414
 
2402
2415
  client = DockerClient(
2403
2416
  base_url="unix://var/run/docker.sock", version="auto"
@@ -2459,7 +2472,7 @@ class DockerStage(BaseStage): # pragma: no cov
2459
2472
  exit_status,
2460
2473
  None,
2461
2474
  f"{self.image}:{self.tag}",
2462
- out,
2475
+ out.decode("utf-8"),
2463
2476
  )
2464
2477
  output_file: Path = Path(f".docker.{result.run_id}.logs/outputs.json")
2465
2478
  if not output_file.exists():
@@ -2518,15 +2531,19 @@ class VirtualPyStage(PyStage): # pragma: no cov
2518
2531
  py: str,
2519
2532
  values: DictData,
2520
2533
  deps: list[str],
2521
- run_id: Optional[str] = None,
2534
+ run_id: StrOrNone = None,
2522
2535
  ) -> Iterator[str]:
2523
- """Create the .py file with an input Python string statement.
2536
+ """Create the `.py` file and write an input Python statement and its
2537
+ Python dependency on the header of this file.
2538
+
2539
+ The format of Python dependency was followed by the `uv`
2540
+ recommended.
2524
2541
 
2525
2542
  :param py: A Python string statement.
2526
2543
  :param values: A variable that want to set before running this
2527
2544
  :param deps: An additional Python dependencies that want install before
2528
2545
  run this python stage.
2529
- :param run_id: (Optional[str]) A running ID of this stage execution.
2546
+ :param run_id: (StrOrNone) A running ID of this stage execution.
2530
2547
  """
2531
2548
  run_id: str = run_id or uuid.uuid4()
2532
2549
  f_name: str = f"{run_id}.py"
@@ -2536,7 +2553,7 @@ class VirtualPyStage(PyStage): # pragma: no cov
2536
2553
  f"{var} = {value!r}" for var, value in values.items()
2537
2554
  )
2538
2555
 
2539
- # NOTE: uv supports PEP 723 — inline TOML metadata.
2556
+ # NOTE: `uv` supports PEP 723 — inline TOML metadata.
2540
2557
  f.write(
2541
2558
  dedent(
2542
2559
  f"""
@@ -2595,6 +2612,16 @@ class VirtualPyStage(PyStage): # pragma: no cov
2595
2612
  run_id=result.run_id,
2596
2613
  ) as py:
2597
2614
  result.trace.debug(f"[STAGE]: ... Create `{py}` file.")
2615
+ try:
2616
+ import uv
2617
+
2618
+ _ = uv
2619
+ except ImportError:
2620
+ raise ImportError(
2621
+ "The VirtualPyStage need you to install `uv` before"
2622
+ "execution."
2623
+ ) from None
2624
+
2598
2625
  rs: CompletedProcess = subprocess.run(
2599
2626
  ["uv", "run", py, "--no-cache"],
2600
2627
  # ["uv", "run", "--python", "3.9", py],
@@ -2644,5 +2671,8 @@ Stage = Annotated[
2644
2671
  RaiseStage,
2645
2672
  EmptyStage,
2646
2673
  ],
2647
- Field(union_mode="smart"),
2674
+ Field(
2675
+ union_mode="smart",
2676
+ description="A stage models that already implemented on this package.",
2677
+ ),
2648
2678
  ] # pragma: no cov
ddeutil/workflow/utils.py CHANGED
@@ -15,16 +15,17 @@ 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, Final, Optional, TypeVar, Union
18
+ from typing import Any, Final, Optional, TypeVar, Union, overload
19
19
  from zoneinfo import ZoneInfo
20
20
 
21
21
  from ddeutil.core import hash_str
22
+ from pydantic import BaseModel
22
23
 
23
24
  from .__types import DictData, Matrix
24
25
 
25
26
  T = TypeVar("T")
26
27
  UTC: Final[ZoneInfo] = ZoneInfo("UTC")
27
- MARK_NL: Final[str] = "||"
28
+ MARK_NEWLINE: Final[str] = "||"
28
29
 
29
30
 
30
31
  def prepare_newline(msg: str) -> str:
@@ -34,11 +35,12 @@ def prepare_newline(msg: str) -> str:
34
35
 
35
36
  :rtype: str
36
37
  """
37
- msg: str = msg.strip("\n").replace("\n", MARK_NL)
38
- if MARK_NL not in msg:
38
+ # NOTE: Remove ending with "\n" and replace "\n" with the "||" value.
39
+ msg: str = msg.strip("\n").replace("\n", MARK_NEWLINE)
40
+ if MARK_NEWLINE not in msg:
39
41
  return msg
40
42
 
41
- msg_lines: list[str] = msg.split(MARK_NL)
43
+ msg_lines: list[str] = msg.split(MARK_NEWLINE)
42
44
  msg_last: str = msg_lines[-1]
43
45
  msg_body: str = (
44
46
  "\n" + "\n".join(f" ... | \t{s}" for s in msg_lines[1:-1])
@@ -288,3 +290,24 @@ def cut_id(run_id: str, *, num: int = 6) -> str:
288
290
  dt, simple = run_id.split("T", maxsplit=1)
289
291
  return dt[:12] + simple[-num:]
290
292
  return run_id[:12] + run_id[-num:]
293
+
294
+
295
+ @overload
296
+ def dump_all(value: BaseModel, by_alias: bool = False) -> DictData: ...
297
+
298
+
299
+ @overload
300
+ def dump_all(value: T, by_alias: bool = False) -> T: ...
301
+
302
+
303
+ def dump_all(
304
+ value: Union[T, BaseModel], by_alias: bool = False
305
+ ) -> Union[T, DictData]:
306
+ """Dump all BaseModel object to dict."""
307
+ if isinstance(value, dict):
308
+ return {k: dump_all(value[k], by_alias=by_alias) for k in value}
309
+ elif isinstance(value, (list, tuple, set)):
310
+ return type(value)([dump_all(i, by_alias=by_alias) for i in value])
311
+ elif isinstance(value, BaseModel):
312
+ return value.model_dump(by_alias=by_alias)
313
+ return value