ddeutil-workflow 0.0.45__tar.gz → 0.0.47__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.45 → ddeutil_workflow-0.0.47}/PKG-INFO +1 -1
  2. {ddeutil_workflow-0.0.45 → ddeutil_workflow-0.0.47}/pyproject.toml +0 -1
  3. ddeutil_workflow-0.0.47/src/ddeutil/workflow/__about__.py +1 -0
  4. ddeutil_workflow-0.0.47/src/ddeutil/workflow/__main__.py +0 -0
  5. {ddeutil_workflow-0.0.45 → ddeutil_workflow-0.0.47}/src/ddeutil/workflow/conf.py +6 -24
  6. {ddeutil_workflow-0.0.45 → ddeutil_workflow-0.0.47}/src/ddeutil/workflow/job.py +3 -0
  7. {ddeutil_workflow-0.0.45 → ddeutil_workflow-0.0.47}/src/ddeutil/workflow/result.py +10 -15
  8. {ddeutil_workflow-0.0.45 → ddeutil_workflow-0.0.47}/src/ddeutil/workflow/reusables.py +4 -3
  9. {ddeutil_workflow-0.0.45 → ddeutil_workflow-0.0.47}/src/ddeutil/workflow/stages.py +80 -27
  10. {ddeutil_workflow-0.0.45 → ddeutil_workflow-0.0.47}/src/ddeutil/workflow/workflow.py +2 -2
  11. {ddeutil_workflow-0.0.45 → ddeutil_workflow-0.0.47}/src/ddeutil_workflow.egg-info/PKG-INFO +1 -1
  12. {ddeutil_workflow-0.0.45 → ddeutil_workflow-0.0.47}/src/ddeutil_workflow.egg-info/SOURCES.txt +1 -0
  13. {ddeutil_workflow-0.0.45 → ddeutil_workflow-0.0.47}/tests/test_reusables_call_tag.py +92 -2
  14. {ddeutil_workflow-0.0.45 → ddeutil_workflow-0.0.47}/tests/test_stage_handler_exec.py +19 -5
  15. {ddeutil_workflow-0.0.45 → ddeutil_workflow-0.0.47}/tests/test_workflow_exec.py +52 -1
  16. ddeutil_workflow-0.0.45/src/ddeutil/workflow/__about__.py +0 -1
  17. {ddeutil_workflow-0.0.45 → ddeutil_workflow-0.0.47}/LICENSE +0 -0
  18. {ddeutil_workflow-0.0.45 → ddeutil_workflow-0.0.47}/README.md +0 -0
  19. {ddeutil_workflow-0.0.45 → ddeutil_workflow-0.0.47}/setup.cfg +0 -0
  20. {ddeutil_workflow-0.0.45 → ddeutil_workflow-0.0.47}/src/ddeutil/workflow/__cron.py +0 -0
  21. {ddeutil_workflow-0.0.45 → ddeutil_workflow-0.0.47}/src/ddeutil/workflow/__init__.py +0 -0
  22. {ddeutil_workflow-0.0.45 → ddeutil_workflow-0.0.47}/src/ddeutil/workflow/__types.py +0 -0
  23. {ddeutil_workflow-0.0.45 → ddeutil_workflow-0.0.47}/src/ddeutil/workflow/api/__init__.py +0 -0
  24. {ddeutil_workflow-0.0.45 → ddeutil_workflow-0.0.47}/src/ddeutil/workflow/api/api.py +0 -0
  25. {ddeutil_workflow-0.0.45 → ddeutil_workflow-0.0.47}/src/ddeutil/workflow/api/log.py +0 -0
  26. {ddeutil_workflow-0.0.45 → ddeutil_workflow-0.0.47}/src/ddeutil/workflow/api/repeat.py +0 -0
  27. {ddeutil_workflow-0.0.45 → ddeutil_workflow-0.0.47}/src/ddeutil/workflow/api/routes/__init__.py +0 -0
  28. {ddeutil_workflow-0.0.45 → ddeutil_workflow-0.0.47}/src/ddeutil/workflow/api/routes/job.py +0 -0
  29. {ddeutil_workflow-0.0.45 → ddeutil_workflow-0.0.47}/src/ddeutil/workflow/api/routes/logs.py +0 -0
  30. {ddeutil_workflow-0.0.45 → ddeutil_workflow-0.0.47}/src/ddeutil/workflow/api/routes/schedules.py +0 -0
  31. {ddeutil_workflow-0.0.45 → ddeutil_workflow-0.0.47}/src/ddeutil/workflow/api/routes/workflows.py +0 -0
  32. {ddeutil_workflow-0.0.45 → ddeutil_workflow-0.0.47}/src/ddeutil/workflow/cron.py +0 -0
  33. {ddeutil_workflow-0.0.45 → ddeutil_workflow-0.0.47}/src/ddeutil/workflow/exceptions.py +0 -0
  34. {ddeutil_workflow-0.0.45 → ddeutil_workflow-0.0.47}/src/ddeutil/workflow/logs.py +0 -0
  35. {ddeutil_workflow-0.0.45 → ddeutil_workflow-0.0.47}/src/ddeutil/workflow/params.py +0 -0
  36. {ddeutil_workflow-0.0.45 → ddeutil_workflow-0.0.47}/src/ddeutil/workflow/scheduler.py +0 -0
  37. {ddeutil_workflow-0.0.45 → ddeutil_workflow-0.0.47}/src/ddeutil/workflow/utils.py +0 -0
  38. {ddeutil_workflow-0.0.45 → ddeutil_workflow-0.0.47}/src/ddeutil_workflow.egg-info/dependency_links.txt +0 -0
  39. {ddeutil_workflow-0.0.45 → ddeutil_workflow-0.0.47}/src/ddeutil_workflow.egg-info/requires.txt +0 -0
  40. {ddeutil_workflow-0.0.45 → ddeutil_workflow-0.0.47}/src/ddeutil_workflow.egg-info/top_level.txt +0 -0
  41. {ddeutil_workflow-0.0.45 → ddeutil_workflow-0.0.47}/tests/test__cron.py +0 -0
  42. {ddeutil_workflow-0.0.45 → ddeutil_workflow-0.0.47}/tests/test__regex.py +0 -0
  43. {ddeutil_workflow-0.0.45 → ddeutil_workflow-0.0.47}/tests/test_conf.py +0 -0
  44. {ddeutil_workflow-0.0.45 → ddeutil_workflow-0.0.47}/tests/test_cron_on.py +0 -0
  45. {ddeutil_workflow-0.0.45 → ddeutil_workflow-0.0.47}/tests/test_job.py +0 -0
  46. {ddeutil_workflow-0.0.45 → ddeutil_workflow-0.0.47}/tests/test_job_exec.py +0 -0
  47. {ddeutil_workflow-0.0.45 → ddeutil_workflow-0.0.47}/tests/test_job_exec_strategy.py +0 -0
  48. {ddeutil_workflow-0.0.45 → ddeutil_workflow-0.0.47}/tests/test_job_strategy.py +0 -0
  49. {ddeutil_workflow-0.0.45 → ddeutil_workflow-0.0.47}/tests/test_logs_audit.py +0 -0
  50. {ddeutil_workflow-0.0.45 → ddeutil_workflow-0.0.47}/tests/test_logs_trace.py +0 -0
  51. {ddeutil_workflow-0.0.45 → ddeutil_workflow-0.0.47}/tests/test_params.py +0 -0
  52. {ddeutil_workflow-0.0.45 → ddeutil_workflow-0.0.47}/tests/test_release.py +0 -0
  53. {ddeutil_workflow-0.0.45 → ddeutil_workflow-0.0.47}/tests/test_release_queue.py +0 -0
  54. {ddeutil_workflow-0.0.45 → ddeutil_workflow-0.0.47}/tests/test_result.py +0 -0
  55. {ddeutil_workflow-0.0.45 → ddeutil_workflow-0.0.47}/tests/test_reusables_template.py +0 -0
  56. {ddeutil_workflow-0.0.45 → ddeutil_workflow-0.0.47}/tests/test_reusables_template_filter.py +0 -0
  57. {ddeutil_workflow-0.0.45 → ddeutil_workflow-0.0.47}/tests/test_schedule.py +0 -0
  58. {ddeutil_workflow-0.0.45 → ddeutil_workflow-0.0.47}/tests/test_schedule_pending.py +0 -0
  59. {ddeutil_workflow-0.0.45 → ddeutil_workflow-0.0.47}/tests/test_schedule_tasks.py +0 -0
  60. {ddeutil_workflow-0.0.45 → ddeutil_workflow-0.0.47}/tests/test_schedule_workflow.py +0 -0
  61. {ddeutil_workflow-0.0.45 → ddeutil_workflow-0.0.47}/tests/test_scheduler_control.py +0 -0
  62. {ddeutil_workflow-0.0.45 → ddeutil_workflow-0.0.47}/tests/test_stage.py +0 -0
  63. {ddeutil_workflow-0.0.45 → ddeutil_workflow-0.0.47}/tests/test_utils.py +0 -0
  64. {ddeutil_workflow-0.0.45 → ddeutil_workflow-0.0.47}/tests/test_workflow.py +0 -0
  65. {ddeutil_workflow-0.0.45 → ddeutil_workflow-0.0.47}/tests/test_workflow_exec_job.py +0 -0
  66. {ddeutil_workflow-0.0.45 → ddeutil_workflow-0.0.47}/tests/test_workflow_exec_poke.py +0 -0
  67. {ddeutil_workflow-0.0.45 → ddeutil_workflow-0.0.47}/tests/test_workflow_exec_release.py +0 -0
  68. {ddeutil_workflow-0.0.45 → ddeutil_workflow-0.0.47}/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.45
