ddeutil-workflow 0.0.61__tar.gz → 0.0.63__tar.gz

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.
Files changed (68) hide show
  1. {ddeutil_workflow-0.0.61 → ddeutil_workflow-0.0.63}/PKG-INFO +11 -5
  2. {ddeutil_workflow-0.0.61 → ddeutil_workflow-0.0.63}/README.md +9 -4
  3. {ddeutil_workflow-0.0.61 → ddeutil_workflow-0.0.63}/pyproject.toml +1 -0
  4. ddeutil_workflow-0.0.63/src/ddeutil/workflow/__about__.py +1 -0
  5. {ddeutil_workflow-0.0.61 → ddeutil_workflow-0.0.63}/src/ddeutil/workflow/__cron.py +24 -25
  6. {ddeutil_workflow-0.0.61 → ddeutil_workflow-0.0.63}/src/ddeutil/workflow/__init__.py +2 -24
  7. {ddeutil_workflow-0.0.61 → ddeutil_workflow-0.0.63}/src/ddeutil/workflow/conf.py +36 -4
  8. {ddeutil_workflow-0.0.61 → ddeutil_workflow-0.0.63}/src/ddeutil/workflow/event.py +13 -1
  9. {ddeutil_workflow-0.0.61 → ddeutil_workflow-0.0.63}/src/ddeutil/workflow/reusables.py +131 -22
  10. {ddeutil_workflow-0.0.61 → ddeutil_workflow-0.0.63}/src/ddeutil/workflow/stages.py +30 -24
  11. {ddeutil_workflow-0.0.61 → ddeutil_workflow-0.0.63}/src/ddeutil/workflow/workflow.py +6 -6
  12. {ddeutil_workflow-0.0.61 → ddeutil_workflow-0.0.63}/src/ddeutil_workflow.egg-info/PKG-INFO +11 -5
  13. {ddeutil_workflow-0.0.61 → ddeutil_workflow-0.0.63}/src/ddeutil_workflow.egg-info/requires.txt +1 -0
  14. {ddeutil_workflow-0.0.61 → ddeutil_workflow-0.0.63}/tests/test__cron.py +36 -28
  15. {ddeutil_workflow-0.0.61 → ddeutil_workflow-0.0.63}/tests/test_conf.py +16 -4
  16. {ddeutil_workflow-0.0.61 → ddeutil_workflow-0.0.63}/tests/test_reusables_call_tag.py +77 -2
  17. {ddeutil_workflow-0.0.61 → ddeutil_workflow-0.0.63}/tests/test_reusables_template.py +76 -0
  18. {ddeutil_workflow-0.0.61 → ddeutil_workflow-0.0.63}/tests/test_reusables_template_filter.py +8 -2
  19. {ddeutil_workflow-0.0.61 → ddeutil_workflow-0.0.63}/tests/test_workflow_exec.py +4 -2
  20. ddeutil_workflow-0.0.61/src/ddeutil/workflow/__about__.py +0 -1
  21. {ddeutil_workflow-0.0.61 → ddeutil_workflow-0.0.63}/LICENSE +0 -0
  22. {ddeutil_workflow-0.0.61 → ddeutil_workflow-0.0.63}/setup.cfg +0 -0
  23. {ddeutil_workflow-0.0.61 → ddeutil_workflow-0.0.63}/src/ddeutil/workflow/__main__.py +0 -0
  24. {ddeutil_workflow-0.0.61 → ddeutil_workflow-0.0.63}/src/ddeutil/workflow/__types.py +0 -0
  25. {ddeutil_workflow-0.0.61 → ddeutil_workflow-0.0.63}/src/ddeutil/workflow/api/__init__.py +0 -0
  26. {ddeutil_workflow-0.0.61 → ddeutil_workflow-0.0.63}/src/ddeutil/workflow/api/logs.py +0 -0
  27. {ddeutil_workflow-0.0.61 → ddeutil_workflow-0.0.63}/src/ddeutil/workflow/api/routes/__init__.py +0 -0
  28. {ddeutil_workflow-0.0.61 → ddeutil_workflow-0.0.63}/src/ddeutil/workflow/api/routes/job.py +0 -0
  29. {ddeutil_workflow-0.0.61 → ddeutil_workflow-0.0.63}/src/ddeutil/workflow/api/routes/logs.py +0 -0
  30. {ddeutil_workflow-0.0.61 → ddeutil_workflow-0.0.63}/src/ddeutil/workflow/api/routes/schedules.py +0 -0
  31. {ddeutil_workflow-0.0.61 → ddeutil_workflow-0.0.63}/src/ddeutil/workflow/api/routes/workflows.py +0 -0
  32. {ddeutil_workflow-0.0.61 → ddeutil_workflow-0.0.63}/src/ddeutil/workflow/api/utils.py +0 -0
  33. {ddeutil_workflow-0.0.61 → ddeutil_workflow-0.0.63}/src/ddeutil/workflow/exceptions.py +0 -0
  34. {ddeutil_workflow-0.0.61 → ddeutil_workflow-0.0.63}/src/ddeutil/workflow/job.py +0 -0
  35. {ddeutil_workflow-0.0.61 → ddeutil_workflow-0.0.63}/src/ddeutil/workflow/logs.py +0 -0
  36. {ddeutil_workflow-0.0.61 → ddeutil_workflow-0.0.63}/src/ddeutil/workflow/params.py +0 -0
  37. {ddeutil_workflow-0.0.61 → ddeutil_workflow-0.0.63}/src/ddeutil/workflow/result.py +0 -0
  38. {ddeutil_workflow-0.0.61 → ddeutil_workflow-0.0.63}/src/ddeutil/workflow/scheduler.py +0 -0
  39. {ddeutil_workflow-0.0.61 → ddeutil_workflow-0.0.63}/src/ddeutil/workflow/utils.py +0 -0
  40. {ddeutil_workflow-0.0.61 → ddeutil_workflow-0.0.63}/src/ddeutil_workflow.egg-info/SOURCES.txt +0 -0
  41. {ddeutil_workflow-0.0.61 → ddeutil_workflow-0.0.63}/src/ddeutil_workflow.egg-info/dependency_links.txt +0 -0
  42. {ddeutil_workflow-0.0.61 → ddeutil_workflow-0.0.63}/src/ddeutil_workflow.egg-info/entry_points.txt +0 -0
  43. {ddeutil_workflow-0.0.61 → ddeutil_workflow-0.0.63}/src/ddeutil_workflow.egg-info/top_level.txt +0 -0
  44. {ddeutil_workflow-0.0.61 → ddeutil_workflow-0.0.63}/tests/test__regex.py +0 -0
  45. {ddeutil_workflow-0.0.61 → ddeutil_workflow-0.0.63}/tests/test_event.py +0 -0
  46. {ddeutil_workflow-0.0.61 → ddeutil_workflow-0.0.63}/tests/test_job.py +0 -0
  47. {ddeutil_workflow-0.0.61 → ddeutil_workflow-0.0.63}/tests/test_job_exec.py +0 -0
  48. {ddeutil_workflow-0.0.61 → ddeutil_workflow-0.0.63}/tests/test_job_exec_strategy.py +0 -0
  49. {ddeutil_workflow-0.0.61 → ddeutil_workflow-0.0.63}/tests/test_logs_audit.py +0 -0
  50. {ddeutil_workflow-0.0.61 → ddeutil_workflow-0.0.63}/tests/test_logs_trace.py +0 -0
  51. {ddeutil_workflow-0.0.61 → ddeutil_workflow-0.0.63}/tests/test_params.py +0 -0
  52. {ddeutil_workflow-0.0.61 → ddeutil_workflow-0.0.63}/tests/test_release.py +0 -0
  53. {ddeutil_workflow-0.0.61 → ddeutil_workflow-0.0.63}/tests/test_release_queue.py +0 -0
  54. {ddeutil_workflow-0.0.61 → ddeutil_workflow-0.0.63}/tests/test_result.py +0 -0
  55. {ddeutil_workflow-0.0.61 → ddeutil_workflow-0.0.63}/tests/test_schedule.py +0 -0
  56. {ddeutil_workflow-0.0.61 → ddeutil_workflow-0.0.63}/tests/test_schedule_pending.py +0 -0
  57. {ddeutil_workflow-0.0.61 → ddeutil_workflow-0.0.63}/tests/test_schedule_tasks.py +0 -0
  58. {ddeutil_workflow-0.0.61 → ddeutil_workflow-0.0.63}/tests/test_schedule_workflow.py +0 -0
  59. {ddeutil_workflow-0.0.61 → ddeutil_workflow-0.0.63}/tests/test_scheduler_control.py +0 -0
  60. {ddeutil_workflow-0.0.61 → ddeutil_workflow-0.0.63}/tests/test_stage.py +0 -0
  61. {ddeutil_workflow-0.0.61 → ddeutil_workflow-0.0.63}/tests/test_stage_handler_exec.py +0 -0
  62. {ddeutil_workflow-0.0.61 → ddeutil_workflow-0.0.63}/tests/test_strategy.py +0 -0
  63. {ddeutil_workflow-0.0.61 → ddeutil_workflow-0.0.63}/tests/test_utils.py +0 -0
  64. {ddeutil_workflow-0.0.61 → ddeutil_workflow-0.0.63}/tests/test_workflow.py +0 -0
  65. {ddeutil_workflow-0.0.61 → ddeutil_workflow-0.0.63}/tests/test_workflow_exec_job.py +0 -0
  66. {ddeutil_workflow-0.0.61 → ddeutil_workflow-0.0.63}/tests/test_workflow_poke.py +0 -0
  67. {ddeutil_workflow-0.0.61 → ddeutil_workflow-0.0.63}/tests/test_workflow_release.py +0 -0
  68. {ddeutil_workflow-0.0.61 → ddeutil_workflow-0.0.63}/tests/test_workflow_task.py +0 -0
