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.
@@ -1 +1 @@
1
- __version__: str = "0.0.5"
1
+ __version__: str = "0.0.6"
@@ -0,0 +1,9 @@
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 .exceptions import StageException
7
+ from .on import On
8
+ from .pipeline import Pipeline
9
+ from .stage import Stage
@@ -5,8 +5,50 @@
5
5
  # ------------------------------------------------------------------------------
6
6
  from __future__ import annotations
7
7
 
8
- from typing import Any
8
+ import re
9
+ from re import (
10
+ IGNORECASE,
11
+ MULTILINE,
12
+ UNICODE,
13
+ VERBOSE,
14
+ Pattern,
15
+ )
16
+ from typing import Any, Union
9
17
 
10
18
  TupleStr = tuple[str, ...]
11
19
  DictData = dict[str, Any]
12
20
  DictStr = dict[str, str]
21
+ Matrix = dict[str, Union[list[str], list[int]]]
22
+ MatrixInclude = list[dict[str, Union[str, int]]]
23
+ MatrixExclude = list[dict[str, Union[str, int]]]
24
+
25
+
26
+ class Re:
27
+ """Regular expression config."""
28
+
29
+ # NOTE: Search caller
30
+ __re_caller: str = r"""
31
+ \$
32
+ {{
33
+ \s*(?P<caller>
34
+ [a-zA-Z0-9_.\s'\"\[\]\(\)\-\{}]+?
35
+ )\s*
36
+ }}
37
+ """
38
+ RE_CALLER: Pattern = re.compile(
39
+ __re_caller, MULTILINE | IGNORECASE | UNICODE | VERBOSE
40
+ )
41
+
42
+ # NOTE: Search task
43
+ __re_task_fmt: str = r"""
44
+ ^
45
+ (?P<path>[^/@]+)
46
+ /
47
+ (?P<func>[^@]+)
48
+ @
49
+ (?P<tag>.+)
50
+ $
51
+ """
52
+ RE_TASK_FMT: Pattern = re.compile(
53
+ __re_task_fmt, MULTILINE | IGNORECASE | UNICODE | VERBOSE
54
+ )
@@ -9,4 +9,16 @@ Define Errors Object for Node package
9
9
  from __future__ import annotations
10
10
 
11
11
 
12
- class TaskException(Exception): ...
12
+ class WorkflowException(Exception): ...
13
+
14
+
15
+ class UtilException(WorkflowException): ...
16
+
17
+
18
+ class StageException(WorkflowException): ...
19
+
20
+
21
+ class JobException(WorkflowException): ...
22
+
23
+
24
+ class PipelineException(WorkflowException): ...
@@ -6,49 +6,17 @@
6
6
  from __future__ import annotations
7
7
 
8
8
  from functools import cached_property
9
- from typing import Any, ClassVar, TypeVar
9
+ from typing import TypeVar
10
10
 
11
- from ddeutil.core import (
12
- getdot,
13
- hasdot,
14
- import_string,
15
- )
16
- from ddeutil.io import (
17
- PathData,
18
- PathSearch,
19
- YamlEnvFl,
20
- )
21
- from pydantic import BaseModel, Field
22
- from pydantic.functional_validators import model_validator
11
+ from ddeutil.core import import_string
12
+ from ddeutil.io import PathSearch, YamlFlResolve
13
+ from pydantic import BaseModel
23
14
 
24
- from .__regex import RegexConf
25
15
  from .__types import DictData
16
+ from .utils import ConfParams, config
26
17
 
27
- T = TypeVar("T")
28
- BaseModelType = type[BaseModel]
29
18
  AnyModel = TypeVar("AnyModel", bound=BaseModel)
