ddeutil-workflow 0.0.5__py3-none-any.whl → 0.0.7__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.
@@ -0,0 +1,134 @@
1
+ # ------------------------------------------------------------------------------
2
+ # Copyright (c) 2023 Priyanshu Panwar. All rights reserved.
3
+ # Licensed under the MIT License.
4
+ # This code refs from: https://github.com/priyanshu-panwar/fastapi-utilities
5
+ # ------------------------------------------------------------------------------
6
+ import asyncio
7
+ import logging
8
+ from asyncio import ensure_future
9
+ from datetime import datetime
10
+ from functools import wraps
11
+
12
+ from croniter import croniter
13
+ from starlette.concurrency import run_in_threadpool
14
+
15
+
16
+ def get_delta(cron: str):
17
+ """This function returns the time delta between now and the next cron
18
+ execution time.
19
+ """
20
+ now: datetime = datetime.now()
21
+ cron = croniter(cron, now)
22
+ return (cron.get_next(datetime) - now).total_seconds()
23
+
24
+
25
+ def repeat_at(
26
+ *,
27
+ cron: str,
28
+ logger: logging.Logger = None,
29
+ raise_exceptions: bool = False,
30
+ max_repetitions: int = None,
31
+ ):
32
+ """This function returns a decorator that makes a function execute
33
+ periodically as per the cron expression provided.
34
+
35
+ :param cron: str
36
+ Cron-style string for periodic execution, eg. '0 0 * * *' every midnight
37
+ :param logger: logging.Logger (default None)
38
+ Logger object to log exceptions
39
+ :param raise_exceptions: bool (default False)
40
+ Whether to raise exceptions or log them
41
+ :param max_repetitions: int (default None)
42
+ Maximum number of times to repeat the function. If None, repeat
43
+ indefinitely.
44
+
45
+ """
46
+
47
+ def decorator(func):
48
+ is_coroutine = asyncio.iscoroutinefunction(func)
49
+
50
+ @wraps(func)
51
+ def wrapper(*_args, **_kwargs):
52
+ repititions = 0
53
+ if not croniter.is_valid(cron):
54
+ raise ValueError("Invalid cron expression")
55
+
56
+ async def loop(*args, **kwargs):
57
+ nonlocal repititions
58
+ while max_repetitions is None or repititions < max_repetitions:
59
+ try:
60
+ sleepTime = get_delta(cron)
61
+ await asyncio.sleep(sleepTime)
62
+ if is_coroutine:
63
+ await func(*args, **kwargs)
64
+ else:
65
+ await run_in_threadpool(func, *args, **kwargs)
66
+ except Exception as e:
67
+ if logger is not None:
68
+ logger.exception(e)
69
+ if raise_exceptions:
70
+ raise e
71
+ repititions += 1
72
+
73
+ ensure_future(loop(*_args, **_kwargs))
74
+
75
+ return wrapper
76
+
77
+ return decorator
78
+
79
+
80
+ def repeat_every(
81
+ *,
82
+ seconds: float,
83
+ wait_first: bool = False,
84
+ logger: logging.Logger = None,
85
+ raise_exceptions: bool = False,
86
+ max_repetitions: int = None,
87
+ ):
88
+ """This function returns a decorator that schedules a function to execute
89
+ periodically after every `seconds` seconds.
90
+
91
+ :param seconds: float
92
+ The number of seconds to wait before executing the function again.
93
+ :param wait_first: bool (default False)
94
+ Whether to wait `seconds` seconds before executing the function for the
95
+ first time.
96
+ :param logger: logging.Logger (default None)
97
+ The logger to use for logging exceptions.
98
+ :param raise_exceptions: bool (default False)
99
+ Whether to raise exceptions instead of logging them.
100
+ :param max_repetitions: int (default None)
101
+ The maximum number of times to repeat the function. If None, the
102
+ function will repeat indefinitely.
103
+ """
104
+
105
+ def decorator(func):
106
+ is_coroutine = asyncio.iscoroutinefunction(func)
107
+
108
+ @wraps(func)
109
+ async def wrapper(*_args, **_kwargs):
110
+ repetitions = 0
111
+
112
+ async def loop(*args, **kwargs):
113
+ nonlocal repetitions
114
+ if wait_first:
115
+ await asyncio.sleep(seconds)
116
+ while max_repetitions is None or repetitions < max_repetitions:
117
+ try:
118
+ if is_coroutine:
119
+ await func(*args, **kwargs)
120
+ else:
121
+ await run_in_threadpool(func, *args, **kwargs)
122
+ except Exception as e:
123
+ if logger is not None:
124
+ logger.exception(e)
125
+ if raise_exceptions:
126
+ raise e
127
+ repetitions += 1
128
+ await asyncio.sleep(seconds)
129
+
130
+ ensure_future(loop(*_args, **_kwargs))
131
+
132
+ return wrapper
133
+
134
+ return decorator
@@ -0,0 +1,78 @@
1
+ from enum import Enum
2
+
3
+ from fastapi import APIRouter, Request, status
4
+ from pydantic import BaseModel, ConfigDict, Field
5
+
6
+ from .log import get_logger
7
+
8
+ logger = get_logger(__name__)
9
+ workflow_route = APIRouter(prefix="/workflow")
10
+
11
+
12
+ @workflow_route.get("/{name}")
13
+ async def get_pipeline(name: str):
14
+ return {"message": f"getting pipeline {name}"}
15
+
16
+
17
+ @workflow_route.get("/{name}/logs")
18
+ async def get_pipeline_log(name: str):
19
+ return {"message": f"getting pipeline {name} logs"}
20
+
21
+
22
+ class JobNotFoundError(Exception):
23
+ pass
24
+
25
+
26
+ schedule_route = APIRouter(prefix="/schedule", tags=["schedule"])
27
+
28
+
29
+ class TriggerEnum(str, Enum):
30
+ interval = "interval"
31
+ cron = "cron"
32
+
33
+
34
+ class Job(BaseModel):
35
+ model_config = ConfigDict(
36
+ json_schema_extra={
37
+ "example": {
38
+ "func": "example.main:pytest_job",
39
+ "trigger": "interval",
40
+ "seconds": 3,
41
+ "id": "pytest_job",
42
+ },
43
+ },
44
+ )
45
+ func: str = Field()
46
+ trigger: TriggerEnum = Field(title="Trigger type")
47
+ seconds: int = Field(title="Interval in seconds")
48
+ id: str = Field(title="Job ID")
49
+
50
+
51
+ @schedule_route.post(
52
+ "/", name="scheduler:add_job", status_code=status.HTTP_201_CREATED
53
+ )
54
+ async def add_job(request: Request, job: Job):
55
+ job = request.app.scheduler.add_job(**job.dict())
56
+ return {"job": f"{job.id}"}
57
+
58
+
59
+ @schedule_route.get("/", name="scheduler:get_jobs", response_model=list)
60
+ async def get_jobs(request: Request):
61
+ jobs = request.app.scheduler.get_jobs()
62
+ jobs = [
63
+ {k: v for k, v in job.__getstate__().items() if k != "trigger"}
64
+ for job in jobs
65
+ ]
66
+ return jobs
67
+
68
+
69
+ @schedule_route.delete("/{job_id}", name="scheduler:remove_job")
70
+ async def remove_job(request: Request, job_id: str):
71
+ try:
72
+ deleted = request.app.scheduler.remove_job(job_id=job_id)
73
+ logger.debug(f"Job {job_id} deleted: {deleted}")
74
+ return {"job": f"{job_id}"}
75
+ except AttributeError as err:
76
+ raise JobNotFoundError(
77
+ f"No job by the id of {job_id} was found"
78
+ ) from err
@@ -8,13 +8,9 @@ from __future__ import annotations
8
8
  import copy