@@ -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
 
@@ -165,19 +165,23 @@ registry-caller/
165
165
  This function will store as module that will import from `WORKFLOW_CORE_REGISTRY_CALLER`
166
166
  value (This config can override by extra parameters with `registry_caller` key).
167
167
 
168
+ > [!NOTE]
169
+ > You can use Pydantic Model as argument of your caller function. The core workflow
170
+ > engine will auto use the `model_validate` method before run your caller function.
171
+
168
172
  ```python
169
- from ddeutil.workflow import Result, tag
173
+ from ddeutil.workflow import Result, WorkflowSecret, tag
170
174
  from ddeutil.workflow.exceptions import StageException
171
- from pydantic import BaseModel, SecretStr
175
+ from pydantic import BaseModel
172
176
 
173
177
  class AwsCredential(BaseModel):
174
178
  path: str
175
179
  access_client_id: str
176
- access_client_secret: SecretStr
180
+ access_client_secret: WorkflowSecret
177
181
 
178
182
  class RestAuth(BaseModel):
179
183
  type: str
180
- keys: SecretStr
184
+ keys: WorkflowSecret
181
185
 
182
186
  @tag("requests", alias="get-api-with-oauth-to-s3")
183
187
  def get_api_with_oauth_to_s3(
@@ -193,6 +197,7 @@ def get_api_with_oauth_to_s3(
193
197
  result.trace.info(f"... {method}: {url}")
194
198
  if method != "post":
195
199
  raise StageException(f"RestAPI does not support for {method} action.")
200
+ # NOTE: If you want to use secret, you can use `auth.keys.get_secret_value()`.
196
201
  return {"records": 1000}
197
202
  ```
198
203
 
@@ -28,6 +28,7 @@ dependencies = [
28
28
  "ddeutil[checksum]>=0.4.8",
29
29
  "ddeutil-io[yaml,toml]>=0.2.13",
30
30
  "pydantic==2.11.4",
31
+ "pydantic-extra-types==2.10.4",
31
32
  "python-dotenv==1.1.0",
32
33
  "schedule==1.2.2,<2.0.0",
33
34
  ]
@@ -0,0 +1 @@
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,
@@ -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())
@@ -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
+ )