ddeutil-workflow 0.0.62__py3-none-any.whl → 0.0.64__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- __version__: str = "0.0.62"
1
+ __version__: str = "0.0.64"
@@ -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,31 +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
- )
58
- from .scheduler import (
59
- Schedule,
60
- ScheduleWorkflow,
61
- schedule_control,
62
- schedule_runner,
63
- schedule_task,
64
- )
35
+ from .reusables import *
65
36
  from .stages import *
66
37
  from .utils import *
67
38
  from .workflow import *
@@ -7,8 +7,6 @@ from __future__ import annotations
7
7
 
8
8
  import contextlib
9
9
  from collections.abc import AsyncIterator
10
- from datetime import datetime, timedelta
11
- from typing import TypedDict
12
10
 
13
11
  from dotenv import load_dotenv
14
12
  from fastapi import FastAPI, Request
@@ -20,49 +18,18 @@ from fastapi.middleware.gzip import GZipMiddleware
20
18
  from fastapi.responses import UJSONResponse
21
19
 
22
20
  from ..__about__ import __version__
23
- from ..conf import api_config, config
21
+ from ..conf import api_config
24
22
  from ..logs import get_logger
25
- from ..scheduler import ReleaseThread, ReleaseThreads
26
- from ..workflow import ReleaseQueue, WorkflowTask
27
- from .routes import job, log
28
- from .utils import repeat_at
23
+ from .routes import job, log, workflow
29
24
 
30
25
  load_dotenv()
31
26
  logger = get_logger("uvicorn.error")
32
27
 
33
28
 
34
- class State(TypedDict):
35
- """TypeDict for State of FastAPI application."""
36
-
37
- scheduler: list[str]
38
- workflow_threads: ReleaseThreads
39
- workflow_tasks: list[WorkflowTask]
40
- workflow_queue: dict[str, ReleaseQueue]
41
-
42
-
43
29
  @contextlib.asynccontextmanager
44
- async def lifespan(a: FastAPI) -> AsyncIterator[State]:
30
+ async def lifespan(_: FastAPI) -> AsyncIterator[dict[str, list]]:
45
31
  """Lifespan function for the FastAPI application."""
46
- a.state.scheduler = []
47
- a.state.workflow_threads = {}
48
- a.state.workflow_tasks = []
49
- a.state.workflow_queue = {}
50
-
51
- yield {
52
- # NOTE: Scheduler value should be contained a key of workflow and
53
- # list of datetime of queue and running.
54
- #
55
- # ... {
56
- # ... '<workflow-name>': (
57
- # ... [<running-datetime>, ...], [<queue-datetime>, ...]
58
- # ... )
59
- # ... }
60
- #
61
- "scheduler": a.state.scheduler,
62
- "workflow_queue": a.state.workflow_queue,
63
- "workflow_threads": a.state.workflow_threads,
64
- "workflow_tasks": a.state.workflow_tasks,
65
- }
32
+ yield {}
66
33
 
67
34
 
