ddeutil-workflow 0.0.33__py3-none-any.whl → 0.0.35__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/__init__.py +19 -10
- ddeutil/workflow/api/api.py +13 -8
- ddeutil/workflow/api/routes/__init__.py +8 -0
- ddeutil/workflow/api/routes/logs.py +36 -0
- ddeutil/workflow/api/{route.py → routes/schedules.py} +2 -131
- ddeutil/workflow/api/routes/workflows.py +137 -0
- ddeutil/workflow/audit.py +28 -37
- ddeutil/workflow/{hook.py → caller.py} +27 -27
- ddeutil/workflow/conf.py +47 -12
- ddeutil/workflow/job.py +149 -138
- ddeutil/workflow/logs.py +214 -0
- ddeutil/workflow/params.py +40 -12
- ddeutil/workflow/result.py +40 -61
- ddeutil/workflow/scheduler.py +185 -163
- ddeutil/workflow/{stage.py → stages.py} +105 -42
- ddeutil/workflow/utils.py +20 -2
- ddeutil/workflow/workflow.py +142 -117
- {ddeutil_workflow-0.0.33.dist-info → ddeutil_workflow-0.0.35.dist-info}/METADATA +36 -32
- ddeutil_workflow-0.0.35.dist-info/RECORD +30 -0
- {ddeutil_workflow-0.0.33.dist-info → ddeutil_workflow-0.0.35.dist-info}/WHEEL +1 -1
- ddeutil_workflow-0.0.33.dist-info/RECORD +0 -26
- {ddeutil_workflow-0.0.33.dist-info → ddeutil_workflow-0.0.35.dist-info}/LICENSE +0 -0
- {ddeutil_workflow-0.0.33.dist-info → ddeutil_workflow-0.0.35.dist-info}/top_level.txt +0 -0
@@ -60,7 +60,7 @@ def tag(
|
|
60
60
|
|
61
61
|
@wraps(func)
|
62
62
|
def wrapped(*args: P.args, **kwargs: P.kwargs) -> TagFunc:
|
63
|
-
# NOTE: Able to do anything before calling
|
63
|
+
# NOTE: Able to do anything before calling the call function.
|
64
64
|
return func(*args, **kwargs)
|
65
65
|
|
66
66
|
return wrapped
|
@@ -79,9 +79,9 @@ def make_registry(submodule: str) -> dict[str, Registry]:
|
|
79
79
|
:rtype: dict[str, Registry]
|
80
80
|
"""
|
81
81
|
rs: dict[str, Registry] = {}
|
82
|
-
|
83
|
-
|
84
|
-
for module in
|
82
|
+
regis_calls: list[str] = config.regis_call
|
83
|
+
regis_calls.extend(["ddeutil.vendors"])
|
84
|
+
for module in regis_calls:
|
85
85
|
# NOTE: try to sequential import task functions
|
86
86
|
try:
|
87
87
|
importer = import_module(f"{module}.{submodule}")
|
@@ -114,9 +114,9 @@ def make_registry(submodule: str) -> dict[str, Registry]:
|
|
114
114
|
|
115
115
|
|
116
116
|
@dataclass(frozen=True)
|
117
|
-
class
|
118
|
-
"""
|
119
|
-
dict from searching
|
117
|
+
class CallSearchData:
|
118
|
+
"""Call Search dataclass that use for receive regular expression grouping
|
119
|
+
dict from searching call string value.
|
120
120
|
"""
|
121
121
|
|
122
122
|
path: str
|
@@ -124,49 +124,49 @@ class HookSearchData:
|
|
124
124
|
tag: str
|
125
125
|
|
126
126
|
|
127
|
-
def
|
128
|
-
"""Extract
|
127
|
+
def extract_call(call: str) -> Callable[[], TagFunc]:
|
128
|
+
"""Extract Call function from string value to call partial function that
|
129
129
|
does run it at runtime.
|
130
130
|
|
131
|
-
:raise NotImplementedError: When the searching
|
131
|
+
:raise NotImplementedError: When the searching call's function result does
|
132
132
|
not exist in the registry.
|
133
|
-
:raise NotImplementedError: When the searching
|
133
|
+
:raise NotImplementedError: When the searching call's tag result does not
|
134
134
|
exist in the registry with its function key.
|
135
135
|
|
136
|
-
:param
|
136
|
+
:param call: A call value that able to match with Task regex.
|
137
137
|
|
138
|
-
The format of
|
138
|
+
The format of call value should contain 3 regular expression groups
|
139
139
|
which match with the below config format:
|
140
140
|
|
141
141
|
>>> "^(?P<path>[^/@]+)/(?P<func>[^@]+)@(?P<tag>.+)$"
|
142
142
|
|
143
143
|
Examples:
|
144
|
-
>>>
|
144
|
+
>>> extract_call("tasks/el-postgres-to-delta@polars")
|
145
145
|
...
|
146
|
-
>>>
|
146
|
+
>>> extract_call("tasks/return-type-not-valid@raise")
|
147
147
|
...
|
148
148
|
|
149
149
|
:rtype: Callable[[], TagFunc]
|
150
150
|
"""
|
151
|
-
if not (found := Re.RE_TASK_FMT.search(
|
151
|
+
if not (found := Re.RE_TASK_FMT.search(call)):
|
152
152
|
raise ValueError(
|
153
|
-
f"
|
153
|
+
f"Call {call!r} does not match with the call regex format."
|
154
154
|
)
|
155
155
|
|
156
|
-
# NOTE: Pass the searching
|
157
|
-
|
156
|
+
# NOTE: Pass the searching call string to `path`, `func`, and `tag`.
|
157
|
+
call: CallSearchData = CallSearchData(**found.groupdict())
|
158
158
|
|
159
159
|
# NOTE: Registry object should implement on this package only.
|
160
|
-
rgt: dict[str, Registry] = make_registry(f"{
|
161
|
-
if
|
160
|
+
rgt: dict[str, Registry] = make_registry(f"{call.path}")
|
161
|
+
if call.func not in rgt:
|
162
162
|
raise NotImplementedError(
|
163
|
-
f"
|
164
|
-
f"implement registry: {
|
163
|
+
f"`REGISTER-MODULES.{call.path}.registries` does not "
|
164
|
+
f"implement registry: {call.func!r}."
|
165
165
|
)
|
166
166
|
|
167
|
-
if
|
167
|
+
if call.tag not in rgt[call.func]:
|
168
168
|
raise NotImplementedError(
|
169
|
-
f"tag: {
|
170
|
-
f"
|
169
|
+
f"tag: {call.tag!r} does not found on registry func: "
|
170
|
+
f"`REGISTER-MODULES.{call.path}.registries.{call.func}`"
|
171
171
|
)
|
172
|
-
return rgt[
|
172
|
+
return rgt[call.func][call.tag]
|
ddeutil/workflow/conf.py
CHANGED
@@ -38,6 +38,7 @@ __all__: TupleStr = (
|
|
38
38
|
"SimLoad",
|
39
39
|
"Loader",
|
40
40
|
"config",
|
41
|
+
"glob_files",
|
41
42
|
)
|
42
43
|
|
43
44
|
|
@@ -97,9 +98,9 @@ class Config(BaseConfig): # pragma: no cov
|
|
97
98
|
|
98
99
|
# NOTE: Register
|
99
100
|
@property
|
100
|
-
def
|
101
|
-
|
102
|
-
return [r.strip() for r in
|
101
|
+
def regis_call(self) -> list[str]:
|
102
|
+
regis_call_str: str = env("CORE_REGISTRY", ".")
|
103
|
+
return [r.strip() for r in regis_call_str.split(",")]
|
103
104
|
|
104
105
|
@property
|
105
106
|
def regis_filter(self) -> list[str]:
|
@@ -129,16 +130,26 @@ class Config(BaseConfig): # pragma: no cov
|
|
129
130
|
)
|
130
131
|
|
131
132
|
@property
|
132
|
-
def
|
133
|
-
return
|
133
|
+
def log_format_file(self) -> str:
|
134
|
+
return env(
|
135
|
+
"LOG_FORMAT_FILE",
|
136
|
+
(
|
137
|
+
"{datetime} ({process:5d}, {thread:5d}) {message:120s} "
|
138
|
+
"({filename}:{lineno})"
|
139
|
+
),
|
140
|
+
)
|
141
|
+
|
142
|
+
@property
|
143
|
+
def enable_write_log(self) -> bool:
|
144
|
+
return str2bool(env("LOG_ENABLE_WRITE", "false"))
|
134
145
|
|
135
146
|
# NOTE: Audit Log
|
136
147
|
@property
|
137
148
|
def audit_path(self) -> Path:
|
138
|
-
return Path(env("AUDIT_PATH", "./
|
149
|
+
return Path(env("AUDIT_PATH", "./audits"))
|
139
150
|
|
140
151
|
@property
|
141
|
-
def
|
152
|
+
def enable_write_audit(self) -> bool:
|
142
153
|
return str2bool(env("AUDIT_ENABLE_WRITE", "false"))
|
143
154
|
|
144
155
|
@property
|
@@ -254,18 +265,22 @@ class SimLoad:
|
|
254
265
|
conf_path: Path,
|
255
266
|
externals: DictData | None = None,
|
256
267
|
) -> None:
|
268
|
+
self.conf_path: Path = conf_path
|
269
|
+
self.externals: DictData = externals or {}
|
270
|
+
|
257
271
|
self.data: DictData = {}
|
258
272
|
for file in glob_files(conf_path):
|
259
273
|
|
260
|
-
if
|
274
|
+
if self.is_ignore(file, conf_path):
|
275
|
+
continue
|
276
|
+
|
277
|
+
if data := self.filter_suffix(file, name=name):
|
261
278
|
self.data = data
|
262
279
|
|
263
280
|
# VALIDATE: check the data that reading should not empty.
|
264
281
|
if not self.data:
|
265
282
|
raise ValueError(f"Config {name!r} does not found on conf path")
|
266
283
|
|
267
|
-
self.conf_path: Path = conf_path
|
268
|
-
self.externals: DictData = externals or {}
|
269
284
|
self.data.update(self.externals)
|
270
285
|
|
271
286
|
@classmethod
|
@@ -283,8 +298,10 @@ class SimLoad:
|
|
283
298
|
|
284
299
|
:param obj: An object that want to validate matching before return.
|
285
300
|
:param conf_path: A config object.
|
286
|
-
:param included:
|
287
|
-
|
301
|
+
:param included: An excluded list of data key that want to reject this
|
302
|
+
data if any key exist.
|
303
|
+
:param excluded: An included list of data key that want to filter from
|
304
|
+
data.
|
288
305
|
|
289
306
|
:rtype: Iterator[tuple[str, DictData]]
|
290
307
|
"""
|
@@ -293,6 +310,9 @@ class SimLoad:
|
|
293
310
|
|
294
311
|
for key, data in cls.filter_suffix(file).items():
|
295
312
|
|
313
|
+
if cls.is_ignore(file, conf_path):
|
314
|
+
continue
|
315
|
+
|
296
316
|
if key in exclude:
|
297
317
|
continue
|
298
318
|
|
@@ -303,11 +323,26 @@ class SimLoad:
|
|
303
323
|
else data
|
304
324
|
)
|
305
325
|
|
326
|
+
@classmethod
|
327
|
+
def is_ignore(cls, file: Path, conf_path: Path) -> bool:
|
328
|
+
ignore_file: Path = conf_path / ".confignore"
|
329
|
+
ignore: list[str] = []
|
330
|
+
if ignore_file.exists():
|
331
|
+
ignore = ignore_file.read_text(encoding="utf-8").splitlines()
|
332
|
+
|
333
|
+
if any(
|
334
|
+
(file.match(f"**/{pattern}/*") or file.match(f"**/{pattern}*"))
|
335
|
+
for pattern in ignore
|
336
|
+
):
|
337
|
+
return True
|
338
|
+
return False
|
339
|
+
|
306
340
|
@classmethod
|
307
341
|
def filter_suffix(cls, file: Path, name: str | None = None) -> DictData:
|
308
342
|
if any(file.suffix.endswith(s) for s in (".yml", ".yaml")):
|
309
343
|
values: DictData = YamlFlResolve(file).read()
|
310
344
|
return values.get(name, {}) if name else values
|
345
|
+
|
311
346
|
return {}
|
312
347
|
|
313
348
|
@cached_property
|
ddeutil/workflow/job.py
CHANGED
@@ -5,7 +5,7 @@
|
|
5
5
|
# ------------------------------------------------------------------------------
|
6
6
|
"""Job Model that use for keeping stages and node that running its stages.
|
7
7
|
The job handle the lineage of stages and location of execution of stages that
|
8
|
-
mean the job model able to define
|
8
|
+
mean the job model able to define `runs-on` key that allow you to run this
|
9
9
|
job.
|
10
10
|
|
11
11
|
This module include Strategy Model that use on the job strategy field.
|
@@ -24,10 +24,10 @@ from enum import Enum
|
|
24
24
|
from functools import lru_cache
|
25
25
|
from textwrap import dedent
|
26
26
|
from threading import Event
|
27
|
-
from typing import Any, Optional, Union
|
27
|
+
from typing import Annotated, Any, Literal, Optional, Union
|
28
28
|
|
29
29
|
from ddeutil.core import freeze_args
|
30
|
-
from pydantic import BaseModel, Field
|
30
|
+
from pydantic import BaseModel, ConfigDict, Field
|
31
31
|
from pydantic.functional_validators import field_validator, model_validator
|
32
32
|
from typing_extensions import Self
|
33
33
|
|
@@ -39,7 +39,7 @@ from .exceptions import (
|
|
39
39
|
UtilException,
|
40
40
|
)
|
41
41
|
from .result import Result, Status
|
42
|
-
from .
|
42
|
+
from .stages import Stage
|
43
43
|
from .templates import has_template
|
44
44
|
from .utils import (
|
45
45
|
cross_product,
|
@@ -56,6 +56,11 @@ __all__: TupleStr = (
|
|
56
56
|
"Strategy",
|
57
57
|
"Job",
|
58
58
|
"TriggerRules",
|
59
|
+
"RunsOn",
|
60
|
+
"RunsOnLocal",
|
61
|
+
"RunsOnSelfHosted",
|
62
|
+
"RunsOnDocker",
|
63
|
+
"RunsOnK8s",
|
59
64
|
"make",
|
60
65
|
)
|
61
66
|
|
@@ -216,13 +221,60 @@ class TriggerRules(str, Enum):
|
|
216
221
|
none_skipped: str = "none_skipped"
|
217
222
|
|
218
223
|
|
219
|
-
class
|
224
|
+
class RunsOnType(str, Enum):
|
220
225
|
"""Runs-On enum object."""
|
221
226
|
|
222
|
-
|
223
|
-
|
224
|
-
|
225
|
-
|
227
|
+
LOCAL: str = "local"
|
228
|
+
DOCKER: str = "docker"
|
229
|
+
SELF_HOSTED: str = "self_hosted"
|
230
|
+
K8S: str = "k8s"
|
231
|
+
|
232
|
+
|
233
|
+
class BaseRunsOn(BaseModel):
|
234
|
+
model_config = ConfigDict(use_enum_values=True)
|
235
|
+
|
236
|
+
type: Literal[RunsOnType.LOCAL]
|
237
|
+
args: DictData = Field(
|
238
|
+
default_factory=dict,
|
239
|
+
alias="with",
|
240
|
+
)
|
241
|
+
|
242
|
+
|
243
|
+
class RunsOnLocal(BaseRunsOn):
|
244
|
+
"""Runs-on local."""
|
245
|
+
|
246
|
+
type: Literal[RunsOnType.LOCAL] = Field(default=RunsOnType.LOCAL)
|
247
|
+
|
248
|
+
|
249
|
+
class RunsOnSelfHosted(BaseRunsOn):
|
250
|
+
"""Runs-on self-hosted."""
|
251
|
+
|
252
|
+
type: Literal[RunsOnType.SELF_HOSTED] = Field(
|
253
|
+
default=RunsOnType.SELF_HOSTED
|
254
|
+
)
|
255
|
+
|
256
|
+
|
257
|
+
class RunsOnDocker(BaseRunsOn):
|
258
|
+
"""Runs-on local Docker."""
|
259
|
+
|
260
|
+
type: Literal[RunsOnType.DOCKER] = Field(default=RunsOnType.DOCKER)
|
261
|
+
|
262
|
+
|
263
|
+
class RunsOnK8s(BaseRunsOn):
|
264
|
+
"""Runs-on Kubernetes."""
|
265
|
+
|
266
|
+
type: Literal[RunsOnType.K8S] = Field(default=RunsOnType.K8S)
|
267
|
+
|
268
|
+
|
269
|
+
RunsOn = Annotated[
|
270
|
+
Union[
|
271
|
+
RunsOnLocal,
|
272
|
+
RunsOnSelfHosted,
|
273
|
+
RunsOnDocker,
|
274
|
+
RunsOnK8s,
|
275
|
+
],
|
276
|
+
Field(discriminator="type"),
|
277
|
+
]
|
226
278
|
|
227
279
|
|
228
280
|
class Job(BaseModel):
|
@@ -263,9 +315,9 @@ class Job(BaseModel):
|
|
263
315
|
default=None,
|
264
316
|
description="A job description that can be string of markdown content.",
|
265
317
|
)
|
266
|
-
runs_on:
|
267
|
-
|
268
|
-
description="A target
|
318
|
+
runs_on: RunsOn = Field(
|
319
|
+
default_factory=RunsOnLocal,
|
320
|
+
description="A target node for this job to use for execution.",
|
269
321
|
serialization_alias="runs-on",
|
270
322
|
)
|
271
323
|
stages: list[Stage] = Field(
|
@@ -359,7 +411,7 @@ class Job(BaseModel):
|
|
359
411
|
|
360
412
|
def set_outputs(self, output: DictData, to: DictData) -> DictData:
|
361
413
|
"""Set an outputs from execution process to the received context. The
|
362
|
-
result from execution will pass to value of
|
414
|
+
result from execution will pass to value of `strategies` key.
|
363
415
|
|
364
416
|
For example of setting output method, If you receive execute output
|
365
417
|
and want to set on the `to` like;
|
@@ -400,10 +452,15 @@ class Job(BaseModel):
|
|
400
452
|
# NOTE: If the job ID did not set, it will use index of jobs key
|
401
453
|
# instead.
|
402
454
|
_id: str = self.id or str(len(to["jobs"]) + 1)
|
455
|
+
|
456
|
+
errors: DictData = (
|
457
|
+
{"errors": output.pop("errors", {})} if "errors" in output else {}
|
458
|
+
)
|
459
|
+
|
403
460
|
to["jobs"][_id] = (
|
404
|
-
{"strategies": output}
|
461
|
+
{"strategies": output, **errors}
|
405
462
|
if self.strategy.is_set()
|
406
|
-
else output.get(next(iter(output), "DUMMY"), {})
|
463
|
+
else {**output.get(next(iter(output), "DUMMY"), {}), **errors}
|
407
464
|
)
|
408
465
|
return to
|
409
466
|
|
@@ -412,7 +469,6 @@ class Job(BaseModel):
|
|
412
469
|
strategy: DictData,
|
413
470
|
params: DictData,
|
414
471
|
*,
|
415
|
-
run_id: str | None = None,
|
416
472
|
result: Result | None = None,
|
417
473
|
event: Event | None = None,
|
418
474
|
) -> Result:
|
@@ -420,19 +476,18 @@ class Job(BaseModel):
|
|
420
476
|
workflow execution to strategy matrix.
|
421
477
|
|
422
478
|
This execution is the minimum level of execution of this job model.
|
423
|
-
It different with
|
479
|
+
It different with `self.execute` because this method run only one
|
424
480
|
strategy and return with context of this strategy data.
|
425
481
|
|
426
482
|
The result of this execution will return result with strategy ID
|
427
483
|
that generated from the `gen_id` function with an input strategy value.
|
428
484
|
|
429
|
-
:raise JobException: If it has any error from
|
430
|
-
|
485
|
+
:raise JobException: If it has any error from `StageException` or
|
486
|
+
`UtilException`.
|
431
487
|
|
432
488
|
:param strategy: A strategy metrix value that use on this execution.
|
433
489
|
This value will pass to the `matrix` key for templating.
|
434
490
|
:param params: A dynamic parameters that will deepcopy to the context.
|
435
|
-
:param run_id: A job running ID for this strategy execution.
|
436
491
|
:param result: (Result) A result object for keeping context and status
|
437
492
|
data.
|
438
493
|
:param event: An event manager that pass to the PoolThreadExecutor.
|
@@ -440,9 +495,7 @@ class Job(BaseModel):
|
|
440
495
|
:rtype: Result
|
441
496
|
"""
|
442
497
|
if result is None: # pragma: no cov
|
443
|
-
result: Result = Result(
|
444
|
-
run_id=(run_id or gen_id(self.id or "", unique=True))
|
445
|
-
)
|
498
|
+
result: Result = Result(run_id=gen_id(self.id or "", unique=True))
|
446
499
|
|
447
500
|
strategy_id: str = gen_id(strategy)
|
448
501
|
|
@@ -492,8 +545,11 @@ class Job(BaseModel):
|
|
492
545
|
# "stages": filter_func(context.pop("stages", {})),
|
493
546
|
#
|
494
547
|
"stages": context.pop("stages", {}),
|
495
|
-
"
|
496
|
-
|
548
|
+
"errors": {
|
549
|
+
"class": JobException(error_msg),
|
550
|
+
"name": "JobException",
|
551
|
+
"message": error_msg,
|
552
|
+
},
|
497
553
|
},
|
498
554
|
},
|
499
555
|
)
|
@@ -506,7 +562,7 @@ class Job(BaseModel):
|
|
506
562
|
#
|
507
563
|
# ... params |= stage.execute(params=params)
|
508
564
|
#
|
509
|
-
# This step will add the stage result to
|
565
|
+
# This step will add the stage result to `stages` key in
|
510
566
|
# that stage id. It will have structure like;
|
511
567
|
#
|
512
568
|
# {
|
@@ -516,10 +572,18 @@ class Job(BaseModel):
|
|
516
572
|
# "stages": { { "stage-id-1": ... }, ... }
|
517
573
|
# }
|
518
574
|
#
|
575
|
+
# IMPORTANT:
|
576
|
+
# This execution change all stage running IDs to the current job
|
577
|
+
# running ID, but it still trac log to the same parent running ID
|
578
|
+
# (with passing `run_id` and `parent_run_id` to the stage
|
579
|
+
# execution arguments).
|
580
|
+
#
|
519
581
|
try:
|
520
582
|
stage.set_outputs(
|
521
583
|
stage.handler_execute(
|
522
|
-
params=context,
|
584
|
+
params=context,
|
585
|
+
run_id=result.run_id,
|
586
|
+
parent_run_id=result.parent_run_id,
|
523
587
|
).context,
|
524
588
|
to=context,
|
525
589
|
)
|
@@ -527,17 +591,21 @@ class Job(BaseModel):
|
|
527
591
|
result.trace.error(f"[JOB]: {err.__class__.__name__}: {err}")
|
528
592
|
if config.job_raise_error:
|
529
593
|
raise JobException(
|
530
|
-
f"
|
594
|
+
f"Stage execution error: {err.__class__.__name__}: "
|
531
595
|
f"{err}"
|
532
596
|
) from None
|
597
|
+
|
533
598
|
return result.catch(
|
534
599
|
status=1,
|
535
600
|
context={
|
536
601
|
strategy_id: {
|
537
602
|
"matrix": strategy,
|
538
603
|
"stages": context.pop("stages", {}),
|
539
|
-
"
|
540
|
-
|
604
|
+
"errors": {
|
605
|
+
"class": err,
|
606
|
+
"name": err.__class__.__name__,
|
607
|
+
"message": f"{err.__class__.__name__}: {err}",
|
608
|
+
},
|
541
609
|
},
|
542
610
|
},
|
543
611
|
)
|
@@ -560,25 +628,28 @@ class Job(BaseModel):
|
|
560
628
|
params: DictData,
|
561
629
|
*,
|
562
630
|
run_id: str | None = None,
|
631
|
+
parent_run_id: str | None = None,
|
563
632
|
result: Result | None = None,
|
564
633
|
) -> Result:
|
565
634
|
"""Job execution with passing dynamic parameters from the workflow
|
566
635
|
execution. It will generate matrix values at the first step and run
|
567
|
-
multithread on this metrics to the
|
636
|
+
multithread on this metrics to the `stages` field of this job.
|
568
637
|
|
569
638
|
:param params: An input parameters that use on job execution.
|
570
639
|
:param run_id: A job running ID for this execution.
|
640
|
+
:param parent_run_id: A parent workflow running ID for this release.
|
571
641
|
:param result: (Result) A result object for keeping context and status
|
572
642
|
data.
|
573
643
|
|
574
644
|
:rtype: Result
|
575
645
|
"""
|
576
|
-
|
577
|
-
# NOTE: I use this condition because this method allow passing empty
|
578
|
-
# params and I do not want to create new dict object.
|
579
646
|
if result is None: # pragma: no cov
|
580
|
-
|
581
|
-
|
647
|
+
result: Result = Result(
|
648
|
+
run_id=(run_id or gen_id(self.id or "", unique=True)),
|
649
|
+
parent_run_id=parent_run_id,
|
650
|
+
)
|
651
|
+
elif parent_run_id: # pragma: no cov
|
652
|
+
result.set_parent_run_id(parent_run_id)
|
582
653
|
|
583
654
|
# NOTE: Normal Job execution without parallel strategy matrix. It uses
|
584
655
|
# for-loop to control strategy execution sequentially.
|
@@ -614,110 +685,50 @@ class Job(BaseModel):
|
|
614
685
|
for strategy in self.strategy.make()
|
615
686
|
]
|
616
687
|
|
617
|
-
|
618
|
-
|
619
|
-
|
620
|
-
else self.__catch_all_completed(futures=futures, result=result)
|
621
|
-
)
|
622
|
-
|
623
|
-
@staticmethod
|
624
|
-
def __catch_fail_fast(
|
625
|
-
event: Event,
|
626
|
-
futures: list[Future],
|
627
|
-
result: Result,
|
628
|
-
*,
|
629
|
-
timeout: int = 1800,
|
630
|
-
) -> Result:
|
631
|
-
"""Job parallel pool futures catching with fail-fast mode. That will
|
632
|
-
stop and set event on all not done futures if it receives the first
|
633
|
-
exception from all running futures.
|
634
|
-
|
635
|
-
:param event: An event manager instance that able to set stopper on the
|
636
|
-
observing multithreading.
|
637
|
-
:param futures: A list of futures.
|
638
|
-
:param result: (Result) A result object for keeping context and status
|
639
|
-
data.
|
640
|
-
:param timeout: A timeout to waiting all futures complete.
|
641
|
-
|
642
|
-
:rtype: Result
|
643
|
-
"""
|
644
|
-
context: DictData = {}
|
645
|
-
status: Status = Status.SUCCESS
|
688
|
+
context: DictData = {}
|
689
|
+
status: Status = Status.SUCCESS
|
690
|
+
fail_fast_flag: bool = self.strategy.fail_fast
|
646
691
|
|
647
|
-
|
648
|
-
|
649
|
-
|
650
|
-
|
651
|
-
|
652
|
-
nd: str = (
|
653
|
-
f", the strategies do not run is {not_done}" if not_done else ""
|
654
|
-
)
|
655
|
-
result.trace.debug(f"[JOB]: Strategy is set Fail Fast{nd}")
|
656
|
-
|
657
|
-
# NOTE:
|
658
|
-
# Stop all running tasks with setting the event manager and cancel
|
659
|
-
# any scheduled tasks.
|
660
|
-
#
|
661
|
-
if len(done) != len(futures):
|
662
|
-
event.set()
|
663
|
-
for future in not_done:
|
664
|
-
future.cancel()
|
665
|
-
|
666
|
-
future: Future
|
667
|
-
for future in done:
|
668
|
-
|
669
|
-
# NOTE: Handle the first exception from feature
|
670
|
-
if err := future.exception():
|
671
|
-
status: Status = Status.FAILED
|
672
|
-
result.trace.error(
|
673
|
-
f"[JOB]: Fail-fast catching:\n\t{future.exception()}"
|
692
|
+
if fail_fast_flag:
|
693
|
+
# NOTE: Get results from a collection of tasks with a timeout
|
694
|
+
# that has the first exception.
|
695
|
+
done, not_done = wait(
|
696
|
+
futures, timeout=1800, return_when=FIRST_EXCEPTION
|
674
697
|
)
|
675
|
-
|
676
|
-
{
|
677
|
-
|
678
|
-
|
679
|
-
},
|
680
|
-
)
|
681
|
-
continue
|
682
|
-
|
683
|
-
# NOTE: Update the result context to main job context.
|
684
|
-
future.result()
|
685
|
-
|
686
|
-
return result.catch(status=status, context=context)
|
687
|
-
|
688
|
-
@staticmethod
|
689
|
-
def __catch_all_completed(
|
690
|
-
futures: list[Future],
|
691
|
-
result: Result,
|
692
|
-
*,
|
693
|
-
timeout: int = 1800,
|
694
|
-
) -> Result:
|
695
|
-
"""Job parallel pool futures catching with all-completed mode.
|
696
|
-
|
697
|
-
:param futures: A list of futures.
|
698
|
-
:param result: (Result) A result object for keeping context and status
|
699
|
-
data.
|
700
|
-
:param timeout: A timeout to waiting all futures complete.
|
701
|
-
|
702
|
-
:rtype: Result
|
703
|
-
"""
|
704
|
-
context: DictData = {}
|
705
|
-
status: Status = Status.SUCCESS
|
706
|
-
|
707
|
-
for future in as_completed(futures, timeout=timeout):
|
708
|
-
try:
|
709
|
-
future.result()
|
710
|
-
except JobException as err:
|
711
|
-
status = Status.FAILED
|
712
|
-
result.trace.error(
|
713
|
-
f"[JOB]: All-completed catching:\n\t"
|
714
|
-
f"{err.__class__.__name__}:\n\t{err}"
|
715
|
-
)
|
716
|
-
context.update(
|
717
|
-
{
|
718
|
-
"error": err,
|
719
|
-
"error_message": f"{err.__class__.__name__}: {err}",
|
720
|
-
},
|
698
|
+
nd: str = (
|
699
|
+
f", the strategies do not run is {not_done}"
|
700
|
+
if not_done
|
701
|
+
else ""
|
721
702
|
)
|
703
|
+
result.trace.debug(f"[JOB]: Strategy is set Fail Fast{nd}")
|
704
|
+
|
705
|
+
# NOTE: Stop all running tasks with setting the event manager
|
706
|
+
# and cancel any scheduled tasks.
|
707
|
+
if len(done) != len(futures):
|
708
|
+
event.set()
|
709
|
+
for future in not_done:
|
710
|
+
future.cancel()
|
711
|
+
else:
|
712
|
+
done = as_completed(futures, timeout=1800)
|
713
|
+
|
714
|
+
for future in done:
|
715
|
+
try:
|
716
|
+
future.result()
|
717
|
+
except JobException as err:
|
718
|
+
status = Status.FAILED
|
719
|
+
ls: str = "Fail-Fast" if fail_fast_flag else "All-Completed"
|
720
|
+
result.trace.error(
|
721
|
+
f"[JOB]: {ls} Catch:\n\t{err.__class__.__name__}:"
|
722
|
+
f"\n\t{err}"
|
723
|
+
)
|
724
|
+
context.update(
|
725
|
+
{
|
726
|
+
"errors": {
|
727
|
+
"class": err,
|
728
|
+
"name": err.__class__.__name__,
|
729
|
+
"message": f"{err.__class__.__name__}: {err}",
|
730
|
+
},
|
731
|
+
},
|
732
|
+
)
|
722
733
|
|
723
734
|
return result.catch(status=status, context=context)
|