python-library-scheduler 0.1.1__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,7 @@
|
|
|
1
|
+
scheduler/__init__.py,sha256=XkjAnTIAJxvC4rr8G55UJ0ObcNsC6SPznMEIAQ4ax34,87
|
|
2
|
+
scheduler/at.py,sha256=97gnzAJKoivqeVTsiN3CIgfK81Q0b8QUCscuVSwWv_0,1317
|
|
3
|
+
scheduler/base.py,sha256=ZYWbsQDE1_GKVwmxboLWg6oJeHvRzJ4j0wAF1bedfUs,3952
|
|
4
|
+
scheduler/every.py,sha256=uDKKQG8lYkiU6Xpf4wtUddDuNDo5XPhy60iYQ3qxIuA,1171
|
|
5
|
+
python_library_scheduler-0.1.1.dist-info/METADATA,sha256=-o5XDa6rOpq3Qts62mVcLrMuX3J175uVzbLtPFvnW5I,124
|
|
6
|
+
python_library_scheduler-0.1.1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
7
|
+
python_library_scheduler-0.1.1.dist-info/RECORD,,
|
scheduler/__init__.py
ADDED
scheduler/at.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from datetime import datetime, timedelta
|
|
2
|
+
|
|
3
|
+
from pydantic import Field
|
|
4
|
+
|
|
5
|
+
from .base import BaseScheduler
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class At(BaseScheduler):
|
|
9
|
+
hour: int = Field(0, ge=0, le=23, description="小时")
|
|
10
|
+
minute: int = Field(0, ge=0, le=59, description="分钟")
|
|
11
|
+
second: int = Field(0, ge=0, le=59, description="秒")
|
|
12
|
+
weekday: int | None = Field(None, ge=0, le=6, description="星期几 (0=周一, 6=周日)")
|
|
13
|
+
|
|
14
|
+
def _next_target(self, now: datetime) -> datetime:
|
|
15
|
+
target = now.replace(
|
|
16
|
+
hour=self.hour,
|
|
17
|
+
minute=self.minute,
|
|
18
|
+
second=self.second,
|
|
19
|
+
microsecond=0,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
if self.weekday is None:
|
|
23
|
+
if target <= now:
|
|
24
|
+
target += timedelta(days=1)
|
|
25
|
+
return target
|
|
26
|
+
|
|
27
|
+
days = (self.weekday - now.weekday()) % 7
|
|
28
|
+
if days == 0 and target <= now:
|
|
29
|
+
days = 7
|
|
30
|
+
|
|
31
|
+
return target + timedelta(days=days)
|
|
32
|
+
|
|
33
|
+
def _next_delay(self) -> float:
|
|
34
|
+
now = datetime.now()
|
|
35
|
+
return max((self._next_target(now) - now).total_seconds(), 0)
|
|
36
|
+
|
|
37
|
+
def _condition(self) -> bool:
|
|
38
|
+
now = datetime.now()
|
|
39
|
+
last = self._last_fire_at
|
|
40
|
+
if last is None:
|
|
41
|
+
return False
|
|
42
|
+
|
|
43
|
+
target = self._next_target(last)
|
|
44
|
+
return last < target <= now
|
scheduler/base.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import Callable, Any
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, model_validator
|
|
8
|
+
|
|
9
|
+
Handler = Callable[[], Any]
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class BaseScheduler(BaseModel, ABC):
|
|
15
|
+
model_config = ConfigDict(
|
|
16
|
+
arbitrary_types_allowed=True,
|
|
17
|
+
extra="forbid",
|
|
18
|
+
validate_assignment=True,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
immediate: bool = Field(False, description="是否立即执行")
|
|
22
|
+
max_runs: int | None = Field(None,description="最大触发次数,不填则表示不限制")
|
|
23
|
+
|
|
24
|
+
_handlers: list[Handler] = PrivateAttr(default_factory=list)
|
|
25
|
+
_runner_task: asyncio.Task | None = PrivateAttr(None)
|
|
26
|
+
_stop_event: asyncio.Event = PrivateAttr(default_factory=asyncio.Event)
|
|
27
|
+
_running: bool = PrivateAttr(False)
|
|
28
|
+
_last_fire_at: datetime | None = PrivateAttr(None)
|
|
29
|
+
_run_count: int = PrivateAttr(0)
|
|
30
|
+
_first_tick: bool = PrivateAttr(True)
|
|
31
|
+
|
|
32
|
+
@model_validator(mode="after")
|
|
33
|
+
def _validate_max_runs(self):
|
|
34
|
+
if self.max_runs is not None and self.max_runs < 1:
|
|
35
|
+
raise ValueError("max_runs must be >= 1 when set")
|
|
36
|
+
return self
|
|
37
|
+
|
|
38
|
+
def _next_delay(self) -> float:
|
|
39
|
+
"""距离下次触发的推荐等待秒数,子类可覆盖以实现动态间隔"""
|
|
40
|
+
return 0.5
|
|
41
|
+
|
|
42
|
+
def add(self, fn: Handler):
|
|
43
|
+
self._handlers.append(fn)
|
|
44
|
+
return fn
|
|
45
|
+
|
|
46
|
+
def __call__(self, fn: Handler):
|
|
47
|
+
return self.add(fn)
|
|
48
|
+
|
|
49
|
+
async def start(self):
|
|
50
|
+
if self._runner_task and not self._runner_task.done():
|
|
51
|
+
return self
|
|
52
|
+
|
|
53
|
+
loop = asyncio.get_running_loop()
|
|
54
|
+
self._stop_event.clear()
|
|
55
|
+
self._first_tick = True
|
|
56
|
+
self._run_count = 0
|
|
57
|
+
self._last_fire_at = None if self.immediate else datetime.now()
|
|
58
|
+
self._runner_task = loop.create_task(self._serve())
|
|
59
|
+
return self
|
|
60
|
+
|
|
61
|
+
async def wait(self):
|
|
62
|
+
if self._runner_task is None:
|
|
63
|
+
raise RuntimeError("scheduler has not been started")
|
|
64
|
+
await self._runner_task
|
|
65
|
+
return self
|
|
66
|
+
|
|
67
|
+
async def stop(self):
|
|
68
|
+
self._stop_event.set()
|
|
69
|
+
return self
|
|
70
|
+
|
|
71
|
+
async def run(self):
|
|
72
|
+
await self.start()
|
|
73
|
+
await self.wait()
|
|
74
|
+
return self
|
|
75
|
+
|
|
76
|
+
async def _serve(self):
|
|
77
|
+
self._running = True
|
|
78
|
+
try:
|
|
79
|
+
while not self._stop_event.is_set():
|
|
80
|
+
if self._should_fire():
|
|
81
|
+
await self._fire_all()
|
|
82
|
+
|
|
83
|
+
self._first_tick = False
|
|
84
|
+
|
|
85
|
+
delay = max(self._next_delay(), 0.1)
|
|
86
|
+
try:
|
|
87
|
+
await asyncio.wait_for(
|
|
88
|
+
self._stop_event.wait(),
|
|
89
|
+
timeout=delay,
|
|
90
|
+
)
|
|
91
|
+
except asyncio.TimeoutError:
|
|
92
|
+
pass
|
|
93
|
+
finally:
|
|
94
|
+
self._running = False
|
|
95
|
+
|
|
96
|
+
def _should_fire(self) -> bool:
|
|
97
|
+
if not self._handlers:
|
|
98
|
+
return False
|
|
99
|
+
|
|
100
|
+
if self._first_tick:
|
|
101
|
+
return self.immediate
|
|
102
|
+
|
|
103
|
+
return self._condition()
|
|
104
|
+
|
|
105
|
+
async def _fire_all(self):
|
|
106
|
+
self._last_fire_at = datetime.now()
|
|
107
|
+
self._run_count += 1
|
|
108
|
+
|
|
109
|
+
results = await asyncio.gather(
|
|
110
|
+
*(self._invoke(fn) for fn in self._handlers),
|
|
111
|
+
return_exceptions=True,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
for fn, result in zip(self._handlers, results):
|
|
115
|
+
if isinstance(result, Exception):
|
|
116
|
+
logger.exception("scheduler handler failed: %r", fn, exc_info=result)
|
|
117
|
+
|
|
118
|
+
if self.max_runs is not None and self._run_count >= self.max_runs:
|
|
119
|
+
self._stop_event.set()
|
|
120
|
+
|
|
121
|
+
async def _invoke(self, fn: Handler):
|
|
122
|
+
if asyncio.iscoroutinefunction(fn):
|
|
123
|
+
await fn()
|
|
124
|
+
else:
|
|
125
|
+
await asyncio.to_thread(fn)
|
|
126
|
+
|
|
127
|
+
@abstractmethod
|
|
128
|
+
def _condition(self) -> bool: ...
|
scheduler/every.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
|
|
3
|
+
from pydantic import Field, model_validator
|
|
4
|
+
|
|
5
|
+
from .base import BaseScheduler
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Every(BaseScheduler):
|
|
9
|
+
seconds: float = Field(0, ge=0, description="秒")
|
|
10
|
+
minutes: float = Field(0, ge=0, description="分钟")
|
|
11
|
+
hours: float = Field(0, ge=0, description="小时")
|
|
12
|
+
days: float = Field(0, ge=0, description="天")
|
|
13
|
+
|
|
14
|
+
@model_validator(mode="after")
|
|
15
|
+
def _validate_period(self):
|
|
16
|
+
if self.period <= 0:
|
|
17
|
+
raise ValueError("Every period must be greater than 0")
|
|
18
|
+
return self
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def period(self) -> float:
|
|
22
|
+
return (
|
|
23
|
+
self.seconds
|
|
24
|
+
+ self.minutes * 60
|
|
25
|
+
+ self.hours * 3600
|
|
26
|
+
+ self.days * 86400
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
def _next_delay(self) -> float:
|
|
30
|
+
if self._last_fire_at is None:
|
|
31
|
+
return 0
|
|
32
|
+
elapsed = (datetime.now() - self._last_fire_at).total_seconds()
|
|
33
|
+
return max(self.period - elapsed, 0)
|
|
34
|
+
|
|
35
|
+
def _condition(self) -> bool:
|
|
36
|
+
if self._last_fire_at is None:
|
|
37
|
+
return False
|
|
38
|
+
return (datetime.now() - self._last_fire_at).total_seconds() >= self.period
|