68
35
  app = FastAPI(
@@ -99,53 +66,7 @@ async def health():
99
66
  # NOTE Add the jobs and logs routes by default.
100
67
  app.include_router(job, prefix=api_config.prefix_path)
101
68
  app.include_router(log, prefix=api_config.prefix_path)
102
-
103
-
104
- # NOTE: Enable the workflows route.
105
- if api_config.enable_route_workflow:
106
- from .routes import workflow
107
-
108
- app.include_router(workflow, prefix=api_config.prefix_path)
109
-
110
-
111
- # NOTE: Enable the schedules route.
112
- if api_config.enable_route_schedule:
113
- from ..logs import get_audit
114
- from ..scheduler import schedule_task
115
- from .routes import schedule
116
-
117
- app.include_router(schedule, prefix=api_config.prefix_path)
118
-
119
- @schedule.on_event("startup")
120
- @repeat_at(cron="* * * * *", delay=2)
121
- def scheduler_listener():
122
- """Schedule broker every minute at 02 second."""
123
- logger.debug(
124
- f"[SCHEDULER]: Start listening schedule from queue "
125
- f"{app.state.scheduler}"
126
- )
127
- if app.state.workflow_tasks:
128
- schedule_task(
129
- app.state.workflow_tasks,
130
- stop=datetime.now(config.tz) + timedelta(minutes=1),
131
- queue=app.state.workflow_queue,
132
- threads=app.state.workflow_threads,
133
- audit=get_audit(),
134
- )
135
-
136
- @schedule.on_event("startup")
137
- @repeat_at(cron="*/5 * * * *", delay=10)
138
- def monitoring():
139
- """Monitoring workflow thread that running in the background."""
140
- logger.debug("[MONITOR]: Start monitoring threading.")
141
- snapshot_threads: list[str] = list(app.state.workflow_threads.keys())
142
- for t_name in snapshot_threads:
143
-
144
- thread_release: ReleaseThread = app.state.workflow_threads[t_name]
145
-
146
- # NOTE: remove the thread that running success.
147
- if not thread_release["thread"].is_alive():
148
- app.state.workflow_threads.pop(t_name)
69
+ app.include_router(workflow, prefix=api_config.prefix_path)
149
70
 
150
71
 
151
72
  @app.exception_handler(RequestValidationError)
@@ -5,5 +5,4 @@
5
5
  # ------------------------------------------------------------------------------
6
6
  from .job import job_route as job
7
7
  from .logs import log_route as log
8
- from .schedules import schedule_route as schedule
9
8
  from .workflows import workflow_route as workflow
@@ -69,7 +69,6 @@ async def job_execute(
69
69
  by_alias=True,
70
70
  exclude_none=False,
71
71
  exclude_unset=True,
72
- exclude_defaults=True,
73
72
  ),
74
73
  "params": params,
75
74
  "context": context,
@@ -44,7 +44,6 @@ async def get_traces(
44
44
  by_alias=True,
45
45
  exclude_none=True,
46
46
  exclude_unset=True,
47
- exclude_defaults=True,
48
47
  )
49
48
  for trace in result.trace.find_traces()
50
49
  ],
@@ -73,7 +72,6 @@ async def get_trace_with_id(run_id: str):
73
72
  by_alias=True,
74
73
  exclude_none=True,
75
74
  exclude_unset=True,
76
- exclude_defaults=True,
77
75
  )
78
76
  ),
79
77
  }
@@ -56,7 +56,6 @@ async def get_workflow_by_name(name: str) -> DictData:
56
56
  by_alias=True,
57
57
  exclude_none=False,
58
58
  exclude_unset=True,
59
- exclude_defaults=True,
60
59
  )
61
60
 
62
61
 
@@ -99,7 +98,6 @@ async def get_workflow_audits(name: str):
99
98
  by_alias=True,
100
99
  exclude_none=False,
101
100
  exclude_unset=True,
102
- exclude_defaults=True,
103
101
  )
104
102
  for audit in get_audit().find_audits(name=name)
105
103
  ],
@@ -133,6 +131,5 @@ async def get_workflow_release_audit(name: str, release: str):
133
131
  by_alias=True,
134
132
  exclude_none=False,
135
133
  exclude_unset=True,
136
- exclude_defaults=True,
137
134
  ),
138
135
  }
ddeutil/workflow/conf.py CHANGED
@@ -6,11 +6,9 @@
6
6
  from __future__ import annotations
7
7
 
8
8
  import copy
9
- import json
10
9
  import os
11
10
  from abc import ABC, abstractmethod
12
11
  from collections.abc import Iterator
13
- from datetime import timedelta
14
12
  from functools import cached_property
15
13
  from inspect import isclass
16
14
  from pathlib import Path
@@ -18,8 +16,9 @@ from typing import Final, Optional, Protocol, TypeVar, Union
18
16
  from zoneinfo import ZoneInfo
19
17
 
20
18
  from ddeutil.core import str2bool
21
- from ddeutil.io import YamlFlResolve
19
+ from ddeutil.io import YamlFlResolve, search_env_replace
22
20
  from ddeutil.io.paths import glob_files, is_ignored, read_ignore
21
+ from pydantic import SecretStr, TypeAdapter
23
22
 
24
23
  from .__types import DictData
25
24
 
@@ -162,28 +161,6 @@ class Config: # pragma: no cov
162
161
  def max_queue_complete_hist(self) -> int:
163
162
  return int(env("CORE_MAX_QUEUE_COMPLETE_HIST", "16"))
164
163
 