3
+ Version: 0.0.47
4
4
  Summary: Lightweight workflow orchestration
5
5
  Author-email: ddeutils <korawich.anu@gmail.com>
6
6
  License: MIT
@@ -13,7 +13,6 @@ classifiers = [
13
13
  "Topic :: Utilities",
14
14
  "Natural Language :: English",
15
15
  "Development Status :: 4 - Beta",
16
- # "Development Status :: 5 - Production/Stable",
17
16
  "Intended Audience :: Developers",
18
17
  "Operating System :: OS Independent",
19
18
  "Programming Language :: Python",
@@ -0,0 +1 @@
1
+ __version__: str = "0.0.47"
@@ -26,7 +26,10 @@ PREFIX: str = "WORKFLOW"
26
26
 
27
27
 
28
28
  def env(var: str, default: str | None = None) -> str | None: # pragma: no cov
29
- """Get environment variable with uppercase and adding prefix string."""
29
+ """Get environment variable with uppercase and adding prefix string.
30
+
31
+ :rtype: str | None
32
+ """
30
33
  return os.getenv(f"{PREFIX}_{var.upper().replace(' ', '_')}", default)
31
34
 
32
35
 
@@ -42,29 +45,7 @@ __all__: TupleStr = (
42
45
  )
43
46
 
44
47
 
45
- class BaseConfig: # pragma: no cov
46
- """BaseConfig object inheritable."""
47
-
48
- __slots__ = ()
49
-
50
- @property
51
- def root_path(self) -> Path:
52
- """Root path or the project path.
53
-
54
- :rtype: Path
55
- """
56
- return Path(os.getenv("ROOT_PATH", "."))
57
-
58
- @property
59
- def conf_path(self) -> Path:
60
- """Config path that use root_path class argument for this construction.
61
-
62
- :rtype: Path
63
- """
64
- return self.root_path / os.getenv("CONF_PATH", "conf")
65
-
66
-
67
- class Config(BaseConfig): # pragma: no cov
48
+ class Config: # pragma: no cov
68
49
  """Config object for keeping core configurations on the current session
69
50
  without changing when if the application still running.
70
51
 
@@ -217,6 +198,7 @@ class Config(BaseConfig): # pragma: no cov
217
198
 
218
199
 
219
200
  class APIConfig:
201
+ """API Config object."""
220
202
 
221
203
  @property
222
204
  def prefix_path(self) -> str:
@@ -641,6 +641,9 @@ def local_execute_strategy(
641
641
 
642
642
  for stage in job.stages:
643
643
 
644
+ if job.extras:
645
+ stage.extras = job.extras
646
+
644
647
  if stage.is_skipped(params=context):
645
648
  result.trace.info(f"[STAGE]: Skip stage: {stage.iden!r}")
646
649
  stage.set_outputs(output={"skipped": True}, to=context)
@@ -18,19 +18,10 @@ from pydantic.dataclasses import dataclass
18
18
  from pydantic.functional_validators import model_validator
19
19
  from typing_extensions import Self
20
20
 
21
- from .__types import DictData, TupleStr
21
+ from .__types import DictData
22
22
  from .logs import TraceLog, get_dt_tznow, get_trace
23
23
  from .utils import default_gen_id, gen_id
24
24
 
25
- __all__: TupleStr = (
26
- "SUCCESS",
27
- "FAILED",
28
- "WAIT",
29
- "SKIP",
30
- "Result",
31
- "Status",
32
- )
33
-
34
25
 
35
26
  class Status(IntEnum):
36
27
  """Status Int Enum object that use for tracking execution status to the
@@ -62,6 +53,10 @@ class Result:
62
53
 
63
54
  For comparison property, this result will use ``status``, ``context``,
64
55
  and ``_run_id`` fields to comparing with other result instance.
56
+
57
+ Warning:
58
+ I use dataclass object instead of Pydantic model object because context
59
+ field that keep dict value change its ID when update new value to it.
65
60
  """
66
61
 
67
62
  status: Status = field(default=WAIT)
@@ -87,11 +82,11 @@ class Result:
87
82
  """Create the Result object or set parent running id if passing Result
88
83
  object.
89
84
 
90
- :param result:
91
- :param run_id:
92
- :param parent_run_id:
93
- :param id_logic:
94
- :param extras:
85
+ :param result: A Result instance.
86
+ :param run_id: A running ID.
87
+ :param parent_run_id: A parent running ID.
88
+ :param id_logic: A logic function that use to generate a running ID.
89
+ :param extras: An extra parameter that want to override the core config.
95
90
 
96
91
  :rtype: Self
97
92
  """
@@ -10,7 +10,6 @@ from __future__ import annotations
10
10
  import inspect
11
11
  import logging
12
12
  from ast import Call, Constant, Expr, Module, Name, parse
13
- from dataclasses import dataclass
14
13
  from datetime import datetime
15
14
  from functools import wraps
16
15
  from importlib import import_module
@@ -23,6 +22,7 @@ except ImportError:
23
22
 
24
23
  from ddeutil.core import getdot, import_string, lazy
25
24
  from ddeutil.io import search_env_replace
25
+ from pydantic.dataclasses import dataclass
26
26
 
27
27
  from .__types import DictData, Re
28
28
  from .conf import dynamic
@@ -437,6 +437,7 @@ Registry = dict[str, Callable[[], TagFunc]]
437
437
 
438
438
  def make_registry(
439
439
  submodule: str,
440
+ *,
440
441
  registries: Optional[list[str]] = None,
441
442
  ) -> dict[str, Registry]:
442
443
  """Return registries of all functions that able to called with task.
@@ -539,13 +540,13 @@ def extract_call(
539
540
 
540
541
  if call.func not in rgt:
541
542
  raise NotImplementedError(
542
- f"`REGISTER-MODULES.{call.path}.registries` not implement "
543
+ f"`REGISTERS.{call.path}.registries` not implement "
543
544
  f"registry: {call.func!r}."
544
545
  )
545
546
 
546
547
  if call.tag not in rgt[call.func]:
547
548
  raise NotImplementedError(
548
549
  f"tag: {call.tag!r} not found on registry func: "
549
- f"`REGISTER-MODULES.{call.path}.registries.{call.func}`"
550
+ f"`REGISTER.{call.path}.registries.{call.func}`"
550
551
  )
551
552
  return rgt[call.func][call.tag]
@@ -38,12 +38,13 @@ from concurrent.futures import (
38
38
  ThreadPoolExecutor,
39
39
  as_completed,
40
40
  )
41
+ from dataclasses import is_dataclass
41
42
  from inspect import Parameter
42
43
  from pathlib import Path
43
44
  from subprocess import CompletedProcess
44
45
  from textwrap import dedent
45
46
  from threading import Event
46
- from typing import Annotated, Optional, Union
47
+ from typing import Annotated, Any, Optional, Union, get_type_hints
47
48
 
48
49
  from pydantic import BaseModel, Field
49
50
  from pydantic.functional_validators import model_validator
@@ -814,7 +815,8 @@ class CallStage(BaseStage):
814
815
  run_id=gen_id(self.name + (self.id or ""), unique=True)
815
816
  )
816
817
 
817
- t_func: TagFunc = extract_call(
818
+ has_keyword: bool = False
819
+ call_func: TagFunc = extract_call(
818
820
  param2template(self.uses, params, extras=self.extras),
819
821
  registries=self.extras.get("regis_call"),
820
822
  )()
@@ -824,55 +826,92 @@ class CallStage(BaseStage):
824
826
  args: DictData = {"result": result} | param2template(
825
827
  self.args, params, extras=self.extras
826
828
  )
827
- ips = inspect.signature(t_func)
828
- necessary_params: list[str] = [
829
- k
830
- for k in ips.parameters
829
+ ips = inspect.signature(call_func)
830
+ necessary_params: list[str] = []
831
+ for k in ips.parameters:
831
832
  if (
832
- (v := ips.parameters[k]).default == Parameter.empty
833
- and (
834
- v.kind != Parameter.VAR_KEYWORD
835
- or v.kind != Parameter.VAR_POSITIONAL
836
- )
837
- )
838
- ]
833
+ v := ips.parameters[k]
834
+ ).default == Parameter.empty and v.kind not in (
835
+ Parameter.VAR_KEYWORD,
836
+ Parameter.VAR_POSITIONAL,
837
+ ):
838
+ necessary_params.append(k)
839
+ elif v.kind == Parameter.VAR_KEYWORD:
840
+ has_keyword = True
841
+
839
842
  if any(
840
843
  (k.removeprefix("_") not in args and k not in args)
841
844
  for k in necessary_params
842
845
  ):
843
846
  raise ValueError(
844
847
  f"Necessary params, ({', '.join(necessary_params)}, ), "
845
- f"does not set to args"
848
+ f"does not set to args, {list(args.keys())}."
846
849
  )
847
850
 
848
- # NOTE: add '_' prefix if it wants to use.
849
- for k in ips.parameters:
850
- if k.removeprefix("_") in args:
851
- args[k] = args.pop(k.removeprefix("_"))
852
-
853
- if "result" not in ips.parameters:
851
+ if "result" not in ips.parameters and not has_keyword:
854
852
  args.pop("result")
855
853
 
856
- result.trace.info(f"[STAGE]: Call-Execute: {t_func.name}@{t_func.tag}")
857
- if inspect.iscoroutinefunction(t_func): # pragma: no cov
854
+ result.trace.info(
855
+ f"[STAGE]: Call-Execute: {call_func.name}@{call_func.tag}"
856
+ )
857
+
858
+ args = self.parse_model_args(call_func, args, result)
859
+ if inspect.iscoroutinefunction(call_func):
858
860
  loop = asyncio.get_event_loop()
859
861
  rs: DictData = loop.run_until_complete(
860
- t_func(**param2template(args, params, extras=self.extras))
862
+ call_func(**param2template(args, params, extras=self.extras))
861
863
  )
862
864
  else:
863
- rs: DictData = t_func(
865
+ rs: DictData = call_func(
864
866
  **param2template(args, params, extras=self.extras)
865
867
  )
866
868
 
867
869
  # VALIDATE:
868
870
  # Check the result type from call function, it should be dict.
869
- if not isinstance(rs, dict):
871
+ if isinstance(rs, BaseModel):
872
+ rs: DictData = rs.model_dump()
873
+ elif not isinstance(rs, dict):
870
874
  raise TypeError(
871
- f"Return type: '{t_func.name}@{t_func.tag}' does not serialize "
872
- f"to result model, you change return type to `dict`."
875
+ f"Return type: '{call_func.name}@{call_func.tag}' does not "
876
+ f"serialize to result model, you change return type to `dict`."
873
877
  )
874
878
  return result.catch(status=SUCCESS, context=rs)
875
879
 
880
+ @staticmethod
881
+ def parse_model_args(
882
+ func: TagFunc,
883
+ args: DictData,
884
+ result: Result,
885
+ ) -> DictData:
886
+ """Parse Pydantic model from any dict data before parsing to target
887
+ caller function.
888
+ """
889
+ try:
890
+ type_hints: dict[str, Any] = get_type_hints(func)
891
+ except TypeError as e:
892
+ result.trace.warning(
893
+ f"[STAGE]: Get type hint raise TypeError: {e}, so, it skip "
894
+ f"parsing model args process."
895
+ )
896
+ return args
897
+
898
+ for arg in type_hints:
899
+
900
+ if arg == "return":
901
+ continue
902
+
903
+ if arg.removeprefix("_") in args:
904
+ args[arg] = args.pop(arg.removeprefix("_"))
905
+
906
+ t: Any = type_hints[arg]
907
+ if is_dataclass(t) and t.__name__ == "Result" and arg not in args:
908
+ args[arg] = result
909
+
910
+ if issubclass(t, BaseModel) and arg in args:
911
+ args[arg] = t.model_validate(obj=args[arg])
912
+
913
+ return args
914
+
876
915
 
877
916
  class TriggerStage(BaseStage):
878
917
  """Trigger Workflow execution stage that execute another workflow. This
@@ -976,6 +1015,8 @@ class ParallelStage(BaseStage): # pragma: no cov
976
1015
  params: DictData,
977
1016
  result: Result,
978
1017
  stages: list[Stage],
1018
+ *,
1019
+ extras: DictData | None = None,
979
1020
  ) -> DictData:
980
1021
  """Task execution method for passing a branch to each thread.
981
1022
 
@@ -984,12 +1025,16 @@ class ParallelStage(BaseStage): # pragma: no cov
984
1025
  :param result: (Result) A result object for keeping context and status
985
1026
  data.
986
1027
  :param stages:
1028
+ :param extras
987
1029
 
988
1030
  :rtype: DictData
989
1031
  """
990
1032
  context = {"branch": branch, "stages": {}}
991
1033
  result.trace.debug(f"[STAGE]: Execute parallel branch: {branch!r}")
992
1034
  for stage in stages:
1035
+ if extras:
1036
+ stage.extras = extras
1037
+
993
1038
  try:
994
1039
  stage.set_outputs(
995
1040
  stage.handler_execute(
@@ -1048,6 +1093,7 @@ class ParallelStage(BaseStage): # pragma: no cov
1048
1093
  params=params,
1049
1094
  result=result,
1050
1095
  stages=self.parallel[branch],
1096
+ extras=self.extras,
1051
1097
  )
1052
1098
  )
1053
1099
 
@@ -1144,6 +1190,10 @@ class ForEachStage(BaseStage):
1144
1190
  context = {"stages": {}}
1145
1191
 
1146
1192
  for stage in self.stages:
1193
+
1194
+ if self.extras:
1195
+ stage.extras = self.extras
1196
+
1147
1197
  try:
1148
1198
  stage.set_outputs(
1149
1199
  stage.handler_execute(
@@ -1284,6 +1334,9 @@ class CaseStage(BaseStage): # pragma: no cov
1284
1334
 
1285
1335
  if match == _condition:
1286
1336
  stage: Stage = match.stage
1337
+ if self.extras:
1338
+ stage.extras = self.extras
1339
+
1287
1340
  try:
1288
1341
  stage.set_outputs(
1289
1342
  stage.handler_execute(
@@ -1139,7 +1139,7 @@ class Workflow(BaseModel):
1139
1139
  not_timeout_flag := ((time.monotonic() - ts) < timeout)
1140
1140
  ):
1141
1141
  job_id: str = job_queue.get()
1142
- job: Job = self.jobs[job_id]
1142
+ job: Job = self.job(name=job_id)
1143
1143
 
1144
1144
  if (check := job.check_needs(context["jobs"])) == WAIT:
1145
1145
  job_queue.task_done()
@@ -1231,7 +1231,7 @@ class Workflow(BaseModel):
1231
1231
  not_timeout_flag := ((time.monotonic() - ts) < timeout)
1232
1232
  ):
1233
1233
  job_id: str = job_queue.get()
1234
- job: Job = self.jobs[job_id]
1234
+ job: Job = self.job(name=job_id)
1235
1235
 
1236
1236
  if (check := job.check_needs(context["jobs"])) == WAIT:
1237
1237
  job_queue.task_done()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ddeutil-workflow
3
- Version: 0.0.45
3
+ Version: 0.0.47
4
4
  Summary: Lightweight workflow orchestration
5
5
  Author-email: ddeutils <korawich.anu@gmail.com>
6
6
  License: MIT
@@ -4,6 +4,7 @@ pyproject.toml
4
4
  src/ddeutil/workflow/__about__.py
5
5
  src/ddeutil/workflow/__cron.py
6
6
  src/ddeutil/workflow/__init__.py
7
+ src/ddeutil/workflow/__main__.py
7
8
  src/ddeutil/workflow/__types.py
8
9
  src/ddeutil/workflow/conf.py
9
10
  src/ddeutil/workflow/cron.py
@@ -3,11 +3,14 @@ from __future__ import annotations
3
3
  import asyncio
4
4
  import inspect
5
5
  import shutil
6
+ import typing
7
+ from dataclasses import is_dataclass
6
8
  from pathlib import Path
7
9
  from textwrap import dedent
8
10
 
9
11
  import pytest
10
- from ddeutil.workflow.reusables import Registry, make_registry
12
+ from ddeutil.workflow.reusables import Registry, extract_call, make_registry
13
+ from pydantic import BaseModel
11
14
 
12
15
 
13
16
  @pytest.fixture(scope="module")
@@ -94,10 +97,10 @@ def test_make_registry(call_function):
94
97
 
95
98
  def test_make_registry_from_env():
96
99
  rs: dict[str, Registry] = make_registry("tasks")
97
- print(rs)
98
100
  assert set(rs.keys()) == {
99
101
  "async-el-csv-to-parquet",
100
102
  "get-items",
103
+ "gen-type",
101
104
  "mssql-proc",
102
105
  "el-csv-to-parquet",
103
106
  "return-type-not-valid",
@@ -116,12 +119,36 @@ def test_make_registry_raise(call_function_dup):
116
119
  make_registry("new_tasks_dup")
117
120
 
118
121
 
122
+ def test_extract_call():
123
+ func = extract_call("tasks/el-csv-to-parquet@polars-dir")
124
+ call_func = func()
125
+ assert call_func.name == "el-csv-to-parquet"
126
+ assert call_func.tag == "polars-dir"
127
+
128
+
129
+ def test_extract_call_args_type():
130
+ func = extract_call("tasks/gen-type@demo")
131
+ call_func = func()
132
+
133
+ get_types = typing.get_type_hints(call_func)
134
+ for p in get_types:
135
+ if is_dataclass(get_types[p]) and get_types[p].__name__ == "Result":
136
+ print("found result", p, get_types[p])
137
+ if issubclass(get_types[p], BaseModel):
138
+ print(p, "with type:", get_types[p])
139
+
140
+
119
141
  @pytest.mark.skip("Skip because it uses for local test only.")
120
142
  def test_inspec_func():
121
143
 
122
144
  def demo_func(
123
145
  args_1: str, args_2: Path, *args, kwargs_1: str | None = None, **kwargs
124
146
  ): # pragma: no cov
147
+ _ = args_1
148
+ _ = args_2
149
+ _ = args
150
+ _ = kwargs_1
151
+ _ = kwargs
125
152
  pass
126
153
 
127
154
  assert inspect.isfunction(demo_func)
@@ -141,6 +168,11 @@ def test_inspec_func():
141
168
  args_1: str, args_2: Path, *args, kwargs_1: str | None = None, **kwargs
142
169
  ): # pragma: no cov
143
170
  await asyncio.sleep(0.1)
171
+ _ = args_1
172
+ _ = args_2
173
+ _ = args
174
+ _ = kwargs_1
175
+ _ = kwargs
144
176
  pass
145
177
 
146
178
  print(inspect.isfunction(ademo_func))
@@ -158,3 +190,61 @@ def test_inspec_func():
158
190
  # print(v.default)
159
191
  # print(v.kind, " (", type(v.kind), ")")
160
192
  # print("-----")
193
+
194
+
195
+ class MockModel(BaseModel): # pragma: no cov
196
+ name: str
197
+
198
+
199
+ def outside_func(args: MockModel) -> MockModel: # pragma: no cov
200
+ _ = args
201
+ pass
202
+
203
+
204
+ @pytest.mark.skip("Skip because it uses for local test only.")
205
+ def test_inspec_with_pydantic_model_args():
206
+ from pydantic import BaseModel
207
+
208
+ class MockModelLocal(BaseModel):
209
+ name: str
210
+
211
+ def demo_func(
212
+ args_1: MockModel,
213
+ args_2: MockModelLocal,
214
+ *args,
215
+ kwargs_1: str = None,
216
+ **kwargs,
217
+ ) -> MockModel: # pragma: no cov
218
+ _ = args_1
219
+ _ = args_2
220
+ _ = args
221
+ _ = kwargs_1
222
+ _ = kwargs
223
+ pass
224
+
225
+ # ips = inspect.signature(demo_func)
226
+ # for k, v in ips.parameters.items():
227
+ # print(k)
228
+ # print(ips.parameters[k].default)
229
+ # print(v)
230
+ # print(v.name)
231
+ # print(v.annotation, "type:", type(v.annotation))
232
+ # print(v.default)
233
+ # print(v.kind, " (", type(v.kind), ")")
234
+ # print("-----")
235
+ #
236
+ # print(ips.return_annotation, "type:", type(ips.return_annotation))
237
+ # print(ips.return_annotation is MockModel)
238
+ # print(ips.return_annotation.__parameter__)
239
+
240
+ import typing
241
+
242
+ rs = typing.get_type_hints(demo_func, localns=locals(), globalns=globals())
243
+ print(rs)
244
+
245
+ print(demo_func.__annotations__)
246
+ print(globals()["MockModel"])
247
+ print(locals()["MockModelLocal"])
248
+
249
+ rs = typing.get_type_hints(outside_func)
250
+ print(rs)
@@ -97,20 +97,29 @@ def test_stage_exec_call(test_path):
97
97
  with:
98
98
  source: src
99
99
  sink: sink
100
+ - name: "Return with Pydantic Model"
101
+ id: return-model
102
+ uses: tasks/gen-type@demo
103
+ with:
104
+ args1: foo
105
+ args2: conf/path
106
+ args3:
107
+ name: test
108
+ data:
109
+ input: hello
100
110
  """,
101
111
  ):
102
112
  workflow = Workflow.from_conf(name="tmp-wf-call-return-type")
103
113
 
104
114
  stage: Stage = workflow.job("second-job").stage("extract-load")
105
115
  rs: Result = stage.handler_execute({})
106
- print(rs)
116
+ assert 0 == rs.status
117
+ assert {"records": 1} == rs.context
107
118
 
108
119
  stage: Stage = workflow.job("second-job").stage("async-extract-load")
109
120
  rs: Result = stage.handler_execute({})
110
- print(rs)
111
-
112
- assert 0 == rs.status
113
- assert {"records": 1} == rs.context
121
+ assert rs.status == 0
122
+ assert rs.context == {"records": 1}
114
123
 
115
124
  # NOTE: Raise because invalid return type.
116
125
  with pytest.raises(StageException):
@@ -132,6 +141,11 @@ def test_stage_exec_call(test_path):
132
141
  stage: Stage = workflow.job("first-job").stage("call-not-register")
133
142
  stage.handler_execute({})
134
143
 
144
+ stage: Stage = workflow.job("second-job").stage("return-model")
145
+ rs: Result = stage.handler_execute({})
146
+ assert rs.status == 0
147
+ assert rs.context == {"name": "foo", "data": {"key": "value"}}
148
+
135
149
 
136
150
  @mock.patch.object(Config, "stage_raise_error", True)
137
151
  def test_stage_exec_py_raise():
@@ -1,8 +1,10 @@
1
+ import shutil
1
2
  from datetime import datetime
3
+ from textwrap import dedent
2
4
  from unittest import mock
3
5
 
4
6
  from ddeutil.core import getdot
5
- from ddeutil.workflow import SUCCESS, Workflow
7
+ from ddeutil.workflow import SUCCESS, Workflow, extract_call
6
8
  from ddeutil.workflow.conf import Config
7
9
  from ddeutil.workflow.job import Job
8
10
  from ddeutil.workflow.result import FAILED, Result
@@ -488,6 +490,55 @@ def test_workflow_exec_call(test_path):
488
490
  } == rs.context
