ddeutil-workflow 0.0.21__py3-none-any.whl → 0.0.23__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.
@@ -70,7 +70,7 @@ logging.getLogger("schedule").setLevel(logging.INFO)
70
70
 
71
71
  __all__: TupleStr = (
72
72
  "Schedule",
73
- "ScheduleWorkflow",
73
+ "WorkflowSchedule",
74
74
  "workflow_task_release",
75
75
  "workflow_monitor",
76
76
  "workflow_control",
@@ -78,25 +78,29 @@ __all__: TupleStr = (
78
78
  )
79
79
 
80
80
 
81
- class ScheduleWorkflow(BaseModel):
82
- """Schedule Workflow Pydantic model that use to keep workflow model for the
83
- Schedule model. it should not use Workflow model directly because on the
81
+ class WorkflowSchedule(BaseModel):
82
+ """Workflow Schedule Pydantic model that use to keep workflow model for
83
+ the Schedule model. it should not use Workflow model directly because on the
84
84
  schedule config it can adjust crontab value that different from the Workflow
85
85
  model.
86
86
  """
87
87
 
88
88
  alias: Optional[str] = Field(
89
89
  default=None,
90
- description="An alias name of workflow.",
90
+ description="An alias name of workflow that use for schedule model.",
91
91
  )
92
92
  name: str = Field(description="A workflow name.")
93
93
  on: list[On] = Field(
94
94
  default_factory=list,
95
- description="An override On instance value.",
95
+ description="An override the list of On object values.",
96
96
  )
97
- params: DictData = Field(
97
+ values: DictData = Field(
98
98
  default_factory=dict,
99
- description="A parameters that want to use in workflow execution.",
99
+ description=(
100
+ "A value that want to pass to the workflow parameters when "
101
+ "calling release method."
102
+ ),
103
+ alias="params",
100
104
  )
101
105
 
102
106
  @model_validator(mode="before")
@@ -105,10 +109,13 @@ class ScheduleWorkflow(BaseModel):
105
109
 
106
110
  :rtype: DictData
107
111
  """
108
- values["name"] = values["name"].replace(" ", "_")
112
+ # VALIDATE: Prepare a workflow name that should not include space.
113
+ if name := values.get("name"):
114
+ values["name"] = name.replace(" ", "_")
109
115
 
116
+ # VALIDATE: Add default the alias field with the name.
110
117
  if not values.get("alias"):
111
- values["alias"] = values["name"]
118
+ values["alias"] = values.get("name")
112
119
 
113
120
  cls.__bypass_on(values)
114
121
  return values
@@ -135,6 +142,7 @@ class ScheduleWorkflow(BaseModel):
135
142
  Loader(n, externals={}).data if isinstance(n, str) else n
136
143
  for n in on
137
144
  ]
145
+
138
146
  return data
139
147
 
140
148
  @field_validator("on", mode="after")
@@ -150,19 +158,20 @@ class ScheduleWorkflow(BaseModel):
150
158
  "The on fields should not contain duplicate on value."
151
159
  )
152
160
 
153
- # WARNING:
154
- # if '* * * * *' in set_ons and len(set_ons) > 1:
155
- # raise ValueError(
156
- # "If it has every minute cronjob on value, it should has only "
157
- # "one value in the on field."
158
- # )
161
+ if len(set_ons) > config.max_on_per_workflow:
162
+ raise ValueError(
163
+ f"The number of the on should not more than "
164
+ f"{config.max_on_per_workflow} crontab."
165
+ )
166
+
159
167
  return value
160
168
 
161
169
 
162
170
  class Schedule(BaseModel):
163
- """Schedule Pydantic Model that use to run with scheduler package. It does
164
- not equal the on value in Workflow model but it use same logic to running
165
- release date with crontab interval.
171
+ """Schedule Pydantic model that use to run with any scheduler package.
172
+
173
+ It does not equal the on value in Workflow model but it use same logic
174
+ to running release date with crontab interval.
166
175
  """
167
176
 
168
177
  desc: Optional[str] = Field(
@@ -171,9 +180,9 @@ class Schedule(BaseModel):
171
180
  "A schedule description that can be string of markdown content."
172
181
  ),
173
182
  )
174
- workflows: list[ScheduleWorkflow] = Field(
183
+ workflows: list[WorkflowSchedule] = Field(
175
184
  default_factory=list,
176
- description="A list of ScheduleWorkflow models.",
185
+ description="A list of WorkflowSchedule models.",
177
186
  )
178
187
 
179
188
  @field_validator("desc", mode="after")
@@ -181,6 +190,7 @@ class Schedule(BaseModel):
181
190
  """Prepare description string that was created on a template.