30
-
31
-
32
- class Engine(BaseModel):
33
- """Engine Model"""
34
-
35
- paths: PathData = Field(default_factory=PathData)
36
- registry: list[str] = Field(default_factory=lambda: ["ddeutil.workflow"])
37
-
38
- @model_validator(mode="before")
39
- def __prepare_registry(cls, values: DictData) -> DictData:
40
- """Prepare registry value that passing with string type. It convert the
41
- string type to list of string.
42
- """
43
- if (_regis := values.get("registry")) and isinstance(_regis, str):
44
- values["registry"] = [_regis]
45
- return values
46
-
47
-
48
- class Params(BaseModel):
49
- """Params Model"""
50
-
51
- engine: Engine = Field(default_factory=Engine)
19
+ AnyModelType = type[AnyModel]
52
20
 
53
21
 
54
22
  class SimLoad:
@@ -66,26 +34,22 @@ class SimLoad:
66
34
  def __init__(
67
35
  self,
68
36
  name: str,
69
- params: Params,
37
+ params: ConfParams,
70
38
  externals: DictData,
71
39
  ) -> None:
72
40
  self.data: DictData = {}
73
41
  for file in PathSearch(params.engine.paths.conf).files:
74
42
  if any(file.suffix.endswith(s) for s in ("yml", "yaml")) and (
75
- data := YamlEnvFl(file).read().get(name, {})
43
+ data := YamlFlResolve(file).read().get(name, {})
76
44
  ):
77
45
  self.data = data
78
46
  if not self.data:
79
47
  raise ValueError(f"Config {name!r} does not found on conf path")
80
- self.__conf_params: Params = params
48
+ self.conf_params: ConfParams = params
81
49
  self.externals: DictData = externals
82
50
 
83
- @property
84
- def conf_params(self) -> Params:
85
- return self.__conf_params
86
-
87
51
  @cached_property
88
- def type(self) -> BaseModelType:
52
+ def type(self) -> AnyModelType:
89
53
  """Return object of string type which implement on any registry. The
90
54
  object type
91
55
  """
@@ -104,79 +68,13 @@ class SimLoad:
104
68
  continue
105
69
  return import_string(f"{_typ}")
106
70
 
107
- def load(self) -> AnyModel:
108
- """Parsing config data to the object type for initialize with model
109
- validate method.
110
- """
111
- return self.type.model_validate(self.data)
112
-
113
71
 
114
72
  class Loader(SimLoad):
115
- """Main Loader Object that get the config `yaml` file from current path.
73
+ """Loader Object that get the config `yaml` file from current path.
116
74
 
117
75
  :param name: A name of config data that will read by Yaml Loader object.
118
76
  :param externals: An external parameters
