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,5 @@
1
+ Metadata-Version: 2.4
2
+ Name: python-library-scheduler
3
+ Version: 0.1.1
4
+ Requires-Python: >=3.10
5
+ Requires-Dist: pydantic>=2.12.5
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
scheduler/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ from .at import At
2
+ from .every import Every
3
+
4
+ __all__ = [
5
+ "At",
6
+ "Every",
7
+ ]
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