llm-async-scheduler 0.1.0__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,9 @@
1
+ .venv/
2
+ dist/
3
+ *.egg-info/
4
+ __pycache__/
5
+ .pytest_cache/
6
+ .mypy_cache/
7
+ .ruff_cache/
8
+ *.pyc
9
+ .DS_Store
@@ -0,0 +1 @@
1
+ 3.12
@@ -0,0 +1,17 @@
1
+ # Changelog
2
+
3
+ 本项目的所有 notable 变更都会记录在此文件。
4
+
5
+ 格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),
6
+ 版本号遵循 [Semantic Versioning](https://semver.org/lang/zh-CN/)。
7
+
8
+ ## [0.1.0] - 2026-06-11
9
+
10
+ ### Added
11
+
12
+ - `ScheduleTask` 抽象基类:超时、重试、`before_run` / `after_run` / `on_error` 钩子
13
+ - `AsyncTaskScheduler`:cron、interval、manual 三种触发模式
14
+ - 基于 APScheduler `AsyncIOScheduler` 的异步调度
15
+ - PyPI 发布为 `llm-async-scheduler`(`uv build` / `uv publish`)
16
+
17
+ [0.1.0]: https://github.com/realwrtoff/async-scheduler/releases/tag/v0.1.0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 realwrtoff
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,177 @@
1
+ Metadata-Version: 2.4
2
+ Name: llm-async-scheduler
3
+ Version: 0.1.0
4
+ Summary: 基于 APScheduler 的异步任务调度通用库,支持 cron、interval 与 manual 触发模式。
5
+ Project-URL: Homepage, https://github.com/realwrtoff/async-scheduler
6
+ Project-URL: Repository, https://github.com/realwrtoff/async-scheduler
7
+ Project-URL: Issues, https://github.com/realwrtoff/async-scheduler/issues
8
+ Author: realwrtoff
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: apscheduler,async,cron,scheduler,task
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Framework :: AsyncIO
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
+ Requires-Python: >=3.12
21
+ Requires-Dist: apscheduler<4,>=3.10.4
22
+ Description-Content-Type: text/markdown
23
+
24
+ # llm-async-scheduler
25
+
26
+ 基于 [APScheduler](https://apscheduler.readthedocs.io/) 的异步任务调度通用库,提供统一的任务抽象与 cron / interval / manual 三种触发模式。
27
+
28
+ > PyPI 包名:`llm-async-scheduler`,import 名:`async_scheduler`。
29
+
30
+ ## 特性
31
+
32
+ - 统一的 `ScheduleTask` 基类,内置超时、重试与生命周期钩子
33
+ - 基于 `AsyncIOScheduler` 的 cron 与 interval 调度
34
+ - manual 模式支持按需手动触发
35
+ - Python 3.12+,纯 asyncio
36
+
37
+ ## 安装
38
+
39
+ ```bash
40
+ pip install llm-async-scheduler
41
+ ```
42
+
43
+ 或使用 [uv](https://docs.astral.sh/uv/):
44
+
45
+ ```bash
46
+ uv add llm-async-scheduler
47
+ ```
48
+
49
+ ## 快速开始
50
+
51
+ ### 定义任务
52
+
53
+ 继承 `ScheduleTask` 并实现 `run()`:
54
+
55
+ ```python
56
+ import asyncio
57
+ from async_scheduler import AsyncTaskScheduler, ScheduleTask
58
+
59
+
60
+ class HelloTask(ScheduleTask):
61
+ async def run(self) -> None:
62
+ print(f"hello from {self.task_id}")
63
+
64
+
65
+ async def main() -> None:
66
+ task = HelloTask("hello", timeout=30, retry_times=1, tags=["demo"])
67
+ scheduler = AsyncTaskScheduler(task, "manual")
68
+ await scheduler.run_manual_once()
69
+
70
+
71
+ asyncio.run(main())
72
+ ```
73
+
74
+ ### Cron 调度
75
+
76
+ ```python
77
+ import asyncio
78
+ from async_scheduler import AsyncTaskScheduler, ScheduleTask
79
+
80
+
81
+ class SyncDataTask(ScheduleTask):
82
+ async def run(self) -> None:
83
+ # 业务逻辑
84
+ ...
85
+
86
+
87
+ async def main() -> None:
88
+ task = SyncDataTask("sync-data")
89
+ scheduler = AsyncTaskScheduler(
90
+ task,
91
+ "cron",
92
+ cron_expr="0 */6 * * *", # 每 6 小时
93
+ )
94
+ scheduler.start()
95
+ await scheduler.wait_until_done() # 常驻进程,可通过 stop() 退出
96
+
97
+
98
+ asyncio.run(main())
99
+ ```
100
+
101
+ ### Interval 调度
102
+
103
+ ```python
104
+ scheduler = AsyncTaskScheduler(
105
+ task,
106
+ "interval",
107
+ interval_seconds=60,
108
+ )
109
+ scheduler.start()
110
+ await scheduler.wait_until_done()
111
+ ```
112
+
113
+ ## API 概览
114
+
115
+ ### ScheduleTask
116
+
117
+ | 参数 | 类型 | 默认值 | 说明 |
118
+ |------|------|--------|------|
119
+ | `task_id` | `str` | — | 任务唯一标识 |
120
+ | `timeout` | `int` | `3600` | 单次执行超时(秒) |
121
+ | `retry_times` | `int` | `0` | 失败后重试次数 |
122
+ | `retry_delay` | `int` | `5` | 重试间隔(秒) |
123
+ | `tags` | `Sequence[str]` | `[]` | 可选标签,便于日志与监控 |
124
+
125
+ 可覆写钩子:
126
+
127
+ - `before_run()`:执行前
128
+ - `run()`:核心业务(必须实现)
129
+ - `after_run(success)`:执行后
130
+ - `on_error(exc)`:每次失败时
131
+
132
+ 调度器调用 `safe_execute()`,不要直接调用 `run()`。
133
+
134
+ ### AsyncTaskScheduler
135
+
136
+ | 参数 | 类型 | 说明 |
137
+ |------|------|------|
138
+ | `task` | `ScheduleTask` | 任务实例 |
139
+ | `trigger_mode` | `"cron" \| "interval" \| "manual"` | 触发模式 |
140
+ | `cron_expr` | `str \| None` | cron 表达式(5 段,如 `0 2 * * *`) |
141
+ | `interval_seconds` | `int \| None` | interval 间隔秒数 |
142
+
143
+ 常用方法:
144
+
145
+ | 方法 | 说明 |
146
+ |------|------|
147
+ | `start()` | 启动调度(manual 模式可跳过) |
148
+ | `stop()` | 停止调度并唤醒 `wait_until_done()` |
149
+ | `run_manual_once()` | manual 模式手动执行一次 |
150
+ | `wait_until_done()` | 阻塞等待结束或 shutdown |
151
+ | `request_shutdown()` | 仅唤醒 `wait_until_done()`,不停止 job |
152
+
153
+ ## 开发
154
+
155
+ ```bash
156
+ git clone https://github.com/realwrtoff/async-scheduler.git
157
+ cd async-scheduler
158
+ uv sync --group dev
159
+ uv run pytest
160
+ uv build
161
+ ```
162
+
163
+ ## 发布
164
+
165
+ ```bash
166
+ # 构建 wheel / sdist
167
+ uv build
168
+
169
+ # 发布到 PyPI(需配置 PYPI_TOKEN 或 ~/.pypirc)
170
+ uv publish
171
+ ```
172
+
173
+ 发布前请确认 `pyproject.toml` 中 `version` 与 `[project.urls]` 指向正确仓库。
174
+
175
+ ## 许可证
176
+
177
+ [MIT](./LICENSE)
@@ -0,0 +1,154 @@
1
+ # llm-async-scheduler
2
+
3
+ 基于 [APScheduler](https://apscheduler.readthedocs.io/) 的异步任务调度通用库,提供统一的任务抽象与 cron / interval / manual 三种触发模式。
4
+
5
+ > PyPI 包名:`llm-async-scheduler`,import 名:`async_scheduler`。
6
+
7
+ ## 特性
8
+
9
+ - 统一的 `ScheduleTask` 基类,内置超时、重试与生命周期钩子
10
+ - 基于 `AsyncIOScheduler` 的 cron 与 interval 调度
11
+ - manual 模式支持按需手动触发
12
+ - Python 3.12+,纯 asyncio
13
+
14
+ ## 安装
15
+
16
+ ```bash
17
+ pip install llm-async-scheduler
18
+ ```
19
+
20
+ 或使用 [uv](https://docs.astral.sh/uv/):
21
+
22
+ ```bash
23
+ uv add llm-async-scheduler
24
+ ```
25
+
26
+ ## 快速开始
27
+
28
+ ### 定义任务
29
+
30
+ 继承 `ScheduleTask` 并实现 `run()`:
31
+
32
+ ```python
33
+ import asyncio
34
+ from async_scheduler import AsyncTaskScheduler, ScheduleTask
35
+
36
+
37
+ class HelloTask(ScheduleTask):
38
+ async def run(self) -> None:
39
+ print(f"hello from {self.task_id}")
40
+
41
+
42
+ async def main() -> None:
43
+ task = HelloTask("hello", timeout=30, retry_times=1, tags=["demo"])
44
+ scheduler = AsyncTaskScheduler(task, "manual")
45
+ await scheduler.run_manual_once()
46
+
47
+
48
+ asyncio.run(main())
49
+ ```
50
+
51
+ ### Cron 调度
52
+
53
+ ```python
54
+ import asyncio
55
+ from async_scheduler import AsyncTaskScheduler, ScheduleTask
56
+
57
+
58
+ class SyncDataTask(ScheduleTask):
59
+ async def run(self) -> None:
60
+ # 业务逻辑
61
+ ...
62
+
63
+
64
+ async def main() -> None:
65
+ task = SyncDataTask("sync-data")
66
+ scheduler = AsyncTaskScheduler(
67
+ task,
68
+ "cron",
69
+ cron_expr="0 */6 * * *", # 每 6 小时
70
+ )
71
+ scheduler.start()
72
+ await scheduler.wait_until_done() # 常驻进程,可通过 stop() 退出
73
+
74
+
75
+ asyncio.run(main())
76
+ ```
77
+
78
+ ### Interval 调度
79
+
80
+ ```python
81
+ scheduler = AsyncTaskScheduler(
82
+ task,
83
+ "interval",
84
+ interval_seconds=60,
85
+ )
86
+ scheduler.start()
87
+ await scheduler.wait_until_done()
88
+ ```
89
+
90
+ ## API 概览
91
+
92
+ ### ScheduleTask
93
+
94
+ | 参数 | 类型 | 默认值 | 说明 |
95
+ |------|------|--------|------|
96
+ | `task_id` | `str` | — | 任务唯一标识 |
97
+ | `timeout` | `int` | `3600` | 单次执行超时(秒) |
98
+ | `retry_times` | `int` | `0` | 失败后重试次数 |
99
+ | `retry_delay` | `int` | `5` | 重试间隔(秒) |
100
+ | `tags` | `Sequence[str]` | `[]` | 可选标签,便于日志与监控 |
101
+
102
+ 可覆写钩子:
103
+
104
+ - `before_run()`:执行前
105
+ - `run()`:核心业务(必须实现)
106
+ - `after_run(success)`:执行后
107
+ - `on_error(exc)`:每次失败时
108
+
109
+ 调度器调用 `safe_execute()`,不要直接调用 `run()`。
110
+
111
+ ### AsyncTaskScheduler
112
+
113
+ | 参数 | 类型 | 说明 |
114
+ |------|------|------|
115
+ | `task` | `ScheduleTask` | 任务实例 |
116
+ | `trigger_mode` | `"cron" \| "interval" \| "manual"` | 触发模式 |
117
+ | `cron_expr` | `str \| None` | cron 表达式(5 段,如 `0 2 * * *`) |
118
+ | `interval_seconds` | `int \| None` | interval 间隔秒数 |
119
+
120
+ 常用方法:
121
+
122
+ | 方法 | 说明 |
123
+ |------|------|
124
+ | `start()` | 启动调度(manual 模式可跳过) |
125
+ | `stop()` | 停止调度并唤醒 `wait_until_done()` |
126
+ | `run_manual_once()` | manual 模式手动执行一次 |
127
+ | `wait_until_done()` | 阻塞等待结束或 shutdown |
128
+ | `request_shutdown()` | 仅唤醒 `wait_until_done()`,不停止 job |
129
+
130
+ ## 开发
131
+
132
+ ```bash
133
+ git clone https://github.com/realwrtoff/async-scheduler.git
134
+ cd async-scheduler
135
+ uv sync --group dev
136
+ uv run pytest
137
+ uv build
138
+ ```
139
+
140
+ ## 发布
141
+
142
+ ```bash
143
+ # 构建 wheel / sdist
144
+ uv build
145
+
146
+ # 发布到 PyPI(需配置 PYPI_TOKEN 或 ~/.pypirc)
147
+ uv publish
148
+ ```
149
+
150
+ 发布前请确认 `pyproject.toml` 中 `version` 与 `[project.urls]` 指向正确仓库。
151
+
152
+ ## 许可证
153
+
154
+ [MIT](./LICENSE)
@@ -0,0 +1,7 @@
1
+ """异步任务调度通用库。"""
2
+
3
+ from .scheduler import AsyncTaskScheduler
4
+ from .task import ScheduleTask
5
+
6
+ __all__ = ["AsyncTaskScheduler", "ScheduleTask"]
7
+ __version__ = "0.1.0"
@@ -0,0 +1,111 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ from typing import Literal
6
+
7
+ from apscheduler.schedulers.asyncio import AsyncIOScheduler
8
+ from apscheduler.triggers.cron import CronTrigger
9
+ from apscheduler.triggers.interval import IntervalTrigger
10
+
11
+ from .task import ScheduleTask
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ TriggerMode = Literal["cron", "interval", "manual"]
16
+
17
+
18
+ class AsyncTaskScheduler:
19
+ """统一调度器,绑定单个 ScheduleTask 实例并按触发模式注册 job。"""
20
+
21
+ def __init__(
22
+ self,
23
+ task: ScheduleTask,
24
+ trigger_mode: TriggerMode,
25
+ *,
26
+ cron_expr: str | None = None,
27
+ interval_seconds: int | None = None,
28
+ ) -> None:
29
+ self.task = task
30
+ self.trigger_mode = trigger_mode
31
+ self.cron_expr = cron_expr
32
+ self.interval_seconds = interval_seconds
33
+
34
+ self.scheduler = AsyncIOScheduler()
35
+ self._manual_done = asyncio.Event()
36
+ self._shutdown = asyncio.Event()
37
+
38
+ self._setup_job()
39
+
40
+ def _setup_job(self) -> None:
41
+ task_id = self.task.task_id
42
+ if self.trigger_mode == "cron":
43
+ if not self.cron_expr:
44
+ raise ValueError(f"task {task_id}: cron mode requires cron_expr")
45
+ trigger = CronTrigger.from_crontab(self.cron_expr)
46
+ self.scheduler.add_job(
47
+ self.task.safe_execute,
48
+ trigger=trigger,
49
+ id=task_id,
50
+ name=f"{task_id}_cron_job",
51
+ )
52
+ logger.info("[%s] register cron: %s", task_id, self.cron_expr)
53
+
54
+ elif self.trigger_mode == "interval":
55
+ if not isinstance(self.interval_seconds, int) or self.interval_seconds <= 0:
56
+ raise ValueError(
57
+ f"task {task_id}: interval mode requires positive interval_seconds"
58
+ )
59
+ trigger = IntervalTrigger(seconds=self.interval_seconds)
60
+ self.scheduler.add_job(
61
+ self.task.safe_execute,
62
+ trigger=trigger,
63
+ id=task_id,
64
+ name=f"{task_id}_interval_job",
65
+ )
66
+ logger.info("[%s] register interval %ss", task_id, self.interval_seconds)
67
+
68
+ elif self.trigger_mode == "manual":
69
+ logger.info("[%s] register manual mode, run via run_manual_once()", task_id)
70
+
71
+ else:
72
+ raise ValueError(f"unsupported trigger_mode: {self.trigger_mode!r}")
73
+
74
+ async def run_manual_once(self) -> None:
75
+ """manual 模式下手动触发一次任务执行。"""
76
+ if self.trigger_mode != "manual":
77
+ raise RuntimeError("run_manual_once() is only available in manual mode")
78
+ try:
79
+ await self.task.safe_execute()
80
+ finally:
81
+ self._manual_done.set()
82
+
83
+ def start(self) -> None:
84
+ """启动底层 APScheduler(manual 模式可跳过)。"""
85
+ if self.trigger_mode == "manual":
86
+ logger.debug("[%s] manual mode skips scheduler.start()", self.task.task_id)
87
+ return
88
+ self.scheduler.start()
89
+ logger.info("[%s] scheduler started", self.task.task_id)
90
+
91
+ def stop(self) -> None:
92
+ """停止调度器并唤醒 wait_until_done()。"""
93
+ if self.scheduler.running:
94
+ self.scheduler.shutdown(wait=True)
95
+ logger.info("[%s] scheduler stopped", self.task.task_id)
96
+ self._shutdown.set()
97
+
98
+ def request_shutdown(self) -> None:
99
+ """请求 wait_until_done() 退出,但不停止已注册的 cron/interval job。"""
100
+ self._shutdown.set()
101
+
102
+ async def wait_until_done(self) -> None:
103
+ """阻塞直到任务结束或收到 shutdown 信号。
104
+
105
+ - manual:等待 run_manual_once() 完成。
106
+ - cron/interval:默认永久阻塞,可通过 stop() 或 request_shutdown() 退出。
107
+ """
108
+ if self.trigger_mode == "manual":
109
+ await self._manual_done.wait()
110
+ return
111
+ await self._shutdown.wait()
@@ -0,0 +1,73 @@
1
+ from __future__ import annotations
2
+
3
+ import abc
4
+ import asyncio
5
+ import logging
6
+ from typing import Sequence
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ class ScheduleTask(abc.ABC):
12
+ """所有业务异步任务的统一抽象基类。"""
13
+
14
+ def __init__(
15
+ self,
16
+ task_id: str,
17
+ *,
18
+ timeout: int = 3600,
19
+ retry_times: int = 0,
20
+ retry_delay: int = 5,
21
+ tags: Sequence[str] | None = None,
22
+ ) -> None:
23
+ self.task_id = task_id
24
+ self.timeout = timeout
25
+ self.retry_times = retry_times
26
+ self.retry_delay = retry_delay
27
+ self.tags = list(tags or [])
28
+
29
+ async def before_run(self) -> None:
30
+ """执行前钩子:校验、初始化、加锁、状态上报。"""
31
+ logger.info("[%s] task before run, tags=%s", self.task_id, self.tags)
32
+
33
+ @abc.abstractmethod
34
+ async def run(self) -> None:
35
+ """核心业务逻辑,子类必须实现。"""
36
+
37
+ async def after_run(self, success: bool) -> None:
38
+ """执行后钩子:清理资源、上报状态。"""
39
+ if success:
40
+ logger.info("[%s] task run success", self.task_id)
41
+ else:
42
+ logger.error("[%s] task run failed", self.task_id)
43
+
44
+ async def on_error(self, exc: Exception) -> None:
45
+ """异常钩子:告警、埋点、堆栈打印。"""
46
+ logger.exception("[%s] task caught exception: %s", self.task_id, exc)
47
+
48
+ async def safe_execute(self) -> None:
49
+ """带超时与重试的安全执行入口,调度器应调用此方法而非直接调用 run。"""
50
+ success = False
51
+ try:
52
+ await self.before_run()
53
+ for attempt in range(self.retry_times + 1):
54
+ try:
55
+ await asyncio.wait_for(self.run(), timeout=self.timeout)
56
+ success = True
57
+ break
58
+ except asyncio.CancelledError:
59
+ raise
60
+ except Exception as exc:
61
+ await self.on_error(exc)
62
+ if attempt >= self.retry_times:
63
+ break
64
+ logger.warning(
65
+ "[%s] retry %s/%s, delay %ss",
66
+ self.task_id,
67
+ attempt + 1,
68
+ self.retry_times,
69
+ self.retry_delay,
70
+ )
71
+ await asyncio.sleep(self.retry_delay)
72
+ finally:
73
+ await self.after_run(success)
@@ -0,0 +1,45 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "llm-async-scheduler"
7
+ version = "0.1.0"
8
+ description = "基于 APScheduler 的异步任务调度通用库,支持 cron、interval 与 manual 触发模式。"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.12"
12
+ authors = [{ name = "realwrtoff" }]
13
+ keywords = ["async", "scheduler", "cron", "apscheduler", "task"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.12",
20
+ "Programming Language :: Python :: 3.13",
21
+ "Framework :: AsyncIO",
22
+ "Topic :: Software Development :: Libraries :: Python Modules",
23
+ ]
24
+ dependencies = [
25
+ "apscheduler>=3.10.4,<4",
26
+ ]
27
+
28
+ [project.urls]
29
+ Homepage = "https://github.com/realwrtoff/async-scheduler"
30
+ Repository = "https://github.com/realwrtoff/async-scheduler"
31
+ Issues = "https://github.com/realwrtoff/async-scheduler/issues"
32
+
33
+ [dependency-groups]
34
+ dev = [
35
+ "pytest>=8.0",
36
+ "pytest-asyncio>=0.24",
37
+ ]
38
+
39
+ [tool.hatch.build.targets.wheel]
40
+ packages = ["async_scheduler"]
41
+
42
+ [tool.pytest.ini_options]
43
+ asyncio_mode = "auto"
44
+ asyncio_default_fixture_loop_scope = "function"
45
+ testpaths = ["tests"]
@@ -0,0 +1,91 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+
5
+ import pytest
6
+
7
+ from async_scheduler import AsyncTaskScheduler, ScheduleTask
8
+
9
+
10
+ class SuccessTask(ScheduleTask):
11
+ def __init__(self, task_id: str) -> None:
12
+ super().__init__(task_id)
13
+ self.runs = 0
14
+
15
+ async def run(self) -> None:
16
+ self.runs += 1
17
+
18
+
19
+ class FlakyTask(ScheduleTask):
20
+ def __init__(self, task_id: str, fail_times: int) -> None:
21
+ super().__init__(task_id, retry_times=2, retry_delay=0)
22
+ self.fail_times = fail_times
23
+ self.runs = 0
24
+ self.errors: list[Exception] = []
25
+
26
+ async def run(self) -> None:
27
+ self.runs += 1
28
+ if self.runs <= self.fail_times:
29
+ raise RuntimeError("boom")
30
+
31
+ async def on_error(self, exc: Exception) -> None:
32
+ self.errors.append(exc)
33
+
34
+
35
+ class SlowTask(ScheduleTask):
36
+ async def run(self) -> None:
37
+ await asyncio.sleep(0.2)
38
+
39
+
40
+ @pytest.mark.asyncio
41
+ async def test_safe_execute_success() -> None:
42
+ task = SuccessTask("ok")
43
+ await task.safe_execute()
44
+ assert task.runs == 1
45
+
46
+
47
+ @pytest.mark.asyncio
48
+ async def test_safe_execute_retries_then_succeeds() -> None:
49
+ task = FlakyTask("flaky", fail_times=2)
50
+ await task.safe_execute()
51
+ assert task.runs == 3
52
+ assert len(task.errors) == 2
53
+
54
+
55
+ @pytest.mark.asyncio
56
+ async def test_safe_execute_timeout() -> None:
57
+ task = SlowTask("slow", timeout=1)
58
+ await task.safe_execute()
59
+
60
+
61
+ @pytest.mark.asyncio
62
+ async def test_manual_scheduler() -> None:
63
+ task = SuccessTask("manual")
64
+ scheduler = AsyncTaskScheduler(task, "manual")
65
+ await scheduler.run_manual_once()
66
+ assert task.runs == 1
67
+
68
+
69
+ def test_cron_requires_expression() -> None:
70
+ task = SuccessTask("cron")
71
+ with pytest.raises(ValueError, match="cron_expr"):
72
+ AsyncTaskScheduler(task, "cron")
73
+
74
+
75
+ def test_interval_requires_positive_seconds() -> None:
76
+ task = SuccessTask("interval")
77
+ with pytest.raises(ValueError, match="interval_seconds"):
78
+ AsyncTaskScheduler(task, "interval", interval_seconds=0)
79
+
80
+
81
+ @pytest.mark.asyncio
82
+ async def test_wait_until_done_shutdown() -> None:
83
+ task = SuccessTask("interval-wait")
84
+ scheduler = AsyncTaskScheduler(task, "interval", interval_seconds=3600)
85
+ scheduler.start()
86
+
87
+ async def shutdown_soon() -> None:
88
+ await asyncio.sleep(0.05)
89
+ scheduler.stop()
90
+
91
+ await asyncio.gather(scheduler.wait_until_done(), shutdown_soon())
@@ -0,0 +1,142 @@
1
+ version = 1
2
+ revision = 3
3
+ requires-python = ">=3.12"
4
+
5
+ [[package]]
6
+ name = "apscheduler"
7
+ version = "3.11.2"
8
+ source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
9
+ dependencies = [
10
+ { name = "tzlocal" },
11
+ ]
12
+ sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/07/12/3e4389e5920b4c1763390c6d371162f3784f86f85cd6d6c1bfe68eef14e2/apscheduler-3.11.2.tar.gz", hash = "sha256:2a9966b052ec805f020c8c4c3ae6e6a06e24b1bf19f2e11d91d8cca0473eef41", size = 108683, upload-time = "2025-12-22T00:39:34.884Z" }
13
+ wheels = [
14
+ { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9f/64/2e54428beba8d9992aa478bb8f6de9e4ecaa5f8f513bcfd567ed7fb0262d/apscheduler-3.11.2-py3-none-any.whl", hash = "sha256:ce005177f741409db4e4dd40a7431b76feb856b9dd69d57e0da49d6715bfd26d", size = 64439, upload-time = "2025-12-22T00:39:33.303Z" },
15
+ ]
16
+
17
+ [[package]]
18
+ name = "colorama"
19
+ version = "0.4.6"
20
+ source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
21
+ sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
22
+ wheels = [
23
+ { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
24
+ ]
25
+
26
+ [[package]]
27
+ name = "iniconfig"
28
+ version = "2.3.0"
29
+ source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
30
+ sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
31
+ wheels = [
32
+ { url = "https://pypi.tuna.tsinghua.edu.cn/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
33
+ ]
34
+
35
+ [[package]]
36
+ name = "llm-async-scheduler"
37
+ version = "0.1.0"
38
+ source = { editable = "." }
39
+ dependencies = [
40
+ { name = "apscheduler" },
41
+ ]
42
+
43
+ [package.dev-dependencies]
44
+ dev = [
45
+ { name = "pytest" },
46
+ { name = "pytest-asyncio" },
47
+ ]
48
+
49
+ [package.metadata]
50
+ requires-dist = [{ name = "apscheduler", specifier = ">=3.10.4,<4" }]
51
+
52
+ [package.metadata.requires-dev]
53
+ dev = [
54
+ { name = "pytest", specifier = ">=8.0" },
55
+ { name = "pytest-asyncio", specifier = ">=0.24" },
56
+ ]
57
+
58
+ [[package]]
59
+ name = "packaging"
60
+ version = "26.2"
61
+ source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
62
+ sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
63
+ wheels = [
64
+ { url = "https://pypi.tuna.tsinghua.edu.cn/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
65
+ ]
66
+
67
+ [[package]]
68
+ name = "pluggy"
69
+ version = "1.6.0"
70
+ source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
71
+ sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
72
+ wheels = [
73
+ { url = "https://pypi.tuna.tsinghua.edu.cn/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
74
+ ]
75
+
76
+ [[package]]
77
+ name = "pygments"
78
+ version = "2.20.0"
79
+ source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
80
+ sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
81
+ wheels = [
82
+ { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
83
+ ]
84
+
85
+ [[package]]
86
+ name = "pytest"
87
+ version = "9.0.3"
88
+ source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
89
+ dependencies = [
90
+ { name = "colorama", marker = "sys_platform == 'win32'" },
91
+ { name = "iniconfig" },
92
+ { name = "packaging" },
93
+ { name = "pluggy" },
94
+ { name = "pygments" },
95
+ ]
96
+ sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
97
+ wheels = [
98
+ { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
99
+ ]
100
+
101
+ [[package]]
102
+ name = "pytest-asyncio"
103
+ version = "1.4.0"
104
+ source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
105
+ dependencies = [
106
+ { name = "pytest" },
107
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
108
+ ]
109
+ sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" }
110
+ wheels = [
111
+ { url = "https://pypi.tuna.tsinghua.edu.cn/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" },
112
+ ]
113
+
114
+ [[package]]
115
+ name = "typing-extensions"
116
+ version = "4.15.0"
117
+ source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
118
+ sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
119
+ wheels = [
120
+ { url = "https://pypi.tuna.tsinghua.edu.cn/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
121
+ ]
122
+
123
+ [[package]]
124
+ name = "tzdata"
125
+ version = "2026.2"
126
+ source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
127
+ sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" }
128
+ wheels = [
129
+ { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" },
130
+ ]
131
+
132
+ [[package]]
133
+ name = "tzlocal"
134
+ version = "5.3.1"
135
+ source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
136
+ dependencies = [
137
+ { name = "tzdata", marker = "sys_platform == 'win32'" },
138
+ ]
139
+ sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761, upload-time = "2025-03-05T21:17:41.549Z" }
140
+ wheels = [
141
+ { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" },
142
+ ]