9
9
  from collections.abc import Iterator
10
10
  from dataclasses import dataclass, field
11
- from datetime import datetime, timedelta, timezone
11
+ from datetime import datetime, timedelta
12
12
  from functools import partial, total_ordering
13
- from typing import (
14
- Callable,
15
- Optional,
16
- Union,
17
- )
13
+ from typing import Callable, Optional, Union
18
14
  from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
19
15
 
20
16
  from ddeutil.core import (
@@ -117,7 +113,7 @@ CRON_UNITS: tuple[Unit, ...] = (
117
113
  ),
118
114
  )
119
115
 
120
- CRON_UNITS_AWS: tuple[Unit, ...] = CRON_UNITS + (
116
+ CRON_UNITS_YEAR: tuple[Unit, ...] = CRON_UNITS + (
121
117
  Unit(
122
118
  name="year",
123
119
  range=partial(range, 1990, 2101),
@@ -152,6 +148,7 @@ class CronPart:
152
148
  values: list[int] = self.replace_weekday(values)
153
149
  else:
154
150
  raise TypeError(f"Invalid type of value in cron part: {values}.")
151
+
155
152
  self.values: list[int] = self.out_of_range(
156
153
  sorted(dict.fromkeys(values))
157
154
  )
@@ -190,10 +187,16 @@ class CronPart:
190
187
  )
191
188
 
192
189
  def __lt__(self, other) -> bool:
193
- return self.values < other.values
190
+ if isinstance(other, CronPart):
191
+ return self.values < other.values
192
+ elif isinstance(other, list):
193
+ return self.values < other
194
194
 
195
195
  def __eq__(self, other) -> bool:
196
- return self.values == other.values
196
+ if isinstance(other, CronPart):
197
+ return self.values == other.values
198
+ elif isinstance(other, list):
199
+ return self.values == other
197
200
 
198
201
  @property
199
202
  def min(self) -> int:
@@ -225,11 +228,11 @@ class CronPart:
225
228
  """Parses a string as a range of positive integers. The string should
226
229
  include only `-` and `,` special strings.
227
230
 
228
- :param value: A string value
231
+ :param value: A string value that want to parse
229
232
  :type value: str
230
233
 
231
234
  TODO: support for `L`, `W`, and `#`
232
- TODO: if you didn't care what day of the week the 7th was, you
235
+ TODO: if you didn't care what day of the week the 7th was, you
233
236
  could enter ? in the Day-of-week field.
234
237
  TODO: L : the Day-of-month or Day-of-week fields specifies the last day
235
238
  of the month or week.
@@ -239,7 +242,7 @@ class CronPart:
239
242
  TODO: # : 3#2 would be the second Tuesday of the month,
240
243
  the 3 refers to Tuesday because it is the third day of each week.
241
244
 
242
- Examples:
245
+ Noted:
243
246
  - 0 10 * * ? *
244
247
  Run at 10:00 am (UTC) every day
245
248
 
@@ -302,9 +305,14 @@ class CronPart:
302
305
  return value
303
306
 
304
307
  def replace_weekday(self, values: list[int] | Iterator[int]) -> list[int]:
305
- """Replaces all 7 with 0 as Sunday can be represented by both."""
308
+ """Replaces all 7 with 0 as Sunday can be represented by both.
309
+
310
+ :param values: list or iter of int that want to mode by 7
311
+ :rtype: list[int]
312
+ """
306
313
  if self.unit.name == "weekday":
307
- return [0 if value == 7 else value for value in values]
314
+ # NOTE: change weekday value in range 0-6 (div-mod by 7).
315
+ return [value % 7 for value in values]
308
316
  return list(values)
309
317
 
310
318
  def out_of_range(self, values: list[int]) -> list[int]:
@@ -531,27 +539,27 @@ class CronJob:
531
539
  return reversed(self.parts[:3] + [self.parts[4], self.parts[3]])
532
540
 
533
541
  @property
534
- def minute(self):
542
+ def minute(self) -> CronPart:
535
543
  """Return part of minute."""
536
544
  return self.parts[0]
537
545
 
538
546
  @property
539
- def hour(self):
547
+ def hour(self) -> CronPart:
540
548
  """Return part of hour."""
541
549
  return self.parts[1]
542
550
 
543
551
  @property
544
- def day(self):
552
+ def day(self) -> CronPart:
545
553
  """Return part of day."""
546
554
  return self.parts[2]
547
555
 
548
556
  @property
549
- def month(self):
557
+ def month(self) -> CronPart:
550
558
  """Return part of month."""
551
559
  return self.parts[3]
552
560
 
553
561
  @property
554
- def dow(self):
562
+ def dow(self) -> CronPart:
555
563
  """Return part of day of month."""
556
564
  return self.parts[4]
557
565
 
@@ -560,15 +568,29 @@ class CronJob:
560
568
  return [part.values for part in self.parts]
561
569
 
562
570
  def schedule(
563
- self, date: Optional[datetime] = None, _tz: Optional[str] = None
571
+ self,
572
+ date: datetime | None = None,
573
+ *,
574
+ tz: str | None = None,
564
575
  ) -> CronRunner:
565
- """Returns the time the schedule would run next."""
566
- return CronRunner(self, date, tz_str=_tz)
576
+ """Returns the schedule datetime runner with this cronjob. It would run
577
+ ``next``, ``prev``, or ``reset`` to generate running date that you want.
578
+
579
+ :param date: An initial date that want to mark as the start point.
580
+ :param tz: A string timezone that want to change on runner.
581
+ :rtype: CronRunner
582
+ """
583
+ return CronRunner(self, date, tz=tz)
567
584
 
568
585
 
569
- class CronJobAWS(CronJob):
586
+ class CronJobYear(CronJob):
570
587
  cron_length = 6
571
- cron_units = CRON_UNITS_AWS
588
+ cron_units = CRON_UNITS_YEAR
589
+
590
+ @property
591
+ def year(self) -> CronPart:
592
+ """Return part of year."""
593
+ return self.parts[5]
572
594
 
573
595
 
574
596
  class CronRunner:
@@ -586,33 +608,37 @@ class CronRunner:
586
608
 
587
609
  def __init__(
588
610
  self,
589
- cron: CronJob,
590
- date: Optional[datetime] = None,
611
+ cron: CronJob | CronJobYear,
612
+ date: datetime | None = None,
591
613
  *,
592
- tz_str: Optional[str] = None,
614
+ tz: str | None = None,
593
615
  ) -> None:
594
- # NOTE: Prepare date and tz_info
595
- self.tz = timezone.utc
596
- if tz_str:
616
+ # NOTE: Prepare timezone if this value does not set, it will use UTC.
617
+ self.tz: ZoneInfo = ZoneInfo("UTC")
618
+ if tz:
597
619
  try:
598
- self.tz = ZoneInfo(tz_str)
620
+ self.tz = ZoneInfo(tz)
599
621
  except ZoneInfoNotFoundError as err:
600
- raise ValueError(f"Invalid timezone: {tz_str}") from err
622
+ raise ValueError(f"Invalid timezone: {tz}") from err
623
+
624
+ # NOTE: Prepare date
601
625
  if date:
602
626
  if not isinstance(date, datetime):
603
627
  raise ValueError(
604
628
  "Input schedule start time is not a valid datetime object."
605
629
  )
606
- self.tz = date.tzinfo
607
- self.date: datetime = date
630
+ if tz is None:
631
+ self.tz = date.tzinfo
632
+ self.date: datetime = date.astimezone(self.tz)
608
633
  else:
609
634
  self.date: datetime = datetime.now(tz=self.tz)
610
635
 
636
+ # NOTE: Add one minute if the second value more than 0.
611
637
  if self.date.second > 0:
612
- self.date: datetime = self.date + timedelta(minutes=+1)
638
+ self.date: datetime = self.date + timedelta(minutes=1)
613
639
 
614
640
  self.__start_date: datetime = self.date
615
- self.cron: CronJob = cron
641
+ self.cron: CronJob | CronJobYear = cron
616
642
  self.reset_flag: bool = True
617
643
 
618
644
  def reset(self) -> None:
@@ -637,7 +663,11 @@ class CronRunner:
637
663
  return self.find_date(reverse=True)
638
664
 
639
665
  def find_date(self, reverse: bool = False) -> datetime:
640
- """Returns the time the schedule would run by `next` or `prev`."""
666
+ """Returns the time the schedule would run by `next` or `prev`.
667
+
668
+ :param reverse: A reverse flag.
669
+ """
670
+ # NOTE: Set reset flag to false if start any action.
641
671
  self.reset_flag: bool = False
642
672
  for _ in range(25):
643
673
  if all(
@@ -656,7 +686,7 @@ class CronRunner:
656
686
  "minute": "hour",
657
687
  }
658
688
  current_value: int = getattr(self.date, switch[mode])
659
- _addition: Callable[[], bool] = (
689
+ _addition_condition: Callable[[], bool] = (
660
690
  (
661
691
  lambda: WEEKDAYS.get(self.date.strftime("%a"))
662
692
  not in self.cron.dow.values
@@ -664,15 +694,12 @@ class CronRunner:
664
694
  if mode == "day"
665
695
  else lambda: False
666
696
  )
697
+ # NOTE: Start while-loop for checking this date include in this cronjob.
667
698
  while (
668
699
  getattr(self.date, mode) not in getattr(self.cron, mode).values
669
- ) or _addition():
670
- self.date: datetime = next_date(
671
- self.date, mode=mode, reverse=reverse
672
- )
673
- self.date: datetime = replace_date(
674
- self.date, mode=mode, reverse=reverse
675
- )
700
+ ) or _addition_condition():
701
+ self.date: datetime = next_date(self.date, mode, reverse=reverse)
702
+ self.date: datetime = replace_date(self.date, mode, reverse=reverse)
676
703
  if current_value != getattr(self.date, switch[mode]):
677
704
  return mode != "month"
678
705
  return False
@@ -680,6 +707,7 @@ class CronRunner:
680
707
 
681
708
  __all__ = (
682
709
  "CronJob",
710
+ "CronJobYear",
683
711
  "CronRunner",
684
712
  "WEEKDAYS",
685
713
  )