165
- # NOTE: App
166
- @property
167
- def max_schedule_process(self) -> int:
168
- return int(env("APP_MAX_PROCESS", "2"))
169
-
170
- @property
171
- def max_schedule_per_process(self) -> int:
172
- return int(env("APP_MAX_SCHEDULE_PER_PROCESS", "100"))
173
-
174
- @property
175
- def stop_boundary_delta(self) -> timedelta:
176
- stop_boundary_delta_str: str = env(
177
- "APP_STOP_BOUNDARY_DELTA", '{"minutes": 5, "seconds": 20}'
178
- )
179
- try:
180
- return timedelta(**json.loads(stop_boundary_delta_str))
181
- except Exception as err:
182
- raise ValueError(
183
- "Config `WORKFLOW_APP_STOP_BOUNDARY_DELTA` can not parsing to"
184
- f"timedelta with {stop_boundary_delta_str}."
185
- ) from err
186
-
187
164
 
188
165
  class APIConfig:
189
166
  """API Config object."""
@@ -192,14 +169,6 @@ class APIConfig:
192
169
  def prefix_path(self) -> str:
193
170
  return env("API_PREFIX_PATH", "/api/v1")
194
171
 
195
- @property
196
- def enable_route_workflow(self) -> bool:
197
- return str2bool(env("API_ENABLE_ROUTE_WORKFLOW", "true"))
198
-
199
- @property
200
- def enable_route_schedule(self) -> bool:
201
- return str2bool(env("API_ENABLE_ROUTE_SCHEDULE", "true"))
202
-
203
172
 
204
173
  class BaseLoad(ABC): # pragma: no cov
205
174
  """Base Load object is the abstraction object for any Load object that
@@ -321,15 +290,16 @@ class FileLoad(BaseLoad):
321
290
  *,
322
291
  path: Optional[Path] = None,
323
292
  paths: Optional[list[Path]] = None,
324
- excluded: list[str] | None = None,
293
+ excluded: Optional[list[str]] = None,
325
294
  extras: Optional[DictData] = None,
326
295
  ) -> Iterator[tuple[str, DictData]]:
327
296
  """Find all data that match with object type in config path. This class
328
297
  method can use include and exclude list of identity name for filter and
329
298
  adds-on.
330
299
 
331
- :param obj: An object that want to validate matching before return.
332
- :param path: A config path object.
300
+ :param obj: (object) An object that want to validate matching before
301
+ return.
302
+ :param path: (Path) A config path object.
333
303
  :param paths: (list[Path]) A list of config path object.
334
304
  :param excluded: An included list of data key that want to filter from
335
305
  data.
@@ -474,3 +444,37 @@ class Loader(Protocol): # pragma: no cov
474
444
  def finds(
475
445
  cls, obj: object, *args, **kwargs
476
446
  ) -> Iterator[tuple[str, DictData]]: ...
447
+
448
+
449
+ def pass_env(value: T) -> T: # pragma: no cov
450
+ """Passing environment variable to an input value.
451
+
452
+ :param value: (Any) A value that want to pass env var searching.
453
+
454
+ :rtype: Any
455
+ """
456
+ if isinstance(value, dict):
457
+ return {k: pass_env(value[k]) for k in value}
458
+ elif isinstance(value, (list, tuple, set)):
459
+ return type(value)([pass_env(i) for i in value])
460
+ if not isinstance(value, str):
461
+ return value
462
+
463
+ rs: str = search_env_replace(value)
464
+ return None if rs == "null" else rs
465
+
466
+
467
+ class CallerSecret(SecretStr): # pragma: no cov
468
+ """Workflow Secret String model."""
469
+
470
+ def get_secret_value(self) -> str:
471
+ """Override get_secret_value by adding pass_env before return the
472
+ real-value.
473
+
474
+ :rtype: str
475
+ """
476
+ return pass_env(super().get_secret_value())
477
+
478
+
479
+ # NOTE: Define the caller secret type for use it directly in the caller func.
480
+ CallerSecretType = TypeAdapter(CallerSecret)
ddeutil/workflow/event.py CHANGED
@@ -17,6 +17,7 @@ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
17
17
  from pydantic import BaseModel, ConfigDict, Field, ValidationInfo
18
18
  from pydantic.functional_serializers import field_serializer
