python-library-scheduler 0.1.1__tar.gz
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.
- python_library_scheduler-0.1.1/.gitignore +11 -0
- python_library_scheduler-0.1.1/PKG-INFO +5 -0
- python_library_scheduler-0.1.1/pyproject.toml +12 -0
- python_library_scheduler-0.1.1/scheduler/__init__.py +7 -0
- python_library_scheduler-0.1.1/scheduler/at.py +44 -0
- python_library_scheduler-0.1.1/scheduler/base.py +128 -0
- python_library_scheduler-0.1.1/scheduler/every.py +38 -0
- python_library_scheduler-0.1.1/test.bat +10 -0
- python_library_scheduler-0.1.1/tests/test_scheduler.py +116 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "python-library-scheduler"
|
|
7
|
+
version = "0.1.1"
|
|
8
|
+
requires-python = ">=3.10"
|
|
9
|
+
dependencies = ["pydantic>=2.12.5"]
|
|
10
|
+
|
|
11
|
+
[tool.hatch.build.targets.wheel]
|
|
12
|
+
packages = ["scheduler"]
|
|
@@ -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
|
|
@@ -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: ...
|
|
@@ -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
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import unittest
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from pydantic import ValidationError
|
|
7
|
+
|
|
8
|
+
from scheduler import At, Every
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class EveryTests(unittest.IsolatedAsyncioTestCase):
|
|
12
|
+
async def test_every_immediate_max_runs_stops_scheduler(self) -> None:
|
|
13
|
+
runs: list[int] = []
|
|
14
|
+
|
|
15
|
+
sch = Every(seconds=0.01, immediate=True, max_runs=2)
|
|
16
|
+
sch.add(lambda: runs.append(1))
|
|
17
|
+
|
|
18
|
+
await asyncio.wait_for(sch.run(), timeout=5.0)
|
|
19
|
+
|
|
20
|
+
self.assertEqual(len(runs), 2)
|
|
21
|
+
|
|
22
|
+
async def test_every_decorator_registers_handler(self) -> None:
|
|
23
|
+
runs: list[int] = []
|
|
24
|
+
|
|
25
|
+
sch = Every(seconds=0.01, immediate=True, max_runs=1)
|
|
26
|
+
|
|
27
|
+
@sch
|
|
28
|
+
def tick() -> None:
|
|
29
|
+
runs.append(1)
|
|
30
|
+
|
|
31
|
+
await asyncio.wait_for(sch.run(), timeout=5.0)
|
|
32
|
+
|
|
33
|
+
self.assertEqual(runs, [1])
|
|
34
|
+
|
|
35
|
+
async def test_async_handler(self) -> None:
|
|
36
|
+
runs: list[int] = []
|
|
37
|
+
|
|
38
|
+
sch = Every(seconds=0.01, immediate=True, max_runs=1)
|
|
39
|
+
|
|
40
|
+
async def tick() -> None:
|
|
41
|
+
runs.append(1)
|
|
42
|
+
|
|
43
|
+
sch.add(tick)
|
|
44
|
+
await asyncio.wait_for(sch.run(), timeout=5.0)
|
|
45
|
+
|
|
46
|
+
self.assertEqual(runs, [1])
|
|
47
|
+
|
|
48
|
+
async def test_stop(self) -> None:
|
|
49
|
+
runs: list[int] = []
|
|
50
|
+
sch = Every(seconds=0.01, immediate=True)
|
|
51
|
+
|
|
52
|
+
sch.add(lambda: runs.append(1))
|
|
53
|
+
|
|
54
|
+
await sch.start()
|
|
55
|
+
await asyncio.sleep(0.25)
|
|
56
|
+
await sch.stop()
|
|
57
|
+
await sch.wait()
|
|
58
|
+
|
|
59
|
+
self.assertGreaterEqual(len(runs), 1)
|
|
60
|
+
|
|
61
|
+
async def test_wait_without_start_raises(self) -> None:
|
|
62
|
+
sch = Every(seconds=1)
|
|
63
|
+
with self.assertRaises(RuntimeError) as ctx:
|
|
64
|
+
await sch.wait()
|
|
65
|
+
self.assertIn("not been started", str(ctx.exception).lower())
|
|
66
|
+
|
|
67
|
+
def test_every_zero_period_rejected(self) -> None:
|
|
68
|
+
with self.assertRaises(ValidationError):
|
|
69
|
+
Every()
|
|
70
|
+
|
|
71
|
+
def test_max_runs_invalid(self) -> None:
|
|
72
|
+
with self.assertRaises(ValidationError):
|
|
73
|
+
Every(seconds=1, max_runs=0)
|
|
74
|
+
|
|
75
|
+
def test_forbid_unknown_fields(self) -> None:
|
|
76
|
+
with self.assertRaises(ValidationError):
|
|
77
|
+
Every(seconds=1, not_a_field=True) # type: ignore[call-arg]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class AtNextTargetTests(unittest.TestCase):
|
|
81
|
+
def test_same_day_later(self) -> None:
|
|
82
|
+
sch = At(hour=15, minute=30, second=0)
|
|
83
|
+
now = datetime(2025, 6, 1, 10, 0, 0)
|
|
84
|
+
self.assertEqual(
|
|
85
|
+
sch._next_target(now),
|
|
86
|
+
datetime(2025, 6, 1, 15, 30, 0),
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
def test_rolls_to_next_calendar_day(self) -> None:
|
|
90
|
+
sch = At(hour=9, minute=0, second=0)
|
|
91
|
+
now = datetime(2025, 6, 1, 10, 0, 0)
|
|
92
|
+
self.assertEqual(
|
|
93
|
+
sch._next_target(now),
|
|
94
|
+
datetime(2025, 6, 2, 9, 0, 0),
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
def test_weekday_same_day_still_future(self) -> None:
|
|
98
|
+
sch = At(hour=12, minute=0, second=0, weekday=0)
|
|
99
|
+
now = datetime(2025, 1, 6, 8, 0, 0)
|
|
100
|
+
self.assertEqual(now.weekday(), 0)
|
|
101
|
+
self.assertEqual(
|
|
102
|
+
sch._next_target(now),
|
|
103
|
+
datetime(2025, 1, 6, 12, 0, 0),
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
def test_weekday_same_day_already_passed(self) -> None:
|
|
107
|
+
sch = At(hour=10, minute=0, second=0, weekday=0)
|
|
108
|
+
now = datetime(2025, 1, 6, 15, 0, 0)
|
|
109
|
+
self.assertEqual(
|
|
110
|
+
sch._next_target(now),
|
|
111
|
+
datetime(2025, 1, 13, 10, 0, 0),
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
if __name__ == "__main__":
|
|
116
|
+
unittest.main()
|