ddeutil-workflow 0.0.61__py3-none-any.whl → 0.0.63__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.
@@ -1 +1 @@
1
- __version__: str = "0.0.61"
1
+ __version__: str = "0.0.63"
@@ -18,7 +18,7 @@ from ddeutil.core import (
18
18
  isinstance_check,
19
19
  must_split,
20
20
  )
21
- from ddeutil.core.dtutils import next_date, replace_date
21
+ from ddeutil.core.dtutils import DatetimeMode, next_date, replace_date
22
22
 
23
23
  WEEKDAYS: dict[str, int] = {
24
24
  "Sun": 0,
@@ -31,7 +31,8 @@ WEEKDAYS: dict[str, int] = {
31
31
  }
32
32
 
33
33
 
34
- class CronYearLimit(Exception): ...
34
+ class YearReachLimit(Exception):
35
+ """"""
35
36
 
36
37
 
37
38
  def str2cron(value: str) -> str: # pragma: no cov
@@ -178,7 +179,7 @@ class CronPart:
178
179
  def __init__(
179
180
  self,
180
181
  unit: Unit,
181
- values: str | list[int],
182
+ values: Union[str, list[int]],
182
183
  options: Options,
183
184
  ) -> None:
184
185
  self.unit: Unit = unit
@@ -229,19 +230,21 @@ class CronPart:
229
230
  f"(unit={self.unit}, values={self.__str__()!r})"
230
231
  )
231
232
 
232
- def __lt__(self, other) -> bool:
233
+ def __lt__(self, other: Union[CronPart, list]) -> bool:
233
234
  """Override __lt__ method."""
234
235
  if isinstance(other, CronPart):
235
236
  return self.values < other.values
236
237
  elif isinstance(other, list):
237
238
  return self.values < other
239
+ return NotImplemented
238
240
 
239
- def __eq__(self, other) -> bool:
241
+ def __eq__(self, other: Union[CronPart, list]) -> bool:
240
242
  """Override __eq__ method."""
241
243
  if isinstance(other, CronPart):
242
244
  return self.values == other.values
243
245
  elif isinstance(other, list):
244
246
  return self.values == other
247
+ return NotImplemented
245
248
 
246
249
  @property
247
250
  def min(self) -> int:
@@ -271,6 +274,7 @@ class CronPart:
271
274
  and (step := self.values[1] - self.values[0]) > 1
272
275
  ):
273
276
  return step
277
+ return None
274
278
 
275
279
  @property
276
280
  def is_full(self) -> bool:
@@ -355,6 +359,8 @@ class CronPart:
355
359
  f"Invalid interval step value {value_step!r} for "
356
360
  f"{self.unit.name!r}"
357
361
  )
362
+ elif value_step:
363
+ value_step: int = int(value_step)
358
364
 
359
365
  # NOTE: Generate interval that has step
360
366
  interval_list.append(self._interval(value_range_list, value_step))
@@ -375,7 +381,9 @@ class CronPart:
375
381
  value: str = value.replace(alt, str(self.unit.min + i))
376
382
  return value
377
383
 
378
- def replace_weekday(self, values: list[int] | Iterator[int]) -> list[int]:
384
+ def replace_weekday(
385
+ self, values: Union[list[int], Iterator[int]]
386
+ ) -> list[int]:
379
387
  """Replaces all 7 with 0 as Sunday can be represented by both.
380
388
 
381
389
  :param values: list or iter of int that want to mode by 7
@@ -433,12 +441,12 @@ class CronPart:
433
441
  def _interval(
434
442
  self,
435
443
  values: list[int],
436
- step: int | None = None,
444
+ step: Optional[int] = None,
437
445
  ) -> list[int]:
438
446
  """Applies an interval step to a collection of values.
439
447
 
440
448
  :param values:
441
- :param step:
449
+ :param step: (int) A step
442
450
 
443
451
  :rtype: list[int]
444
452
  """
@@ -515,7 +523,7 @@ class CronPart:
515
523
  start_number: Optional[int] = value
516
524
  return multi_dim_values
517
525
 
518
- def filler(self, value: int) -> int | str:
526
+ def filler(self, value: int) -> Union[int, str]:
519
527
  """Formats weekday and month names as string when the relevant options
520
528
  are set.
521
529
 
@@ -765,12 +773,12 @@ class CronRunner:
765
773
 
766
774
  def __init__(
767
775
  self,
768
- cron: CronJob | CronJobYear,
776
+ cron: Union[CronJob, CronJobYear],
769
777
  date: Optional[datetime] = None,
770
778
  *,
771
- tz: str | ZoneInfo | None = None,
779
+ tz: Optional[Union[str, ZoneInfo]] = None,
772
780
  ) -> None:
773
- self.tz: ZoneInfo | None = None
781
+ self.tz: Optional[ZoneInfo] = None
774
782
  if tz:
775
783
  if isinstance(tz, ZoneInfo):
776
784
  self.tz = tz
@@ -810,7 +818,7 @@ class CronRunner:
810
818
  )
811
819
 
812
820
  self.__start_date: datetime = self.date
813
- self.cron: CronJob | CronJobYear = cron
821
+ self.cron: Union[CronJob, CronJobYear] = cron
814
822
  self.is_year: bool = isinstance(cron, CronJobYear)
815
823
  self.reset_flag: bool = True
816
824
 
@@ -863,7 +871,7 @@ class CronRunner:
863
871
 
864
872
  raise RecursionError("Unable to find execution time for schedule")
865
873
 
