ddeutil-workflow 0.0.9__py3-none-any.whl → 0.0.10__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.9"
1
+ __version__: str = "0.0.10"
ddeutil/workflow/api.py CHANGED
@@ -6,7 +6,6 @@
6
6
  from __future__ import annotations
7
7
 
8
8
  import asyncio
9
- import logging
10
9
  import os
11
10
  import uuid
12
11
  from queue import Empty, Queue
@@ -18,33 +17,33 @@ from fastapi.middleware.gzip import GZipMiddleware
18
17
  from fastapi.responses import UJSONResponse
19
18
  from pydantic import BaseModel
20
19
 
20
+ from .__about__ import __version__
21
+ from .log import get_logger
21
22
  from .repeat import repeat_every
22
23
 
23
24
  load_dotenv()
24
- logger = logging.getLogger(__name__)
25
- logging.basicConfig(
26
- level=logging.DEBUG,
27
- format=(
28
- "%(asctime)s.%(msecs)03d (%(name)-10s, %(process)-5d, %(thread)-5d) "
29
- "[%(levelname)-7s] %(message)-120s (%(filename)s:%(lineno)s)"
30
- ),
31
- handlers=[logging.StreamHandler()],
32
- datefmt="%Y-%m-%d %H:%M:%S",
33
- )
25
+ logger = get_logger("ddeutil.workflow")
34
26
 
35
27
 
36
- app = FastAPI()
28
+ app = FastAPI(
29
+ titile="Workflow API",
30
+ description=(
31
+ "This is workflow FastAPI web application that use to manage manual "
32
+ "execute or schedule workflow via RestAPI."
33
+ ),
34
+ version=__version__,
35
+ )
37
36
  app.add_middleware(GZipMiddleware, minimum_size=1000)
38
37
  app.queue = Queue()
39
38
  app.output_dict = {}
40
- app.queue_limit = 2
39
+ app.queue_limit = 5
41
40
 
42
41
 
43
42
  @app.on_event("startup")
44
- @repeat_every(seconds=10, logger=logger)
43
+ @repeat_every(seconds=10)
45
44
  def broker_upper_messages():
46
45
  """Broker for receive message from the `/upper` path and change it to upper
47
- case.
46
+ case. This broker use interval running in background every 10 seconds.
48
47
  """
49
48
  for _ in range(app.queue_limit):
50
49
  try:
@@ -66,11 +65,12 @@ async def get_result(request_id):
66
65
  result = app.output_dict[request_id]
67
66
  del app.output_dict[request_id]
68
67
  return {"message": result}
69
- await asyncio.sleep(0.001)
68
+ await asyncio.sleep(0.0025)
70
69
 
71
70
 
72
71
  @app.post("/upper", response_class=UJSONResponse)
73
72
  async def message_upper(payload: Payload):
73
+ """Convert message from any case to the upper case."""
74
74
  request_id: str = str(uuid.uuid4())