182
191
 
183
192
  :param value: A description string value that want to dedent.
193
+
184
194
  :rtype: str
185
195
  """
186
196
  return dedent(value)
@@ -224,6 +234,9 @@ class Schedule(BaseModel):
224
234
  """Return the list of WorkflowTaskData object from the specific input
225
235
  datetime that mapping with the on field.
226
236
 
237
+ This task creation need queue to tracking release date already
238
+ mapped or not.
239
+
227
240
  :param start_date: A start date that get from the workflow schedule.
228
241
  :param queue: A mapping of name and list of datetime for queue.
229
242
  :param externals: An external parameters that pass to the Loader object.
@@ -232,22 +245,21 @@ class Schedule(BaseModel):
232
245
  :return: Return the list of WorkflowTaskData object from the specific
233
246
  input datetime that mapping with the on field.
234
247
  """
235
-
236
- # NOTE: Create pair of workflow and on.
237
248
  workflow_tasks: list[WorkflowTaskData] = []
238
249
  extras: DictData = externals or {}
239
250
 
240
251
  for sch_wf in self.workflows:
241
252
 
253
+ # NOTE: Loading workflow model from the name of workflow.
242
254
  wf: Workflow = Workflow.from_loader(sch_wf.name, externals=extras)
243
255
 
244
- # NOTE: Create default list of release datetime.
256
+ # NOTE: Create default list of release datetime by empty list.
245
257
  if sch_wf.alias not in queue:
246
258
  queue[sch_wf.alias]: list[datetime] = []
247
259
 
248
260
  # IMPORTANT: Create the default 'on' value if it does not passing
249
261
  # the on field to the Schedule object.
250
- ons: list[On] = wf.on.copy() if len(sch_wf.on) == 0 else sch_wf.on
262
+ ons: list[On] = sch_wf.on or wf.on.copy()
251
263
 
252
264
  for on in ons:
253
265
 
@@ -263,7 +275,7 @@ class Schedule(BaseModel):
263
275
  alias=sch_wf.alias,
264
276
  workflow=wf,
265
277
  runner=runner,
266
- params=sch_wf.params,
278
+ params=sch_wf.values,
267
279
  ),
268
280
  )
269
281
 
ddeutil/workflow/stage.py CHANGED
@@ -51,10 +51,11 @@ from typing_extensions import Self
51
51
  from .__types import DictData, DictStr, Re, TupleStr
52
52
  from .conf import config, get_logger
53
53
  from .exceptions import StageException
54
+ from .result import Result
54
55
  from .utils import (
55
56
  Registry,
56
- Result,
57
57
  TagFunc,
58
+ cut_id,
58
59
  gen_id,
59
60
  make_exec,
60
61
  make_registry,
@@ -124,13 +125,16 @@ def handler_result(message: str | None = None) -> DecoratorResult:
124
125
  run_id: str = gen_id(self.name + (self.id or ""), unique=True)
125
126
  kwargs["run_id"] = run_id
126
127
 
128
+ rs_raise: Result = Result(status=1, run_id=run_id)
129
+
127
130
  try:
128
131
  # NOTE: Start calling origin function with a passing args.
129
132
  return func(self, *args, **kwargs)
130
133
  except Exception as err:
131
134
  # NOTE: Start catching error from the stage execution.
132
135
  logger.error(
133
- f"({run_id}) [STAGE]: {err.__class__.__name__}: {err}"
136
+ f"({cut_id(run_id)}) [STAGE]: {err.__class__.__name__}: "
137
+ f"{err}"
134
138
  )
135
139
  if config.stage_raise_error:
136
140
  # NOTE: If error that raise from stage execution course by
@@ -147,13 +151,12 @@ def handler_result(message: str | None = None) -> DecoratorResult:
147
151
 
148
152
  # NOTE: Catching exception error object to result with
149
153
  # error_message and error keys.
150
- return Result(
154
+ return rs_raise.catch(
151
155
  status=1,
152
156
  context={
153
157
  "error": err,
154
158
  "error_message": f"{err.__class__.__name__}: {err}",
155
159
  },
156
- run_id=run_id,
157
160
  )
158
161
 
159
162
  return wrapped
@@ -339,7 +342,7 @@ class EmptyStage(BaseStage):
339
342
  :rtype: Result
340
343
  """