866
- def __shift_date(self, mode: str, reverse: bool = False) -> bool:
874
+ def __shift_date(self, mode: DatetimeMode, reverse: bool = False) -> bool:
867
875
  """Increments the mode of date value ("month", "day", "hour", "minute")
868
876
  until matches with the schedule.
869
877
 
@@ -897,8 +905,8 @@ class CronRunner:
897
905
  getattr(self.date, mode)
898
906
  > (max_year := max(self.cron.year.values))
899
907
  ):
900
- raise CronYearLimit(
901
- f"The year is out of limit with this crontab value: "
908
+ raise YearReachLimit(
909
+ f"The year is reach the limit with this crontab setting: "
902
910
  f"{max_year}."
903
911
  )
904
912
 
@@ -917,12 +925,3 @@ class CronRunner:
917
925
 
918
926
  # NOTE: Return False if the date that match with condition.
919
927
  return False
920
-
921
-
922
- __all__ = (
923
- "CronJob",
924
- "CronJobYear",
925
- "CronRunner",
926
- "Options",
927
- "WEEKDAYS",
928
- )
@@ -5,12 +5,7 @@
5
5
  # ------------------------------------------------------------------------------
6
6
  from .__cron import CronJob, CronRunner
7
7
  from .__types import DictData, DictStr, Matrix, Re, TupleStr
8
- from .conf import (
9
- Config,
10
- FileLoad,
11
- config,
12
- env,
13
- )
8
+ from .conf import *
14
9
  from .event import *
15
10
  from .exceptions import *
16
11
  from .job import *
@@ -37,24 +32,7 @@ from .result import (
37
32
  Result,
38
33
  Status,
39
34
  )
40
- from .reusables import (
41
- FILTERS,
42
- FilterFunc,
43
- FilterRegistry,
44
- ReturnTagFunc,
45
- TagFunc,
46
- custom_filter,
47
- extract_call,
48
- get_args_const,
49
- has_template,
50
- make_filter_registry,
51
- make_registry,
52
- map_post_filter,
53
- not_in_template,
54
- param2template,
55
- str2template,
56
- tag,
57
- )
35
+ from .reusables import *
58
36
  from .scheduler import (
59
37
  Schedule,
60
38
  ScheduleWorkflow,
ddeutil/workflow/conf.py CHANGED
@@ -18,8 +18,9 @@ from typing import Final, Optional, Protocol, TypeVar, Union
18
18
  from zoneinfo import ZoneInfo
19
19
 
20
20
  from ddeutil.core import str2bool
21
- from ddeutil.io import YamlFlResolve
21
+ from ddeutil.io import YamlFlResolve, search_env_replace
22
22
  from ddeutil.io.paths import glob_files, is_ignored, read_ignore
23
+ from pydantic import SecretStr
23
24
 
24
25
  from .__types import DictData
25
26
 
@@ -321,15 +322,16 @@ class FileLoad(BaseLoad):
321
322
  *,
322
323
  path: Optional[Path] = None,
323
324
  paths: Optional[list[Path]] = None,
324
- excluded: list[str] | None = None,
325
+ excluded: Optional[list[str]] = None,
325
326
  extras: Optional[DictData] = None,
326
327
  ) -> Iterator[tuple[str, DictData]]:
327
328
  """Find all data that match with object type in config path. This class
328
329
  method can use include and exclude list of identity name for filter and
329
330
  adds-on.
330
331
 
331
- :param obj: An object that want to validate matching before return.
332
- :param path: A config path object.
332
+ :param obj: (object) An object that want to validate matching before
333
+ return.
334
+ :param path: (Path) A config path object.
333
335
  :param paths: (list[Path]) A list of config path object.
334
336
  :param excluded: An included list of data key that want to filter from
335
337
  data.
@@ -474,3 +476,33 @@ class Loader(Protocol): # pragma: no cov
474
476
  def finds(
475
477
  cls, obj: object, *args, **kwargs
476
478
  ) -> Iterator[tuple[str, DictData]]: ...
479
+
480
+
481
+ def pass_env(value: T) -> T: # pragma: no cov
482
+ """Passing environment variable to an input value.
483
+
484
+ :param value: (Any) A value that want to pass env var searching.
485
+
486
+ :rtype: Any
487
+ """
488
+ if isinstance(value, dict):
489
+ return {k: pass_env(value[k]) for k in value}
490
+ elif isinstance(value, (list, tuple, set)):
491
+ return type(value)([pass_env(i) for i in value])
492
+ if not isinstance(value, str):
493
+ return value
494
+
495
+ rs: str = search_env_replace(value)
496
+ return None if rs == "null" else rs
497
+
498
+
499
+ class WorkflowSecret(SecretStr): # pragma: no cov
500
+ """Workflow Secret String model."""
501
+
502
+ def get_secret_value(self) -> str:
503
+ """Override get_secret_value by adding pass_env before return the
504
+ real-value.
505
+
506
+ :rtype: str
507
+ """
508
+ return pass_env(super().get_secret_value())
ddeutil/workflow/event.py CHANGED
@@ -17,6 +17,7 @@ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
17
17
  from pydantic import BaseModel, ConfigDict, Field, ValidationInfo
18
18
  from pydantic.functional_serializers import field_serializer
19
19
  from pydantic.functional_validators import field_validator, model_validator
20
+ from pydantic_extra_types.timezone_name import TimeZoneName
20
21
  from typing_extensions import Self
21
22
 
22
23
  from .__cron import WEEKDAYS, CronJob, CronJobYear, CronRunner, Options
