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.
- llm_async_scheduler-0.1.0/.gitignore +9 -0
- llm_async_scheduler-0.1.0/.python-version +1 -0
- llm_async_scheduler-0.1.0/CHANGELOG.md +17 -0
- llm_async_scheduler-0.1.0/LICENSE +21 -0
- llm_async_scheduler-0.1.0/PKG-INFO +177 -0
- llm_async_scheduler-0.1.0/README.md +154 -0
- llm_async_scheduler-0.1.0/async_scheduler/__init__.py +7 -0
- llm_async_scheduler-0.1.0/async_scheduler/scheduler.py +111 -0
- llm_async_scheduler-0.1.0/async_scheduler/task.py +73 -0
- llm_async_scheduler-0.1.0/pyproject.toml +45 -0
- llm_async_scheduler-0.1.0/tests/test_scheduler.py +91 -0
- llm_async_scheduler-0.1.0/uv.lock +142 -0
|
@@ -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,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
|
+
]
|