341
344
  logger.info(
342
- f"({run_id}) [STAGE]: Empty-Execute: {self.name!r}: "
345
+ f"({cut_id(run_id)}) [STAGE]: Empty-Execute: {self.name!r}: "
343
346
  f"( {param2template(self.echo, params=params) or '...'} )"
344
347
  )
345
348
  if self.sleep > 0:
@@ -393,7 +396,9 @@ class BashStage(BaseStage):
393
396
  f_name: str = f"{run_id}.sh"
394
397
  f_shebang: str = "bash" if sys.platform.startswith("win") else "sh"
395
398
 
396
- logger.debug(f"({run_id}) [STAGE]: Start create `{f_name}` file.")
399
+ logger.debug(
400
+ f"({cut_id(run_id)}) [STAGE]: Start create `{f_name}` file."
401
+ )
397
402
 
398
403
  with open(f"./{f_name}", mode="w", newline="\n") as f:
399
404
  # NOTE: write header of `.sh` file
@@ -425,7 +430,7 @@ class BashStage(BaseStage):
425
430
  """
426
431
  bash: str = param2template(dedent(self.bash), params)
427
432
 
428
- logger.info(f"({run_id}) [STAGE]: Shell-Execute: {self.name}")
433
+ logger.info(f"({cut_id(run_id)}) [STAGE]: Shell-Execute: {self.name}")
429
434
  with self.create_sh_file(
430
435
  bash=bash, env=param2template(self.env, params), run_id=run_id
431
436
  ) as sh:
@@ -535,7 +540,7 @@ class PyStage(BaseStage):
535
540
  lc: DictData = {}
536
541
 
537
542
  # NOTE: Start exec the run statement.
538
- logger.info(f"({run_id}) [STAGE]: Py-Execute: {self.name}")
543
+ logger.info(f"({cut_id(run_id)}) [STAGE]: Py-Execute: {self.name}")
539
544
 
540
545
  # WARNING: The exec build-in function is vary dangerous. So, it
541
546
  # should us the re module to validate exec-string before running.
@@ -660,7 +665,8 @@ class HookStage(BaseStage):
660
665
  args[k] = args.pop(k.removeprefix("_"))
661
666
 
662
667
  logger.info(
663
- f"({run_id}) [STAGE]: Hook-Execute: {t_func.name}@{t_func.tag}"
668
+ f"({cut_id(run_id)}) [STAGE]: Hook-Execute: "
669
+ f"{t_func.name}@{t_func.tag}"
664
670
  )
665
671
  rs: DictData = t_func(**param2template(args, params))
666
672
 
@@ -716,7 +722,9 @@ class TriggerStage(BaseStage):
716
722
  # NOTE: Set running workflow ID from running stage ID to external
717
723
  # params on Loader object.
718
724
  wf: Workflow = Workflow.from_loader(name=_trigger)
719
- logger.info(f"({run_id}) [STAGE]: Trigger-Execute: {_trigger!r}")
725
+ logger.info(
726
+ f"({cut_id(run_id)}) [STAGE]: Trigger-Execute: {_trigger!r}"
727
+ )
720
728
  return wf.execute(
721
729
  params=param2template(self.params, params),
722
730
  run_id=run_id,
ddeutil/workflow/utils.py CHANGED
@@ -9,11 +9,9 @@ import inspect
9
9
  import logging
10
10
  import stat
11
11
  import time
12
- from abc import ABC, abstractmethod
13
12
  from ast import Call, Constant, Expr, Module, Name, parse
14
13
  from collections.abc import Iterator
15
- from dataclasses import field
16
- from datetime import date, datetime, timedelta
14
+ from datetime import datetime, timedelta
17
15
  from functools import wraps
18
16
  from hashlib import md5
19
17
  from importlib import import_module
@@ -21,7 +19,7 @@ from inspect import isfunction
21
19
  from itertools import chain, islice, product
22
20
  from pathlib import Path
23
21
  from random import randrange
24
- from typing import Any, Callable, Literal, Optional, Protocol, TypeVar, Union
22
+ from typing import Any, Callable, Protocol, TypeVar, Union
25
23
  from zoneinfo import ZoneInfo
26
24
 
27
25
  try:
@@ -31,14 +29,11 @@ except ImportError:
31
29
 
32
30
  from ddeutil.core import getdot, hasdot, hash_str, import_string, lazy
33
31
  from ddeutil.io import search_env_replace
34
- from pydantic import BaseModel, Field
35
- from pydantic.dataclasses import dataclass
36
- from pydantic.functional_validators import model_validator
37
- from typing_extensions import Self
32
+ from pydantic import BaseModel
38
33
 
39
34
  from .__types import DictData, Matrix, Re
40
35
  from .conf import config
41
- from .exceptions import ParamValueException, UtilException
36
+ from .exceptions import UtilException
42
37
 
43
38
  T = TypeVar("T")
44
39
  P = ParamSpec("P")
@@ -198,239 +193,7 @@ def make_registry(submodule: str) -> dict[str, Registry]:
198
193
  return rs
199
194
 
200
195
 
201
- class BaseParam(BaseModel, ABC):
202
- """Base Parameter that use to make Params Model."""
203
-
204
- desc: Optional[str] = Field(
205
- default=None, description="A description of parameter providing."
206
- )
207
- required: bool = Field(
208
- default=True,
209
- description="A require flag that force to pass this parameter value.",
210
- )
211
- type: str = Field(description="A type of parameter.")
212
-
213
- @abstractmethod
214
- def receive(self, value: Optional[Any] = None) -> Any:
215
- raise NotImplementedError(
216
- "Receive value and validate typing before return valid value."
217
- )
218
-
219
-
220
- class DefaultParam(BaseParam):
221
- """Default Parameter that will check default if it required. This model do
222
- not implement the receive method.
223
- """
224
-
225
- required: bool = Field(
226
- default=False,
227
- description="A require flag for the default-able parameter value.",
228
- )
229
- default: Optional[str] = Field(
230
- default=None,
231
- description="A default value if parameter does not pass.",
232
- )
233
-
234
- @abstractmethod
235
- def receive(self, value: Optional[Any] = None) -> Any:
236
- raise NotImplementedError(
237
- "Receive value and validate typing before return valid value."
238
- )
239
-
240
-
241
- class DatetimeParam(DefaultParam):
242
- """Datetime parameter."""
243
-
244
- type: Literal["datetime"] = "datetime"
245
- default: datetime = Field(default_factory=get_dt_now)
246
-
247
- def receive(self, value: str | datetime | date | None = None) -> datetime:
248
- """Receive value that match with datetime. If a input value pass with
249
- None, it will use default value instead.
250
-
251
- :param value: A value that want to validate with datetime parameter
252
- type.
253
- :rtype: datetime
254
- """
255
- if value is None:
256
- return self.default
257
-
258
- if isinstance(value, datetime):
259
- return value
260
- elif isinstance(value, date):
261
- return datetime(value.year, value.month, value.day)
262
- elif not isinstance(value, str):
263
- raise ParamValueException(
264
- f"Value that want to convert to datetime does not support for "
265
- f"type: {type(value)}"
266
- )
267
- try:
268
- return datetime.fromisoformat(value)
269
- except ValueError:
270
- raise ParamValueException(
271
- f"Invalid isoformat string: {value!r}"
272
- ) from None
273
-
274
-
275
- class StrParam(DefaultParam):
276
- """String parameter."""
277
-
278
- type: Literal["str"] = "str"
279
-
280
- def receive(self, value: str | None = None) -> str | None:
281
- """Receive value that match with str.
282
-
283
- :param value: A value that want to validate with string parameter type.
284
- :rtype: str | None
285
- """
286
- if value is None:
287
- return self.default
288
- return str(value)
289
-
290
-
291
- class IntParam(DefaultParam):
292
- """Integer parameter."""
293
-
294
- type: Literal["int"] = "int"
295
- default: Optional[int] = Field(
296
- default=None,
297
- description="A default value if parameter does not pass.",
298
- )
299
-
300
- def receive(self, value: int | None = None) -> int | None:
301
- """Receive value that match with int.
302
-
303
- :param value: A value that want to validate with integer parameter type.
304
- :rtype: int | None
305
- """
306
- if value is None:
307
- return self.default
308
- if not isinstance(value, int):
309
- try:
310
- return int(str(value))
311
- except ValueError as err:
312
- raise ParamValueException(
313
- f"Value can not convert to int, {value}, with base 10"
314
- ) from err
315
- return value
316
-
317
-
318
- class ChoiceParam(BaseParam):
319
- """Choice parameter."""
320
-
321
- type: Literal["choice"] = "choice"
322
- options: list[str] = Field(description="A list of choice parameters.")
323
-
324
- def receive(self, value: str | None = None) -> str:
325
- """Receive value that match with options.
326
-
327
- :param value: A value that want to select from the options field.
328
- :rtype: str
329
- """
330
- # NOTE:
331
- # Return the first value in options if does not pass any input value
332
- if value is None:
333
- return self.options[0]
334
- if value not in self.options:
335
- raise ParamValueException(
336
- f"{value!r} does not match any value in choice options."
337
- )
338
- return value
339
-
340
-
341
- Param = Union[
342
- ChoiceParam,
343
- DatetimeParam,
344
- IntParam,
345
- StrParam,
346
- ]
347
-
348
-
349
- @dataclass
350
- class Result:
351
- """Result Pydantic Model for passing and receiving data context from any
352
- module execution process like stage execution, job execution, or workflow
353
- execution.
354
-
355
- For comparison property, this result will use ``status``, ``context``,
356
- and ``_run_id`` fields to comparing with other result instance.
357
- """
358
-
359
- status: int = field(default=2)
360
- context: DictData = field(default_factory=dict)
361
-
362
- # NOTE: Ignore this field to compare another result model with __eq__.
363
- run_id: Optional[str] = field(default=None)
364
- parent_run_id: Optional[str] = field(default=None, compare=False)
365
-
366
- @model_validator(mode="after")
367
- def __prepare_run_id(self) -> Self:
368
- """Prepare running ID which use default ID if it initialize at the first
369
- time
370
-
371
- :rtype: Self
372
- """
373
- self._run_id = gen_id("manual", unique=True)
374
- return self
375
-
376
- def set_run_id(self, running_id: str) -> Self:
377
- """Set a running ID.
378
-
379
- :param running_id: A running ID that want to update on this model.
380
- :rtype: Self
381
- """
382
- self.run_id = running_id
383
- return self
384
-
385
- def set_parent_run_id(self, running_id: str) -> Self:
386
- """Set a parent running ID.
387
-
388
- :param running_id: A running ID that want to update on this model.
389
- :rtype: Self
390
- """
391
- self.parent_run_id: str = running_id
392
- return self
393
-
394
- def catch(self, status: int, context: DictData) -> Self:
395
- """Catch the status and context to current data."""
396
- self.__dict__["status"] = status
397
- self.__dict__["context"].update(context)
398
- return self
399
-
400
- def receive(self, result: Result) -> Self:
401
- """Receive context from another result object.
402
-
403
- :rtype: Self
404
- """
405
- self.__dict__["status"] = result.status
406
- self.__dict__["context"].update(result.context)
407
-
408
- # NOTE: Update running ID from an incoming result.
409
- self.parent_run_id = result.parent_run_id
410
- self.run_id = result.run_id
411
- return self
412
-
413
- def receive_jobs(self, result: Result) -> Self:
414
- """Receive context from another result object that use on the workflow
415
- execution which create a ``jobs`` keys on the context if it do not
416
- exist.
417
-
418
- :rtype: Self
419
- """
420
- self.__dict__["status"] = result.status
421
-
422
- # NOTE: Check the context has jobs key.
423
- if "jobs" not in self.__dict__["context"]:
424
- self.__dict__["context"]["jobs"] = {}
425
- self.__dict__["context"]["jobs"].update(result.context)
426
-
427
- # NOTE: Update running ID from an incoming result.
428
- self.parent_run_id: str = result.parent_run_id
429
- self.run_id: str = result.run_id
430
- return self
431
-
432
-
433
- def make_exec(path: str | Path) -> None: # pragma: no cov
196
+ def make_exec(path: str | Path) -> None:
434
197
  """Change mode of file to be executable file.
435
198
 
436
199
  :param path: A file path that want to make executable permission.
@@ -451,7 +214,9 @@ FILTERS: dict[str, callable] = { # pragma: no cov
451
214
 
452
215
 
453
216
  class FilterFunc(Protocol):
454
- """Tag Function Protocol"""
217
+ """Tag Function Protocol. This protocol that use to represent any callable
218
+ object that able to access the name attribute.
219
+ """
455
220
 
456
221
  name: str
457
222
 
@@ -814,3 +579,17 @@ def batch(iterable: Iterator[Any], n: int) -> Iterator[Any]:
814
579
 
815
580
  def queue2str(queue: list[datetime]) -> Iterator[str]: # pragma: no cov
816
581
  return (f"{q:%Y-%m-%d %H:%M:%S}" for q in queue)
582
+
583
+
584
+ def cut_id(run_id: str, *, num: int = 6):
585
+ """Cutting running ID with length.
586
+
587
+ Example:
588
+ >>> cut_id(run_id='668931127320241228100331254567')
589
+ '254567'
590
+
591
+ :param run_id:
592
+ :param num:
593
+ :return:
594
+ """
595
+ return run_id[-num:]