@@ -92,7 +93,7 @@ class Crontab(BaseModel):
92
93
  ),
93
94
  ]
94
95
  tz: Annotated[
95
- str,
96
+ TimeZoneName,
96
97
  Field(
97
98
  description="A timezone string value",
98
99
  alias="timezone",
@@ -316,6 +317,17 @@ class CrontabYear(Crontab):
316
317
  )
317
318
 
318
319
 
320
+ class ReleaseEvent(BaseModel): # pragma: no cov
321
+ """Release trigger event."""
322
+
323
+ release: list[str] = Field(
324
+ description=(
325
+ "A list of workflow name that want to receive event from release"
326
+ "trigger."
327
+ )
328
+ )
329
+
330
+
319
331
  Event = Annotated[
320
332
  Union[
321
333
  CronJobYear,
@@ -4,7 +4,7 @@
4
4
  # license information.
5
5
  # ------------------------------------------------------------------------------
6
6
  # [x] Use dynamic config
7
- """Reusables module that keep any templating functions."""
7
+ """Reusables module that keep any template and template filter functions."""
8
8
  from __future__ import annotations
9
9
 
10
10
  import copy
@@ -14,7 +14,16 @@ from ast import Call, Constant, Expr, Module, Name, parse
14
14
  from datetime import datetime
15
15
  from functools import wraps
16
16
  from importlib import import_module
17
- from typing import Any, Callable, Optional, Protocol, TypeVar, Union
17
+ from typing import (
18
+ Any,
19
+ Callable,
20
+ Literal,
21
+ Optional,
22
+ Protocol,
23
+ TypeVar,
24
+ Union,
25
+ get_type_hints,
26
+ )
18
27
 
19
28
  try:
20
29
  from typing import ParamSpec
@@ -23,6 +32,7 @@ except ImportError:
23
32
 
24
33
  from ddeutil.core import getdot, import_string, lazy
25
34
  from ddeutil.io import search_env_replace
35
+ from pydantic import BaseModel, create_model
26
36
  from pydantic.dataclasses import dataclass
27
37
 
28
38
  from .__types import DictData, Re
@@ -32,7 +42,7 @@ from .exceptions import UtilException
32
42
  T = TypeVar("T")
33
43
  P = ParamSpec("P")
34
44
 
35
- logger = logging.getLogger("ddeutil.workflow")
45
+ # NOTE: Adjust logging level of the `asyncio` to INFO level.
36
46
  logging.getLogger("asyncio").setLevel(logging.INFO)
37
47
 
38
48
 
@@ -40,12 +50,14 @@ FILTERS: dict[str, Callable] = { # pragma: no cov
40
50
  "abs": abs,
41
51
  "str": str,
42
52
  "int": int,
53
+ "list": list,
54
+ "dict": dict,
43
55
  "title": lambda x: x.title(),
44
56
  "upper": lambda x: x.upper(),
45
57
  "lower": lambda x: x.lower(),
46
58
  "rstr": [str, repr],
47
- "keys": lambda x: list(x.keys()),
48
- "values": lambda x: list(x.values()),
59
+ "keys": lambda x: x.keys(),
60
+ "values": lambda x: x.values(),
49
61
  }
50
62
 
51
63
 
@@ -55,6 +67,7 @@ class FilterFunc(Protocol):
55
67
  """
56
68
 
57
69
  filter: str
70
+ mark: Literal["filter"] = "filter"
58
71
 
59
72
  def __call__(self, *args, **kwargs): ... # pragma: no cov
60
73
 
@@ -73,6 +86,7 @@ def custom_filter(name: str) -> Callable[P, FilterFunc]:
73
86
 
74
87
  def func_internal(func: Callable[[...], Any]) -> FilterFunc:
75
88
  func.filter = name
89
+ func.mark = "filter"
76
90
 
77
91
  @wraps(func)
78
92
  def wrapped(*args, **kwargs):
@@ -104,7 +118,10 @@ def make_filter_registry(
104
118
  for fstr, func in inspect.getmembers(importer, inspect.isfunction):
105
119
  # NOTE: check function attribute that already set tag by
106
120
  # ``utils.tag`` decorator.
107
- if not hasattr(func, "filter"):
121
+ if not (
122
+ hasattr(func, "filter")
123
+ and str(getattr(func, "mark", "NOT SET")) == "filter"
124
+ ):
108
125
  continue
109
126
 
110
127
  func: FilterFunc
@@ -209,11 +226,9 @@ def map_post_filter(
209
226
  value: T = func(value)
210
227
  else:
211
228
  value: T = f_func(value, *args, **kwargs)
212
- except UtilException as err:
213
- logger.warning(str(err))
229
+ except UtilException:
214
230
  raise
215
- except Exception as err:
216
- logger.warning(str(err))
231
+ except Exception:
217
232
  raise UtilException(
218
233
  f"The post-filter: {func_name!r} does not fit with {value!r} "
219
234
  f"(type: {type(value).__name__})."
@@ -226,6 +241,7 @@ def not_in_template(value: Any, *, not_in: str = "matrix.") -> bool:
226
241
 
227
242
  :param value: A value that want to find parameter template prefix.
228
243
  :param not_in: The not-in string that use in the `.startswith` function.
244
+ (Default is `matrix.`)
229
245
 
230
246
  :rtype: bool
231
247
  """
@@ -274,7 +290,7 @@ def str2template(
274
290
  :param value: (str) A string value that want to map with params.
275
291
  :param params: (DictData) A parameter value that getting with matched
276
292
  regular expression.
277
- :param filters: A mapping of filter registry.
293
+ :param filters: (dict[str, FilterRegistry]) A mapping of filter registry.
278
294
  :param registers: (Optional[list[str]]) Override list of register.
279
295
 
280
296
  :rtype: str
@@ -299,12 +315,14 @@ def str2template(
299
315
  ]
300
316
 
301
317
  # NOTE: from validate step, it guarantees that caller exists in params.
318
+ # I recommend to avoid logging params context on this case because it
319
+ # can include secret value.
302
320
  try:
303
321
  getter: Any = getdot(caller, params)
304
- except ValueError as err:
322
+ except ValueError:
305
323
  raise UtilException(
306
- f"Params does not set caller: {caller!r}."
307
- ) from err
324
+ f"Parameters does not get dot with caller: {caller!r}."
325
+ ) from None
308
326
 
309
327
  # NOTE:
310
328
  # If type of getter caller is not string type, and it does not use to
@@ -332,17 +350,18 @@ def param2template(
332
350
  filters: Optional[dict[str, FilterRegistry]] = None,
333
351
  *,
334
352
  extras: Optional[DictData] = None,
335
- ) -> T:
353
+ ) -> Any:
336
354
  """Pass param to template string that can search by ``RE_CALLER`` regular
337
355
  expression.
338
356
 
339
- :param value: A value that want to map with params
340
- :param params: A parameter value that getting with matched regular
341
- expression.
342
- :param filters: A filter mapping for mapping with `map_post_filter` func.
357
+ :param value: (Any) A value that want to map with params.
358
+ :param params: (DictData) A parameter value that getting with matched
359
+ regular expression.
360
+ :param filters: (dict[str, FilterRegistry]) A filter mapping for mapping
361
+ with `map_post_filter` func.
343
362
  :param extras: (Optional[list[str]]) An Override extras.
344
363
 
345
- :rtype: T
364
+ :rtype: Any
346
365
  :returns: An any getter value from the params input.
347
366
  """
348
367
  registers: Optional[list[str]] = (
@@ -369,6 +388,11 @@ def param2template(
369
388
  def datetime_format(value: datetime, fmt: str = "%Y-%m-%d %H:%M:%S") -> str:
370
389
  """Format datetime object to string with the format.
371
390
 
391
+ Examples:
392
+
393
+ >>> "${{ start-date | fmt('%Y%m%d') }}"
394
+ >>> "${{ start-date | fmt }}"
395
+
372
396
  :param value: (datetime) A datetime value that want to format to string
373
397
  value.
374
398
  :param fmt: (str) A format string pattern that passing to the `dt.strftime`
@@ -385,15 +409,68 @@ def datetime_format(value: datetime, fmt: str = "%Y-%m-%d %H:%M:%S") -> str:
385
409
 
386
410
  @custom_filter("coalesce") # pragma: no cov
387
411
  def coalesce(value: Optional[T], default: Any) -> T:
388
- """Coalesce with default value if the main value is None."""
412
+ """Coalesce with default value if the main value is None.
413
+
414
+ Examples:
415
+
416
+ >>> "${{ value | coalesce('foo') }}"
417
+
418
+ :param value: A value that want to check nullable.
419
+ :param default: A default value that use to returned value if an input
420
+ value was null.
421
+ """
389
422
  return default if value is None else value
390
423
 
391
424
 
425
+ @custom_filter("getitem") # pragma: no cov
426
+ def get_item(
427
+ value: DictData, key: Union[str, int], default: Optional[Any] = None
428
+ ) -> Any:
429
+ """Get a value with an input specific key.
430
+
431
+ Examples:
432
+
433
+ >>> "${{ value | getitem('key') }}"
434
+ >>> "${{ value | getitem('key', 'default') }}"
435
+
436
+ """
437
+ if not isinstance(value, dict):
438
+ raise UtilException(
439
+ f"The value that pass to `getitem` filter should be `dict` not "
440
+ f"`{type(value)}`."
441
+ )
442
+ return value.get(key, default)
443
+
444
+
445
+ @custom_filter("getindex") # pragma: no cov
446
+ def get_index(value: list[Any], index: int) -> Any:
447
+ """Get a value with an input specific index.
448
+
449
+ Examples:
450
+
451
+ >>> "${{ value | getindex(1) }}"
452
+
453
+ """
454
+ if not isinstance(value, list):
455
+ raise UtilException(
456
+ f"The value that pass to `getindex` filter should be `list` not "
457
+ f"`{type(value)}`."
458
+ )
459
+ try:
460
+ return value[index]
461
+ except IndexError as e:
462
+ raise UtilException(
463
+ f"Index: {index} is out of range of value (The maximum range is "
464
+ f"{len(value)})."
465
+ ) from e
466
+
467
+
392
468
  class TagFunc(Protocol):
393
469
  """Tag Function Protocol"""
394
470
 
395
471
  name: str
396
472
  tag: str
473
+ mark: Literal["tag"] = "tag"
397
474
 
398
475
  def __call__(self, *args, **kwargs): ... # pragma: no cov
399
476
 
@@ -421,6 +498,7 @@ def tag(
421
498
  def func_internal(func: Callable[[...], Any]) -> ReturnTagFunc:
422
499
  func.tag = name or "latest"
423
500
  func.name = alias or func.__name__.replace("_", "-")
501
+ func.mark = "tag"
424
502
 
425
503
  @wraps(func)
426
504
  def wrapped(*args: P.args, **kwargs: P.kwargs) -> TagFunc:
@@ -468,7 +546,9 @@ def make_registry(
468
546
  # NOTE: check function attribute that already set tag by
469
547
  # ``utils.tag`` decorator.
470
548
  if not (
471
- hasattr(func, "tag") and hasattr(func, "name")
549
+ hasattr(func, "tag")
550
+ and hasattr(func, "name")
551
+ and str(getattr(func, "mark", "NOT SET")) == "tag"
472
552
  ): # pragma: no cov
473
553
  continue
474
554
 
@@ -553,3 +633,32 @@ def extract_call(
553
633
  f"`REGISTER.{call.path}.registries.{call.func}`"
554
634
  )
555
635
  return rgt[call.func][call.tag]
636
+
637
+
638
+ def create_model_from_caller(func: Callable) -> BaseModel: # pragma: no cov
639
+ """Create model from the caller function. This function will use for
640
+ validate the caller function argument typed-hint that valid with the args
641
+ field.
642
+
643
+ :param func: A caller function.
644
+
645
+ :rtype: BaseModel
646
+ """
647
+ sig: inspect.Signature = inspect.signature(func)
648
+ type_hints: dict[str, Any] = get_type_hints(func)
649
+ fields: dict[str, Any] = {}
650
+ for name in sig.parameters:
651
+ param: inspect.Parameter = sig.parameters[name]
652
+ if param.kind in (
653
+ inspect.Parameter.VAR_KEYWORD,
654
+ inspect.Parameter.VAR_POSITIONAL,
655
+ ):
656
+ continue
657
+ if param.default != inspect.Parameter.empty:
658
+ fields[name] = (type_hints[name], param.default)
659
+ else:
660
+ fields[name] = (type_hints[name], ...)
661
+
662
+ return create_model(
663
+ "".join(i.title() for i in func.__name__.split("_")), **fields
664
+ )
@@ -60,7 +60,7 @@ from pydantic.functional_validators import model_validator
60
60
  from typing_extensions import Self
61
61
 
62
62
  from .__types import DictData, DictStr, StrOrInt, StrOrNone, TupleStr
63
- from .conf import dynamic
63
+ from .conf import dynamic, pass_env
64
64
  from .exceptions import StageException, to_dict
65
65
  from .result import CANCEL, FAILED, SUCCESS, WAIT, Result, Status
66
66
  from .reusables import TagFunc, extract_call, not_in_template, param2template
@@ -626,10 +626,10 @@ class BashStage(BaseAsyncStage):
626
626
  await f.write(f"#!/bin/{f_shebang}\n\n")
627
627
 
628
628
  # NOTE: add setting environment variable before bash skip statement.
629
- await f.writelines([f"{k}='{env[k]}';\n" for k in env])
629
+ await f.writelines(pass_env([f"{k}='{env[k]}';\n" for k in env]))
630
630
 
631
631
  # NOTE: make sure that shell script file does not have `\r` char.
632
- await f.write("\n" + bash.replace("\r\n", "\n"))
632
+ await f.write("\n" + pass_env(bash.replace("\r\n", "\n")))
633
633
 
634
634
  # NOTE: Make this .sh file able to executable.
635
635
  make_exec(f"./{f_name}")
@@ -662,10 +662,10 @@ class BashStage(BaseAsyncStage):
662
662
  f.write(f"#!/bin/{f_shebang}\n\n")
663
663
 
664
664
  # NOTE: add setting environment variable before bash skip statement.
665
- f.writelines([f"{k}='{env[k]}';\n" for k in env])
665
+ f.writelines(pass_env([f"{k}='{env[k]}';\n" for k in env]))
666
666
 
667
667
  # NOTE: make sure that shell script file does not have `\r` char.
668
- f.write("\n" + bash.replace("\r\n", "\n"))
668
+ f.write("\n" + pass_env(bash.replace("\r\n", "\n")))
669
669
 
670
670
  # NOTE: Make this .sh file able to executable.
671
671
  make_exec(f"./{f_name}")
@@ -895,7 +895,9 @@ class PyStage(BaseAsyncStage):
895
895
  # WARNING: The exec build-in function is very dangerous. So, it
896
896
  # should use the re module to validate exec-string before running.
897
897
  exec(
898
- param2template(dedent(self.run), params, extras=self.extras),
898
+ pass_env(
899
+ param2template(dedent(self.run), params, extras=self.extras)
900
+ ),
899
901
  gb,
900
902
  lc,
901
903
  )
@@ -1060,12 +1062,12 @@ class CallStage(BaseAsyncStage):
1060
1062
  args: DictData = {"result": result} | param2template(
1061
1063
  self.args, params, extras=self.extras
1062
1064
  )
1063
- ips = inspect.signature(call_func)
1065
+ sig = inspect.signature(call_func)
1064
1066
  necessary_params: list[str] = []
1065
1067
  has_keyword: bool = False
1066
- for k in ips.parameters:
1068
+ for k in sig.parameters:
1067
1069
  if (
1068
- v := ips.parameters[k]
1070
+ v := sig.parameters[k]
1069
1071
  ).default == Parameter.empty and v.kind not in (
1070
1072
  Parameter.VAR_KEYWORD,
1071
1073
  Parameter.VAR_POSITIONAL,
@@ -1083,7 +1085,7 @@ class CallStage(BaseAsyncStage):
1083
1085
  f"does not set to args, {list(args.keys())}."
1084
1086
  )
1085
1087
 
1086
- if "result" not in ips.parameters and not has_keyword:
1088
+ if "result" not in sig.parameters and not has_keyword:
1087
1089
  args.pop("result")
1088
1090
 
1089
1091
  args = self.parse_model_args(call_func, args, result)
@@ -1149,12 +1151,12 @@ class CallStage(BaseAsyncStage):
1149
1151
  args: DictData = {"result": result} | param2template(
1150
1152
  self.args, params, extras=self.extras
1151
1153
  )
1152
- ips = inspect.signature(call_func)
1154
+ sig = inspect.signature(call_func)
1153
1155
  necessary_params: list[str] = []
1154
1156
  has_keyword: bool = False
1155
- for k in ips.parameters:
1157
+ for k in sig.parameters:
1156
1158
  if (
1157
- v := ips.parameters[k]
1159
+ v := sig.parameters[k]
1158
1160
  ).default == Parameter.empty and v.kind not in (
1159
1161
  Parameter.VAR_KEYWORD,
1160
1162
  Parameter.VAR_POSITIONAL,
@@ -1172,7 +1174,7 @@ class CallStage(BaseAsyncStage):
1172
1174
  f"does not set to args, {list(args.keys())}."
1173
1175
  )
1174
1176
 
1175
- if "result" not in ips.parameters and not has_keyword:
1177
+ if "result" not in sig.parameters and not has_keyword:
1176
1178
  args.pop("result")
1177
1179
 
1178
1180
  args = self.parse_model_args(call_func, args, result)
@@ -1295,7 +1297,7 @@ class TriggerStage(BaseStage):
1295
1297
  _trigger: str = param2template(self.trigger, params, extras=self.extras)
1296
1298
  result.trace.info(f"[STAGE]: Execute Trigger-Stage: {_trigger!r}")
1297
1299
  rs: Result = Workflow.from_conf(
1298
- name=_trigger,
1300
+ name=pass_env(_trigger),
1299
1301
  extras=self.extras | {"stage_raise_error": True},
1300
1302
  ).execute(
1301
1303
  params=param2template(self.params, params, extras=self.extras),
@@ -2417,9 +2419,11 @@ class DockerStage(BaseStage): # pragma: no cov
2417
2419
  )
2418
2420
 
2419
2421
  resp = client.api.pull(
2420
- repository=f"{self.image}",
2421
- tag=self.tag,
2422
- auth_config=param2template(self.auth, params, extras=self.extras),
2422
+ repository=pass_env(self.image),
2423
+ tag=pass_env(self.tag),
2424
+ auth_config=pass_env(
2425
+ param2template(self.auth, params, extras=self.extras)
2426
+ ),
2423
2427
  stream=True,
2424
2428
  decode=True,
2425
2429
  )
@@ -2438,10 +2442,10 @@ class DockerStage(BaseStage): # pragma: no cov
2438
2442
 
2439
2443
  unique_image_name: str = f"{self.image}_{datetime.now():%Y%m%d%H%M%S%f}"
2440
2444
  container = client.containers.run(
2441
- image=f"{self.image}:{self.tag}",
2445
+ image=pass_env(f"{self.image}:{self.tag}"),
2442
2446
  name=unique_image_name,
2443
- environment=self.env,
2444
- volumes=(
2447
+ environment=pass_env(self.env),
2448
+ volumes=pass_env(
2445
2449
  {
2446
2450
  Path.cwd()
2447
2451
  / f".docker.{result.run_id}.logs": {
@@ -2549,8 +2553,10 @@ class VirtualPyStage(PyStage): # pragma: no cov
2549
2553
  f_name: str = f"{run_id}.py"
2550
2554
  with open(f"./{f_name}", mode="w", newline="\n") as f:
2551
2555
  # NOTE: Create variable mapping that write before running statement.
2552
- vars_str: str = "\n ".join(
2553
- f"{var} = {value!r}" for var, value in values.items()
2556
+ vars_str: str = pass_env(
2557
+ "\n ".join(
2558
+ f"{var} = {value!r}" for var, value in values.items()
2559
+ )
2554
2560
  )
2555
2561
 
2556
2562
  # NOTE: `uv` supports PEP 723 — inline TOML metadata.
@@ -2568,7 +2574,7 @@ class VirtualPyStage(PyStage): # pragma: no cov
2568
2574
  )
2569
2575
 
2570
2576
  # NOTE: make sure that py script file does not have `\r` char.
2571
- f.write("\n" + py.replace("\r\n", "\n"))
2577
+ f.write("\n" + pass_env(py.replace("\r\n", "\n")))
2572
2578
 
2573
2579
  # NOTE: Make this .py file able to executable.
2574
2580
  make_exec(f"./{f_name}")
@@ -774,7 +774,7 @@ class Workflow(BaseModel):
774
774
  parent_run_id: Optional[str] = None,
775
775
  result: Optional[Result] = None,
776
776
  event: Optional[Event] = None,
777
- timeout: int = 3600,
777
+ timeout: float = 3600,
778
778
  max_job_parallel: int = 2,
779
779
  ) -> Result:
780
780
  """Execute workflow with passing a dynamic parameters to all jobs that
@@ -805,10 +805,10 @@ class Workflow(BaseModel):
805
805
  :param result: (Result) A Result instance for return context and status.
806
806
  :param event: (Event) An Event manager instance that use to cancel this
807
807
  execution if it forces stopped by parent execution.
808
- :param timeout: (int) A workflow execution time out in second unit that
809
- use for limit time of execution and waiting job dependency. This
810
- value does not force stop the task that still running more than this
811
- limit time. (Default: 60 * 60 seconds)
808
+ :param timeout: (float) A workflow execution time out in second unit
809
+ that use for limit time of execution and waiting job dependency.
810
+ This value does not force stop the task that still running more than
811
+ this limit time. (Default: 60 * 60 seconds)
812
812
  :param max_job_parallel: (int) The maximum workers that use for job
813
813
  execution in `PoolThreadExecutor` object. (Default: 2 workers)
814
814
 
@@ -840,7 +840,7 @@ class Workflow(BaseModel):
840
840
  job_queue.put(job_id)
841
841
 
842
842
  not_timeout_flag: bool = True
843
- timeout: int = dynamic(
843
+ timeout: float = dynamic(
844
844
  "max_job_exec_timeout", f=timeout, extras=self.extras
845
845
  )
846
846
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ddeutil-workflow
3
- Version: 0.0.61
3
+ Version: 0.0.63
4
4
  Summary: Lightweight workflow orchestration
5
5
  Author-email: ddeutils <korawich.anu@gmail.com>
6
6
  License: MIT
@@ -25,6 +25,7 @@ License-File: LICENSE
25
25
  Requires-Dist: ddeutil[checksum]>=0.4.8
26
26
  Requires-Dist: ddeutil-io[toml,yaml]>=0.2.13
27
27
  Requires-Dist: pydantic==2.11.4
28
+ Requires-Dist: pydantic-extra-types==2.10.4
28
29
  Requires-Dist: python-dotenv==1.1.0
29
30
  Requires-Dist: schedule<2.0.0,==1.2.2
30
31
  Provides-Extra: all
@@ -215,19 +216,23 @@ registry-caller/
215
216
  This function will store as module that will import from `WORKFLOW_CORE_REGISTRY_CALLER`
216
217
  value (This config can override by extra parameters with `registry_caller` key).
217
218
 
219
+ > [!NOTE]
220
+ > You can use Pydantic Model as argument of your caller function. The core workflow
221
+ > engine will auto use the `model_validate` method before run your caller function.
222
+
218
223
  ```python
219
- from ddeutil.workflow import Result, tag
224
+ from ddeutil.workflow import Result, WorkflowSecret, tag
220
225
  from ddeutil.workflow.exceptions import StageException
221
- from pydantic import BaseModel, SecretStr
226
+ from pydantic import BaseModel
222
227
 
223
228
  class AwsCredential(BaseModel):
224
229
  path: str
225
230
  access_client_id: str
226
- access_client_secret: SecretStr
231
+ access_client_secret: WorkflowSecret
227
232
 
228
233
  class RestAuth(BaseModel):
229
234
  type: str
230
- keys: SecretStr
235
+ keys: WorkflowSecret
231
236
 
232
237
  @tag("requests", alias="get-api-with-oauth-to-s3")
233
238
  def get_api_with_oauth_to_s3(
@@ -243,6 +248,7 @@ def get_api_with_oauth_to_s3(
243
248
  result.trace.info(f"... {method}: {url}")
244
249
  if method != "post":
245
250
  raise StageException(f"RestAPI does not support for {method} action.")
251
+ # NOTE: If you want to use secret, you can use `auth.keys.get_secret_value()`.
246
252
  return {"records": 1000}
247
253
  ```
248
254
 
@@ -1,20 +1,20 @@
1
- ddeutil/workflow/__about__.py,sha256=MeIsfJkRHwfi0J3-dkYVf-U5ewlNB1-grkzIr3wMoa0,28
2
- ddeutil/workflow/__cron.py,sha256=5DHQKejG-76L_oREW78RcwMzeyKddJxSMmBzYyMAeeY,28536
3
- ddeutil/workflow/__init__.py,sha256=NXEhjzKFdIGa-jtIq9HXChLCjSXNPd8VJ8ltggxbBO8,1371
1
+ ddeutil/workflow/__about__.py,sha256=xnUKKC8j_qfLMiPiNf3P8eAETFo17HtTgo2_-q1Nkvw,28
2
+ ddeutil/workflow/__cron.py,sha256=BOKQcreiex0SAigrK1gnLxpvOeF3aca_rQwyz9Kfve4,28751
3
+ ddeutil/workflow/__init__.py,sha256=4QIAmK-p6Oex3VPrFCSwo8brhpL7_mEF3AQQlZdTqvs,1030
4
4
  ddeutil/workflow/__main__.py,sha256=x-sYedl4T8p6054aySk-EQX6vhytvPR0HvaBNYxMzp0,364
5
5
  ddeutil/workflow/__types.py,sha256=uNfoRbVmNK5O37UUMVnqcmoghD9oMS1q9fXC0APnjSI,4584
6
- ddeutil/workflow/conf.py,sha256=NLvjZ8bpDsn4e0MG3m1vgMdAwtmii5hP1D0STKQyZeo,14907
7
- ddeutil/workflow/event.py,sha256=ATQhCgx4F3I2SPQesXxLgOREEGtwkX6uc6jjViQ5pQg,10716
6
+ ddeutil/workflow/conf.py,sha256=kjjFZmBK7SzSpQnMJ0KKzB2I0Zcn4oGA6uLdHRRroj0,15828
7
+ ddeutil/workflow/event.py,sha256=S2eJAZZx_V5TuQ0l417hFVCtjWXnfNPZBgSCICzxQ48,11041
8
8
  ddeutil/workflow/exceptions.py,sha256=TKHBIlfquz3yEb8_kg6UXpxVLKxstt3QA9a1XYsLPJk,2455
9
9
  ddeutil/workflow/job.py,sha256=Php1b3n6c-jddel8PTSa61kAW22QBTetzoLVR4XXM4E,35240
10
10
  ddeutil/workflow/logs.py,sha256=iVtyl8i69y7t07tAuWkihc54WlkHCcBy_Ur0WtzJ_lM,31367
11
11
  ddeutil/workflow/params.py,sha256=1u8gXs1ZyMq-2eD9H8L7Yjfu5t7b_OzjA0fJvhxdYWY,12505
12
12
  ddeutil/workflow/result.py,sha256=4M9VCcveI8Yz6ZrnI-67SZlry-Z8G7e0hziy1k-pklk,5906
13
- ddeutil/workflow/reusables.py,sha256=JIXuAicRXhGuocQy71C7pjK4BTl2wo9lNK2p-zhEA6M,17743
13
+ ddeutil/workflow/reusables.py,sha256=gbSHUptdEar5HSfBx13ldHJw0IBGF6BShQB3XpPd3Wg,20812
14
14
  ddeutil/workflow/scheduler.py,sha256=OsEyj2zscQ-3bDMk2z7UtKlCWLlgoGjaRFt17o1B1ew,27263
15
- ddeutil/workflow/stages.py,sha256=xH_f7IRohFCnUrtyD-QW86BlJ72p64JmpRTRrLPoF6A,93241
15
+ ddeutil/workflow/stages.py,sha256=4JBcgXxxpDfwUFob30ocWwVqoxuk7t-WoDlj8dh8Dqs,93482
16
16
  ddeutil/workflow/utils.py,sha256=rcaDwXaEs4SCdcBKWx4ZCEtpnNfPI8du7Er6b_rg8t4,9569
17
- ddeutil/workflow/workflow.py,sha256=ty4PUhci7YCPlsNCH8qsaxbzHmBliCVmXczpwlAy_mk,44852
17
+ ddeutil/workflow/workflow.py,sha256=8Z_h8OtNHkaGf8MJixTHNeXsyA4mBlYtHDqj0oEVFBs,44858
18
18
  ddeutil/workflow/api/__init__.py,sha256=kY30dL8HPY8tY_GBmm7y_3OdoXzB1-EA2a96PLU0AQw,5278
19
19
  ddeutil/workflow/api/logs.py,sha256=NMTnOnsBrDB5129329xF2myLdrb-z9k1MQrmrP7qXJw,1818
20
20
  ddeutil/workflow/api/utils.py,sha256=uTtUFVLpiYYahXvCVx8sueRQ03K2Xw1id_gW3IMmX1U,5295
@@ -23,9 +23,9 @@ ddeutil/workflow/api/routes/job.py,sha256=8X5VLDJH6PumyNIY6JGRNBsf2gWN0eG9DzxRPS
23
23
  ddeutil/workflow/api/routes/logs.py,sha256=U6vOni3wd-ZTOwd3yVdSOpgyRmNdcgfngU5KlLM3Cww,5383
24
24
  ddeutil/workflow/api/routes/schedules.py,sha256=14RnaJKEGMSJtncI1H_QQVZNBe_jDS40PPRO6qFc3i0,4805
25
25
  ddeutil/workflow/api/routes/workflows.py,sha256=GJu5PiXEylswrXylEImpncySjeU9chrvrtjhiMCw2RQ,4529
26
- ddeutil_workflow-0.0.61.dist-info/licenses/LICENSE,sha256=nGFZ1QEhhhWeMHf9n99_fdt4vQaXS29xWKxt-OcLywk,1085
27
- ddeutil_workflow-0.0.61.dist-info/METADATA,sha256=LO7R_mpY7OkPg4J6epm2MwWn4qaI2HIMhTqw7T_LEhU,19427
28
- ddeutil_workflow-0.0.61.dist-info/WHEEL,sha256=DnLRTWE75wApRYVsjgc6wsVswC54sMSJhAEd4xhDpBk,91
29
- ddeutil_workflow-0.0.61.dist-info/entry_points.txt,sha256=qDTpPSauL0ciO6T4iSVt8bJeYrVEkkoEEw_RlGx6Kgk,63
30
- ddeutil_workflow-0.0.61.dist-info/top_level.txt,sha256=m9M6XeSWDwt_yMsmH6gcOjHZVK5O0-vgtNBuncHjzW4,8
31
- ddeutil_workflow-0.0.61.dist-info/RECORD,,
26
+ ddeutil_workflow-0.0.63.dist-info/licenses/LICENSE,sha256=nGFZ1QEhhhWeMHf9n99_fdt4vQaXS29xWKxt-OcLywk,1085
27
+ ddeutil_workflow-0.0.63.dist-info/METADATA,sha256=9h5mIv3IdcYoOG9Qaz02PgCm2KOJqjkDCS09WjDCAUo,19748
28
+ ddeutil_workflow-0.0.63.dist-info/WHEEL,sha256=DnLRTWE75wApRYVsjgc6wsVswC54sMSJhAEd4xhDpBk,91
29
+ ddeutil_workflow-0.0.63.dist-info/entry_points.txt,sha256=qDTpPSauL0ciO6T4iSVt8bJeYrVEkkoEEw_RlGx6Kgk,63
30
+ ddeutil_workflow-0.0.63.dist-info/top_level.txt,sha256=m9M6XeSWDwt_yMsmH6gcOjHZVK5O0-vgtNBuncHjzW4,8
31
+ ddeutil_workflow-0.0.63.dist-info/RECORD,,