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.
@@ -0,0 +1,11 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.pyo
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .venv/
8
+ .env
9
+ .pytest_cache/
10
+ config.yaml
11
+ logs/
@@ -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,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,7 @@
1
+ from .at import At
2
+ from .every import Every
3
+
4
+ __all__ = [
5
+ "At",
6
+ "Every",
7
+ ]
@@ -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,10 @@
1
+ @echo off
2
+ cd /d %~dp0
3
+
4
+ if not exist .venv (
5
+ python -m venv .venv
6
+ )
7
+
8
+ call .venv\Scripts\activate.bat
9
+ python -m pip install -e .
10
+ python -m unittest discover -s tests -p "test_*.py"
@@ -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()