ddeutil-workflow 0.0.15__py3-none-any.whl → 0.0.16__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.
- ddeutil/workflow/__about__.py +1 -1
- ddeutil/workflow/__types.py +18 -6
- ddeutil/workflow/api.py +3 -4
- ddeutil/workflow/cli.py +2 -5
- ddeutil/workflow/conf.py +276 -3
- ddeutil/workflow/job.py +42 -25
- ddeutil/workflow/log.py +5 -8
- ddeutil/workflow/on.py +1 -1
- ddeutil/workflow/repeat.py +2 -5
- ddeutil/workflow/route.py +4 -11
- ddeutil/workflow/scheduler.py +64 -46
- ddeutil/workflow/stage.py +33 -28
- ddeutil/workflow/utils.py +79 -266
- {ddeutil_workflow-0.0.15.dist-info → ddeutil_workflow-0.0.16.dist-info}/METADATA +39 -23
- ddeutil_workflow-0.0.16.dist-info/RECORD +22 -0
- {ddeutil_workflow-0.0.15.dist-info → ddeutil_workflow-0.0.16.dist-info}/WHEEL +1 -1
- ddeutil_workflow-0.0.15.dist-info/RECORD +0 -22
- {ddeutil_workflow-0.0.15.dist-info → ddeutil_workflow-0.0.16.dist-info}/LICENSE +0 -0
- {ddeutil_workflow-0.0.15.dist-info → ddeutil_workflow-0.0.16.dist-info}/entry_points.txt +0 -0
- {ddeutil_workflow-0.0.15.dist-info → ddeutil_workflow-0.0.16.dist-info}/top_level.txt +0 -0
ddeutil/workflow/__about__.py
CHANGED
@@ -1 +1 @@
|
|
1
|
-
__version__: str = "0.0.
|
1
|
+
__version__: str = "0.0.16"
|
ddeutil/workflow/__types.py
CHANGED
@@ -16,7 +16,7 @@ from re import (
|
|
16
16
|
Match,
|
17
17
|
Pattern,
|
18
18
|
)
|
19
|
-
from typing import Any, Optional, Union
|
19
|
+
from typing import Any, Optional, TypedDict, Union
|
20
20
|
|
21
21
|
from typing_extensions import Self
|
22
22
|
|
@@ -24,8 +24,11 @@ TupleStr = tuple[str, ...]
|
|
24
24
|
DictData = dict[str, Any]
|
25
25
|
DictStr = dict[str, str]
|
26
26
|
Matrix = dict[str, Union[list[str], list[int]]]
|
27
|
-
|
28
|
-
|
27
|
+
|
28
|
+
|
29
|
+
class Context(TypedDict):
|
30
|
+
params: dict[str, Any]
|
31
|
+
jobs: dict[str, Any]
|
29
32
|
|
30
33
|
|
31
34
|
@dataclass(frozen=True)
|
@@ -56,20 +59,24 @@ class Re:
|
|
56
59
|
# Regular expression:
|
57
60
|
# - Version 1:
|
58
61
|
# \${{\s*(?P<caller>[a-zA-Z0-9_.\s'\"\[\]\(\)\-\{}]+?)\s*(?P<post_filters>(?:\|\s*(?:[a-zA-Z0-9_]{3,}[a-zA-Z0-9_.,-\\%\s'\"[\]()\{}]+)\s*)*)}}
|
59
|
-
# - Version 2 (2024-09-30):
|
62
|
+
# - Version 2: (2024-09-30):
|
60
63
|
# \${{\s*(?P<caller>(?P<caller_prefix>(?:[a-zA-Z_-]+\.)*)(?P<caller_last>[a-zA-Z0-9_\-.'\"(\)[\]{}]+))\s*(?P<post_filters>(?:\|\s*(?:[a-zA-Z0-9_]{3,}[a-zA-Z0-9_.,-\\%\s'\"[\]()\{}]+)\s*)*)}}
|
64
|
+
# - Version 3: (2024-10-05):
|
65
|
+
# \${{\s*(?P<caller>(?P<caller_prefix>(?:[a-zA-Z_-]+\??\.)*)(?P<caller_last>[a-zA-Z0-9_\-.'\"(\)[\]{}]+\??))\s*(?P<post_filters>(?:\|\s*(?:[a-zA-Z0-9_]{3,}[a-zA-Z0-9_.,-\\%\s'\"[\]()\{}]+)\s*)*)}}
|
61
66
|
#
|
62
67
|
# Examples:
|
63
68
|
# - ${{ params.data_dt }}
|
64
69
|
# - ${{ params.source.table }}
|
70
|
+
# - ${{ params.datetime | fmt('%Y-%m-%d') }}
|
71
|
+
# - ${{ params.source?.schema }}
|
65
72
|
#
|
66
73
|
__re_caller: str = r"""
|
67
74
|
\$
|
68
75
|
{{
|
69
76
|
\s*
|
70
77
|
(?P<caller>
|
71
|
-
(?P<caller_prefix>(?:[a-zA-Z_-]
|
72
|
-
(?P<caller_last>[a-zA-Z0-9_\-.'\"(\)[\]{}]
|
78
|
+
(?P<caller_prefix>(?:[a-zA-Z_-]+\??\.)*)
|
79
|
+
(?P<caller_last>[a-zA-Z0-9_\-.'\"(\)[\]{}]+\??)
|
73
80
|
)
|
74
81
|
\s*
|
75
82
|
(?P<post_filters>
|
@@ -109,5 +116,10 @@ class Re:
|
|
109
116
|
|
110
117
|
@classmethod
|
111
118
|
def finditer_caller(cls, value) -> Iterator[CallerRe]:
|
119
|
+
"""Generate CallerRe object that create from matching object that
|
120
|
+
extract with re.finditer function.
|
121
|
+
|
122
|
+
:rtype: Iterator[CallerRe]
|
123
|
+
"""
|
112
124
|
for found in cls.RE_CALLER.finditer(value):
|
113
125
|
yield CallerRe.from_regex(found)
|
ddeutil/workflow/api.py
CHANGED
@@ -7,7 +7,6 @@ from __future__ import annotations
|
|
7
7
|
|
8
8
|
import asyncio
|
9
9
|
import contextlib
|
10
|
-
import os
|
11
10
|
import uuid
|
12
11
|
from collections.abc import AsyncIterator
|
13
12
|
from datetime import datetime, timedelta
|
@@ -15,7 +14,6 @@ from queue import Empty, Queue
|
|
15
14
|
from threading import Thread
|
16
15
|
from typing import TypedDict
|
17
16
|
|
18
|
-
from ddeutil.core import str2bool
|
19
17
|
from dotenv import load_dotenv
|
20
18
|
from fastapi import FastAPI
|
21
19
|
from fastapi.middleware.gzip import GZipMiddleware
|
@@ -23,6 +21,7 @@ from fastapi.responses import UJSONResponse
|
|
23
21
|
from pydantic import BaseModel
|
24
22
|
|
25
23
|
from .__about__ import __version__
|
24
|
+
from .conf import config
|
26
25
|
from .log import get_logger
|
27
26
|
from .repeat import repeat_at, repeat_every
|
28
27
|
from .scheduler import WorkflowTaskData
|
@@ -131,12 +130,12 @@ async def message_upper(payload: Payload):
|
|
131
130
|
return await get_result(request_id)
|
132
131
|
|
133
132
|
|
134
|
-
if
|
133
|
+
if config.enable_route_workflow:
|
135
134
|
from .route import workflow
|
136
135
|
|
137
136
|
app.include_router(workflow)
|
138
137
|
|
139
|
-
if
|
138
|
+
if config.enable_route_schedule:
|
140
139
|
from .route import schedule
|
141
140
|
from .scheduler import workflow_task
|
142
141
|
|
ddeutil/workflow/cli.py
CHANGED
@@ -6,15 +6,14 @@
|
|
6
6
|
from __future__ import annotations
|
7
7
|
|
8
8
|
import json
|
9
|
-
import os
|
10
9
|
from datetime import datetime
|
11
10
|
from enum import Enum
|
12
11
|
from typing import Annotated, Optional
|
13
|
-
from zoneinfo import ZoneInfo
|
14
12
|
|
15
13
|
from ddeutil.core import str2list
|
16
14
|
from typer import Argument, Option, Typer
|
17
15
|
|
16
|
+
from .conf import config
|
18
17
|
from .log import get_logger
|
19
18
|
|
20
19
|
logger = get_logger("ddeutil.workflow")
|
@@ -73,9 +72,7 @@ def schedule(
|
|
73
72
|
excluded: list[str] = str2list(excluded) if excluded else []
|
74
73
|
externals: str = externals or "{}"
|
75
74
|
if stop:
|
76
|
-
stop: datetime = stop.astimezone(
|
77
|
-
tz=ZoneInfo(os.getenv("WORKFLOW_CORE_TIMEZONE", "UTC"))
|
78
|
-
)
|
75
|
+
stop: datetime = stop.astimezone(tz=config.tz)
|
79
76
|
|
80
77
|
from .scheduler import workflow_runner
|
81
78
|
|
ddeutil/workflow/conf.py
CHANGED
@@ -5,14 +5,26 @@
|
|
5
5
|
# ------------------------------------------------------------------------------
|
6
6
|
from __future__ import annotations
|
7
7
|
|
8
|
+
import json
|
8
9
|
import os
|
10
|
+
from collections.abc import Iterator
|
11
|
+
from datetime import timedelta
|
12
|
+
from functools import cached_property
|
13
|
+
from pathlib import Path
|
14
|
+
from typing import Any, TypeVar
|
9
15
|
from zoneinfo import ZoneInfo
|
10
16
|
|
11
|
-
from ddeutil.core import str2bool
|
17
|
+
from ddeutil.core import import_string, str2bool
|
18
|
+
from ddeutil.io import Paths, PathSearch, YamlFlResolve
|
12
19
|
from dotenv import load_dotenv
|
20
|
+
from pydantic import BaseModel, Field
|
21
|
+
from pydantic.functional_validators import model_validator
|
13
22
|
|
14
23
|
load_dotenv()
|
15
24
|
env = os.getenv
|
25
|
+
DictData = dict[str, Any]
|
26
|
+
AnyModel = TypeVar("AnyModel", bound=BaseModel)
|
27
|
+
AnyModelType = type[AnyModel]
|
16
28
|
|
17
29
|
|
18
30
|
class Config:
|
@@ -21,25 +33,286 @@ class Config:
|
|
21
33
|
"""
|
22
34
|
|
23
35
|
# NOTE: Core
|
36
|
+
root_path: Path = Path(os.getenv("WORKFLOW_ROOT_PATH", "."))
|
24
37
|
tz: ZoneInfo = ZoneInfo(env("WORKFLOW_CORE_TIMEZONE", "UTC"))
|
38
|
+
workflow_id_simple_mode: bool = str2bool(
|
39
|
+
os.getenv("WORKFLOW_CORE_WORKFLOW_ID_SIMPLE_MODE", "true")
|
40
|
+
)
|
41
|
+
|
42
|
+
# NOTE: Logging
|
43
|
+
debug: bool = str2bool(os.getenv("WORKFLOW_LOG_DEBUG_MODE", "true"))
|
44
|
+
enable_write_log: bool = str2bool(
|
45
|
+
os.getenv("WORKFLOW_LOG_ENABLE_WRITE", "false")
|
46
|
+
)
|
25
47
|
|
26
48
|
# NOTE: Stage
|
27
49
|
stage_raise_error: bool = str2bool(
|
28
|
-
env("WORKFLOW_CORE_STAGE_RAISE_ERROR", "
|
50
|
+
env("WORKFLOW_CORE_STAGE_RAISE_ERROR", "false")
|
29
51
|
)
|
30
52
|
stage_default_id: bool = str2bool(
|
31
53
|
env("WORKFLOW_CORE_STAGE_DEFAULT_ID", "false")
|
32
54
|
)
|
33
55
|
|
56
|
+
# NOTE: Job
|
57
|
+
job_default_id: bool = str2bool(
|
58
|
+
env("WORKFLOW_CORE_JOB_DEFAULT_ID", "false")
|
59
|
+
)
|
60
|
+
|
34
61
|
# NOTE: Workflow
|
35
62
|
max_job_parallel: int = int(env("WORKFLOW_CORE_MAX_JOB_PARALLEL", "2"))
|
63
|
+
max_poking_pool_worker: int = int(
|
64
|
+
os.getenv("WORKFLOW_CORE_MAX_NUM_POKING", "4")
|
65
|
+
)
|
66
|
+
|
67
|
+
# NOTE: Schedule App
|
68
|
+
max_schedule_process: int = int(env("WORKFLOW_APP_MAX_PROCESS", "2"))
|
69
|
+
max_schedule_per_process: int = int(
|
70
|
+
env("WORKFLOW_APP_MAX_SCHEDULE_PER_PROCESS", "100")
|
71
|
+
)
|
72
|
+
__stop_boundary_delta: str = env(
|
73
|
+
"WORKFLOW_APP_STOP_BOUNDARY_DELTA", '{"minutes": 5, "seconds": 20}'
|
74
|
+
)
|
75
|
+
|
76
|
+
# NOTE: API
|
77
|
+
enable_route_workflow: bool = str2bool(
|
78
|
+
os.getenv("WORKFLOW_API_ENABLE_ROUTE_WORKFLOW", "true")
|
79
|
+
)
|
80
|
+
enable_route_schedule: bool = str2bool(
|
81
|
+
os.getenv("WORKFLOW_API_ENABLE_ROUTE_SCHEDULE", "true")
|
82
|
+
)
|
36
83
|
|
37
84
|
def __init__(self):
|
38
85
|
if self.max_job_parallel < 0:
|
39
86
|
raise ValueError(
|
40
|
-
f"MAX_JOB_PARALLEL should more than 0 but got "
|
87
|
+
f"``MAX_JOB_PARALLEL`` should more than 0 but got "
|
41
88
|
f"{self.max_job_parallel}."
|
42
89
|
)
|
90
|
+
try:
|
91
|
+
self.stop_boundary_delta: timedelta = timedelta(
|
92
|
+
**json.loads(self.__stop_boundary_delta)
|
93
|
+
)
|
94
|
+
except Exception as err:
|
95
|
+
raise ValueError(
|
96
|
+
"Config ``WORKFLOW_APP_STOP_BOUNDARY_DELTA`` can not parsing to"
|
97
|
+
f"timedelta with {self.__stop_boundary_delta}."
|
98
|
+
) from err
|
99
|
+
|
100
|
+
def refresh_dotenv(self):
|
101
|
+
"""Reload environment variables from the current stage."""
|
102
|
+
self.tz: ZoneInfo = ZoneInfo(env("WORKFLOW_CORE_TIMEZONE", "UTC"))
|
103
|
+
self.stage_raise_error: bool = str2bool(
|
104
|
+
env("WORKFLOW_CORE_STAGE_RAISE_ERROR", "false")
|
105
|
+
)
|
106
|
+
|
107
|
+
|
108
|
+
class Engine(BaseModel):
|
109
|
+
"""Engine Pydantic Model for keeping application path."""
|
110
|
+
|
111
|
+
paths: Paths = Field(default_factory=Paths)
|
112
|
+
registry: list[str] = Field(
|
113
|
+
default_factory=lambda: ["ddeutil.workflow"], # pragma: no cover
|
114
|
+
)
|
115
|
+
registry_filter: list[str] = Field(
|
116
|
+
default_factory=lambda: ["ddeutil.workflow.utils"], # pragma: no cover
|
117
|
+
)
|
118
|
+
|
119
|
+
@model_validator(mode="before")
|
120
|
+
def __prepare_registry(cls, values: DictData) -> DictData:
|
121
|
+
"""Prepare registry value that passing with string type. It convert the
|
122
|
+
string type to list of string.
|
123
|
+
"""
|
124
|
+
if (_regis := values.get("registry")) and isinstance(_regis, str):
|
125
|
+
values["registry"] = [_regis]
|
126
|
+
if (_regis_filter := values.get("registry_filter")) and isinstance(
|
127
|
+
_regis_filter, str
|
128
|
+
):
|
129
|
+
values["registry_filter"] = [_regis_filter]
|
130
|
+
return values
|
131
|
+
|
132
|
+
|
133
|
+
class ConfParams(BaseModel):
|
134
|
+
"""Params Model"""
|
135
|
+
|
136
|
+
engine: Engine = Field(
|
137
|
+
default_factory=Engine,
|
138
|
+
description="A engine mapping values.",
|
139
|
+
)
|
140
|
+
|
141
|
+
|
142
|
+
def load_config() -> ConfParams:
|
143
|
+
"""Load Config data from ``workflows-conf.yaml`` file.
|
144
|
+
|
145
|
+
Configuration Docs:
|
146
|
+
---
|
147
|
+
:var engine.registry:
|
148
|
+
:var engine.registry_filter:
|
149
|
+
:var paths.root:
|
150
|
+
:var paths.conf:
|
151
|
+
"""
|
152
|
+
root_path: str = config.root_path
|
153
|
+
|
154
|
+
regis: list[str] = ["ddeutil.workflow"]
|
155
|
+
if regis_env := os.getenv("WORKFLOW_CORE_REGISTRY"):
|
156
|
+
regis = [r.strip() for r in regis_env.split(",")]
|
157
|
+
|
158
|
+
regis_filter: list[str] = ["ddeutil.workflow.utils"]
|
159
|
+
if regis_filter_env := os.getenv("WORKFLOW_CORE_REGISTRY_FILTER"):
|
160
|
+
regis_filter = [r.strip() for r in regis_filter_env.split(",")]
|
161
|
+
|
162
|
+
conf_path: str = (
|
163
|
+
f"{root_path}/{conf_env}"
|
164
|
+
if (conf_env := os.getenv("WORKFLOW_CORE_PATH_CONF"))
|
165
|
+
else None
|
166
|
+
)
|
167
|
+
return ConfParams.model_validate(
|
168
|
+
obj={
|
169
|
+
"engine": {
|
170
|
+
"registry": regis,
|
171
|
+
"registry_filter": regis_filter,
|
172
|
+
"paths": {
|
173
|
+
"root": root_path,
|
174
|
+
"conf": conf_path,
|
175
|
+
},
|
176
|
+
},
|
177
|
+
}
|
178
|
+
)
|
179
|
+
|
180
|
+
|
181
|
+
class SimLoad:
|
182
|
+
"""Simple Load Object that will search config data by given some identity
|
183
|
+
value like name of workflow or on.
|
184
|
+
|
185
|
+
:param name: A name of config data that will read by Yaml Loader object.
|
186
|
+
:param params: A Params model object.
|
187
|
+
:param externals: An external parameters
|
188
|
+
|
189
|
+
Noted:
|
190
|
+
|
191
|
+
The config data should have ``type`` key for modeling validation that
|
192
|
+
make this loader know what is config should to do pass to.
|
193
|
+
|
194
|
+
... <identity-key>:
|
195
|
+
... type: <importable-object>
|
196
|
+
... <key-data>: <value-data>
|
197
|
+
... ...
|
198
|
+
|
199
|
+
"""
|
200
|
+
|
201
|
+
def __init__(
|
202
|
+
self,
|
203
|
+
name: str,
|
204
|
+
params: ConfParams,
|
205
|
+
externals: DictData | None = None,
|
206
|
+
) -> None:
|
207
|
+
self.data: DictData = {}
|
208
|
+
for file in PathSearch(params.engine.paths.conf).files:
|
209
|
+
if any(file.suffix.endswith(s) for s in (".yml", ".yaml")) and (
|
210
|
+
data := YamlFlResolve(file).read().get(name, {})
|
211
|
+
):
|
212
|
+
self.data = data
|
213
|
+
|
214
|
+
# VALIDATE: check the data that reading should not empty.
|
215
|
+
if not self.data:
|
216
|
+
raise ValueError(f"Config {name!r} does not found on conf path")
|
217
|
+
|
218
|
+
self.conf_params: ConfParams = params
|
219
|
+
self.externals: DictData = externals or {}
|
220
|
+
self.data.update(self.externals)
|
221
|
+
|
222
|
+
@classmethod
|
223
|
+
def finds(
|
224
|
+
cls,
|
225
|
+
obj: object,
|
226
|
+
params: ConfParams,
|
227
|
+
*,
|
228
|
+
include: list[str] | None = None,
|
229
|
+
exclude: list[str] | None = None,
|
230
|
+
) -> Iterator[tuple[str, DictData]]:
|
231
|
+
"""Find all data that match with object type in config path. This class
|
232
|
+
method can use include and exclude list of identity name for filter and
|
233
|
+
adds-on.
|
234
|
+
|
235
|
+
:param obj: A object that want to validate matching before return.
|
236
|
+
:param params:
|
237
|
+
:param include:
|
238
|
+
:param exclude:
|
239
|
+
:rtype: Iterator[tuple[str, DictData]]
|
240
|
+
"""
|
241
|
+
exclude: list[str] = exclude or []
|
242
|
+
for file in PathSearch(params.engine.paths.conf).files:
|
243
|
+
if any(file.suffix.endswith(s) for s in (".yml", ".yaml")) and (
|
244
|
+
values := YamlFlResolve(file).read()
|
245
|
+
):
|
246
|
+
for key, data in values.items():
|
247
|
+
if key in exclude:
|
248
|
+
continue
|
249
|
+
if issubclass(get_type(data["type"], params), obj) and (
|
250
|
+
include is None or all(i in data for i in include)
|
251
|
+
):
|
252
|
+
yield key, data
|
253
|
+
|
254
|
+
@cached_property
|
255
|
+
def type(self) -> AnyModelType:
|
256
|
+
"""Return object of string type which implement on any registry. The
|
257
|
+
object type.
|
258
|
+
|
259
|
+
:rtype: AnyModelType
|
260
|
+
"""
|
261
|
+
if not (_typ := self.data.get("type")):
|
262
|
+
raise ValueError(
|
263
|
+
f"the 'type' value: {_typ} does not exists in config data."
|
264
|
+
)
|
265
|
+
return get_type(_typ, self.conf_params)
|
266
|
+
|
267
|
+
|
268
|
+
class Loader(SimLoad):
|
269
|
+
"""Loader Object that get the config `yaml` file from current path.
|
270
|
+
|
271
|
+
:param name: A name of config data that will read by Yaml Loader object.
|
272
|
+
:param externals: An external parameters
|
273
|
+
"""
|
274
|
+
|
275
|
+
@classmethod
|
276
|
+
def finds(
|
277
|
+
cls,
|
278
|
+
obj: object,
|
279
|
+
*,
|
280
|
+
include: list[str] | None = None,
|
281
|
+
exclude: list[str] | None = None,
|
282
|
+
**kwargs,
|
283
|
+
) -> DictData:
|
284
|
+
"""Override the find class method from the Simple Loader object.
|
285
|
+
|
286
|
+
:param obj: A object that want to validate matching before return.
|
287
|
+
:param include:
|
288
|
+
:param exclude:
|
289
|
+
"""
|
290
|
+
return super().finds(
|
291
|
+
obj=obj, params=load_config(), include=include, exclude=exclude
|
292
|
+
)
|
293
|
+
|
294
|
+
def __init__(self, name: str, externals: DictData) -> None:
|
295
|
+
super().__init__(name, load_config(), externals)
|
296
|
+
|
297
|
+
|
298
|
+
def get_type(t: str, params: ConfParams) -> AnyModelType:
|
299
|
+
"""Return import type from string importable value in the type key.
|
300
|
+
|
301
|
+
:param t: A importable type string.
|
302
|
+
:param params: A config parameters that use registry to search this
|
303
|
+
type.
|
304
|
+
:rtype: AnyModelType
|
305
|
+
"""
|
306
|
+
try:
|
307
|
+
# NOTE: Auto adding module prefix if it does not set
|
308
|
+
return import_string(f"ddeutil.workflow.{t}")
|
309
|
+
except ModuleNotFoundError:
|
310
|
+
for registry in params.engine.registry:
|
311
|
+
try:
|
312
|
+
return import_string(f"{registry}.{t}")
|
313
|
+
except ModuleNotFoundError:
|
314
|
+
continue
|
315
|
+
return import_string(f"{t}")
|
43
316
|
|
44
317
|
|
45
318
|
config = Config()
|
ddeutil/workflow/job.py
CHANGED
@@ -4,6 +4,9 @@
|
|
4
4
|
# license information.
|
5
5
|
# ------------------------------------------------------------------------------
|
6
6
|
"""Job Model that use for keeping stages and node that running its stages.
|
7
|
+
The job handle the lineage of stages and location of execution of stages that
|
8
|
+
mean the job model able to define ``runs-on`` key that allow you to run this
|
9
|
+
job.
|
7
10
|
"""
|
8
11
|
from __future__ import annotations
|
9
12
|
|
@@ -19,21 +22,15 @@ from concurrent.futures import (
|
|
19
22
|
from functools import lru_cache
|
20
23
|
from textwrap import dedent
|
21
24
|
from threading import Event
|
22
|
-
from typing import Optional
|
25
|
+
from typing import Optional, Union
|
23
26
|
|
24
27
|
from ddeutil.core import freeze_args
|
25
28
|
from pydantic import BaseModel, Field
|
26
29
|
from pydantic.functional_validators import field_validator, model_validator
|
27
30
|
from typing_extensions import Self
|
28
31
|
|
29
|
-
from .__types import
|
30
|
-
|
31
|
-
DictStr,
|
32
|
-
Matrix,
|
33
|
-
MatrixExclude,
|
34
|
-
MatrixInclude,
|
35
|
-
TupleStr,
|
36
|
-
)
|
32
|
+
from .__types import DictData, DictStr, Matrix, TupleStr
|
33
|
+
from .conf import config
|
37
34
|
from .exceptions import (
|
38
35
|
JobException,
|
39
36
|
StageException,
|
@@ -51,6 +48,8 @@ from .utils import (
|
|
51
48
|
)
|
52
49
|
|
53
50
|
logger = get_logger("ddeutil.workflow")
|
51
|
+
MatrixInclude = list[dict[str, Union[str, int]]]
|
52
|
+
MatrixExclude = list[dict[str, Union[str, int]]]
|
54
53
|
|
55
54
|
|
56
55
|
__all__: TupleStr = (
|
@@ -262,7 +261,7 @@ class Job(BaseModel):
|
|
262
261
|
)
|
263
262
|
|
264
263
|
@model_validator(mode="before")
|
265
|
-
def
|
264
|
+
def __prepare_keys__(cls, values: DictData) -> DictData:
|
266
265
|
"""Rename key that use dash to underscore because Python does not
|
267
266
|
support this character exist in any variable name.
|
268
267
|
|
@@ -273,12 +272,12 @@ class Job(BaseModel):
|
|
273
272
|
return values
|
274
273
|
|
275
274
|
@field_validator("desc", mode="after")
|
276
|
-
def
|
275
|
+
def ___prepare_desc__(cls, value: str) -> str:
|
277
276
|
"""Prepare description string that was created on a template."""
|
278
277
|
return dedent(value)
|
279
278
|
|
280
279
|
@model_validator(mode="after")
|
281
|
-
def
|
280
|
+
def __prepare_running_id__(self) -> Self:
|
282
281
|
"""Prepare the job running ID.
|
283
282
|
|
284
283
|
:rtype: Self
|
@@ -319,33 +318,43 @@ class Job(BaseModel):
|
|
319
318
|
For example of setting output method, If you receive execute output
|
320
319
|
and want to set on the `to` like;
|
321
320
|
|
322
|
-
... (i) output: {'
|
323
|
-
... (ii) to: {'jobs'}
|
321
|
+
... (i) output: {'strategy-01': bar, 'strategy-02': bar}
|
322
|
+
... (ii) to: {'jobs': {}}
|
324
323
|
|
325
324
|
The result of the `to` variable will be;
|
326
325
|
|
327
326
|
... (iii) to: {
|
328
|
-
|
327
|
+
'jobs': {
|
328
|
+
'<job-id>': {
|
329
329
|
'strategies': {
|
330
|
-
'
|
330
|
+
'strategy-01': bar,
|
331
|
+
'strategy-02': bar
|
331
332
|
}
|
332
333
|
}
|
333
334
|
}
|
335
|
+
}
|
334
336
|
|
335
337
|
:param output: An output context.
|
336
338
|
:param to: A context data that want to add output result.
|
337
339
|
:rtype: DictData
|
338
340
|
"""
|
339
|
-
if self.id is None:
|
341
|
+
if self.id is None and not config.job_default_id:
|
340
342
|
raise JobException(
|
341
|
-
"This job do not set the ID before setting output."
|
343
|
+
"This job do not set the ID before setting execution output."
|
342
344
|
)
|
343
345
|
|
344
|
-
|
346
|
+
# NOTE: Create jobs key to receive an output from the job execution.
|
347
|
+
if "jobs" not in to:
|
348
|
+
to["jobs"] = {}
|
349
|
+
|
350
|
+
# NOTE: If the job ID did not set, it will use index of jobs key
|
351
|
+
# instead.
|
352
|
+
_id: str = self.id or str(len(to["jobs"]) + 1)
|
353
|
+
|
354
|
+
logger.debug(f"({self.run_id}) [JOB]: Set outputs on: {_id}")
|
355
|
+
to["jobs"][_id] = (
|
345
356
|
{"strategies": output}
|
346
357
|
if self.strategy.is_set()
|
347
|
-
# NOTE:
|
348
|
-
# This is the best way to get single key from dict.
|
349
358
|
else output[next(iter(output))]
|
350
359
|
)
|
351
360
|
return to
|
@@ -384,6 +393,7 @@ class Job(BaseModel):
|
|
384
393
|
# "params": { ... }, <== Current input params
|
385
394
|
# "jobs": { ... }, <== Current input params
|
386
395
|
# "matrix": { ... } <== Current strategy value
|
396
|
+
# "stages": { ... } <== Catching stage outputs
|
387
397
|
# }
|
388
398
|
#
|
389
399
|
context: DictData = copy.deepcopy(params)
|
@@ -491,15 +501,18 @@ class Job(BaseModel):
|
|
491
501
|
:param params: An input parameters that use on job execution.
|
492
502
|
:rtype: Result
|
493
503
|
"""
|
494
|
-
|
504
|
+
|
505
|
+
# NOTE: I use this condition because this method allow passing empty
|
506
|
+
# params and I do not want to create new dict object.
|
495
507
|
params: DictData = {} if params is None else params
|
508
|
+
context: DictData = {}
|
496
509
|
|
497
510
|
# NOTE: Normal Job execution without parallel strategy.
|
498
511
|
if (not self.strategy.is_set()) or self.strategy.max_parallel == 1:
|
499
512
|
for strategy in self.strategy.make():
|
500
513
|
rs: Result = self.execute_strategy(
|
501
514
|
strategy=strategy,
|
502
|
-
params=
|
515
|
+
params=params,
|
503
516
|
)
|
504
517
|
context.update(rs.context)
|
505
518
|
return Result(
|
@@ -507,11 +520,15 @@ class Job(BaseModel):
|
|
507
520
|
context=context,
|
508
521
|
)
|
509
522
|
|
510
|
-
# NOTE: Create event for cancel executor stop running.
|
523
|
+
# NOTE: Create event for cancel executor by trigger stop running event.
|
511
524
|
event: Event = Event()
|
512
525
|
|
526
|
+
# IMPORTANT: Start running strategy execution by multithreading because
|
527
|
+
# it will running by strategy values without waiting previous
|
528
|
+
# execution.
|
513
529
|
with ThreadPoolExecutor(
|
514
|
-
max_workers=self.strategy.max_parallel
|
530
|
+
max_workers=self.strategy.max_parallel,
|
531
|
+
thread_name_prefix="job_strategy_exec_",
|
515
532
|
) as executor:
|
516
533
|
futures: list[Future] = [
|
517
534
|
executor.submit(
|
ddeutil/workflow/log.py
CHANGED
@@ -7,20 +7,18 @@ from __future__ import annotations
|
|
7
7
|
|
8
8
|
import json
|
9
9
|
import logging
|
10
|
-
import os
|
11
10
|
from abc import ABC, abstractmethod
|
12
11
|
from datetime import datetime
|
13
12
|
from functools import lru_cache
|
14
13
|
from pathlib import Path
|
15
14
|
from typing import ClassVar, Optional, Union
|
16
15
|
|
17
|
-
from ddeutil.core import str2bool
|
18
16
|
from pydantic import BaseModel, Field
|
19
17
|
from pydantic.functional_validators import model_validator
|
20
18
|
from typing_extensions import Self
|
21
19
|
|
22
20
|
from .__types import DictData
|
23
|
-
from .
|
21
|
+
from .conf import config, load_config
|
24
22
|
|
25
23
|
|
26
24
|
@lru_cache
|
@@ -42,8 +40,7 @@ def get_logger(name: str):
|
|
42
40
|
stream.setFormatter(formatter)
|
43
41
|
logger.addHandler(stream)
|
44
42
|
|
45
|
-
debug
|
46
|
-
logger.setLevel(logging.DEBUG if debug else logging.INFO)
|
43
|
+
logger.setLevel(logging.DEBUG if config.debug else logging.INFO)
|
47
44
|
return logger
|
48
45
|
|
49
46
|
|
@@ -72,7 +69,7 @@ class BaseLog(BaseModel, ABC):
|
|
72
69
|
|
73
70
|
:rtype: Self
|
74
71
|
"""
|
75
|
-
if
|
72
|
+
if config.enable_write_log:
|
76
73
|
self.do_before()
|
77
74
|
return self
|
78
75
|
|
@@ -141,7 +138,7 @@ class FileLog(BaseLog):
|
|
141
138
|
future.
|
142
139
|
"""
|
143
140
|
# NOTE: Check environ variable was set for real writing.
|
144
|
-
if not
|
141
|
+
if not config.enable_write_log:
|
145
142
|
return False
|
146
143
|
|
147
144
|
# NOTE: create pointer path that use the same logic of pointer method.
|
@@ -171,7 +168,7 @@ class FileLog(BaseLog):
|
|
171
168
|
:rtype: Self
|
172
169
|
"""
|
173
170
|
# NOTE: Check environ variable was set for real writing.
|
174
|
-
if not
|
171
|
+
if not config.enable_write_log:
|
175
172
|
return self
|
176
173
|
|
177
174
|
log_file: Path = self.pointer() / f"{self.run_id}.log"
|
ddeutil/workflow/on.py
CHANGED
@@ -15,8 +15,8 @@ from pydantic.functional_validators import field_validator, model_validator
|
|
15
15
|
from typing_extensions import Self
|
16
16
|
|
17
17
|
from .__types import DictData, DictStr, TupleStr
|
18
|
+
from .conf import Loader
|
18
19
|
from .cron import WEEKDAYS, CronJob, CronJobYear, CronRunner
|
19
|
-
from .utils import Loader
|
20
20
|
|
21
21
|
__all__: TupleStr = (
|
22
22
|
"On",
|