119
77
  """
120
78
 
121
- conf_name: ClassVar[str] = "workflows-conf"
122
-
123
- def __init__(
124
- self,
125
- name: str,
126
- externals: DictData,
127
- *,
128
- path: str | None = None,
129
- ) -> None:
130
- self.data: DictData = {}
131
-
132
- # NOTE: import params object from specific config file
133
- params: Params = self.config(path)
134
-
135
- super().__init__(name, params, externals)
136
-
137
- @classmethod
138
- def config(cls, path: str | None = None) -> Params:
139
- """Load Config data from ``workflows-conf.yaml`` file."""
140
- return Params.model_validate(
141
- YamlEnvFl(path or f"./{cls.conf_name}.yaml").read()
142
- )
143
-
144
-
145
- def map_params(value: Any, params: dict[str, Any]) -> Any:
146
- """Map caller value that found from ``RE_CALLER`` regular expression.
147
-
148
- :param value: A value that want to mapped with an params
149
- :param params: A parameter value that getting with matched regular
150
- expression.
151
-
152
- :rtype: Any
153
- :returns: An any getter value from the params input.
154
- """
155
- if isinstance(value, dict):
156
- return {k: map_params(value[k], params) for k in value}
157
- elif isinstance(value, (list, tuple, set)):
158
- return type(value)([map_params(i, params) for i in value])
159
- elif not isinstance(value, str):
160
- return value
161
-
162
- if not (found := RegexConf.RE_CALLER.search(value)):
163
- return value
164
-
165
- # NOTE: get caller value that setting inside; ``${{ <caller-value> }}``
166
- caller: str = found.group("caller")
167
- if not hasdot(caller, params):
168
- raise ValueError(f"params does not set caller: {caller!r}")
169
- getter: Any = getdot(caller, params)
170
-
171
- # NOTE: check type of vars
172
- if isinstance(getter, (str, int)):
173
- return value.replace(found.group(0), str(getter))
174
-
175
- # NOTE:
176
- # If type of getter caller does not formatting, it will return origin
177
- # value.
178
- if value.replace(found.group(0), "") != "":
179
- raise ValueError(
180
- "Callable variable should not pass other outside ${{ ... }}"
181
- )
182
- return getter
79
+ def __init__(self, name: str, externals: DictData) -> None:
80
+ super().__init__(name, config(), externals)
ddeutil/workflow/on.py CHANGED
@@ -10,29 +10,35 @@ from typing import Annotated, Literal
10
10
  from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
11
11
 
12
12
  from pydantic import BaseModel, ConfigDict, Field
13
- from pydantic.functional_validators import field_validator
13
+ from pydantic.functional_validators import field_validator, model_validator
14
14
  from typing_extensions import Self
15
15
 
16
16
  try:
17
- from .__schedule import WEEKDAYS
18
17
  from .__types import DictData, DictStr
19
- from .loader import CronJob, CronRunner, Loader
18
+ from .loader import Loader
19
+ from .scheduler import WEEKDAYS, CronJob, CronJobYear, CronRunner
20
20
  except ImportError:
21
- from ddeutil.workflow.__scheduler import WEEKDAYS, CronJob, CronRunner
22
21
  from ddeutil.workflow.__types import DictData, DictStr
23
22
  from ddeutil.workflow.loader import Loader
23
+ from ddeutil.workflow.scheduler import (
24
+ WEEKDAYS,
25
+ CronJob,
26
+ CronJobYear,
27
+ CronRunner,
28
+ )
24
29
 
25
30
 
26
31
  def interval2crontab(
27
32
  interval: Literal["daily", "weekly", "monthly"],
28
- day: str = "monday",
33
+ day: str | None = None,
29
34
  time: str = "00:00",
30
35
  ) -> str:
31
36
  """Return the crontab string that was generated from specific values.
32
37
 
33
38
  :param interval: A interval value that is one of 'daily', 'weekly', or
34
39
  'monthly'.
35
- :param day: A day value that will be day of week.
40
+ :param day: A day value that will be day of week. The default value is
41
+ monday if it be weekly interval.
36
42
  :param time: A time value that passing with format '%H:%M'.
37
43
 
38
44
  Examples:
