ddeutil-workflow 0.0.5__py3-none-any.whl → 0.0.6__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 +9 -0
- ddeutil/workflow/__types.py +43 -1
- ddeutil/workflow/exceptions.py +13 -1
- ddeutil/workflow/loader.py +13 -115
- ddeutil/workflow/on.py +78 -26
- ddeutil/workflow/pipeline.py +341 -392
- ddeutil/workflow/{__scheduler.py → scheduler.py} +73 -45
- ddeutil/workflow/stage.py +402 -0
- ddeutil/workflow/utils.py +205 -35
- {ddeutil_workflow-0.0.5.dist-info → ddeutil_workflow-0.0.6.dist-info}/METADATA +95 -66
- ddeutil_workflow-0.0.6.dist-info/RECORD +15 -0
- ddeutil/workflow/__regex.py +0 -44
- ddeutil/workflow/tasks/__init__.py +0 -6
- ddeutil/workflow/tasks/dummy.py +0 -52
- ddeutil_workflow-0.0.5.dist-info/RECORD +0 -17
- {ddeutil_workflow-0.0.5.dist-info → ddeutil_workflow-0.0.6.dist-info}/LICENSE +0 -0
- {ddeutil_workflow-0.0.5.dist-info → ddeutil_workflow-0.0.6.dist-info}/WHEEL +0 -0
- {ddeutil_workflow-0.0.5.dist-info → ddeutil_workflow-0.0.6.dist-info}/top_level.txt +0 -0
@@ -8,13 +8,9 @@ from __future__ import annotations
|
|
8
8
|
import copy
|
9
9
|
from collections.abc import Iterator
|
10
10
|
from dataclasses import dataclass, field
|
11
|
-
from datetime import datetime, timedelta
|
11
|
+
from datetime import datetime, timedelta
|
12
12
|
from functools import partial, total_ordering
|
13
|
-
from typing import
|
14
|
-
Callable,
|
15
|
-
Optional,
|
16
|
-
Union,
|
17
|
-
)
|
13
|
+
from typing import Callable, Optional, Union
|
18
14
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
19
15
|
|
20
16
|
from ddeutil.core import (
|
@@ -117,7 +113,7 @@ CRON_UNITS: tuple[Unit, ...] = (
|
|
117
113
|
),
|
118
114
|
)
|
119
115
|
|
120
|
-
|
116
|
+
CRON_UNITS_YEAR: tuple[Unit, ...] = CRON_UNITS + (
|
121
117
|
Unit(
|
122
118
|
name="year",
|
123
119
|
range=partial(range, 1990, 2101),
|
@@ -152,6 +148,7 @@ class CronPart:
|
|
152
148
|
values: list[int] = self.replace_weekday(values)
|
153
149
|
else:
|
154
150
|
raise TypeError(f"Invalid type of value in cron part: {values}.")
|
151
|
+
|
155
152
|
self.values: list[int] = self.out_of_range(
|
156
153
|
sorted(dict.fromkeys(values))
|
157
154
|
)
|
@@ -190,10 +187,16 @@ class CronPart:
|
|
190
187
|
)
|
191
188
|
|
192
189
|
def __lt__(self, other) -> bool:
|
193
|
-
|
190
|
+
if isinstance(other, CronPart):
|
191
|
+
return self.values < other.values
|
192
|
+
elif isinstance(other, list):
|
193
|
+
return self.values < other
|
194
194
|
|
195
195
|
def __eq__(self, other) -> bool:
|
196
|
-
|
196
|
+
if isinstance(other, CronPart):
|
197
|
+
return self.values == other.values
|
198
|
+
elif isinstance(other, list):
|
199
|
+
return self.values == other
|
197
200
|
|
198
201
|
@property
|
199
202
|
def min(self) -> int:
|
@@ -225,11 +228,11 @@ class CronPart:
|
|
225
228
|
"""Parses a string as a range of positive integers. The string should
|
226
229
|
include only `-` and `,` special strings.
|
227
230
|
|
228
|
-
:param value: A string value
|
231
|
+
:param value: A string value that want to parse
|
229
232
|
:type value: str
|
230
233
|
|
231
234
|
TODO: support for `L`, `W`, and `#`
|
232
|
-
TODO:
|
235
|
+
TODO: if you didn't care what day of the week the 7th was, you
|
233
236
|
could enter ? in the Day-of-week field.
|
234
237
|
TODO: L : the Day-of-month or Day-of-week fields specifies the last day
|
235
238
|
of the month or week.
|
@@ -239,7 +242,7 @@ class CronPart:
|
|
239
242
|
TODO: # : 3#2 would be the second Tuesday of the month,
|
240
243
|
the 3 refers to Tuesday because it is the third day of each week.
|
241
244
|
|
242
|
-
|
245
|
+
Noted:
|
243
246
|
- 0 10 * * ? *
|
244
247
|
Run at 10:00 am (UTC) every day
|
245
248
|
|
@@ -302,9 +305,14 @@ class CronPart:
|
|
302
305
|
return value
|
303
306
|
|
304
307
|
def replace_weekday(self, values: list[int] | Iterator[int]) -> list[int]:
|
305
|
-
"""Replaces all 7 with 0 as Sunday can be represented by both.
|
308
|
+
"""Replaces all 7 with 0 as Sunday can be represented by both.
|
309
|
+
|
310
|
+
:param values: list or iter of int that want to mode by 7
|
311
|
+
:rtype: list[int]
|
312
|
+
"""
|
306
313
|
if self.unit.name == "weekday":
|
307
|
-
|
314
|
+
# NOTE: change weekday value in range 0-6 (div-mod by 7).
|
315
|
+
return [value % 7 for value in values]
|
308
316
|
return list(values)
|
309
317
|
|
310
318
|
def out_of_range(self, values: list[int]) -> list[int]:
|
@@ -531,27 +539,27 @@ class CronJob:
|
|
531
539
|
return reversed(self.parts[:3] + [self.parts[4], self.parts[3]])
|
532
540
|
|
533
541
|
@property
|
534
|
-
def minute(self):
|
542
|
+
def minute(self) -> CronPart:
|
535
543
|
"""Return part of minute."""
|
536
544
|
return self.parts[0]
|
537
545
|
|
538
546
|
@property
|
539
|
-
def hour(self):
|
547
|
+
def hour(self) -> CronPart:
|
540
548
|
"""Return part of hour."""
|
541
549
|
return self.parts[1]
|
542
550
|
|
543
551
|
@property
|
544
|
-
def day(self):
|
552
|
+
def day(self) -> CronPart:
|
545
553
|
"""Return part of day."""
|
546
554
|
return self.parts[2]
|
547
555
|
|
548
556
|
@property
|
549
|
-
def month(self):
|
557
|
+
def month(self) -> CronPart:
|
550
558
|
"""Return part of month."""
|
551
559
|
return self.parts[3]
|
552
560
|
|
553
561
|
@property
|
554
|
-
def dow(self):
|
562
|
+
def dow(self) -> CronPart:
|
555
563
|
"""Return part of day of month."""
|
556
564
|
return self.parts[4]
|
557
565
|
|
@@ -560,15 +568,29 @@ class CronJob:
|
|
560
568
|
return [part.values for part in self.parts]
|
561
569
|
|
562
570
|
def schedule(
|
563
|
-
self,
|
571
|
+
self,
|
572
|
+
date: datetime | None = None,
|
573
|
+
*,
|
574
|
+
tz: str | None = None,
|
564
575
|
) -> CronRunner:
|
565
|
-
"""Returns the
|
566
|
-
|
576
|
+
"""Returns the schedule datetime runner with this cronjob. It would run
|
577
|
+
``next``, ``prev``, or ``reset`` to generate running date that you want.
|
578
|
+
|
579
|
+
:param date: An initial date that want to mark as the start point.
|
580
|
+
:param tz: A string timezone that want to change on runner.
|
581
|
+
:rtype: CronRunner
|
582
|
+
"""
|
583
|
+
return CronRunner(self, date, tz=tz)
|
567
584
|
|
568
585
|
|
569
|
-
class
|
586
|
+
class CronJobYear(CronJob):
|
570
587
|
cron_length = 6
|
571
|
-
cron_units =
|
588
|
+
cron_units = CRON_UNITS_YEAR
|
589
|
+
|
590
|
+
@property
|
591
|
+
def year(self) -> CronPart:
|
592
|
+
"""Return part of year."""
|
593
|
+
return self.parts[5]
|
572
594
|
|
573
595
|
|
574
596
|
class CronRunner:
|
@@ -586,33 +608,37 @@ class CronRunner:
|
|
586
608
|
|
587
609
|
def __init__(
|
588
610
|
self,
|
589
|
-
cron: CronJob,
|
590
|
-
date:
|
611
|
+
cron: CronJob | CronJobYear,
|
612
|
+
date: datetime | None = None,
|
591
613
|
*,
|
592
|
-
|
614
|
+
tz: str | None = None,
|
593
615
|
) -> None:
|
594
|
-
# NOTE: Prepare
|
595
|
-
self.tz =
|
596
|
-
if
|
616
|
+
# NOTE: Prepare timezone if this value does not set, it will use UTC.
|
617
|
+
self.tz: ZoneInfo = ZoneInfo("UTC")
|
618
|
+
if tz:
|
597
619
|
try:
|
598
|
-
self.tz = ZoneInfo(
|
620
|
+
self.tz = ZoneInfo(tz)
|
599
621
|
except ZoneInfoNotFoundError as err:
|
600
|
-
raise ValueError(f"Invalid timezone: {
|
622
|
+
raise ValueError(f"Invalid timezone: {tz}") from err
|
623
|
+
|
624
|
+
# NOTE: Prepare date
|
601
625
|
if date:
|
602
626
|
if not isinstance(date, datetime):
|
603
627
|
raise ValueError(
|
604
628
|
"Input schedule start time is not a valid datetime object."
|
605
629
|
)
|
606
|
-
|
607
|
-
|
630
|
+
if tz is None:
|
631
|
+
self.tz = date.tzinfo
|
632
|
+
self.date: datetime = date.astimezone(self.tz)
|
608
633
|
else:
|
609
634
|
self.date: datetime = datetime.now(tz=self.tz)
|
610
635
|
|
636
|
+
# NOTE: Add one minute if the second value more than 0.
|
611
637
|
if self.date.second > 0:
|
612
|
-
self.date: datetime = self.date + timedelta(minutes
|
638
|
+
self.date: datetime = self.date + timedelta(minutes=1)
|
613
639
|
|
614
640
|
self.__start_date: datetime = self.date
|
615
|
-
self.cron: CronJob = cron
|
641
|
+
self.cron: CronJob | CronJobYear = cron
|
616
642
|
self.reset_flag: bool = True
|
617
643
|
|
618
644
|
def reset(self) -> None:
|
@@ -637,7 +663,11 @@ class CronRunner:
|
|
637
663
|
return self.find_date(reverse=True)
|
638
664
|
|
639
665
|
def find_date(self, reverse: bool = False) -> datetime:
|
640
|
-
"""Returns the time the schedule would run by `next` or `prev`.
|
666
|
+
"""Returns the time the schedule would run by `next` or `prev`.
|
667
|
+
|
668
|
+
:param reverse: A reverse flag.
|
669
|
+
"""
|
670
|
+
# NOTE: Set reset flag to false if start any action.
|
641
671
|
self.reset_flag: bool = False
|
642
672
|
for _ in range(25):
|
643
673
|
if all(
|
@@ -656,7 +686,7 @@ class CronRunner:
|
|
656
686
|
"minute": "hour",
|
657
687
|
}
|
658
688
|
current_value: int = getattr(self.date, switch[mode])
|
659
|
-
|
689
|
+
_addition_condition: Callable[[], bool] = (
|
660
690
|
(
|
661
691
|
lambda: WEEKDAYS.get(self.date.strftime("%a"))
|
662
692
|
not in self.cron.dow.values
|
@@ -664,15 +694,12 @@ class CronRunner:
|
|
664
694
|
if mode == "day"
|
665
695
|
else lambda: False
|
666
696
|
)
|
697
|
+
# NOTE: Start while-loop for checking this date include in this cronjob.
|
667
698
|
while (
|
668
699
|
getattr(self.date, mode) not in getattr(self.cron, mode).values
|
669
|
-
) or
|
670
|
-
self.date: datetime = next_date(
|
671
|
-
|
672
|
-
)
|
673
|
-
self.date: datetime = replace_date(
|
674
|
-
self.date, mode=mode, reverse=reverse
|
675
|
-
)
|
700
|
+
) or _addition_condition():
|
701
|
+
self.date: datetime = next_date(self.date, mode, reverse=reverse)
|
702
|
+
self.date: datetime = replace_date(self.date, mode, reverse=reverse)
|
676
703
|
if current_value != getattr(self.date, switch[mode]):
|
677
704
|
return mode != "month"
|
678
705
|
return False
|
@@ -680,6 +707,7 @@ class CronRunner:
|
|
680
707
|
|
681
708
|
__all__ = (
|
682
709
|
"CronJob",
|
710
|
+
"CronJobYear",
|
683
711
|
"CronRunner",
|
684
712
|
"WEEKDAYS",
|
685
713
|
)
|
@@ -0,0 +1,402 @@
|
|
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 contextlib
|
9
|
+
import inspect
|
10
|
+
import logging
|
11
|
+
import os
|
12
|
+
import subprocess
|
13
|
+
import sys
|
14
|
+
import uuid
|
15
|
+
from abc import ABC, abstractmethod
|
16
|
+
from collections.abc import Iterator
|
17
|
+
from dataclasses import dataclass
|
18
|
+
from inspect import Parameter
|
19
|
+
from pathlib import Path
|
20
|
+
from subprocess import CompletedProcess
|
21
|
+
from typing import Callable, Optional, Union
|
22
|
+
|
23
|
+
from ddeutil.core import str2bool
|
24
|
+
from pydantic import BaseModel, Field
|
25
|
+
|
26
|
+
from .__types import DictData, DictStr, Re, TupleStr
|
27
|
+
from .exceptions import StageException
|
28
|
+
from .utils import (
|
29
|
+
Registry,
|
30
|
+
Result,
|
31
|
+
TagFunc,
|
32
|
+
gen_id,
|
33
|
+
make_exec,
|
34
|
+
make_registry,
|
35
|
+
param2template,
|
36
|
+
)
|
37
|
+
|
38
|
+
|
39
|
+
class BaseStage(BaseModel, ABC):
|
40
|
+
"""Base Stage Model that keep only id and name fields for the stage
|
41
|
+
metadata. If you want to implement any custom stage, you can use this class
|
42
|
+
to parent and implement ``self.execute()`` method only.
|
43
|
+
"""
|
44
|
+
|
45
|
+
id: Optional[str] = Field(
|
46
|
+
default=None,
|
47
|
+
description=(
|
48
|
+
"A stage ID that use to keep execution output or getting by job "
|
49
|
+
"owner."
|
50
|
+
),
|
51
|
+
)
|
52
|
+
name: str = Field(
|
53
|
+
description="A stage name that want to logging when start execution."
|
54
|
+
)
|
55
|
+
condition: Optional[str] = Field(
|
56
|
+
default=None,
|
57
|
+
alias="if",
|
58
|
+
)
|
59
|
+
|
60
|
+
@abstractmethod
|
61
|
+
def execute(self, params: DictData) -> Result:
|
62
|
+
"""Execute abstraction method that action something by sub-model class.
|
63
|
+
This is important method that make this class is able to be the stage.
|
64
|
+
|
65
|
+
:param params: A parameter data that want to use in this execution.
|
66
|
+
:rtype: Result
|
67
|
+
"""
|
68
|
+
raise NotImplementedError("Stage should implement ``execute`` method.")
|
69
|
+
|
70
|
+
def set_outputs(self, output: DictData, params: DictData) -> DictData:
|
71
|
+
"""Set an outputs from execution process to an input params.
|
72
|
+
|
73
|
+
:param output: A output data that want to extract to an output key.
|
74
|
+
:param params: A context data that want to add output result.
|
75
|
+
:rtype: DictData
|
76
|
+
"""
|
77
|
+
if self.id:
|
78
|
+
_id: str = param2template(self.id, params)
|
79
|
+
elif str2bool(os.getenv("WORKFLOW_CORE_DEFAULT_STAGE_ID", "false")):
|
80
|
+
_id: str = gen_id(param2template(self.name, params))
|
81
|
+
else:
|
82
|
+
return params
|
83
|
+
|
84
|
+
# NOTE: Create stages key to receive an output from the stage execution.
|
85
|
+
if "stages" not in params:
|
86
|
+
params["stages"] = {}
|
87
|
+
|
88
|
+
params["stages"][_id] = {"outputs": output}
|
89
|
+
return params
|
90
|
+
|
91
|
+
def is_skip(self, params: DictData | None = None) -> bool:
|
92
|
+
"""Return true if condition of this stage do not correct.
|
93
|
+
|
94
|
+
:param params: A parameters that want to pass to condition template.
|
95
|
+
"""
|
96
|
+
params: DictData = params or {}
|
97
|
+
if self.condition is None:
|
98
|
+
return False
|
99
|
+
|
100
|
+
_g: DictData = globals() | params
|
101
|
+
try:
|
102
|
+
rs: bool = eval(
|
103
|
+
param2template(self.condition, params, repr_flag=True), _g, {}
|
104
|
+
)
|
105
|
+
if not isinstance(rs, bool):
|
106
|
+
raise TypeError("Return type of condition does not be boolean")
|
107
|
+
return not rs
|
108
|
+
except Exception as err:
|
109
|
+
logging.error(str(err))
|
110
|
+
raise StageException(str(err)) from err
|
111
|
+
|
112
|
+
|
113
|
+
class EmptyStage(BaseStage):
|
114
|
+
"""Empty stage that do nothing (context equal empty stage) and logging the
|
115
|
+
name of stage only to stdout.
|
116
|
+
"""
|
117
|
+
|
118
|
+
echo: Optional[str] = Field(
|
119
|
+
default=None,
|
120
|
+
description="A string statement that want to logging",
|
121
|
+
)
|
122
|
+
|
123
|
+
def execute(self, params: DictData) -> Result:
|
124
|
+
"""Execution method for the Empty stage that do only logging out to
|
125
|
+
stdout.
|
126
|
+
|
127
|
+
:param params: A context data that want to add output result. But this
|
128
|
+
stage does not pass any output.
|
129
|
+
"""
|
130
|
+
logging.info(f"[STAGE]: Empty-Execute: {self.name!r}")
|
131
|
+
return Result(status=0, context={})
|
132
|
+
|
133
|
+
|
134
|
+
class BashStage(BaseStage):
|
135
|
+
"""Bash execution stage that execute bash script on the current OS.
|
136
|
+
That mean if your current OS is Windows, it will running bash in the WSL.
|
137
|
+
|
138
|
+
I get some limitation when I run shell statement with the built-in
|
139
|
+
supprocess package. It does not good enough to use multiline statement.
|
140
|
+
Thus, I add writing ``.sh`` file before execution process for fix this
|
141
|
+
issue.
|
142
|
+
|
143
|
+
Data Validate:
|
144
|
+
>>> stage = {
|
145
|
+
... "name": "Shell stage execution",
|
146
|
+
... "bash": 'echo "Hello $FOO"',
|
147
|
+
... "env": {
|
148
|
+
... "FOO": "BAR",
|
149
|
+
... },
|
150
|
+
... }
|
151
|
+
"""
|
152
|
+
|
153
|
+
bash: str = Field(description="A bash statement that want to execute.")
|
154
|
+
env: DictStr = Field(
|
155
|
+
default_factory=dict,
|
156
|
+
description=(
|
157
|
+
"An environment variable mapping that want to set before execute "
|
158
|
+
"this shell statement."
|
159
|
+
),
|
160
|
+
)
|
161
|
+
|
162
|
+
@contextlib.contextmanager
|
163
|
+
def __prepare_bash(self, bash: str, env: DictStr) -> Iterator[TupleStr]:
|
164
|
+
"""Return context of prepared bash statement that want to execute. This
|
165
|
+
step will write the `.sh` file before giving this file name to context.
|
166
|
+
After that, it will auto delete this file automatic.
|
167
|
+
"""
|
168
|
+
f_name: str = f"{uuid.uuid4()}.sh"
|
169
|
+
f_shebang: str = "bash" if sys.platform.startswith("win") else "sh"
|
170
|
+
with open(f"./{f_name}", mode="w", newline="\n") as f:
|
171
|
+
# NOTE: write header of `.sh` file
|
172
|
+
f.write(f"#!/bin/{f_shebang}\n")
|
173
|
+
|
174
|
+
# NOTE: add setting environment variable before bash skip statement.
|
175
|
+
f.writelines([f"{k}='{env[k]}';\n" for k in env])
|
176
|
+
|
177
|
+
# NOTE: make sure that shell script file does not have `\r` char.
|
178
|
+
f.write(bash.replace("\r\n", "\n"))
|
179
|
+
|
180
|
+
make_exec(f"./{f_name}")
|
181
|
+
|
182
|
+
yield [f_shebang, f_name]
|
183
|
+
|
184
|
+
Path(f"./{f_name}").unlink()
|
185
|
+
|
186
|
+
def execute(self, params: DictData) -> Result:
|
187
|
+
"""Execute the Bash statement with the Python build-in ``subprocess``
|
188
|
+
package.
|
189
|
+
|
190
|
+
:param params: A parameter data that want to use in this execution.
|
191
|
+
:rtype: Result
|
192
|
+
"""
|
193
|
+
bash: str = param2template(self.bash, params)
|
194
|
+
with self.__prepare_bash(
|
195
|
+
bash=bash, env=param2template(self.env, params)
|
196
|
+
) as sh:
|
197
|
+
logging.info(f"[STAGE]: Shell-Execute: {sh}")
|
198
|
+
rs: CompletedProcess = subprocess.run(
|
199
|
+
sh,
|
200
|
+
shell=False,
|
201
|
+
capture_output=True,
|
202
|
+
text=True,
|
203
|
+
)
|
204
|
+
if rs.returncode > 0:
|
205
|
+
err: str = (
|
206
|
+
rs.stderr.encode("utf-8").decode("utf-16")
|
207
|
+
if "\\x00" in rs.stderr
|
208
|
+
else rs.stderr
|
209
|
+
)
|
210
|
+
logging.error(f"{err}\nRunning Statement:\n---\n{bash}")
|
211
|
+
raise StageException(f"{err}\nRunning Statement:\n---\n{bash}")
|
212
|
+
return Result(
|
213
|
+
status=0,
|
214
|
+
context={
|
215
|
+
"return_code": rs.returncode,
|
216
|
+
"stdout": rs.stdout.rstrip("\n"),
|
217
|
+
"stderr": rs.stderr.rstrip("\n"),
|
218
|
+
},
|
219
|
+
)
|
220
|
+
|
221
|
+
|
222
|
+
class PyStage(BaseStage):
|
223
|
+
"""Python executor stage that running the Python statement that receive
|
224
|
+
globals nad additional variables.
|
225
|
+
"""
|
226
|
+
|
227
|
+
run: str = Field(
|
228
|
+
description="A Python string statement that want to run with exec.",
|
229
|
+
)
|
230
|
+
vars: DictData = Field(
|
231
|
+
default_factory=dict,
|
232
|
+
description=(
|
233
|
+
"A mapping to variable that want to pass to globals in exec."
|
234
|
+
),
|
235
|
+
)
|
236
|
+
|
237
|
+
def set_outputs(self, output: DictData, params: DictData) -> DictData:
|
238
|
+
"""Set an outputs from the Python execution process to an input params.
|
239
|
+
|
240
|
+
:param output: A output data that want to extract to an output key.
|
241
|
+
:param params: A context data that want to add output result.
|
242
|
+
:rtype: DictData
|
243
|
+
"""
|
244
|
+
# NOTE: The output will fileter unnecessary keys from locals.
|
245
|
+
_locals: DictData = output["locals"]
|
246
|
+
super().set_outputs(
|
247
|
+
{k: _locals[k] for k in _locals if k != "__annotations__"},
|
248
|
+
params=params,
|
249
|
+
)
|
250
|
+
|
251
|
+
# NOTE:
|
252
|
+
# Override value that changing from the globals that pass via exec.
|
253
|
+
_globals: DictData = output["globals"]
|
254
|
+
params.update({k: _globals[k] for k in params if k in _globals})
|
255
|
+
return params
|
256
|
+
|
257
|
+
def execute(self, params: DictData) -> Result:
|
258
|
+
"""Execute the Python statement that pass all globals and input params
|
259
|
+
to globals argument on ``exec`` build-in function.
|
260
|
+
|
261
|
+
:param params: A parameter that want to pass before run any statement.
|
262
|
+
:rtype: Result
|
263
|
+
"""
|
264
|
+
# NOTE: create custom globals value that will pass to exec function.
|
265
|
+
_globals: DictData = (
|
266
|
+
globals() | params | param2template(self.vars, params)
|
267
|
+
)
|
268
|
+
_locals: DictData = {}
|
269
|
+
try:
|
270
|
+
logging.info(f"[STAGE]: Py-Execute: {uuid.uuid4()}")
|
271
|
+
exec(param2template(self.run, params), _globals, _locals)
|
272
|
+
except Exception as err:
|
273
|
+
raise StageException(
|
274
|
+
f"{err.__class__.__name__}: {err}\nRunning Statement:\n---\n"
|
275
|
+
f"{self.run}"
|
276
|
+
) from None
|
277
|
+
return Result(
|
278
|
+
status=0,
|
279
|
+
context={"locals": _locals, "globals": _globals},
|
280
|
+
)
|
281
|
+
|
282
|
+
|
283
|
+
@dataclass
|
284
|
+
class HookSearch:
|
285
|
+
"""Hook Search dataclass."""
|
286
|
+
|
287
|
+
path: str
|
288
|
+
func: str
|
289
|
+
tag: str
|
290
|
+
|
291
|
+
|
292
|
+
class HookStage(BaseStage):
|
293
|
+
"""Hook executor that hook the Python function from registry with tag
|
294
|
+
decorator function in ``utils`` module and run it with input arguments.
|
295
|
+
|
296
|
+
This stage is different with PyStage because the PyStage is just calling
|
297
|
+
a Python statement with the ``eval`` and pass that locale before eval that
|
298
|
+
statement. So, you can create your function complexly that you can for your
|
299
|
+
propose to invoked by this stage object.
|
300
|
+
|
301
|
+
Data Validate:
|
302
|
+
>>> stage = {
|
303
|
+
... "name": "Task stage execution",
|
304
|
+
... "task": "tasks/function-name@tag-name",
|
305
|
+
... "args": {
|
306
|
+
... "FOO": "BAR",
|
307
|
+
... },
|
308
|
+
... }
|
309
|
+
"""
|
310
|
+
|
311
|
+
uses: str = Field(
|
312
|
+
description="A pointer that want to load function from registry",
|
313
|
+
)
|
314
|
+
args: DictData = Field(alias="with")
|
315
|
+
|
316
|
+
@staticmethod
|
317
|
+
def extract_hook(hook: str) -> Callable[[], TagFunc]:
|
318
|
+
"""Extract Hook string value to hook function.
|
319
|
+
|
320
|
+
:param hook: A hook value that able to match with Task regex.
|
321
|
+
"""
|
322
|
+
if not (found := Re.RE_TASK_FMT.search(hook)):
|
323
|
+
raise ValueError("Task does not match with task format regex.")
|
324
|
+
|
325
|
+
# NOTE: Pass the searching hook string to `path`, `func`, and `tag`.
|
326
|
+
hook: HookSearch = HookSearch(**found.groupdict())
|
327
|
+
|
328
|
+
# NOTE: Registry object should implement on this package only.
|
329
|
+
rgt: dict[str, Registry] = make_registry(f"{hook.path}")
|
330
|
+
if hook.func not in rgt:
|
331
|
+
raise NotImplementedError(
|
332
|
+
f"``REGISTER-MODULES.{hook.path}.registries`` does not "
|
333
|
+
f"implement registry: {hook.func!r}."
|
334
|
+
)
|
335
|
+
|
336
|
+
if hook.tag not in rgt[hook.func]:
|
337
|
+
raise NotImplementedError(
|
338
|
+
f"tag: {hook.tag!r} does not found on registry func: "
|
339
|
+
f"``REGISTER-MODULES.{hook.path}.registries.{hook.func}``"
|
340
|
+
)
|
341
|
+
return rgt[hook.func][hook.tag]
|
342
|
+
|
343
|
+
def execute(self, params: DictData) -> Result:
|
344
|
+
"""Execute the Task function that already mark registry.
|
345
|
+
|
346
|
+
:param params: A parameter that want to pass before run any statement.
|
347
|
+
:type params: DictData
|
348
|
+
:rtype: Result
|
349
|
+
"""
|
350
|
+
t_func: TagFunc = self.extract_hook(param2template(self.uses, params))()
|
351
|
+
if not callable(t_func):
|
352
|
+
raise ImportError("Hook caller function does not callable.")
|
353
|
+
|
354
|
+
args: DictData = param2template(self.args, params)
|
355
|
+
# VALIDATE: check input task caller parameters that exists before
|
356
|
+
# calling.
|
357
|
+
ips = inspect.signature(t_func)
|
358
|
+
if any(
|
359
|
+
k not in args
|
360
|
+
for k in ips.parameters
|
361
|
+
if ips.parameters[k].default == Parameter.empty
|
362
|
+
):
|
363
|
+
raise ValueError(
|
364
|
+
f"Necessary params, ({', '.join(ips.parameters.keys())}), "
|
365
|
+
f"does not set to args"
|
366
|
+
)
|
367
|
+
|
368
|
+
try:
|
369
|
+
logging.info(f"[STAGE]: Hook-Execute: {t_func.name}@{t_func.tag}")
|
370
|
+
rs: DictData = t_func(**param2template(args, params))
|
371
|
+
except Exception as err:
|
372
|
+
raise StageException(f"{err.__class__.__name__}: {err}") from err
|
373
|
+
return Result(status=0, context=rs)
|
374
|
+
|
375
|
+
|
376
|
+
class TriggerStage(BaseStage):
|
377
|
+
"""Trigger Pipeline execution stage that execute another pipeline object."""
|
378
|
+
|
379
|
+
trigger: str = Field(description="A trigger pipeline name.")
|
380
|
+
params: DictData = Field(default_factory=dict)
|
381
|
+
|
382
|
+
def execute(self, params: DictData) -> Result:
|
383
|
+
"""Trigger execution.
|
384
|
+
|
385
|
+
:param params: A parameter data that want to use in this execution.
|
386
|
+
:rtype: Result
|
387
|
+
"""
|
388
|
+
from .pipeline import Pipeline
|
389
|
+
|
390
|
+
pipe: Pipeline = Pipeline.from_loader(name=self.trigger, externals={})
|
391
|
+
rs = pipe.execute(params=self.params)
|
392
|
+
return Result(status=0, context=rs)
|
393
|
+
|
394
|
+
|
395
|
+
# NOTE: Order of parsing stage data
|
396
|
+
Stage = Union[
|
397
|
+
PyStage,
|
398
|
+
BashStage,
|
399
|
+
HookStage,
|
400
|
+
TriggerStage,
|
401
|
+
EmptyStage,
|
402
|
+
]
|