19
19
  from pydantic.functional_validators import field_validator, model_validator
20
+ from pydantic_extra_types.timezone_name import TimeZoneName
20
21
  from typing_extensions import Self
21
22
 
22
23
  from .__cron import WEEKDAYS, CronJob, CronJobYear, CronRunner, Options
@@ -92,7 +93,7 @@ class Crontab(BaseModel):
92
93
  ),
93
94
  ]
94
95
  tz: Annotated[
95
- str,
96
+ TimeZoneName,
96
97
  Field(
97
98
  description="A timezone string value",
98
99
  alias="timezone",
@@ -83,6 +83,3 @@ class WorkflowException(BaseWorkflowException): ...
83
83
 
84
84
 
85
85
  class ParamValueException(WorkflowException): ...
86
-
87
-
88
- class ScheduleException(BaseWorkflowException): ...
@@ -14,7 +14,17 @@ 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
+ Annotated,
19
+ Any,
20
+ Callable,
21
+ Literal,
22
+ Optional,
23
+ Protocol,
24
+ TypeVar,
25
+ Union,
26
+ get_type_hints,
27
+ )
18
28
 
19
29
  try:
20
30
  from typing import ParamSpec
@@ -23,6 +33,8 @@ except ImportError:
23
33
 
24
34
  from ddeutil.core import getdot, import_string, lazy
25
35
  from ddeutil.io import search_env_replace
36
+ from pydantic import BaseModel, ConfigDict, Field, create_model
37
+ from pydantic.alias_generators import to_pascal
26
38
  from pydantic.dataclasses import dataclass
27
39
 
28
40
  from .__types import DictData, Re
@@ -111,7 +123,7 @@ def make_filter_registry(
111
123
  if not (
112
124
  hasattr(func, "filter")
113
125
  and str(getattr(func, "mark", "NOT SET")) == "filter"
114
- ):
126
+ ): # pragma: no cov
115
127
  continue
116
128
 
117
129
  func: FilterFunc
@@ -231,6 +243,7 @@ def not_in_template(value: Any, *, not_in: str = "matrix.") -> bool:
231
243
 
232
244
  :param value: A value that want to find parameter template prefix.
233
245
  :param not_in: The not-in string that use in the `.startswith` function.
246
+ (Default is `matrix.`)
234
247
 
235
248
  :rtype: bool
236
249
  """
@@ -279,7 +292,7 @@ def str2template(
279
292
  :param value: (str) A string value that want to map with params.
280
293
  :param params: (DictData) A parameter value that getting with matched
281
294
  regular expression.
282
- :param filters: A mapping of filter registry.
295
+ :param filters: (dict[str, FilterRegistry]) A mapping of filter registry.
283
296
  :param registers: (Optional[list[str]]) Override list of register.
284
297
 
285
298
  :rtype: str
@@ -304,12 +317,14 @@ def str2template(
304
317
  ]
305
318
 
306
319
  # NOTE: from validate step, it guarantees that caller exists in params.
320
+ # I recommend to avoid logging params context on this case because it
321
+ # can include secret value.
307
322
  try:
308
323
  getter: Any = getdot(caller, params)
309
- except ValueError as err:
324
+ except ValueError:
310
325
  raise UtilException(
311
326
  f"Parameters does not get dot with caller: {caller!r}."
312
- ) from err
327
+ ) from None
313
328
 
314
329
  # NOTE:
315
330
  # If type of getter caller is not string type, and it does not use to
@@ -341,10 +356,11 @@ def param2template(
341
356
  """Pass param to template string that can search by ``RE_CALLER`` regular
342
357
  expression.
343
358
 
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.
359
+ :param value: (Any) A value that want to map with params.
360
+ :param params: (DictData) A parameter value that getting with matched
361
+ regular expression.
362
+ :param filters: (dict[str, FilterRegistry]) A filter mapping for mapping
363
+ with `map_post_filter` func.
348
364
  :param extras: (Optional[list[str]]) An Override extras.
349
365
 
350
366
  :rtype: Any
@@ -376,8 +392,8 @@ def datetime_format(value: datetime, fmt: str = "%Y-%m-%d %H:%M:%S") -> str:
376
392
 
377
393
  Examples:
378
394
 
379
- > ${{ start-date | fmt('%Y%m%d') }}
380
- > ${{ start-date | fmt }}
395
+ >>> "${{ start-date | fmt('%Y%m%d') }}"
396
+ >>> "${{ start-date | fmt }}"
381
397
 
382
398
  :param value: (datetime) A datetime value that want to format to string
383
399
  value.
@@ -399,7 +415,7 @@ def coalesce(value: Optional[T], default: Any) -> T:
399
415
 
400
416
  Examples:
401
417
 
402
- > ${{ value | coalesce("foo") }}
418
+ >>> "${{ value | coalesce('foo') }}"
403
419
 
404
420
  :param value: A value that want to check nullable.
405
421
  :param default: A default value that use to returned value if an input
@@ -412,7 +428,14 @@ def coalesce(value: Optional[T], default: Any) -> T:
412
428
  def get_item(
413
429
  value: DictData, key: Union[str, int], default: Optional[Any] = None
414
430
  ) -> Any:
415
- """Get a value with an input specific key."""
431
+ """Get a value with an input specific key.
432
+
433
+ Examples:
434
+
435
+ >>> "${{ value | getitem('key') }}"
436
+ >>> "${{ value | getitem('key', 'default') }}"
437
+
438
+ """
416
439
  if not isinstance(value, dict):
417
440
  raise UtilException(
418
441
  f"The value that pass to `getitem` filter should be `dict` not "
@@ -422,7 +445,14 @@ def get_item(
422
445
 
423
446
 
424
447
  @custom_filter("getindex") # pragma: no cov
425
- def get_index(value: list[Any], index: int):
448
+ def get_index(value: list[Any], index: int) -> Any:
449
+ """Get a value with an input specific index.
450
+
451
+ Examples:
452
+
453
+ >>> "${{ value | getindex(1) }}"
454
+
455
+ """
426
456
  if not isinstance(value, list):
427
457
  raise UtilException(
428
458
  f"The value that pass to `getindex` filter should be `list` not "
@@ -605,3 +635,63 @@ def extract_call(
605
635
  f"`REGISTER.{call.path}.registries.{call.func}`"
606
636
  )
607
637
  return rgt[call.func][call.tag]
638
+
639
+
640
+ class BaseCallerArgs(BaseModel): # pragma: no cov
641
+ """Base Caller Args model."""
642
+
643
+ model_config = ConfigDict(
644
+ arbitrary_types_allowed=True,
645
+ use_enum_values=True,
646
+ )
647
+
648
+
649
+ def create_model_from_caller(func: Callable) -> BaseModel: # pragma: no cov
650
+ """Create model from the caller function. This function will use for
651
+ validate the caller function argument typed-hint that valid with the args
652
+ field.
653
+
654
+ Reference:
655
+ - https://github.com/lmmx/pydantic-function-models
656
+ - https://docs.pydantic.dev/1.10/usage/models/#dynamic-model-creation
657
+
658
+ :param func: (Callable) A caller function.
659
+
660
+ :rtype: BaseModel
661
+ """
662
+ sig: inspect.Signature = inspect.signature(func)
663
+ type_hints: dict[str, Any] = get_type_hints(func)
664
+ fields: dict[str, Any] = {}
665
+ for name in sig.parameters:
666
+ param: inspect.Parameter = sig.parameters[name]
667
+
668
+ # NOTE: Skip all `*args` and `**kwargs` parameters.
669
+ if param.kind in (
670
+ inspect.Parameter.VAR_KEYWORD,
671
+ inspect.Parameter.VAR_POSITIONAL,
672
+ ):
673
+ continue
674
+
675
+ if name.startswith("_"):
676
+ kwargs = {"serialization_alias": name}
677
+ rename: str = name.removeprefix("_")
678
+ else:
679
+ kwargs = {}
680
+ rename: str = name
681
+
682
+ if param.default != inspect.Parameter.empty:
683
+ fields[rename] = Annotated[
684
+ type_hints[name],
685
+ Field(default=param.default, **kwargs),
686
+ ]
687
+ else:
688
+ fields[rename] = Annotated[
689
+ type_hints[name],
690
+ Field(..., **kwargs),
691
+ ]
692
+
693
+ return create_model(
694
+ to_pascal(func.__name__),
695
+ __base__=BaseCallerArgs,
696
+ **fields,
697
+ )