ddeutil-workflow 0.0.62__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.62 → ddeutil_workflow-0.0.63}/PKG-INFO +11 -5
  2. {ddeutil_workflow-0.0.62 → ddeutil_workflow-0.0.63}/README.md +9 -4
  3. {ddeutil_workflow-0.0.62 → 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.62 → ddeutil_workflow-0.0.63}/src/ddeutil/workflow/__init__.py +2 -24
  6. {ddeutil_workflow-0.0.62 → ddeutil_workflow-0.0.63}/src/ddeutil/workflow/conf.py +36 -4
  7. {ddeutil_workflow-0.0.62 → ddeutil_workflow-0.0.63}/src/ddeutil/workflow/event.py +2 -1
  8. {ddeutil_workflow-0.0.62 → ddeutil_workflow-0.0.63}/src/ddeutil/workflow/reusables.py +70 -13
  9. {ddeutil_workflow-0.0.62 → ddeutil_workflow-0.0.63}/src/ddeutil/workflow/stages.py +30 -24
  10. {ddeutil_workflow-0.0.62 → ddeutil_workflow-0.0.63}/src/ddeutil_workflow.egg-info/PKG-INFO +11 -5
  11. {ddeutil_workflow-0.0.62 → ddeutil_workflow-0.0.63}/src/ddeutil_workflow.egg-info/requires.txt +1 -0
  12. {ddeutil_workflow-0.0.62 → ddeutil_workflow-0.0.63}/tests/test_conf.py +16 -4
  13. {ddeutil_workflow-0.0.62 → ddeutil_workflow-0.0.63}/tests/test_reusables_call_tag.py +75 -1
  14. ddeutil_workflow-0.0.62/src/ddeutil/workflow/__about__.py +0 -1
  15. {ddeutil_workflow-0.0.62 → ddeutil_workflow-0.0.63}/LICENSE +0 -0
  16. {ddeutil_workflow-0.0.62 → ddeutil_workflow-0.0.63}/setup.cfg +0 -0
  17. {ddeutil_workflow-0.0.62 → ddeutil_workflow-0.0.63}/src/ddeutil/workflow/__cron.py +0 -0
  18. {ddeutil_workflow-0.0.62 → ddeutil_workflow-0.0.63}/src/ddeutil/workflow/__main__.py +0 -0
  19. {ddeutil_workflow-0.0.62 → ddeutil_workflow-0.0.63}/src/ddeutil/workflow/__types.py +0 -0
  20. {ddeutil_workflow-0.0.62 → ddeutil_workflow-0.0.63}/src/ddeutil/workflow/api/__init__.py +0 -0
  21. {ddeutil_workflow-0.0.62 → ddeutil_workflow-0.0.63}/src/ddeutil/workflow/api/logs.py +0 -0
  22. {ddeutil_workflow-0.0.62 → ddeutil_workflow-0.0.63}/src/ddeutil/workflow/api/routes/__init__.py +0 -0
  23. {ddeutil_workflow-0.0.62 → ddeutil_workflow-0.0.63}/src/ddeutil/workflow/api/routes/job.py +0 -0
  24. {ddeutil_workflow-0.0.62 → ddeutil_workflow-0.0.63}/src/ddeutil/workflow/api/routes/logs.py +0 -0
  25. {ddeutil_workflow-0.0.62 → ddeutil_workflow-0.0.63}/src/ddeutil/workflow/api/routes/schedules.py +0 -0
  26. {ddeutil_workflow-0.0.62 → ddeutil_workflow-0.0.63}/src/ddeutil/workflow/api/routes/workflows.py +0 -0
  27. {ddeutil_workflow-0.0.62 → ddeutil_workflow-0.0.63}/src/ddeutil/workflow/api/utils.py +0 -0
  28. {ddeutil_workflow-0.0.62 → ddeutil_workflow-0.0.63}/src/ddeutil/workflow/exceptions.py +0 -0
  29. {ddeutil_workflow-0.0.62 → ddeutil_workflow-0.0.63}/src/ddeutil/workflow/job.py +0 -0
  30. {ddeutil_workflow-0.0.62 → ddeutil_workflow-0.0.63}/src/ddeutil/workflow/logs.py +0 -0
  31. {ddeutil_workflow-0.0.62 → ddeutil_workflow-0.0.63}/src/ddeutil/workflow/params.py +0 -0
  32. {ddeutil_workflow-0.0.62 → ddeutil_workflow-0.0.63}/src/ddeutil/workflow/result.py +0 -0
  33. {ddeutil_workflow-0.0.62 → ddeutil_workflow-0.0.63}/src/ddeutil/workflow/scheduler.py +0 -0
  34. {ddeutil_workflow-0.0.62 → ddeutil_workflow-0.0.63}/src/ddeutil/workflow/utils.py +0 -0
  35. {ddeutil_workflow-0.0.62 → ddeutil_workflow-0.0.63}/src/ddeutil/workflow/workflow.py +0 -0
  36. {ddeutil_workflow-0.0.62 → ddeutil_workflow-0.0.63}/src/ddeutil_workflow.egg-info/SOURCES.txt +0 -0
  37. {ddeutil_workflow-0.0.62 → ddeutil_workflow-0.0.63}/src/ddeutil_workflow.egg-info/dependency_links.txt +0 -0
  38. {ddeutil_workflow-0.0.62 → ddeutil_workflow-0.0.63}/src/ddeutil_workflow.egg-info/entry_points.txt +0 -0
  39. {ddeutil_workflow-0.0.62 → ddeutil_workflow-0.0.63}/src/ddeutil_workflow.egg-info/top_level.txt +0 -0
  40. {ddeutil_workflow-0.0.62 → ddeutil_workflow-0.0.63}/tests/test__cron.py +0 -0
  41. {ddeutil_workflow-0.0.62 → ddeutil_workflow-0.0.63}/tests/test__regex.py +0 -0
  42. {ddeutil_workflow-0.0.62 → ddeutil_workflow-0.0.63}/tests/test_event.py +0 -0
  43. {ddeutil_workflow-0.0.62 → ddeutil_workflow-0.0.63}/tests/test_job.py +0 -0
  44. {ddeutil_workflow-0.0.62 → ddeutil_workflow-0.0.63}/tests/test_job_exec.py +0 -0
  45. {ddeutil_workflow-0.0.62 → ddeutil_workflow-0.0.63}/tests/test_job_exec_strategy.py +0 -0
  46. {ddeutil_workflow-0.0.62 → ddeutil_workflow-0.0.63}/tests/test_logs_audit.py +0 -0
  47. {ddeutil_workflow-0.0.62 → ddeutil_workflow-0.0.63}/tests/test_logs_trace.py +0 -0
  48. {ddeutil_workflow-0.0.62 → ddeutil_workflow-0.0.63}/tests/test_params.py +0 -0
  49. {ddeutil_workflow-0.0.62 → ddeutil_workflow-0.0.63}/tests/test_release.py +0 -0
  50. {ddeutil_workflow-0.0.62 → ddeutil_workflow-0.0.63}/tests/test_release_queue.py +0 -0
  51. {ddeutil_workflow-0.0.62 → ddeutil_workflow-0.0.63}/tests/test_result.py +0 -0
  52. {ddeutil_workflow-0.0.62 → ddeutil_workflow-0.0.63}/tests/test_reusables_template.py +0 -0
  53. {ddeutil_workflow-0.0.62 → ddeutil_workflow-0.0.63}/tests/test_reusables_template_filter.py +0 -0
  54. {ddeutil_workflow-0.0.62 → ddeutil_workflow-0.0.63}/tests/test_schedule.py +0 -0
  55. {ddeutil_workflow-0.0.62 → ddeutil_workflow-0.0.63}/tests/test_schedule_pending.py +0 -0
  56. {ddeutil_workflow-0.0.62 → ddeutil_workflow-0.0.63}/tests/test_schedule_tasks.py +0 -0
  57. {ddeutil_workflow-0.0.62 → ddeutil_workflow-0.0.63}/tests/test_schedule_workflow.py +0 -0
  58. {ddeutil_workflow-0.0.62 → ddeutil_workflow-0.0.63}/tests/test_scheduler_control.py +0 -0
  59. {ddeutil_workflow-0.0.62 → ddeutil_workflow-0.0.63}/tests/test_stage.py +0 -0
  60. {ddeutil_workflow-0.0.62 → ddeutil_workflow-0.0.63}/tests/test_stage_handler_exec.py +0 -0
  61. {ddeutil_workflow-0.0.62 → ddeutil_workflow-0.0.63}/tests/test_strategy.py +0 -0
  62. {ddeutil_workflow-0.0.62 → ddeutil_workflow-0.0.63}/tests/test_utils.py +0 -0
  63. {ddeutil_workflow-0.0.62 → ddeutil_workflow-0.0.63}/tests/test_workflow.py +0 -0
  64. {ddeutil_workflow-0.0.62 → ddeutil_workflow-0.0.63}/tests/test_workflow_exec.py +0 -0
  65. {ddeutil_workflow-0.0.62 → ddeutil_workflow-0.0.63}/tests/test_workflow_exec_job.py +0 -0
  66. {ddeutil_workflow-0.0.62 → ddeutil_workflow-0.0.63}/tests/test_workflow_poke.py +0 -0
  67. {ddeutil_workflow-0.0.62 → ddeutil_workflow-0.0.63}/tests/test_workflow_release.py +0 -0
  68. {ddeutil_workflow-0.0.62 → 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.62
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"
@@ -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",
@@ -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, Literal, 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
@@ -231,6 +241,7 @@ def not_in_template(value: Any, *, not_in: str = "matrix.") -> bool:
231
241
 
232
242
  :param value: A value that want to find parameter template prefix.
233
243
  :param not_in: The not-in string that use in the `.startswith` function.
244
+ (Default is `matrix.`)
234
245
 
235
246
  :rtype: bool
236
247
  """
@@ -279,7 +290,7 @@ def str2template(
279
290
  :param value: (str) A string value that want to map with params.
280
291
  :param params: (DictData) A parameter value that getting with matched
281
292
  regular expression.
282
- :param filters: A mapping of filter registry.
293
+ :param filters: (dict[str, FilterRegistry]) A mapping of filter registry.
283
294
  :param registers: (Optional[list[str]]) Override list of register.
284
295
 
285
296
  :rtype: str
@@ -304,12 +315,14 @@ def str2template(
304
315
  ]
305
316
 
306
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.
307
320
  try:
308
321
  getter: Any = getdot(caller, params)
309
- except ValueError as err:
322
+ except ValueError:
310
323
  raise UtilException(
311
324
  f"Parameters does not get dot with caller: {caller!r}."
312
- ) from err
325
+ ) from None
313
326
 
314
327
  # NOTE:
315
328
  # If type of getter caller is not string type, and it does not use to
@@ -341,10 +354,11 @@ def param2template(
341
354
  """Pass param to template string that can search by ``RE_CALLER`` regular
342
355
  expression.
343
356
 
344
- :param value: A value that want to map with params
345
- :param params: A parameter value that getting with matched regular
346
- expression.
347
- :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.
348
362
  :param extras: (Optional[list[str]]) An Override extras.
349
363
 
350
364
  :rtype: Any
@@ -376,8 +390,8 @@ def datetime_format(value: datetime, fmt: str = "%Y-%m-%d %H:%M:%S") -> str:
376
390
 
377
391
  Examples:
378
392
 
379
- > ${{ start-date | fmt('%Y%m%d') }}
380
- > ${{ start-date | fmt }}
393
+ >>> "${{ start-date | fmt('%Y%m%d') }}"
394
+ >>> "${{ start-date | fmt }}"
381
395
 
382
396
  :param value: (datetime) A datetime value that want to format to string
383
397
  value.
@@ -399,7 +413,7 @@ def coalesce(value: Optional[T], default: Any) -> T:
399
413
 
400
414
  Examples:
401
415
 
402
- > ${{ value | coalesce("foo") }}
416
+ >>> "${{ value | coalesce('foo') }}"
403
417
 
404
418
  :param value: A value that want to check nullable.
405
419
  :param default: A default value that use to returned value if an input
@@ -412,7 +426,14 @@ def coalesce(value: Optional[T], default: Any) -> T:
412
426
  def get_item(
413
427
  value: DictData, key: Union[str, int], default: Optional[Any] = None
414
428
  ) -> Any:
415
- """Get a value with an input specific key."""
429
+ """Get a value with an input specific key.
430
+
431
+ Examples:
432
+
433
+ >>> "${{ value | getitem('key') }}"
434
+ >>> "${{ value | getitem('key', 'default') }}"
435
+
436
+ """
416
437
  if not isinstance(value, dict):
417
438
  raise UtilException(
418
439
  f"The value that pass to `getitem` filter should be `dict` not "
@@ -422,7 +443,14 @@ def get_item(
422
443
 
423
444
 
424
445
  @custom_filter("getindex") # pragma: no cov
425
- def get_index(value: list[Any], index: int):
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
+ """
426
454
  if not isinstance(value, list):
427
455
  raise UtilException(
428
456
  f"The value that pass to `getindex` filter should be `list` not "
@@ -605,3 +633,32 @@ def extract_call(
605
633
  f"`REGISTER.{call.path}.registries.{call.func}`"
606
634
  )
607
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}")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ddeutil-workflow
3
- Version: 0.0.62
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,6 +1,7 @@
1
1
  ddeutil[checksum]>=0.4.8
2
2
  ddeutil-io[toml,yaml]>=0.2.13
3
3
  pydantic==2.11.4
4
+ pydantic-extra-types==2.10.4
4
5
  python-dotenv==1.1.0
5
6
  schedule<2.0.0,==1.2.2
6
7
 
@@ -8,7 +8,7 @@ from zoneinfo import ZoneInfo
8
8
  import pytest
9
9
  import rtoml
10
10
  import yaml
11
- from ddeutil.workflow.conf import Config, FileLoad, config, dynamic
11
+ from ddeutil.workflow.conf import Config, FileLoad, config, dynamic, pass_env
12
12
  from ddeutil.workflow.scheduler import Schedule
13
13
 
14
14
 
@@ -35,7 +35,7 @@ def target_path(test_path):
35
35
  json.dump({"foo": "bar"}, f)
36
36
 
37
37
  with (target_p / "test_simple_file.toml").open(mode="w") as f:
38
- rtoml.dump({"foo": "bar"}, f)
38
+ rtoml.dump({"foo": "bar", "env": "${ WORKFLOW_CORE_TIMEZONE }"}, f)
39
39
 
40
40
  yield target_p
41
41
 
@@ -60,14 +60,26 @@ def test_load_file(target_path: Path):
60
60
  "test_load_file": {
61
61
  "type": "Workflow",
62
62
  "desc": "Test multi config path",
63
+ "env": "${WORKFLOW_CORE_TIMEZONE}",
63
64
  }
64
65
  },
65
66
  f,
66
67
  )
67
68
 
68
69
  load = FileLoad("test_load_file", extras={"conf_paths": [target_path]})
69
- assert load.data == {"desc": "Test multi config path", "type": "Workflow"}
70
-
70
+ assert load.data == {
71
+ "type": "Workflow",
72
+ "desc": "Test multi config path",
73
+ "env": "${WORKFLOW_CORE_TIMEZONE}",
74
+ }
75
+ assert pass_env(load.data["env"]) == "Asia/Bangkok"
76
+ assert pass_env(load.data) == {
77
+ "type": "Workflow",
78
+ "desc": "Test multi config path",
79
+ "env": "Asia/Bangkok",
80
+ }
81
+
82
+ # NOTE: Raise because passing `conf_paths` invalid type.
71
83
  with pytest.raises(TypeError):
72
84
  FileLoad("test_load_file", extras={"conf_paths": target_path})
73
85
 
@@ -1,11 +1,21 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import shutil
4
+ from inspect import Parameter, signature
4
5
  from pathlib import Path
5
6
  from textwrap import dedent
7
+ from typing import get_type_hints
6
8
 
7
9
  import pytest
8
- from ddeutil.workflow.reusables import Registry, extract_call, make_registry
10
+ from ddeutil.workflow import Result
11
+ from ddeutil.workflow.reusables import (
12
+ Registry,
13
+ create_model_from_caller,
14
+ extract_call,
15
+ make_registry,
16
+ )
17
+ from pydantic import ValidationError, create_model
18
+ from typing_extensions import TypedDict
9
19
 
10
20
 
11
21
  @pytest.fixture(scope="module")
@@ -127,3 +137,67 @@ def test_extract_caller():
127
137
  assert call_func.name == "el-csv-to-parquet"
128
138
  assert call_func.tag == "polars-dir"
129
139
  assert call_func.mark == "tag"
140
+
141
+
142
+ class Kwargs(TypedDict): # pragma: no cov
143
+ foo: str
144
+ bar: str
145
+
146
+
147
+ def dummy_func(
148
+ source: str, result: Result, limit: int = 5, **kwargs: Kwargs
149
+ ): # pragma: no cov
150
+ ...
151
+
152
+
153
+ def test_make_model_from_argument():
154
+ """Create Pydantic Model from function arguments.
155
+
156
+ Refs:
157
+ - https://github.com/lmmx/pydantic-function-models
158
+ - https://docs.pydantic.dev/1.10/usage/models/#dynamic-model-creation
159
+ """
160
+ type_hints: dict = get_type_hints(dummy_func)
161
+ print(type_hints)
162
+ model = create_model("ArgsFunc", **type_hints)
163
+ print(model)
164
+ arg_instance = model.model_validate(
165
+ {
166
+ "source": "some-source",
167
+ "limit": 10,
168
+ "result": Result(),
169
+ "kwargs": {"foo": "baz", "bar": "baz"},
170
+ }
171
+ )
172
+ print(arg_instance)
173
+
174
+ with pytest.raises(ValidationError):
175
+ model.model_validate({"source": []})
176
+
177
+ with pytest.raises(ValidationError):
178
+ model.model_validate({"limit": "10"})
179
+
180
+ sig = signature(dummy_func)
181
+ print(sig.parameters)
182
+ for name in sig.parameters:
183
+ param: Parameter = sig.parameters[name]
184
+ print(name, ":", param)
185
+ print(
186
+ f"\t> default: {param.default}\n"
187
+ f"\t> kind: {param.kind}\n"
188
+ f"\t> annotation: {param.annotation} ({type(param.annotation)})"
189
+ )
190
+
191
+
192
+ def test_create_model_from_caller():
193
+ model = create_model_from_caller(dummy_func)
194
+ arg_instance = model.model_validate(
195
+ {
196
+ "source": "some-source",
197
+ "limit": 10,
198
+ "result": Result(),
199
+ "outer-key": "should not pass to model",
200
+ }
201
+ )
202
+ print(arg_instance)
203
+ print("test")
@@ -1 +0,0 @@
1
- __version__: str = "0.0.62"