@@ -42,18 +48,23 @@ def interval2crontab(
42
48
  '18 30 * * 5'
43
49
  >>> interval2crontab(interval='monthly', time='00:00')
44
50
  '0 0 1 * *'
51
+ >>> interval2crontab(interval='monthly', day='tuesday', time='12:00')
52
+ '12 0 1 * 2'
45
53
  """
54
+ d: str = "*"
55
+ if interval == "weekly":
56
+ d = WEEKDAYS[(day or "monday")[:3].title()]
57
+ elif interval == "monthly" and day:
58
+ d = WEEKDAYS[day[:3].title()]
59
+
46
60
  h, m = tuple(
47
61
  i.lstrip("0") if i != "00" else "0" for i in time.split(":", maxsplit=1)
48
62
  )
49
- return (
50
- f"{h} {m} {'1' if interval == 'monthly' else '*'} * "
51
- f"{WEEKDAYS[day[:3].title()] if interval == 'weekly' else '*'}"
52
- )
63
+ return f"{h} {m} {'1' if interval == 'monthly' else '*'} * {d}"
53
64
 
54
65
 
55
- class Schedule(BaseModel):
56
- """Schedule Model
66
+ class On(BaseModel):
67
+ """On Model (Schedule)
57
68
 
58
69
  See Also:
59
70
  * ``generate()`` is the main usecase of this schedule object.
@@ -62,8 +73,17 @@ class Schedule(BaseModel):
62
73
  model_config = ConfigDict(arbitrary_types_allowed=True)
63
74
 
64
75
  # NOTE: This is fields of the base schedule.
65
- cronjob: Annotated[CronJob, Field(description="Cron job of this schedule")]
66
- tz: Annotated[str, Field(description="A timezone string value")] = "Etc/UTC"
76
+ cronjob: Annotated[
77
+ CronJob,
78
+ Field(description="Cron job of this schedule"),
79
+ ]
80
+ tz: Annotated[
81
+ str,
82
+ Field(
83
+ description="A timezone string value",
84
+ alias="timezone",
85
+ ),
86
+ ] = "Etc/UTC"
67
87
  extras: Annotated[
68
88
  DictData,
69
89
  Field(
@@ -105,17 +125,36 @@ class Schedule(BaseModel):
105
125
  if loader.type != cls:
106
126
  raise ValueError(f"Type {loader.type} does not match with {cls}")
107
127
 
108
- if "interval" in loader.data:
109
- return cls.from_value(loader.data, externals=externals)
110
- if "cronjob" not in loader.data:
111
- raise ValueError("Config does not set ``cronjob`` value")
112
- if "timezone" in loader.data:
113
- return cls(
114
- cronjob=loader.data["cronjob"],
115
- tz=loader.data["timezone"],
128
+ loader_data: DictData = loader.data
129
+ if "interval" in loader_data:
130
+ return cls.model_validate(
131
+ obj=dict(
132
+ cronjob=interval2crontab(
133
+ **{
134
+ v: loader_data[v]
135
+ for v in loader_data
136
+ if v in ("interval", "day", "time")
137
+ }
138
+ ),
139
+ extras=externals,
140
+ **loader_data,
141
+ )
142
+ )
143
+ if "cronjob" not in loader_data:
144
+ raise ValueError("Config does not set ``cronjob`` key")
145
+ return cls.model_validate(
146
+ obj=dict(
147
+ cronjob=loader_data.pop("cronjob"),
116
148
  extras=externals,
149
+ **loader_data,
117
150
  )
118
- return cls(cronjob=loader.data["cronjob"], extras=externals)
151
+ )
152
+
153
+ @model_validator(mode="before")
154
+ def __prepare_values(cls, values):
155
+ if tz := values.pop("tz", None):
156
+ values["timezone"] = tz
157
+ return values
119
158
 
120
159
  @field_validator("tz")
121
160
  def __validate_tz(cls, value: str):
@@ -136,8 +175,21 @@ class Schedule(BaseModel):
136
175
  """Return Cron runner object."""
137
176
  if not isinstance(start, datetime):
138
177
  start: datetime = datetime.fromisoformat(start)
139
- return self.cronjob.schedule(date=(start.astimezone(ZoneInfo(self.tz))))
178
+ return self.cronjob.schedule(date=start, tz=self.tz)
140
179
 
141
180
 
142
- class AwsSchedule(Schedule):
143
- """Implement Schedule for AWS Service."""
181
+ class AwsOn(On):
182
+ """Implement On AWS Schedule for AWS Service like AWS Glue."""
183
+
184
+ model_config = ConfigDict(arbitrary_types_allowed=True)
185
+
186
+ # NOTE: This is fields of the base schedule.
187
+ cronjob: Annotated[
188
+ CronJobYear,
189
+ Field(description="Cron job of this schedule"),
190
+ ]
191
+
192
+ @field_validator("cronjob", mode="before")
193
+ def __prepare_cronjob(cls, value: str | CronJobYear) -> CronJobYear:
194
+ """Prepare crontab value that able to receive with string type."""
195
+ return CronJobYear(value) if isinstance(value, str) else value