489
491
 
490
492
 
493
+ def test_workflow_exec_call_override_registry(test_path):
494
+ task_path = test_path.parent / "mock_tests"
495
+ task_path.mkdir(exist_ok=True)
496
+ (task_path / "__init__.py").open(mode="w")
497
+ (task_path / "mock_tasks").mkdir(exist_ok=True)
498
+
499
+ with (task_path / "mock_tasks/__init__.py").open(mode="w") as f:
500
+ f.write(
501
+ dedent(
502
+ """
503
+ from ddeutil.workflow import tag, Result
504
+
505
+ @tag("v1", alias="get-info")
506
+ def get_info(result: Result):
507
+ result.trace.info("... [CALLER]: Info from mock tasks")
508
+ return {"get-info": "success"}
509
+
510
+ """.strip(
511
+ "\n"
512
+ )
513
+ )
514
+ )
515
+
516
+ with dump_yaml_context(
517
+ test_path / "conf/demo/01_99_wf_test_wf_exec_call_override.yml",
518
+ data="""
519
+ tmp-wf-exec-call-override:
520
+ type: Workflow
521
+ jobs:
522
+ first-job:
523
+ stages:
524
+ - name: "Call from mock tasks"
525
+ uses: mock_tasks/get-info@v1
526
+ """,
527
+ ):
528
+ func = extract_call("mock_tasks/get-info@v1", registries=["mock_tests"])
529
+ assert func().name == "get-info"
530
+
531
+ workflow = Workflow.from_conf(
532
+ name="tmp-wf-exec-call-override",
533
+ extras={"regis_call": ["mock_tests"]},
534
+ )
535
+ rs = workflow.execute(params={})
536
+ assert rs.status == SUCCESS
537
+ print(rs.context)
538
+
539
+ shutil.rmtree(task_path)
540
+
541
+
491
542
  def test_workflow_exec_call_with_prefix(test_path):
492
543
  with dump_yaml_context(
493
544
  test_path / "conf/demo/01_99_wf_test_wf_call_mssql_proc.yml",
@@ -1 +0,0 @@
1
- __version__: str = "0.0.45"