ddeutil-workflow 0.0.30__py3-none-any.whl → 0.0.31__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.30"
1
+ __version__: str = "0.0.31"
@@ -845,5 +845,6 @@ __all__ = (
845
845
  "CronJob",
846
846
  "CronJobYear",
847
847
  "CronRunner",
848
+ "Options",
848
849
  "WEEKDAYS",
849
850
  )
@@ -47,8 +47,10 @@ from .params import (
47
47
  from .result import Result
48
48
  from .scheduler import (
49
49
  Schedule,
50
- WorkflowSchedule,
50
+ ScheduleWorkflow,
51
+ schedule_control,
51
52
  schedule_runner,
53
+ schedule_task,
52
54
  )
53
55
  from .stage import (
54
56
  BashStage,
@@ -83,6 +85,8 @@ from .utils import (
83
85
  make_exec,
84
86
  )
85
87
  from .workflow import (
88
+ Release,
89
+ ReleaseQueue,
86
90
  Workflow,
87
91
  WorkflowTask,
88
92
  )
ddeutil/workflow/conf.py CHANGED
@@ -582,6 +582,10 @@ Log = Union[
582
582
 
583
583
 
584
584
  def get_log() -> type[Log]: # pragma: no cov
585
+ """Get logging class that dynamic base on the config log path value.
586
+
587
+ :rtype: type[Log]
588
+ """
585
589
  if config.log_path.is_file():
586
590
  return SQLiteLog
587
591
  return FileLog
ddeutil/workflow/cron.py CHANGED
@@ -5,16 +5,17 @@
5
5
  # ------------------------------------------------------------------------------
6
6
  from __future__ import annotations
7
7
 
8
+ from dataclasses import fields
8
9
  from datetime import datetime
9
- from typing import Annotated, Literal
10
+ from typing import Annotated, Literal, Union
10
11
  from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
11
12
 
12
- from pydantic import BaseModel, ConfigDict, Field
13
+ from pydantic import BaseModel, ConfigDict, Field, ValidationInfo
13
14
  from pydantic.functional_serializers import field_serializer
14
15
  from pydantic.functional_validators import field_validator, model_validator
15
16
  from typing_extensions import Self
16
17
 
17
- from .__cron import WEEKDAYS, CronJob, CronJobYear, CronRunner
18
+ from .__cron import WEEKDAYS, CronJob, CronJobYear, CronRunner, Options
18
19
  from .__types import DictData, DictStr, TupleStr
19
20
  from .conf import Loader
20
21
 
@@ -47,6 +48,8 @@ def interval2crontab(
47
48
  '0 0 1 * *'
48
49
  >>> interval2crontab(interval='monthly', day='tuesday', time='12:00')
49
50
  '12 0 1 * 2'
51
+
52
+ :rtype: str
50
53
  """
51
54
  d: str = "*"
52
55
  if interval == "weekly":
@@ -64,12 +67,19 @@ class On(BaseModel):
64
67
  """On Pydantic model (Warped crontab object by model).
65
68
 
66
69
  See Also:
67
- * ``generate()`` is the main usecase of this schedule object.
70
+ * ``generate()`` is the main use-case of this schedule object.
68
71
  """
69
72
 
70
73
  model_config = ConfigDict(arbitrary_types_allowed=True)
71
74
 
72
75
  # NOTE: This is fields of the base schedule.
76
+ extras: Annotated[
77
+ DictData,
78
+ Field(
79
+ default_factory=dict,
80
+ description="An extras mapping parameters",
81
+ ),
82
+ ]
73
83
  cronjob: Annotated[
74
84
  CronJob,
75
85
  Field(description="Cron job of this schedule"),
@@ -81,13 +91,6 @@ class On(BaseModel):
81
91
  alias="timezone",
82
92
  ),
83
93
  ] = "Etc/UTC"
84
- extras: Annotated[
85
- DictData,
86
- Field(
87
- default_factory=dict,
88
- description="An extras mapping parameters",
89
- ),
90
- ]
91
94
 
92
95
  @classmethod
93
96
  def from_value(cls, value: DictStr, externals: DictData) -> Self:
@@ -153,6 +156,7 @@ class On(BaseModel):
153
156
 
154
157
  @model_validator(mode="before")
155
158
  def __prepare_values(cls, values: DictData) -> DictData:
159
+ """Extract tz key from value and change name to timezone key."""
156
160
  if tz := values.pop("tz", None):
157
161
  values["timezone"] = tz
158
162
  return values
@@ -160,24 +164,55 @@ class On(BaseModel):
160
164
  @field_validator("tz")
161
165
  def __validate_tz(cls, value: str) -> str:
162
166
  """Validate timezone value that able to initialize with ZoneInfo after
163
- it passing to this model in before mode."""
167
+ it passing to this model in before mode.
168
+
169
+ :rtype: str
170
+ """
164
171
  try:
165
172
  _ = ZoneInfo(value)
166
173
  return value
167
174
  except ZoneInfoNotFoundError as err:
168
175
  raise ValueError(f"Invalid timezone: {value}") from err
169
176
 
170
- @field_validator("cronjob", mode="before")
171
- def __prepare_cronjob(cls, value: str | CronJob) -> CronJob:
172
- """Prepare crontab value that able to receive with string type."""
173
- return CronJob(value) if isinstance(value, str) else value
177
+ @field_validator(
178
+ "cronjob", mode="before", json_schema_input_type=Union[CronJob, str]
179
+ )
180
+ def __prepare_cronjob(
181
+ cls, value: str | CronJob, info: ValidationInfo
182
+ ) -> CronJob:
183
+ """Prepare crontab value that able to receive with string type.
184
+ This step will get options kwargs from extras and pass to the
185
+ CronJob object.
186
+
187
+ :rtype: CronJob
188
+ """
189
+ extras: DictData = info.data.get("extras", {})
190
+ return (
191
+ CronJob(
192
+ value,
193
+ option={
194
+ name: extras[name]
195
+ for name in (f.name for f in fields(Options))
196
+ if name in extras
197
+ },
198
+ )
199
+ if isinstance(value, str)
200
+ else value
201
+ )
174
202
 
175
203
  @field_serializer("cronjob")
176
204
  def __serialize_cronjob(self, value: CronJob) -> str:
205
+ """Serialize the cronjob field that store with CronJob object.
206
+
207
+ :rtype: str
208
+ """
177
209
  return str(value)
178
210
 
179
211
  def generate(self, start: str | datetime) -> CronRunner:
180
- """Return Cron runner object."""
212
+ """Return Cron runner object.
213
+
214
+ :rtype: CronRunner
215
+ """
181
216
  if isinstance(start, str):
182
217
  start: datetime = datetime.fromisoformat(start)
183
218
  elif not isinstance(start, datetime):
@@ -187,6 +222,8 @@ class On(BaseModel):
187
222
  def next(self, start: str | datetime) -> CronRunner:
188
223
  """Return a next datetime from Cron runner object that start with any
189
224
  date that given from input.
225
+
226
+ :rtype: CronRunner
190
227
  """
191
228
  runner: CronRunner = self.generate(start=start)
192
229
 
@@ -209,7 +246,26 @@ class YearOn(On):
209
246
  Field(description="Cron job of this schedule"),
210
247
  ]
211
248
 
212
- @field_validator("cronjob", mode="before")
213
- def __prepare_cronjob(cls, value: str | CronJobYear) -> CronJobYear:
214
- """Prepare crontab value that able to receive with string type."""
215
- return CronJobYear(value) if isinstance(value, str) else value
249
+ @field_validator(
250
+ "cronjob", mode="before", json_schema_input_type=Union[CronJob, str]
251
+ )
252
+ def __prepare_cronjob(
253
+ cls, value: str | CronJobYear, info: ValidationInfo
254
+ ) -> CronJobYear:
255
+ """Prepare crontab value that able to receive with string type.
256
+
257
+ :rtype: CronJobYear
258
+ """
259
+ extras: DictData = info.data.get("extras", {})
260
+ return (
261
+ CronJobYear(
262
+ value,
263
+ option={
264
+ name: extras[name]
265
+ for name in (f.name for f in fields(Options))
266
+ if name in extras
267
+ },
268
+ )
269
+ if isinstance(value, str)
270
+ else value
271
+ )
@@ -29,3 +29,6 @@ class WorkflowFailException(WorkflowException): ...
29
29
 
30
30
 
31
31
  class ParamValueException(WorkflowException): ...
32
+
33
+
34
+ class ScheduleException(BaseWorkflowException): ...
@@ -5,6 +5,7 @@
5
5
  # ------------------------------------------------------------------------------
6
6
  from __future__ import annotations
7
7
 
8
+ import decimal
8
9
  import logging
9
10
  from abc import ABC, abstractmethod
10
11
  from datetime import date, datetime
@@ -49,7 +50,7 @@ class BaseParam(BaseModel, ABC):
49
50
 
50
51
  class DefaultParam(BaseParam):
51
52
  """Default Parameter that will check default if it required. This model do
52
- not implement the receive method.
53
+ not implement the `receive` method.
53
54
  """
54
55
 
55
56
  required: bool = Field(
@@ -68,6 +69,15 @@ class DefaultParam(BaseParam):
68
69
  )
69
70
 
70
71
 
72
+ # TODO: Not implement this parameter yet
73
+ class DateParam(DefaultParam):
74
+ """Date parameter."""
75
+
76
+ type: Literal["date"] = "date"
77
+
78
+ def receive(self, value: Optional[str | date] = None) -> date: ...
79
+
80
+
71
81
  class DatetimeParam(DefaultParam):
72
82
  """Datetime parameter."""
73
83
 
@@ -145,6 +155,13 @@ class IntParam(DefaultParam):
145
155
  return value
146
156
 
147
157
 
158
+ # TODO: Not implement this parameter yet
159
+ class DecimalParam(DefaultParam):
160
+ type: Literal["decimal"] = "decimal"
161
+
162
+ def receive(self, value: float | None = None) -> decimal.Decimal: ...
163
+
164
+
148
165
  class ChoiceParam(BaseParam):
149
166
  """Choice parameter."""
150
167
 
@@ -94,6 +94,7 @@ class Result:
94
94
  # NOTE: Check the context has jobs key.
95
95
  if "jobs" not in self.__dict__["context"]:
96
96
  self.__dict__["context"]["jobs"] = {}
97
+
97
98
  self.__dict__["context"]["jobs"].update(result.context)
98
99
 
99
100
  # NOTE: Update running ID from an incoming result.
@@ -33,7 +33,7 @@ from functools import wraps
33
33
  from heapq import heappop, heappush
34
34
  from textwrap import dedent
35
35
  from threading import Thread
36
- from typing import Callable, Optional, TypedDict
36
+ from typing import Callable, Optional, TypedDict, Union
37
37
 
38
38
  from pydantic import BaseModel, Field
39
39
  from pydantic.functional_validators import field_validator, model_validator
@@ -41,7 +41,7 @@ from typing_extensions import Self
41
41
 
42
42
  try:
43
43
  from typing import ParamSpec
44
- except ImportError:
44
+ except ImportError: # pragma: no cov
45
45
  from typing_extensions import ParamSpec
46
46
 
47
47
  try:
@@ -53,11 +53,9 @@ from .__cron import CronRunner
53
53
  from .__types import DictData, TupleStr
54
54
  from .conf import Loader, Log, config, get_log, get_logger
55
55
  from .cron import On
56
- from .exceptions import WorkflowException
57
- from .utils import (
58
- batch,
59
- delay,
60
- )
56
+ from .exceptions import ScheduleException, WorkflowException
57
+ from .result import Result
58
+ from .utils import batch, delay
61
59
  from .workflow import Release, ReleaseQueue, Workflow, WorkflowTask
62
60
 
63
61
  P = ParamSpec("P")
@@ -69,7 +67,7 @@ logging.getLogger("schedule").setLevel(logging.INFO)
69
67
 
70
68
  __all__: TupleStr = (
71
69
  "Schedule",
72
- "WorkflowSchedule",
70
+ "ScheduleWorkflow",
73
71
  "schedule_task",
74
72
  "monitor",
75
73
  "schedule_control",
@@ -79,8 +77,8 @@ __all__: TupleStr = (
79
77
  )
80
78
 
81
79
 
82
- class WorkflowSchedule(BaseModel):
83
- """Workflow Schedule Pydantic model that use to keep workflow model for
80
+ class ScheduleWorkflow(BaseModel):
81
+ """Schedule Workflow Pydantic model that use to keep workflow model for
84
82
  the Schedule model. it should not use Workflow model directly because on the
85
83
  schedule config it can adjust crontab value that different from the Workflow
86
84
  model.
@@ -233,9 +231,9 @@ class Schedule(BaseModel):
233
231
  "A schedule description that can be string of markdown content."
234
232
  ),
235
233
  )
236
- workflows: list[WorkflowSchedule] = Field(
234
+ workflows: list[ScheduleWorkflow] = Field(
237
235
  default_factory=list,
238
- description="A list of WorkflowSchedule models.",
236
+ description="A list of ScheduleWorkflow models.",
239
237
  )
240
238
 
241
239
  @field_validator("desc", mode="after")
@@ -258,7 +256,7 @@ class Schedule(BaseModel):
258
256
  an input schedule name. The loader object will use this schedule name to
259
257
  searching configuration data of this schedule model in conf path.
260
258
 
261
- :param name: A schedule name that want to pass to Loader object.
259
+ :param name: (str) A schedule name that want to pass to Loader object.
262
260
  :param externals: An external parameters that want to pass to Loader
263
261
  object.
264
262
 
@@ -277,6 +275,37 @@ class Schedule(BaseModel):
277
275
 
278
276
  return cls.model_validate(obj=loader_data)
279
277
 
278
+ @classmethod
279
+ def extract_tasks(
280
+ cls,
281
+ schedules: list[str],
282
+ start_date: datetime,
283
+ queue: dict[str, ReleaseQueue],
284
+ externals: DictData | None = None,
285
+ ) -> list[WorkflowTask]:
286
+ """Return the list of WorkflowTask object from all schedule object that
287
+ include in an input schedules argument.
288
+
289
+ :param schedules: A list of schedule name that will use `from_loader`
290
+ method.
291
+ :param start_date: A start date that get from the workflow schedule.
292
+ :param queue: A mapping of name and list of datetime for queue.
293
+ :param externals: An external parameters that pass to the Loader object.
294
+
295
+ :rtype: list[WorkflowTask]
296
+ """
297
+ tasks: list[WorkflowTask] = []
298
+ for name in schedules:
299
+ schedule: Schedule = Schedule.from_loader(name, externals=externals)
300
+ tasks.extend(
301
+ schedule.tasks(
302
+ start_date,
303
+ queue=queue,
304
+ externals=externals,
305
+ ),
306
+ )
307
+ return tasks
308
+
280
309
  def tasks(
281
310
  self,
282
311
  start_date: datetime,
@@ -311,7 +340,8 @@ class Schedule(BaseModel):
311
340
  return workflow_tasks
312
341
 
313
342
 
314
- ReturnCancelJob = Callable[P, Optional[CancelJob]]
343
+ ResultOrCancelJob = Union[type[CancelJob], Result]
344
+ ReturnCancelJob = Callable[P, ResultOrCancelJob]
315
345
  DecoratorCancelJob = Callable[[ReturnCancelJob], ReturnCancelJob]
316
346
 
317
347
 
@@ -326,24 +356,25 @@ def catch_exceptions(cancel_on_failure: bool = False) -> DecoratorCancelJob:
326
356
  """
327
357
 
328
358
  def decorator(func: ReturnCancelJob) -> ReturnCancelJob: # pragma: no cov
329
- try:
330
359
 
331
- @wraps(func)
332
- def wrapper(*args, **kwargs):
360
+ @wraps(func)
361
+ def wrapper(*args: P.args, **kwargs: P.kwargs) -> ResultOrCancelJob:
362
+ try:
333
363
  return func(*args, **kwargs)
364
+ except Exception as err:
365
+ logger.exception(err)
366
+ if cancel_on_failure:
367
+ return CancelJob
368
+ raise err
334
369
 
335
- return wrapper
336
-
337
- except Exception as err:
338
- logger.exception(err)
339
- if cancel_on_failure:
340
- return CancelJob
341
- raise err
370
+ return wrapper
342
371
 
343
372
  return decorator
344
373
 
345
374
 
346
375
  class ReleaseThread(TypedDict):
376
+ """TypeDict for the release thread."""
377
+
347
378
  thread: Thread
348
379
  start_date: datetime
349
380
 
@@ -358,11 +389,13 @@ def schedule_task(
358
389
  queue: dict[str, ReleaseQueue],
359
390
  threads: ReleaseThreads,
360
391
  log: type[Log],
361
- ) -> CancelJob | None:
362
- """Workflow task generator that create release pair of workflow and on to
363
- the threading in background.
392
+ ) -> type[CancelJob] | None:
393
+ """Schedule task function that generate thread of workflow task release
394
+ method in background. This function do the same logic as the workflow poke
395
+ method, but it runs with map of schedules and the on values.
364
396
 
365
- This workflow task will start every minute at ':02' second.
397
+ This schedule task start runs every minute at ':02' second and it does
398
+ not allow you to run with offset time.
366
399
 
367
400
  :param tasks: A list of WorkflowTask object.
368
401
  :param stop: A stop datetime object that force stop running scheduler.
@@ -370,7 +403,7 @@ def schedule_task(
370
403
  :param threads: A mapping of alias name and Thread object.
371
404
  :param log: A log class that want to make log object.
372
405
 
373
- :rtype: CancelJob | None
406
+ :rtype: type[CancelJob] | None
374
407
  """
375
408
  current_date: datetime = datetime.now(tz=config.tz)
376
409
  if current_date > stop.replace(tzinfo=config.tz):
@@ -410,12 +443,17 @@ def schedule_task(
410
443
  current_release: datetime = current_date.replace(
411
444
  second=0, microsecond=0
412
445
  )
413
- if (first_date := q.first_queue.date) != current_release:
446
+ if (first_date := q.first_queue.date) > current_release:
414
447
  logger.debug(
415
448
  f"[WORKFLOW]: Skip schedule "
416
449
  f"{first_date:%Y-%m-%d %H:%M:%S} for : {task.alias!r}"
417
450
  )
418
451
  continue
452
+ elif first_date < current_release: # pragma: no cov
453
+ raise ScheduleException(
454
+ "The first release date from queue should not less than current"
455
+ "release date."
456
+ )
419
457
 
420
458
  # NOTE: Pop the latest release and push it to running.
421
459
  release: Release = heappop(q.queue)
@@ -445,7 +483,7 @@ def schedule_task(
445
483
 
446
484
  delay()
447
485
 
448
- logger.debug(f"[SCHEDULE]: End schedule release {'=' * 80}")
486
+ logger.debug(f"[SCHEDULE]: End schedule task {'=' * 80}")
449
487
 
450
488
 
451
489
  def monitor(threads: ReleaseThreads) -> None: # pragma: no cov
@@ -455,9 +493,7 @@ def monitor(threads: ReleaseThreads) -> None: # pragma: no cov
455
493
  :param threads: A mapping of Thread object and its name.
456
494
  :type threads: ReleaseThreads
457
495
  """
458
- logger.debug(
459
- "[MONITOR]: Start checking long running workflow release task."
460
- )
496
+ logger.debug("[MONITOR]: Start checking long running schedule task.")
461
497
 
462
498
  snapshot_threads: list[str] = list(threads.keys())
463
499
  for t_name in snapshot_threads:
@@ -476,12 +512,15 @@ def schedule_control(
476
512
  *,
477
513
  log: type[Log] | None = None,
478
514
  ) -> list[str]: # pragma: no cov
479
- """Scheduler control function that running every minute.
515
+ """Scheduler control function that run the chuck of schedules every minute
516
+ and this function release monitoring thread for tracking undead thread in
517
+ the background.
480
518
 
481
519
  :param schedules: A list of workflow names that want to schedule running.
482
520
  :param stop: A datetime value that use to stop running schedule.
483
521
  :param externals: An external parameters that pass to Loader.
484
- :param log:
522
+ :param log: A log class that use on the workflow task release for writing
523
+ its release log context.
485
524
 
486
525
  :rtype: list[str]
487
526
  """
@@ -493,8 +532,11 @@ def schedule_control(
493
532
  "Should install schedule package before use this module."
494
533
  ) from None
495
534
 
535
+ # NOTE: Get default logging.
496
536
  log: type[Log] = log or get_log()
497
537
  scheduler: Scheduler = Scheduler()
538
+
539
+ # NOTE: Create the start and stop datetime.
498
540
  start_date: datetime = datetime.now(tz=config.tz)
499
541
  stop_date: datetime = stop or (start_date + config.stop_boundary_delta)
500
542
 
@@ -506,25 +548,15 @@ def schedule_control(
506
548
  second=0, microsecond=0
507
549
  ) + timedelta(minutes=1)
508
550
 
509
- # NOTE: Start create workflow tasks from list of schedule name.
510
- tasks: list[WorkflowTask] = []
511
- for name in schedules:
512
- schedule: Schedule = Schedule.from_loader(name, externals=externals)
513
- tasks.extend(
514
- schedule.tasks(
515
- start_date_waiting,
516
- queue=queue,
517
- externals=externals,
518
- ),
519
- )
520
-
521
551
  # NOTE: This schedule job will start every minute at :02 seconds.
522
552
  (
523
553
  scheduler.every(1)
524
554
  .minutes.at(":02")
525
555
  .do(
526
556
  schedule_task,
527
- tasks=tasks,
557
+ tasks=Schedule.extract_tasks(
558
+ schedules, start_date_waiting, queue, externals=externals
559
+ ),
528
560
  stop=stop_date,
529
561
  queue=queue,
530
562
  threads=threads,
@@ -533,7 +565,8 @@ def schedule_control(
533
565
  .tag("control")
534
566
  )
535
567
 
536
- # NOTE: Checking zombie task with schedule job will start every 5 minute.
568
+ # NOTE: Checking zombie task with schedule job will start every 5 minute at
569
+ # :10 seconds.
537
570
  (
538
571
  scheduler.every(5)
539
572
  .minutes.at(":10")
@@ -579,16 +612,15 @@ def schedule_runner(
579
612
  externals: DictData | None = None,
580
613
  excluded: list[str] | None = None,
581
614
  ) -> list[str]: # pragma: no cov
582
- """Schedule runner function for start submit the ``schedule_control`` func
583
- in multiprocessing pool with chunk of schedule config that exists in config
584
- path by ``WORKFLOW_APP_MAX_SCHEDULE_PER_PROCESS``.
615
+ """Schedule runner function it the multiprocess controller function for
616
+ split the setting schedule to the `schedule_control` function on the
617
+ process pool. It chunks schedule configs that exists in config
618
+ path by `WORKFLOW_APP_MAX_SCHEDULE_PER_PROCESS` value.
585
619
 
586
620
  :param stop: A stop datetime object that force stop running scheduler.
587
621
  :param externals:
588
622
  :param excluded: A list of schedule name that want to exclude from finding.
589
623
 
590
- :rtype: list[str]
591
-
592
624
  This function will get all workflows that include on value that was
593
625
  created in config path and chuck it with application config variable
594
626
  ``WORKFLOW_APP_MAX_SCHEDULE_PER_PROCESS`` env var to multiprocess executor
@@ -600,7 +632,9 @@ def schedule_runner(
600
632
  --> thread of release task 01 02
601
633
  ==> schedule --> thread of release task 02 01
602
634
  --> thread of release task 02 02
603
- ==> process 02
635
+ ==> process 02 ==> ...
636
+
637
+ :rtype: list[str]
604
638
  """
605
639
  results: list[str] = []
606
640
 
ddeutil/workflow/stage.py CHANGED
@@ -328,7 +328,7 @@ class BashStage(BaseStage):
328
328
  If your current OS is Windows, it will run on the bash in the WSL.
329
329
 
330
330
  I get some limitation when I run shell statement with the built-in
331
- supprocess package. It does not good enough to use multiline statement.
331
+ subprocess package. It does not good enough to use multiline statement.
332
332
  Thus, I add writing ``.sh`` file before execution process for fix this
333
333
  issue.
334
334
 
@@ -665,3 +665,15 @@ Stage = Union[
665
665
  TriggerStage,
666
666
  EmptyStage,
667
667
  ]
668
+
669
+
670
+ # TODO: Not implement this stages yet
671
+ class ParallelStage(BaseModel):
672
+ parallel: list[Stage]
673
+ max_parallel_core: int = Field(default=2)
674
+
675
+
676
+ # TODO: Not implement this stages yet
677
+ class ForEachStage(BaseModel):
678
+ foreach: list[str]
679
+ stages: list[Stage]
ddeutil/workflow/utils.py CHANGED
@@ -21,10 +21,9 @@ from zoneinfo import ZoneInfo
21
21
  from ddeutil.core import hash_str
22
22
 
23
23
  from .__types import DictData, Matrix
24
- from .conf import config
25
24
 
26
25
  T = TypeVar("T")
27
-
26
+ UTC = ZoneInfo("UTC")
28
27
  logger = logging.getLogger("ddeutil.workflow")
29
28
 
30
29
 
@@ -37,7 +36,7 @@ def get_dt_now(
37
36
  :param offset:
38
37
  :return: The current datetime object that use an input timezone or UTC.
39
38
  """
40
- return datetime.now(tz=(tz or ZoneInfo("UTC"))) - timedelta(seconds=offset)
39
+ return datetime.now(tz=(tz or UTC)) - timedelta(seconds=offset)
41
40
 
42
41
 
43
42
  def get_diff_sec(
@@ -52,17 +51,42 @@ def get_diff_sec(
52
51
  """
53
52
  return round(
54
53
  (
55
- dt
56
- - datetime.now(tz=(tz or ZoneInfo("UTC")))
57
- - timedelta(seconds=offset)
54
+ dt - datetime.now(tz=(tz or UTC)) - timedelta(seconds=offset)
58
55
  ).total_seconds()
59
56
  )
60
57
 
61
58
 
62
- def wait_a_minute(now: datetime, second: float = 2) -> None: # pragma: no cov
59
+ def reach_next_minute(
60
+ dt: datetime, tz: ZoneInfo | None = None, offset: float = 0.0
61
+ ) -> bool:
62
+ """Check this datetime object is not in range of minute level on the current
63
+ datetime.
64
+ """
65
+ diff: float = (
66
+ dt.replace(second=0, microsecond=0)
67
+ - (
68
+ get_dt_now(tz=(tz or UTC), offset=offset).replace(
69
+ second=0, microsecond=0
70
+ )
71
+ )
72
+ ).total_seconds()
73
+ if diff >= 60:
74
+ return True
75
+ elif diff >= 0:
76
+ return False
77
+
78
+ raise ValueError(
79
+ "Check reach the next minute function should check a datetime that not "
80
+ "less than the current date"
81
+ )
82
+
83
+
84
+ def wait_to_next_minute(
85
+ dt: datetime, second: float = 0
86
+ ) -> None: # pragma: no cov
63
87
  """Wait with sleep to the next minute with an offset second value."""
64
- future = now.replace(second=0, microsecond=0) + timedelta(minutes=1)
65
- time.sleep((future - now).total_seconds() + second)
88
+ future = dt.replace(second=0, microsecond=0) + timedelta(minutes=1)
89
+ time.sleep((future - dt).total_seconds() + second)
66
90
 
67
91
 
68
92
  def delay(second: float = 0) -> None: # pragma: no cov
@@ -92,6 +116,8 @@ def gen_id(
92
116
 
93
117
  :rtype: str
94
118
  """
119
+ from .conf import config
120
+
95
121
  if not isinstance(value, str):
96
122
  value: str = str(value)
97
123
 
@@ -177,7 +203,7 @@ def batch(iterable: Iterator[Any], n: int) -> Iterator[Any]:
177
203
  """Batch data into iterators of length n. The last batch may be shorter.
178
204
 
179
205
  Example:
180
- >>> for b in batch('ABCDEFG', 3):
206
+ >>> for b in batch(iter('ABCDEFG'), 3):
181
207
  ... print(list(b))
182
208
  ['A', 'B', 'C']
183
209
  ['D', 'E', 'F']
@@ -29,7 +29,8 @@ from concurrent.futures import (
29
29
  )
30
30
  from dataclasses import field
31
31
  from datetime import datetime, timedelta
32
- from functools import total_ordering
32
+ from enum import Enum
33
+ from functools import partial, total_ordering
33
34
  from heapq import heappop, heappush
34
35
  from queue import Queue
35
36
  from textwrap import dedent
@@ -53,21 +54,33 @@ from .utils import (
53
54
  cut_id,
54
55
  gen_id,
55
56
  get_dt_now,
56
- wait_a_minute,
57
+ reach_next_minute,
58
+ wait_to_next_minute,
57
59
  )
58
60
 
59
61
  logger = get_logger("ddeutil.workflow")
60
62
 
61
63
  __all__: TupleStr = (
62
- "Workflow",
63
64
  "Release",
64
65
  "ReleaseQueue",
66
+ "ReleaseType",
67
+ "Workflow",
65
68
  "WorkflowTask",
66
69
  )
67
70
 
68
71
 
72
+ class ReleaseType(str, Enum):
73
+ """Release Type Enum support the type field on the Release dataclass."""
74
+
75
+ DEFAULT: str = "manual"
76
+ TASK: str = "task"
77
+ POKE: str = "poking"
78
+
79
+
69
80
  @total_ordering
70
- @dataclass(config=ConfigDict(arbitrary_types_allowed=True))
81
+ @dataclass(
82
+ config=ConfigDict(arbitrary_types_allowed=True, use_enum_values=True)
83
+ )
71
84
  class Release:
72
85
  """Release Pydantic dataclass object that use for represent
73
86
  the release data that use with the `workflow.release` method."""
@@ -76,7 +89,7 @@ class Release:
76
89
  offset: float
77
90
  end_date: datetime
78
91
  runner: CronRunner
79
- type: str
92
+ type: ReleaseType = field(default=ReleaseType.DEFAULT)
80
93
 
81
94
  def __repr__(self) -> str:
82
95
  return repr(f"{self.date:%Y-%m-%d %H:%M:%S}")
@@ -94,13 +107,16 @@ class Release:
94
107
  """
95
108
  if isinstance(dt, str):
96
109
  dt: datetime = datetime.fromisoformat(dt)
110
+ elif not isinstance(dt, datetime):
111
+ raise TypeError(
112
+ "The `from_dt` need argument type be str or datetime only."
113
+ )
97
114
 
98
115
  return cls(
99
116
  date=dt,
100
117
  offset=0,
101
118
  end_date=dt + timedelta(days=1),
102
119
  runner=CronJob("* * * * *").schedule(dt.replace(tzinfo=config.tz)),
103
- type="manual",
104
120
  )
105
121
 
106
122
  def __eq__(self, other: Release | datetime) -> bool:
@@ -174,13 +190,6 @@ class ReleaseQueue:
174
190
 
175
191
  :rtype: bool
176
192
  """
177
- # NOTE: Old logic to peeking the first release from waiting queue.
178
- #
179
- # first_value: Release = heappop(self.queue)
180
- # heappush(self.queue, first_value)
181
- #
182
- # return first_value
183
- #
184
193
  return self.queue[0]
185
194
 
186
195
  def check_queue(self, value: Release | datetime) -> bool:
@@ -477,7 +486,7 @@ class Workflow(BaseModel):
477
486
  *,
478
487
  run_id: str | None = None,
479
488
  log: type[Log] = None,
480
- queue: ReleaseQueue | list[datetime] | list[Release] | None = None,
489
+ queue: ReleaseQueue | None = None,
481
490
  override_log_name: str | None = None,
482
491
  ) -> Result:
483
492
  """Release the workflow execution with overriding parameter with the
@@ -487,7 +496,7 @@ class Workflow(BaseModel):
487
496
  This method allow workflow use log object to save the execution
488
497
  result to log destination like file log to the local `/logs` directory.
489
498
 
490
- :Steps:
499
+ Steps:
491
500
  - Initialize ReleaseQueue and Release if they do not pass.
492
501
  - Create release data for pass to parameter templating function.
493
502
  - Execute this workflow with mapping release data to its parameters.
@@ -497,7 +506,7 @@ class Workflow(BaseModel):
497
506
 
498
507
  :param release: A release datetime or Release object.
499
508
  :param params: A workflow parameter that pass to execute method.
500
- :param queue: A list of release time that already queue.
509
+ :param queue: A ReleaseQueue that use for mark complete.
501
510
  :param run_id: A workflow running ID for this release.
502
511
  :param log: A log class that want to save the execution result.
503
512
  :param queue: A ReleaseQueue object.
@@ -510,13 +519,14 @@ class Workflow(BaseModel):
510
519
  name: str = override_log_name or self.name
511
520
  run_id: str = run_id or gen_id(name, unique=True)
512
521
  rs_release: Result = Result(run_id=run_id)
513
- rs_release_type: str = "release"
514
522
 
515
- # VALIDATE: Change queue value to ReleaseQueue object.
516
- if queue is None or isinstance(queue, list):
517
- queue: ReleaseQueue = ReleaseQueue.from_list(queue)
523
+ if queue is not None and not isinstance(queue, ReleaseQueue):
524
+ raise TypeError(
525
+ "The queue argument should be ReleaseQueue object only."
526
+ )
518
527
 
519
528
  # VALIDATE: Change release value to Release object.
529
+ rs_release_type: str = "release"
520
530
  if isinstance(release, datetime):
521
531
  rs_release_type: str = "datetime"
522
532
  release: Release = Release.from_dt(release)
@@ -548,24 +558,26 @@ class Workflow(BaseModel):
548
558
  )
549
559
 
550
560
  rs.set_parent_run_id(run_id)
551
- rs_log: Log = log.model_validate(
552
- {
553
- "name": name,
554
- "release": release.date,
555
- "type": release.type,
556
- "context": rs.context,
557
- "parent_run_id": rs.parent_run_id,
558
- "run_id": rs.run_id,
559
- }
560
- )
561
561
 
562
562
  # NOTE: Saving execution result to destination of the input log object.
563
563
  logger.debug(f"({cut_id(run_id)}) [LOG]: Writing log: {name!r}.")
564
- rs_log.save(excluded=None)
564
+ (
565
+ log.model_validate(
566
+ {
567
+ "name": name,
568
+ "release": release.date,
569
+ "type": release.type,
570
+ "context": rs.context,
571
+ "parent_run_id": rs.parent_run_id,
572
+ "run_id": rs.run_id,
573
+ }
574
+ ).save(excluded=None)
575
+ )
565
576
 
566
577
  # NOTE: Remove this release from running.
567
- queue.remove_running(release)
568
- queue.mark_complete(release)
578
+ if queue is not None:
579
+ queue.remove_running(release)
580
+ queue.mark_complete(release)
569
581
 
570
582
  # NOTE: Remove the params key from the result context for deduplicate.
571
583
  context: dict[str, Any] = rs.context
@@ -594,8 +606,16 @@ class Workflow(BaseModel):
594
606
  *,
595
607
  force_run: bool = False,
596
608
  ) -> ReleaseQueue:
597
- """Generate queue of datetime from the cron runner that initialize from
598
- the on field. with offset value.
609
+ """Generate Release from all on values from the on field and store them
610
+ to the ReleaseQueue object.
611
+
612
+ Steps:
613
+ - For-loop all the on value in the on field.
614
+ - Create Release object from the current date that not reach the end
615
+ date.
616
+ - Check this release do not store on the release queue object.
617
+ Generate the next date if it exists.
618
+ - Push this release to the release queue
599
619
 
600
620
  :param offset: An offset in second unit for time travel.
601
621
  :param end_date: An end datetime object.
@@ -621,7 +641,7 @@ class Workflow(BaseModel):
621
641
  offset=offset,
622
642
  end_date=end_date,
623
643
  runner=runner,
624
- type="poking",
644
+ type=ReleaseType.POKE,
625
645
  )
626
646
 
627
647
  while queue.check_queue(workflow_release) or (
@@ -633,7 +653,7 @@ class Workflow(BaseModel):
633
653
  offset=offset,
634
654
  end_date=end_date,
635
655
  runner=runner,
636
- type="poking",
656
+ type=ReleaseType.POKE,
637
657
  )
638
658
 
639
659
  if runner.date > end_date:
@@ -662,6 +682,9 @@ class Workflow(BaseModel):
662
682
  This method will observe its schedule that nearing to run with the
663
683
  ``self.release()`` method.
664
684
 
685
+ The limitation of this method is not allow run a date that less
686
+ than the current date.
687
+
665
688
  :param start_date: A start datetime object.
666
689
  :param params: A parameters that want to pass to the release method.
667
690
  :param run_id: A workflow running ID for this poke.
@@ -678,6 +701,12 @@ class Workflow(BaseModel):
678
701
  log: type[Log] = log or get_log()
679
702
  run_id: str = run_id or gen_id(self.name, unique=True)
680
703
 
704
+ # VALIDATE: Check the periods value should gather than 0.
705
+ if periods <= 0:
706
+ raise WorkflowException(
707
+ "The period of poking should be int and grater or equal than 1."
708
+ )
709
+
681
710
  # NOTE: If this workflow does not set the on schedule, it will return
682
711
  # empty result.
683
712
  if len(self.on) == 0:
@@ -687,23 +716,25 @@ class Workflow(BaseModel):
687
716
  )
688
717
  return []
689
718
 
690
- if periods <= 0:
691
- raise WorkflowException(
692
- "The period of poking should be int and grater or equal than 1."
693
- )
719
+ # NOTE: Create the current date that change microsecond to 0
720
+ current_date: datetime = datetime.now(tz=config.tz).replace(
721
+ microsecond=0
722
+ )
694
723
 
695
724
  # NOTE: Create start_date and offset variables.
696
- current_date: datetime = datetime.now(tz=config.tz)
697
-
698
725
  if start_date and start_date <= current_date:
699
- start_date = start_date.replace(tzinfo=config.tz)
726
+ start_date = start_date.replace(tzinfo=config.tz).replace(
727
+ microsecond=0
728
+ )
700
729
  offset: float = (current_date - start_date).total_seconds()
701
730
  else:
731
+ # NOTE: Force change start date if it gathers than the current date,
732
+ # or it does not pass to this method.
702
733
  start_date: datetime = current_date
703
734
  offset: float = 0
704
735
 
705
- # NOTE: End date is using to stop generate queue with an input periods
706
- # value.
736
+ # NOTE: The end date is using to stop generate queue with an input
737
+ # periods value.
707
738
  end_date: datetime = start_date + timedelta(minutes=periods)
708
739
 
709
740
  logger.info(
@@ -715,17 +746,17 @@ class Workflow(BaseModel):
715
746
  results: list[Result] = []
716
747
 
717
748
  # NOTE: Create empty ReleaseQueue object.
718
- wf_queue: ReleaseQueue = ReleaseQueue()
749
+ q: ReleaseQueue = ReleaseQueue()
719
750
 
720
- # NOTE: Make queue to the workflow queue object.
721
- self.queue(
722
- offset,
723
- end_date=end_date,
724
- queue=wf_queue,
725
- log=log,
726
- force_run=force_run,
751
+ # NOTE: Create reusable partial function and add Release to the release
752
+ # queue object.
753
+ partial_queue = partial(
754
+ self.queue, offset, end_date, log=log, force_run=force_run
727
755
  )
728
- if not wf_queue.is_queued:
756
+ partial_queue(q)
757
+
758
+ # NOTE: Return the empty result if it does not have any Release.
759
+ if not q.is_queued:
729
760
  logger.info(
730
761
  f"({cut_id(run_id)}) [POKING]: {self.name!r} does not have "
731
762
  f"any queue."
@@ -741,34 +772,27 @@ class Workflow(BaseModel):
741
772
 
742
773
  futures: list[Future] = []
743
774
 
744
- while wf_queue.is_queued:
775
+ while q.is_queued:
745
776
 
746
- # NOTE: Pop the latest Release object from queue.
747
- release: Release = heappop(wf_queue.queue)
777
+ # NOTE: Pop the latest Release object from the release queue.
778
+ release: Release = heappop(q.queue)
748
779
 
749
- if (
750
- release.date - get_dt_now(tz=config.tz, offset=offset)
751
- ).total_seconds() > 60:
780
+ if reach_next_minute(release.date, tz=config.tz, offset=offset):
752
781
  logger.debug(
753
- f"({cut_id(run_id)}) [POKING]: Wait because the latest "
754
- f"release has diff time more than 60 seconds ..."
782
+ f"({cut_id(run_id)}) [POKING]: The latest release, "
783
+ f"{release.date:%Y-%m-%d %H:%M:%S}, is not able to run "
784
+ f"on this minute"
755
785
  )
756
- heappush(wf_queue.queue, release)
757
- wait_a_minute(get_dt_now(tz=config.tz, offset=offset))
786
+ heappush(q.queue, release)
787
+ wait_to_next_minute(get_dt_now(tz=config.tz, offset=offset))
758
788
 
759
789
  # WARNING: I already call queue poking again because issue
760
790
  # about the every minute crontab.
761
- self.queue(
762
- offset,
763
- end_date,
764
- queue=wf_queue,
765
- log=log,
766
- force_run=force_run,
767
- )
791
+ partial_queue(q)
768
792
  continue
769
793
 
770
794
  # NOTE: Push the latest Release to the running queue.
771
- heappush(wf_queue.running, release)
795
+ heappush(q.running, release)
772
796
 
773
797
  futures.append(
774
798
  executor.submit(
@@ -776,17 +800,11 @@ class Workflow(BaseModel):
776
800
  release=release,
777
801
  params=params,
778
802
  log=log,
779
- queue=wf_queue,
803
+ queue=q,
780
804
  )
781
805
  )
782
806
 
783
- self.queue(
784
- offset,
785
- end_date,
786
- queue=wf_queue,
787
- log=log,
788
- force_run=force_run,
789
- )
807
+ partial_queue(q)
790
808
 
791
809
  # WARNING: This poking method does not allow to use fail-fast
792
810
  # logic to catching parallel execution result.
@@ -1148,20 +1166,31 @@ class WorkflowTask:
1148
1166
  release: datetime | Release | None = None,
1149
1167
  run_id: str | None = None,
1150
1168
  log: type[Log] = None,
1151
- queue: ReleaseQueue | list[datetime] | list[Release] | None = None,
1169
+ queue: ReleaseQueue | None = None,
1152
1170
  ) -> Result:
1153
1171
  """Release the workflow task data.
1154
1172
 
1155
1173
  :param release: A release datetime or Release object.
1156
1174
  :param run_id: A workflow running ID for this release.
1157
1175
  :param log: A log class that want to save the execution result.
1158
- :param queue: A ReleaseQueue object.
1176
+ :param queue: A ReleaseQueue object that use to mark complete.
1159
1177
 
1160
1178
  :rtype: Result
1161
1179
  """
1162
1180
  log: type[Log] = log or get_log()
1163
1181
 
1164
1182
  if release is None:
1183
+
1184
+ if queue is None:
1185
+ raise ValueError(
1186
+ "If pass None release value, you should to pass the queue"
1187
+ "for generate this release."
1188
+ )
1189
+ elif not isinstance(queue, ReleaseQueue):
1190
+ raise TypeError(
1191
+ "The queue argument should be ReleaseQueue object only."
1192
+ )
1193
+
1165
1194
  if queue.check_queue(self.runner.date):
1166
1195
  release = self.runner.next
1167
1196
 
@@ -1170,6 +1199,7 @@ class WorkflowTask:
1170
1199
  else:
1171
1200
  release = self.runner.date
1172
1201
 
1202
+ # NOTE: Call the workflow release method.
1173
1203
  return self.workflow.release(
1174
1204
  release=release,
1175
1205
  params=self.values,
@@ -1186,8 +1216,9 @@ class WorkflowTask:
1186
1216
  log: type[Log],
1187
1217
  *,
1188
1218
  force_run: bool = False,
1189
- ):
1190
- """Generate Release to ReleaseQueue object.
1219
+ ) -> ReleaseQueue:
1220
+ """Generate Release from the runner field and store it to the
1221
+ ReleaseQueue object.
1191
1222
 
1192
1223
  :param end_date: An end datetime object.
1193
1224
  :param queue: A workflow queue object.
@@ -1205,7 +1236,7 @@ class WorkflowTask:
1205
1236
  offset=0,
1206
1237
  end_date=end_date,
1207
1238
  runner=self.runner,
1208
- type="task",
1239
+ type=ReleaseType.TASK,
1209
1240
  )
1210
1241
 
1211
1242
  while queue.check_queue(workflow_release) or (
@@ -1217,7 +1248,7 @@ class WorkflowTask:
1217
1248
  offset=0,
1218
1249
  end_date=end_date,
1219
1250
  runner=self.runner,
1220
- type="task",
1251
+ type=ReleaseType.TASK,
1221
1252
  )
1222
1253
 
1223
1254
  if self.runner.date > end_date:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: ddeutil-workflow
3
- Version: 0.0.30
3
+ Version: 0.0.31
4
4
  Summary: Lightweight workflow orchestration
5
5
  Author-email: ddeutils <korawich.anu@gmail.com>
6
6
  License: MIT
@@ -192,6 +192,15 @@ The above workflow template is main executor pipeline that you want to do. If yo
192
192
  want to schedule this workflow, you want to dynamic its parameters change base on
193
193
  execution time such as `run-date` should change base on that workflow running date.
194
194
 
195
+ ```python
196
+ from ddeutil.workflow import Workflow, Result
197
+
198
+ workflow: Workflow = Workflow.from_loader('run-py-local')
199
+ result: Result = workflow.execute(
200
+ params={"source-extract": "USD-THB", "asat-dt": "2024-01-01"}
201
+ )
202
+ ```
203
+
195
204
  So, this package provide the `Schedule` template for this action.
196
205
 
197
206
  ```yaml
@@ -0,0 +1,25 @@
1
+ ddeutil/workflow/__about__.py,sha256=3Fv8LNxLB3UmoMSQqDZv556u3eR-OYBH8pw9kM6FeE4,28
2
+ ddeutil/workflow/__cron.py,sha256=3i-wmjTlh0ADCzN9pLKaWHzJkXzC72aIBmVEQSbyCCE,26895
3
+ ddeutil/workflow/__init__.py,sha256=pRIZIGwC7Xs8Ur7-jHPIAMLriD5If9zOPc-ZmKZS2XQ,1678
4
+ ddeutil/workflow/__types.py,sha256=CK1jfzyHP9P-MB0ElhpJZ59ZFGJC9MkQuAop5739_9k,4304
5
+ ddeutil/workflow/conf.py,sha256=6yGbSi69lsccYgnrwTzdjdPhU54hUop2e1GjBNres08,17663
6
+ ddeutil/workflow/cron.py,sha256=j8EeoHst70toRfnD_frix41vrI-eLYVJkZ9yeJtpfnI,8871
7
+ ddeutil/workflow/exceptions.py,sha256=5ghT443VLq0IeU87loHNEqqrrrctklP7YfxwJ51ImWU,949
8
+ ddeutil/workflow/hook.py,sha256=MgZFlTGvaRSBrTouZGlxwYpKQoKDOT26PNhESeL3LY0,5469
9
+ ddeutil/workflow/job.py,sha256=XcewyALsLYYq94ycF6mkj3Ydr6if683z7t1oBqEVInE,24290
10
+ ddeutil/workflow/params.py,sha256=AJLiTaF6lG37SvzyniTCug5-TgZTmNCn5fJXb-CCcqM,5707
11
+ ddeutil/workflow/result.py,sha256=8LItqF-Xe6pAAWkAsY_QFkKBOA0fEBh97I2og3CZsPc,3409
12
+ ddeutil/workflow/scheduler.py,sha256=2Y_ewAP1iQKgD81i6H6fXuJLCVqLocumEmCG2SomEqg,22214
13
+ ddeutil/workflow/stage.py,sha256=glAhvgvyQ98n2JLUBU8MUs48FEPkfU1fvh3Wwi9PSCg,24293
14
+ ddeutil/workflow/templates.py,sha256=bVU_8gnMQmdhhw3W28ZqwmpEaOx10Nx_aauqiLS0lqg,10807
15
+ ddeutil/workflow/utils.py,sha256=rTDQKaaber7cRqzJjWpCP9OTbarti1UMKdLgH6VRjFM,6709
16
+ ddeutil/workflow/workflow.py,sha256=ET1otR5VcfnOMoNiW7EMb1_wIaxNw9yWsBXS5kVWG9s,43428
17
+ ddeutil/workflow/api/__init__.py,sha256=F53NMBWtb9IKaDWkPU5KvybGGfKAcbehgn6TLBwHuuM,21
18
+ ddeutil/workflow/api/api.py,sha256=Ma9R8yuQAhowG_hox-k53swFsf5IAvheEnSxNQ-8DaQ,4039
19
+ ddeutil/workflow/api/repeat.py,sha256=zyvsrXKk-3-_N8ZRZSki0Mueshugum2jtqctEOp9QSc,4927
20
+ ddeutil/workflow/api/route.py,sha256=v96jNbgjM1cJ2MpVSRWs2kgRqF8DQElEBdRZrVFEpEw,8578
21
+ ddeutil_workflow-0.0.31.dist-info/LICENSE,sha256=nGFZ1QEhhhWeMHf9n99_fdt4vQaXS29xWKxt-OcLywk,1085
22
+ ddeutil_workflow-0.0.31.dist-info/METADATA,sha256=-cDcCfcV-4_dU_91GSy5sdV-ZkEWbdk7wDmAJB24puU,15090
23
+ ddeutil_workflow-0.0.31.dist-info/WHEEL,sha256=In9FTNxeP60KnTkGw7wk6mJPYd_dQSjEZmXdBdMCI-8,91
24
+ ddeutil_workflow-0.0.31.dist-info/top_level.txt,sha256=m9M6XeSWDwt_yMsmH6gcOjHZVK5O0-vgtNBuncHjzW4,8
25
+ ddeutil_workflow-0.0.31.dist-info/RECORD,,
@@ -1,25 +0,0 @@
1
- ddeutil/workflow/__about__.py,sha256=zSy9Xk11PPZkgJ5Db1_kZp2yzt5inkEHVJWwFHPWlmk,28
2
- ddeutil/workflow/__cron.py,sha256=uA8XcbY_GwA9rJSHaHUaXaJyGDObJN0ZeYlJSinL8y8,26880
3
- ddeutil/workflow/__init__.py,sha256=dghn2lFl3Own4Pyq7SFHu-FMymOgLontJ6aCfxea9h4,1606
4
- ddeutil/workflow/__types.py,sha256=CK1jfzyHP9P-MB0ElhpJZ59ZFGJC9MkQuAop5739_9k,4304
5
- ddeutil/workflow/conf.py,sha256=7lj_Im9jsa95fWUo19Q4-ZAcHa8Pu1HW-vaLgvrjNUM,17559
6
- ddeutil/workflow/cron.py,sha256=OLgniUxmrn65gzckk-uTmE2Pk1enJJyjYUKVeBbDQz0,7522
7
- ddeutil/workflow/exceptions.py,sha256=XUnpJSuxOyataClP0w_gpYjzn-NIwZK2BHro-J7Yw24,895
8
- ddeutil/workflow/hook.py,sha256=MgZFlTGvaRSBrTouZGlxwYpKQoKDOT26PNhESeL3LY0,5469
9
- ddeutil/workflow/job.py,sha256=XcewyALsLYYq94ycF6mkj3Ydr6if683z7t1oBqEVInE,24290
10
- ddeutil/workflow/params.py,sha256=svCjmFgEhim8yFJVjZhFmKP8JqTDHQ5EPhwJHVuDGno,5289
11
- ddeutil/workflow/result.py,sha256=k4pcj5KjbEcEPymsEUXeGY4gyLMfPkMTO6YDrAtfk7Q,3408
12
- ddeutil/workflow/scheduler.py,sha256=f3d7c5SVgY5Q1JsHQ6cH513CJmJkh4l8YcKAGYudJRc,20426
13
- ddeutil/workflow/stage.py,sha256=wn8CARTvFJY4ZK1SwjzH8sKoMRz_eIeSGUMgnDWNi6g,24031
14
- ddeutil/workflow/templates.py,sha256=bVU_8gnMQmdhhw3W28ZqwmpEaOx10Nx_aauqiLS0lqg,10807
15
- ddeutil/workflow/utils.py,sha256=8LTqpvRPfrEYxsxhwszk6GKkyjrswxnwF3r_9vE8szw,6059
16
- ddeutil/workflow/workflow.py,sha256=ZLbG-K2gSNAsDGiHPjbtJd7rsEFf6jfVGAVB_9jpy84,42103
17
- ddeutil/workflow/api/__init__.py,sha256=F53NMBWtb9IKaDWkPU5KvybGGfKAcbehgn6TLBwHuuM,21
18
- ddeutil/workflow/api/api.py,sha256=Ma9R8yuQAhowG_hox-k53swFsf5IAvheEnSxNQ-8DaQ,4039
19
- ddeutil/workflow/api/repeat.py,sha256=zyvsrXKk-3-_N8ZRZSki0Mueshugum2jtqctEOp9QSc,4927
20
- ddeutil/workflow/api/route.py,sha256=v96jNbgjM1cJ2MpVSRWs2kgRqF8DQElEBdRZrVFEpEw,8578
21
- ddeutil_workflow-0.0.30.dist-info/LICENSE,sha256=nGFZ1QEhhhWeMHf9n99_fdt4vQaXS29xWKxt-OcLywk,1085
22
- ddeutil_workflow-0.0.30.dist-info/METADATA,sha256=zbVHOL41qpFRG83SacZVYK9tS2JRTCM61cpnSXty9LU,14868
23
- ddeutil_workflow-0.0.30.dist-info/WHEEL,sha256=In9FTNxeP60KnTkGw7wk6mJPYd_dQSjEZmXdBdMCI-8,91
24
- ddeutil_workflow-0.0.30.dist-info/top_level.txt,sha256=m9M6XeSWDwt_yMsmH6gcOjHZVK5O0-vgtNBuncHjzW4,8
25
- ddeutil_workflow-0.0.30.dist-info/RECORD,,