75
75
  app.queue.put(
76
76
  {"text": payload.text, "request_id": request_id},
ddeutil/workflow/cli.py CHANGED
@@ -5,46 +5,129 @@
5
5
  # ------------------------------------------------------------------------------
6
6
  from __future__ import annotations
7
7
 
8
- from typing import Optional
8
+ import json
9
+ import os
10
+ from datetime import datetime
11
+ from enum import Enum
12
+ from typing import Annotated, Optional
13
+ from zoneinfo import ZoneInfo
9
14
 
10
- from typer import Typer
15
+ from ddeutil.core import str2list
16
+ from typer import Argument, Option, Typer
11
17
 
18
+ from .log import get_logger
19
+
20
+ logger = get_logger("ddeutil.workflow")
12
21
  cli: Typer = Typer()
13
- state = {"verbose": False}
22
+ cli_log: Typer = Typer()
23
+ cli.add_typer(
24
+ cli_log,
25
+ name="log",
26
+ help="Logging of workflow CLI",
27
+ )
14
28
 
15
29
 
16
30
  @cli.command()
17
- def run(pipeline: str):
18
- """Run workflow manually"""
19
- if state["verbose"]:
20
- print("About to create a user")
31
+ def run(
32
+ pipeline: Annotated[
33
+ str,
34
+ Argument(help="A pipeline name that want to run manually"),
35
+ ],
36
+ params: Annotated[
37
+ str,
38
+ Argument(
39
+ help="A json string for parameters of this pipeline execution."
40
+ ),
41
+ ],
42
+ ):
43
+ """Run pipeline workflow manually with an input custom parameters that able
44
+ to receive with pipeline params config.
45
+ """
46
+ logger.info(f"Running pipeline name: {pipeline}")
47
+ logger.info(f"... with Parameters: {json.dumps(json.loads(params))}")
21
48
 
22
- print(f"Creating user: {pipeline}")
23
49
 
24
- if state["verbose"]:
25
- print("Just created a user")
50
+ @cli.command()
51
+ def schedule(
52
+ stop: Annotated[
53
+ Optional[datetime],
54
+ Argument(
55
+ formats=["%Y-%m-%d", "%Y-%m-%d %H:%M:%S"],
56
+ help="A stopping datetime that want to stop on schedule app.",
57
+ ),
58
+ ] = None,
59
+ excluded: Annotated[
60
+ Optional[str],
61
+ Argument(help="A list of exclude workflow name in str."),
62
+ ] = None,
63
+ externals: Annotated[
64
+ Optional[str],
65
+ Argument(
66
+ help="A json string for parameters of this pipeline execution."
67
+ ),
68
+ ] = None,
69
+ ):
70
+ """Start workflow scheduler that will call workflow function from scheduler
71
+ module.
72
+ """
73
+ excluded: list[str] = str2list(excluded) if excluded else []
74
+ externals: str = externals or "{}"
75
+ if stop:
76
+ stop: datetime = stop.astimezone(
77
+ tz=ZoneInfo(os.getenv("WORKFLOW_CORE_TIMEZONE", "UTC"))
78
+ )
26
79
 
80
+ from .scheduler import workflow
27
81
 
28
- @cli.command()
29
- def schedule(exclude: Optional[str]):
30
- """Start workflow scheduler"""
31
- if state["verbose"]:
32
- print("About to delete a user")
82
+ # NOTE: Start running workflow scheduler application.
83
+ workflow_rs: list[str] = workflow(
84
+ stop=stop, excluded=excluded, externals=json.loads(externals)
85
+ )
86
+ logger.info(f"Application run success: {workflow_rs}")
87
+
88
+
89
+ @cli_log.command("pipeline-get")
90
+ def pipeline_log_get(
91
+ name: Annotated[
92
+ str,
93
+ Argument(help="A pipeline name that want to getting log"),
94
+ ],
95
+ limit: Annotated[
96
+ int,
97
+ Argument(help="A number of the limitation of logging"),
98
+ ] = 100,
99
+ desc: Annotated[
100
+ bool,
101
+ Option(
102
+ "--desc",
103
+ help="A descending flag that order by logging release datetime.",
104
+ ),
105
+ ] = True,
106
+ ):
107
+ logger.info(f"{name} : limit {limit} : desc: {desc}")
108
+ return [""]
109
+
110
+
111
+ class LogMode(str, Enum):
112
+ get = "get"
113
+ delete = "delete"
33
114
 
34
- print(f"Deleting user: {exclude}")
35
115
 
36
- if state["verbose"]:
37
- print("Just deleted a user")
116
+ @cli_log.command("pipeline-delete")
117
+ def pipeline_log_delete(
118
+ mode: Annotated[
119
+ LogMode,
120
+ Argument(case_sensitive=True),
121
+ ]
122
+ ):
123
+ logger.info(mode)
38
124
 
39
125
 
40
126
  @cli.callback()
41
- def main(verbose: bool = False):
127
+ def main():
42
128
  """
43
129
  Manage workflow with CLI.
44
130
  """
45
- if verbose:
46
- print("Will write verbose output")
47
- state["verbose"] = True
48
131
 
49
132
 
50
133
  if __name__ == "__main__":
ddeutil/workflow/cron.py CHANGED
@@ -10,7 +10,7 @@ from collections.abc import Iterator
10
10
  from dataclasses import dataclass, field
11
11
  from datetime import datetime, timedelta
12
12
  from functools import partial, total_ordering
13
- from typing import Callable, Optional, Union
13
+ from typing import ClassVar, Optional, Union
14
14
  from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
15
15
 
16
16
  from ddeutil.core import (
@@ -34,6 +34,34 @@ WEEKDAYS: dict[str, int] = {
34
34
  }
35
35
 
36
36
 
37
+ class CronYearLimit(Exception): ...
38
+
39
+
40
+ def str2cron(value: str) -> str:
41
+ """Convert Special String to Crontab.
42
+
43
+ @reboot Run once, at system startup
44
+ @yearly Run once every year, "0 0 1 1 *"
45
+ @annually (same as @yearly)
46
+ @monthly Run once every month, "0 0 1 * *"
47
+ @weekly Run once every week, "0 0 * * 0"
48
+ @daily Run once each day, "0 0 * * *"
49
+ @midnight (same as @daily)
50
+ @hourly Run once an hour, "0 * * * *"
51
+ """
52
+ mapping_spacial_str = {
53
+ "@reboot": "",
54
+ "@yearly": "0 0 1 1 *",
55
+ "@annually": "0 0 1 1 *",
56
+ "@monthly": "0 0 1 * *",
57
+ "@weekly": "0 0 * * 0",
58
+ "@daily": "0 0 * * *",
59
+ "@midnight": "0 0 * * *",
60
+ "@hourly": "0 * * * *",
61
+ }
62
+ return mapping_spacial_str[value]
63
+
64
+
37
65
  @dataclass(frozen=True)
38
66
  class Unit:
39
67
  name: str
@@ -232,17 +260,21 @@ class CronPart:
232
260
  :type value: str
233
261
 
234
262
  TODO: support for `L`, `W`, and `#`
235
- TODO: if you didn't care what day of the week the 7th was, you
236
- could enter ? in the Day-of-week field.
237
- TODO: L : the Day-of-month or Day-of-week fields specifies the last day
238
- of the month or week.
263
+ ---
264
+ TODO: The ? (question mark) wildcard specifies one or another.
265
+ In the Day-of-month field you could enter 7, and if you didn't care
266
+ what day of the week the seventh was, you could enter ? in the
267
+ Day-of-week field.
268
+ TODO: L : The L wildcard in the Day-of-month or Day-of-week fields
269
+ specifies the last day of the month or week.
239
270
  DEV: use -1 for represent with L
240
- TODO: W : In the Day-of-month field, 3W specifies the weekday closest
241
- to the third day of the month.
242
- TODO: # : 3#2 would be the second Tuesday of the month,
271
+ TODO: W : The W wildcard in the Day-of-month field specifies a weekday.
272
+ In the Day-of-month field, 3W specifies the weekday closest to the
273
+ third day of the month.
274
+ TODO: # : 3#2 would be the second Tuesday of every month,
243
275
  the 3 refers to Tuesday because it is the third day of each week.
244
276
 
245
- Noted:
277
+ Examples:
246
278
  - 0 10 * * ? *
247
279
  Run at 10:00 am (UTC) every day
248
280
 
@@ -275,6 +307,7 @@ class CronPart:
275
307
  :rtype: tuple[int, ...]
276
308
  """
277
309
  interval_list: list[list[int]] = []
310
+ # NOTE: Start replace alternative like JAN to FEB or MON to SUN.
278
311
  for _value in self.replace_alternative(value.upper()).split(","):
279
312
  if _value == "?":
280
313
  continue
@@ -294,11 +327,18 @@ class CronPart:
294
327
  f"{self.unit.name!r}"
295
328
  )
296
329
 
330
+ # NOTE: Generate interval that has step
297
331
  interval_list.append(self._interval(value_range_list, value_step))
332
+
298
333
  return tuple(item for sublist in interval_list for item in sublist)
299
334
 
300
335
  def replace_alternative(self, value: str) -> str:
301
- """Replaces the alternative representations of numbers in a string."""
336
+ """Replaces the alternative representations of numbers in a string.
337
+
338
+ For example if value == 'JAN,AUG' it will replace to '1,8'.
339
+
340
+ :param value: A string value that want to replace alternative to int.
341
+ """
302
342
  for i, alt in enumerate(self.unit.alt):
303
343
  if alt in value:
304
344
  value: str = value.replace(alt, str(self.unit.min + i))
@@ -455,7 +495,7 @@ class CronJob:
455
495
  """The Cron Job Converter object that generate datetime dimension of cron
456
496
  job schedule format,
457
497
 
458
- ... * * * * * <command to execute>
498
+ * * * * * <command to execute>
459
499
 
460
500
  (i) minute (0 - 59)
461
501
  (ii) hour (0 - 23)
@@ -469,9 +509,20 @@ class CronJob:
469
509
  Support special value with `/`, `*`, `-`, `,`, and `?` (in day of month
470
510
  and day of week value).
471
511
 
512
+ Fields | Values | Wildcards
513
+ --- | --- | ---
514
+ Minutes | 0–59 | , - * /
515
+ Hours | 0–23 | , - * /
516
+ Day-of-month | 1–31 | , - * ? / L W
517
+ Month | 1–12 or JAN-DEC | , - * /
518
+ Day-of-week | 1–7 or SUN-SAT | , - * ? / L
519
+ Year | 1970–2199 | , - * /
520
+
472
521
  References:
473
522
  - https://github.com/Sonic0/cron-converter
474
523
  - https://pypi.org/project/python-crontab/
524
+ - https://docs.aws.amazon.com/glue/latest/dg/ -
525
+ monitor-data-warehouse-schedule.html
475
526
  """
476
527
 
477
528
  cron_length: int = 5
@@ -567,6 +618,10 @@ class CronJob:
567
618
  """Returns the cron schedule as a 2-dimensional list of integers."""
568
619
  return [part.values for part in self.parts]
569
620
 
621
+ def check(self, date: datetime, mode: str) -> bool:
622
+ assert mode in ("year", "month", "day", "hour", "minute")
623
+ return getattr(date, mode) in getattr(self, mode).values
624
+
570
625
  def schedule(
571
626
  self,
572
627
  date: datetime | None = None,
@@ -598,9 +653,12 @@ class CronRunner:
598
653
  cron schedule object value.
599
654
  """
600
655
 
656
+ shift_limit: ClassVar[int] = 25
657
+
601
658
  __slots__: tuple[str, ...] = (
602
659
  "__start_date",
603
660
  "cron",
661
+ "is_year",
604
662
  "date",
605
663
  "reset_flag",
606
664
  "tz",
@@ -639,6 +697,7 @@ class CronRunner:
639
697
 
640
698
  self.__start_date: datetime = self.date
641
699
  self.cron: CronJob | CronJobYear = cron
700
+ self.is_year: bool = isinstance(cron, CronJobYear)
642
701
  self.reset_flag: bool = True
643
702
 
644
703
  def reset(self) -> None:
@@ -663,45 +722,76 @@ class CronRunner:
663
722
  return self.find_date(reverse=True)
664
723
 
665
724
  def find_date(self, reverse: bool = False) -> datetime:
666
- """Returns the time the schedule would run by `next` or `prev`.
725
+ """Returns the time the schedule would run by `next` or `prev` methods.
667
726
 
668
727
  :param reverse: A reverse flag.
669
728
  """
670
729
  # NOTE: Set reset flag to false if start any action.
671
730
  self.reset_flag: bool = False
672
- for _ in range(25):
731
+
732
+ # NOTE: For loop with 25 times by default.
733
+ for _ in range(
734
+ max(self.shift_limit, 100) if self.is_year else self.shift_limit
735
+ ):
736
+
737
+ # NOTE: Shift the date
673
738
  if all(
674
739
  not self.__shift_date(mode, reverse)
675
- for mode in ("month", "day", "hour", "minute")
740
+ for mode in ("year", "month", "day", "hour", "minute")
676
741
  ):
677
742
  return copy.deepcopy(self.date.replace(second=0, microsecond=0))
743
+
678
744
  raise RecursionError("Unable to find execution time for schedule")
679
745
 
680
746
  def __shift_date(self, mode: str, reverse: bool = False) -> bool:
681
- """Increments the mode value until matches with the schedule."""
747
+ """Increments the mode of date value ("month", "day", "hour", "minute")
748
+ until matches with the schedule.
749
+
750
+ :param mode: A mode of date that want to shift.
751
+ :param reverse: A flag that define shifting next or previous
752
+ """
682
753
  switch: dict[str, str] = {
754
+ "year": "year",
683
755
  "month": "year",
684
756
  "day": "month",
685
757
  "hour": "day",
686
758
  "minute": "hour",
687
759
  }
688
760
  current_value: int = getattr(self.date, switch[mode])
689
- _addition_condition: Callable[[], bool] = (
690
- (
691
- lambda: WEEKDAYS.get(self.date.strftime("%a"))
692
- not in self.cron.dow.values
761
+
762
+ if not self.is_year and mode == "year":
763
+ return False
764
+
765
+ # NOTE: Additional condition for weekdays
766
+ def addition_cond(dt: datetime) -> bool:
767
+ return (
768
+ WEEKDAYS.get(dt.strftime("%a")) not in self.cron.dow.values
769
+ if mode == "day"
770
+ else False
693
771
  )
694
- if mode == "day"
695
- else lambda: False
696
- )
697
- # NOTE: Start while-loop for checking this date include in this cronjob.
698
- while (
699
- getattr(self.date, mode) not in getattr(self.cron, mode).values
700
- ) or _addition_condition():
772
+
773
+ # NOTE:
774
+ # Start while-loop for checking this date include in this cronjob.
775
+ while not self.cron.check(self.date, mode) or addition_cond(self.date):
776
+ if mode == "year" and (
777
+ getattr(self.date, mode)
778
+ > (max_year := max(self.cron.year.values))
779
+ ):
780
+ raise CronYearLimit(
781
+ f"The year is out of limit with this crontab value: "
782
+ f"{max_year}."
783
+ )
784
+
785
+ # NOTE: Shift date with it mode matrix unit.
701
786
  self.date: datetime = next_date(self.date, mode, reverse=reverse)
787
+
788
+ # NOTE: Replace date that less than it mode to zero.
702
789
  self.date: datetime = replace_date(self.date, mode, reverse=reverse)
790
+
703
791
  if current_value != getattr(self.date, switch[mode]):
704
792
  return mode != "month"
793
+
794
+ # NOTE: Return False if the date that match with condition.
705
795
  return False
706
796
 
707
797
 
@@ -21,4 +21,7 @@ class JobException(WorkflowException): ...
21
21
  class PipelineException(WorkflowException): ...
22
22
 
23
23
 
24
+ class PipelineFailException(WorkflowException): ...
25
+
26
+
24
27
  class ParamValueException(WorkflowException): ...