ddeutil-workflow 0.0.26.post1__py3-none-any.whl → 0.0.28__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.26.post1"
1
+ __version__: str = "0.0.28"
@@ -8,6 +8,7 @@ from .conf import (
8
8
  Config,
9
9
  Loader,
10
10
  Log,
11
+ config,
11
12
  env,
12
13
  get_log,
13
14
  get_logger,
@@ -24,6 +25,13 @@ from .exceptions import (
24
25
  UtilException,
25
26
  WorkflowException,
26
27
  )
28
+ from .hook import (
29
+ ReturnTagFunc,
30
+ TagFunc,
31
+ extract_hook,
32
+ make_registry,
33
+ tag,
34
+ )
27
35
  from .job import (
28
36
  Job,
29
37
  Strategy,
@@ -48,33 +56,30 @@ from .stage import (
48
56
  PyStage,
49
57
  Stage,
50
58
  TriggerStage,
51
- extract_hook,
52
59
  )
53
- from .utils import (
60
+ from .templates import (
54
61
  FILTERS,
55
62
  FilterFunc,
56
63
  FilterRegistry,
57
- ReturnTagFunc,
58
- TagFunc,
64
+ custom_filter,
65
+ get_args_const,
66
+ has_template,
67
+ make_filter_registry,
68
+ map_post_filter,
69
+ not_in_template,
70
+ param2template,
71
+ str2template,
72
+ )
73
+ from .utils import (
59
74
  batch,
60
75
  cross_product,
61
- custom_filter,
62
76
  dash2underscore,
63
77
  delay,
64
78
  filter_func,
65
79
  gen_id,
66
- get_args_const,
67
80
  get_diff_sec,
68
81
  get_dt_now,
69
- has_template,
70
82
  make_exec,
71
- make_filter_registry,
72
- make_registry,
73
- map_post_filter,
74
- not_in_template,
75
- param2template,
76
- str2template,
77
- tag,
78
83
  )
79
84
  from .workflow import (
80
85
  Workflow,
@@ -5,25 +5,21 @@
5
5
  # ------------------------------------------------------------------------------
6
6
  from __future__ import annotations
7
7
 
8
- import asyncio
9
8
  import contextlib
10
- import uuid
11
9
  from collections.abc import AsyncIterator
12
10
  from datetime import datetime, timedelta
13
- from queue import Empty, Queue
14
11
  from typing import TypedDict
15
12
 
16
13
  from dotenv import load_dotenv
17
14
  from fastapi import FastAPI
18
15
  from fastapi.middleware.gzip import GZipMiddleware
19
16
  from fastapi.responses import UJSONResponse
20
- from pydantic import BaseModel
21
17
 
22
18
  from ..__about__ import __version__
23
19
  from ..conf import config, get_logger
24
20
  from ..scheduler import ReleaseThread, ReleaseThreads
25
21
  from ..workflow import WorkflowQueue, WorkflowTask
26
- from .repeat import repeat_at, repeat_every
22
+ from .repeat import repeat_at
27
23
 
28
24
  load_dotenv()
29
25
  logger = get_logger("ddeutil.workflow")
@@ -32,11 +28,6 @@ logger = get_logger("ddeutil.workflow")
32
28
  class State(TypedDict):
33
29
  """TypeDict for State of FastAPI application."""
34
30
 
35
- # NOTE: For upper queue route.
36
- upper_queue: Queue
37
- upper_result: dict[str, str]
38
-
39
- # NOTE: For schedule listener.
40
31
  scheduler: list[str]
41
32
  workflow_threads: ReleaseThreads
42
33
  workflow_tasks: list[WorkflowTask]
@@ -46,15 +37,11 @@ class State(TypedDict):
46
37
  @contextlib.asynccontextmanager
47
38
  async def lifespan(a: FastAPI) -> AsyncIterator[State]:
48
39
  """Lifespan function for the FastAPI application."""
49
- a.state.upper_queue = Queue()
50
- a.state.upper_result = {}
51
40
  a.state.scheduler = []
52
41
  a.state.workflow_threads = {}
53
42
  a.state.workflow_tasks = []
54
43
  a.state.workflow_queue = {}
55
44
 
56
- await asyncio.create_task(broker_upper_messages())
57
-
58
45
  yield {
59
46
  "upper_queue": a.state.upper_queue,
60
47
  "upper_result": a.state.upper_result,
@@ -87,50 +74,11 @@ app = FastAPI(
87
74
  app.add_middleware(GZipMiddleware, minimum_size=1000)
88
75
 
89
76
 
90
- @repeat_every(seconds=10)
91
- async def broker_upper_messages():
92
- """Broker for receive message from the `/upper` path and change it to upper
93
- case. This broker use interval running in background every 10 seconds.
94
- """
95
- for _ in range(10):
96
- try:
97
- obj = app.state.upper_queue.get_nowait()
98
- app.state.upper_result[obj["request_id"]] = obj["text"].upper()
99
- logger.info(f"Upper message: {app.state.upper_result}")
100
- except Empty:
101
- pass
102
- await asyncio.sleep(0.0001)
103
-
104
-
105
- class Payload(BaseModel):
106
- text: str
107
-
108
-
109
- async def get_result(request_id: str) -> dict[str, str]:
110
- """Get data from output dict that global."""
111
- while True:
112
- if request_id in app.state.upper_result:
113
- result: str = app.state.upper_result[request_id]
114
- del app.state.upper_result[request_id]
115
- return {"message": result}
116
- await asyncio.sleep(0.0025)
117
-
118
-
119
77
  @app.get("/")
120
78
  async def health():
121
79
  return {"message": "Workflow API already start up"}
122
80
 
123
81
 
124
- @app.post(f"{config.prefix_path}/upper")
125
- async def message_upper(payload: Payload):
126
- """Convert message from any case to the upper case."""
127
- request_id: str = str(uuid.uuid4())
128
- app.state.upper_queue.put(
129
- {"text": payload.text, "request_id": request_id},
130
- )
131
- return await get_result(request_id)
132
-
133
-
134
82
  # NOTE: Enable the workflow route.
135
83
  if config.enable_route_workflow:
136
84
  from .route import workflow_route
ddeutil/workflow/conf.py CHANGED
@@ -24,9 +24,11 @@ from typing_extensions import Self
24
24
 
25
25
  from .__types import DictData, TupleStr
26
26
 
27
+ PREFIX: str = "WORKFLOW"
28
+
27
29
 
28
30
  def env(var: str, default: str | None = None) -> str | None: # pragma: no cov
29
- return os.getenv(f"WORKFLOW_{var}", default)
31
+ return os.getenv(f"{PREFIX}_{var.upper().replace(' ', '_')}", default)
30
32
 
31
33
 
32
34
  def glob_files(path: Path) -> Iterator[Path]: # pragma: no cov
@@ -80,14 +82,18 @@ def get_logger(name: str):
80
82
 
81
83
 
82
84
  class Config: # pragma: no cov
83
- """Config object for keeping application configuration on current session
85
+ """Config object for keeping core configurations on the current session
84
86
  without changing when if the application still running.
87
+
88
+ The config value can change when you call that config property again.
85
89
  """
86
90
 
91
+ __slots__ = ()
92
+
87
93
  # NOTE: Core
88
94
  @property
89
95
  def root_path(self) -> Path:
90
- return Path(env("ROOT_PATH", "."))
96
+ return Path(env("CORE_ROOT_PATH", "."))
91
97
 
92
98
  @property
93
99
  def conf_path(self) -> Path:
@@ -95,7 +101,7 @@ class Config: # pragma: no cov
95
101
 
96
102
  :rtype: Path
97
103
  """
98
- return self.root_path / env("CORE_PATH_CONF", "conf")
104
+ return self.root_path / env("CORE_CONF_PATH", "conf")
99
105
 
100
106
  @property
101
107
  def tz(self) -> ZoneInfo:
@@ -108,15 +114,13 @@ class Config: # pragma: no cov
108
114
  # NOTE: Register
109
115
  @property
110
116
  def regis_hook(self) -> list[str]:
111
- regis_hook_str: str = env(
112
- "CORE_REGISTRY", "src,src.ddeutil.workflow,tests,tests.utils"
113
- )
117
+ regis_hook_str: str = env("CORE_REGISTRY", "src")
114
118
  return [r.strip() for r in regis_hook_str.split(",")]
115
119
 
116
120
  @property
117
121
  def regis_filter(self) -> list[str]:
118
122
  regis_filter_str: str = env(
119
- "CORE_REGISTRY_FILTER", "ddeutil.workflow.utils"
123
+ "CORE_REGISTRY_FILTER", "ddeutil.workflow.templates"
120
124
  )
121
125
  return [r.strip() for r in regis_filter_str.split(",")]
122
126
 
@@ -312,6 +316,10 @@ class SimLoad:
312
316
  )
313
317
 
314
318
 
319
+ config = Config()
320
+ logger = get_logger("ddeutil.workflow")
321
+
322
+
315
323
  class Loader(SimLoad):
316
324
  """Loader Object that get the config `yaml` file from current path.
317
325
 
@@ -337,21 +345,17 @@ class Loader(SimLoad):
337
345
  :rtype: Iterator[tuple[str, DictData]]
338
346
  """
339
347
  return super().finds(
340
- obj=obj, conf=Config(), included=included, excluded=excluded
348
+ obj=obj, conf=config, included=included, excluded=excluded
341
349
  )
342
350
 
343
351
  def __init__(self, name: str, externals: DictData) -> None:
344
- super().__init__(name, conf=Config(), externals=externals)
345
-
346
-
347
- config = Config()
348
- logger = get_logger("ddeutil.workflow")
352
+ super().__init__(name, conf=config, externals=externals)
349
353
 
350
354
 
351
355
  class BaseLog(BaseModel, ABC):
352
356
  """Base Log Pydantic Model with abstraction class property that implement
353
357
  only model fields. This model should to use with inherit to logging
354
- sub-class like file, sqlite, etc.
358
+ subclass like file, sqlite, etc.
355
359
  """
356
360
 
357
361
  name: str = Field(description="A workflow name.")
@@ -359,9 +363,7 @@ class BaseLog(BaseModel, ABC):
359
363
  type: str = Field(description="A running type before logging.")
360
364
  context: DictData = Field(
361
365
  default_factory=dict,
362
- description=(
363
- "A context data that receive from a workflow execution result.",
364
- ),
366
+ description="A context that receive from a workflow execution result.",
365
367
  )
366
368
  parent_run_id: Optional[str] = Field(default=None)
367
369
  run_id: str
@@ -388,7 +390,7 @@ class BaseLog(BaseModel, ABC):
388
390
 
389
391
  class FileLog(BaseLog):
390
392
  """File Log Pydantic Model that use to saving log data from result of
391
- workflow execution. It inherit from BaseLog model that implement the
393
+ workflow execution. It inherits from BaseLog model that implement the
392
394
  ``self.save`` method for file.
393
395
  """
394
396
 
@@ -427,8 +429,8 @@ class FileLog(BaseLog):
427
429
  workflow name and release values. If a release does not pass to an input
428
430
  argument, it will return the latest release from the current log path.
429
431
 
430
- :param name:
431
- :param release:
432
+ :param name: A workflow name that want to search log.
433
+ :param release: A release datetime that want to search log.
432
434
 
433
435
  :raise FileNotFoundError:
434
436
  :raise NotImplementedError:
@@ -492,8 +494,14 @@ class FileLog(BaseLog):
492
494
 
493
495
  :rtype: Self
494
496
  """
497
+ from .utils import cut_id
498
+
495
499
  # NOTE: Check environ variable was set for real writing.
496
500
  if not config.enable_write_log:
501
+ logger.debug(
502
+ f"({cut_id(self.run_id)}) [LOG]: Skip writing log cause "
503
+ f"config was set"
504
+ )
497
505
  return self
498
506
 
499
507
  log_file: Path = self.pointer() / f"{self.run_id}.log"
@@ -522,7 +530,20 @@ class SQLiteLog(BaseLog): # pragma: no cov
522
530
  primary key ( run_id )
523
531
  """
524
532
 
525
- def save(self, excluded: list[str] | None) -> None:
533
+ def save(self, excluded: list[str] | None) -> SQLiteLog:
534
+ """Save logging data that receive a context data from a workflow
535
+ execution result.
536
+ """
537
+ from .utils import cut_id
538
+
539
+ # NOTE: Check environ variable was set for real writing.
540
+ if not config.enable_write_log:
541
+ logger.debug(
542
+ f"({cut_id(self.run_id)}) [LOG]: Skip writing log cause "
543
+ f"config was set"
544
+ )
545
+ return self
546
+
526
547
  raise NotImplementedError("SQLiteLog does not implement yet.")
527
548
 
528
549
 
@@ -532,7 +553,7 @@ Log = Union[
532
553
  ]
533
554
 
534
555
 
535
- def get_log() -> Log: # pragma: no cov
556
+ def get_log() -> type[Log]: # pragma: no cov
536
557
  if config.log_path.is_file():
537
558
  return SQLiteLog
538
559
  return FileLog
ddeutil/workflow/cron.py CHANGED
@@ -32,10 +32,10 @@ def interval2crontab(
32
32
  ) -> str:
33
33
  """Return the crontab string that was generated from specific values.
34
34
 
35
- :param interval: A interval value that is one of 'daily', 'weekly', or
35
+ :param interval: An interval value that is one of 'daily', 'weekly', or
36
36
  'monthly'.
37
37
  :param day: A day value that will be day of week. The default value is
38
- monday if it be weekly interval.
38
+ monday if it is weekly interval.
39
39
  :param time: A time value that passing with format '%H:%M'.
40
40
 
41
41
  Examples:
@@ -50,9 +50,9 @@ def interval2crontab(
50
50
  """
51
51
  d: str = "*"
52
52
  if interval == "weekly":
53
- d = WEEKDAYS[(day or "monday")[:3].title()]
53
+ d = str(WEEKDAYS[(day or "monday")[:3].title()])
54
54
  elif interval == "monthly" and day:
55
- d = WEEKDAYS[day[:3].title()]
55
+ d = str(WEEKDAYS[day[:3].title()])
56
56
 
57
57
  h, m = tuple(
58
58
  i.lstrip("0") if i != "00" else "0" for i in time.split(":", maxsplit=1)
@@ -95,7 +95,7 @@ class On(BaseModel):
95
95
 
96
96
  :param value: A mapping value that will generate crontab before create
97
97
  schedule model.
98
- :param externals: A extras external parameter that will keep in extras.
98
+ :param externals: An extras external parameter that will keep in extras.
99
99
  """
100
100
  passing: DictStr = {}
101
101
  if "timezone" in value:
@@ -114,8 +114,8 @@ class On(BaseModel):
114
114
  """Constructor from the name of config that will use loader object for
115
115
  getting the data.
116
116
 
117
- :param name: A name of config that will getting from loader.
118
- :param externals: A extras external parameter that will keep in extras.
117
+ :param name: A name of config that will get from loader.
118
+ :param externals: An extras external parameter that will keep in extras.
119
119
  """
120
120
  externals: DictData = externals or {}
121
121
  loader: Loader = Loader(name, externals=externals)
@@ -4,7 +4,7 @@
4
4
  # license information.
5
5
  # ------------------------------------------------------------------------------
6
6
  """Exception objects for this package do not do anything because I want to
7
- create the lightweight workflow package. So, this module do just a exception
7
+ create the lightweight workflow package. So, this module do just an exception
8
8
  annotate for handle error only.
9
9
  """
10
10
  from __future__ import annotations
@@ -29,6 +29,3 @@ class WorkflowFailException(WorkflowException): ...
29
29
 
30
30
 
31
31
  class ParamValueException(WorkflowException): ...
32
-
33
-
34
- class CliException(BaseWorkflowException): ...
@@ -0,0 +1,168 @@
1
+ # ------------------------------------------------------------------------------
2
+ # Copyright (c) 2022 Korawich Anuttra. All rights reserved.
3
+ # Licensed under the MIT License. See LICENSE in the project root for
4
+ # license information.
5
+ # ------------------------------------------------------------------------------
6
+ from __future__ import annotations
7
+
8
+ import inspect
9
+ import logging
10
+ from dataclasses import dataclass
11
+ from functools import wraps
12
+ from importlib import import_module
13
+ from typing import Any, Callable, Protocol, TypeVar
14
+
15
+ try:
16
+ from typing import ParamSpec
17
+ except ImportError:
18
+ from typing_extensions import ParamSpec
19
+
20
+ from ddeutil.core import lazy
21
+
22
+ from .__types import Re
23
+ from .conf import config
24
+
25
+ T = TypeVar("T")
26
+ P = ParamSpec("P")
27
+
28
+ logger = logging.getLogger("ddeutil.workflow")
29
+
30
+
31
+ class TagFunc(Protocol):
32
+ """Tag Function Protocol"""
33
+
34
+ name: str
35
+ tag: str
36
+
37
+ def __call__(self, *args, **kwargs): ... # pragma: no cov
38
+
39
+
40
+ ReturnTagFunc = Callable[P, TagFunc]
41
+ DecoratorTagFunc = Callable[[Callable[[...], Any]], ReturnTagFunc]
42
+
43
+
44
+ def tag(
45
+ name: str, alias: str | None = None
46
+ ) -> DecoratorTagFunc: # pragma: no cov
47
+ """Tag decorator function that set function attributes, ``tag`` and ``name``
48
+ for making registries variable.
49
+
50
+ :param: name: A tag name for make different use-case of a function.
51
+ :param: alias: A alias function name that keeping in registries. If this
52
+ value does not supply, it will use original function name from __name__.
53
+ :rtype: Callable[P, TagFunc]
54
+ """
55
+
56
+ def func_internal(func: Callable[[...], Any]) -> ReturnTagFunc:
57
+ func.tag = name
58
+ func.name = alias or func.__name__.replace("_", "-")
59
+
60
+ @wraps(func)
61
+ def wrapped(*args, **kwargs):
62
+ # NOTE: Able to do anything before calling hook function.
63
+ return func(*args, **kwargs)
64
+
65
+ return wrapped
66
+
67
+ return func_internal
68
+
69
+
70
+ Registry = dict[str, Callable[[], TagFunc]]
71
+
72
+
73
+ def make_registry(submodule: str) -> dict[str, Registry]:
74
+ """Return registries of all functions that able to called with task.
75
+
76
+ :param submodule: A module prefix that want to import registry.
77
+ :rtype: dict[str, Registry]
78
+ """
79
+ rs: dict[str, Registry] = {}
80
+ for module in config.regis_hook:
81
+ # NOTE: try to sequential import task functions
82
+ try:
83
+ importer = import_module(f"{module}.{submodule}")
84
+ except ModuleNotFoundError:
85
+ continue
86
+
87
+ for fstr, func in inspect.getmembers(importer, inspect.isfunction):
88
+ # NOTE: check function attribute that already set tag by
89
+ # ``utils.tag`` decorator.
90
+ if not (hasattr(func, "tag") and hasattr(func, "name")):
91
+ continue
92
+
93
+ # NOTE: Define type of the func value.
94
+ func: TagFunc
95
+
96
+ # NOTE: Create new register name if it not exists
97
+ if func.name not in rs:
98
+ rs[func.name] = {func.tag: lazy(f"{module}.{submodule}.{fstr}")}
99
+ continue
100
+
101
+ if func.tag in rs[func.name]:
102
+ raise ValueError(
103
+ f"The tag {func.tag!r} already exists on "
104
+ f"{module}.{submodule}, you should change this tag name or "
105
+ f"change it func name."
106
+ )
107
+ rs[func.name][func.tag] = lazy(f"{module}.{submodule}.{fstr}")
108
+
109
+ return rs
110
+
111
+
112
+ @dataclass(frozen=True)
113
+ class HookSearchData:
114
+ """Hook Search dataclass that use for receive regular expression grouping
115
+ dict from searching hook string value.
116
+ """
117
+
118
+ path: str
119
+ func: str
120
+ tag: str
121
+
122
+
123
+ def extract_hook(hook: str) -> Callable[[], TagFunc]:
124
+ """Extract Hook function from string value to hook partial function that
125
+ does run it at runtime.
126
+
127
+ :raise NotImplementedError: When the searching hook's function result does
128
+ not exist in the registry.
129
+ :raise NotImplementedError: When the searching hook's tag result does not
130
+ exist in the registry with its function key.
131
+
132
+ :param hook: A hook value that able to match with Task regex.
133
+
134
+ The format of hook value should contain 3 regular expression groups
135
+ which match with the below config format:
136
+
137
+ >>> "^(?P<path>[^/@]+)/(?P<func>[^@]+)@(?P<tag>.+)$"
138
+
139
+ Examples:
140
+ >>> extract_hook("tasks/el-postgres-to-delta@polars")
141
+ ...
142
+ >>> extract_hook("tasks/return-type-not-valid@raise")
143
+ ...
144
+
145
+ :rtype: Callable[[], TagFunc]
146
+ """
147
+ if not (found := Re.RE_TASK_FMT.search(hook)):
148
+ raise ValueError(
149
+ f"Hook {hook!r} does not match with hook format regex."
150
+ )
151
+
152
+ # NOTE: Pass the searching hook string to `path`, `func`, and `tag`.
153
+ hook: HookSearchData = HookSearchData(**found.groupdict())
154
+
155
+ # NOTE: Registry object should implement on this package only.
156
+ rgt: dict[str, Registry] = make_registry(f"{hook.path}")
157
+ if hook.func not in rgt:
158
+ raise NotImplementedError(
159
+ f"``REGISTER-MODULES.{hook.path}.registries`` does not "
160
+ f"implement registry: {hook.func!r}."
161
+ )
162
+
163
+ if hook.tag not in rgt[hook.func]:
164
+ raise NotImplementedError(
165
+ f"tag: {hook.tag!r} does not found on registry func: "
166
+ f"``REGISTER-MODULES.{hook.path}.registries.{hook.func}``"
167
+ )
168
+ return rgt[hook.func][hook.tag]