faster-cron 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.
- faster_cron/__init__.py +9 -0
- faster_cron/async_cron.py +59 -0
- faster_cron/base.py +93 -0
- faster_cron/sync_cron.py +104 -0
- faster_cron-0.1.1.dist-info/METADATA +151 -0
- faster_cron-0.1.1.dist-info/RECORD +8 -0
- faster_cron-0.1.1.dist-info/WHEEL +5 -0
- faster_cron-0.1.1.dist-info/top_level.txt +1 -0
faster_cron/__init__.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import inspect
|
|
3
|
+
import datetime
|
|
4
|
+
import logging
|
|
5
|
+
from typing import Callable, Optional
|
|
6
|
+
from .base import CronBase
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AsyncFasterCron:
|
|
10
|
+
def __init__(self, log_level=logging.INFO):
|
|
11
|
+
self.tasks = []
|
|
12
|
+
self.logger = logging.getLogger("FasterCron.Async")
|
|
13
|
+
self.logger.setLevel(log_level)
|
|
14
|
+
self._running = False
|
|
15
|
+
|
|
16
|
+
def schedule(self, expression: str, allow_overlap: bool = True):
|
|
17
|
+
def decorator(func: Callable):
|
|
18
|
+
self.tasks.append({
|
|
19
|
+
"expression": expression,
|
|
20
|
+
"func": func,
|
|
21
|
+
"allow_overlap": allow_overlap,
|
|
22
|
+
"name": func.__name__
|
|
23
|
+
})
|
|
24
|
+
return func
|
|
25
|
+
|
|
26
|
+
return decorator
|
|
27
|
+
|
|
28
|
+
async def start(self):
|
|
29
|
+
self._running = True
|
|
30
|
+
listeners = [self._monitor(task) for task in self.tasks]
|
|
31
|
+
await asyncio.gather(*listeners)
|
|
32
|
+
|
|
33
|
+
async def _monitor(self, task):
|
|
34
|
+
last_ts = 0
|
|
35
|
+
current_task: Optional[asyncio.Task] = None
|
|
36
|
+
|
|
37
|
+
while self._running:
|
|
38
|
+
now = datetime.datetime.now()
|
|
39
|
+
ts = int(now.timestamp())
|
|
40
|
+
|
|
41
|
+
if ts != last_ts and CronBase.is_time_match(task["expression"], now):
|
|
42
|
+
last_ts = ts
|
|
43
|
+
if not task["allow_overlap"] and current_task and not current_task.done():
|
|
44
|
+
self.logger.warning(f"Skip {task['name']}: overlapping blocked.")
|
|
45
|
+
continue
|
|
46
|
+
|
|
47
|
+
context = {"scheduled_at": now, "task_name": task["name"]}
|
|
48
|
+
current_task = asyncio.create_task(self._wrapper(task["func"], context))
|
|
49
|
+
|
|
50
|
+
await asyncio.sleep(1.0 - (now.microsecond / 1_000_000) + 0.01)
|
|
51
|
+
|
|
52
|
+
async def _wrapper(self, func, context):
|
|
53
|
+
try:
|
|
54
|
+
sig = inspect.signature(func)
|
|
55
|
+
kwargs = {"context": context} if "context" in sig.parameters or any(
|
|
56
|
+
p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values()) else {}
|
|
57
|
+
await func(**kwargs)
|
|
58
|
+
except Exception as e:
|
|
59
|
+
self.logger.error(f"Task {func.__name__} failed: {e}", exc_info=True)
|
faster_cron/base.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
import logging
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class CronBase:
|
|
6
|
+
"""提供符合标准 Cron 规范的解析逻辑"""
|
|
7
|
+
|
|
8
|
+
@staticmethod
|
|
9
|
+
def is_time_match(expression: str, now: datetime.datetime) -> bool:
|
|
10
|
+
"""
|
|
11
|
+
判断当前时间是否匹配 Cron 表达式
|
|
12
|
+
逻辑参考标准 Unix Cron:当日期和星期同时被指定时,采用 OR 关系。
|
|
13
|
+
"""
|
|
14
|
+
parts = expression.split()
|
|
15
|
+
if len(parts) == 5:
|
|
16
|
+
# 分 时 日 月 周 -> 补齐秒为 0
|
|
17
|
+
sec_part, min_part, hour_part, day_part, month_part, weekday_part = "0", *parts
|
|
18
|
+
elif len(parts) == 6:
|
|
19
|
+
sec_part, min_part, hour_part, day_part, month_part, weekday_part = parts
|
|
20
|
+
else:
|
|
21
|
+
return False
|
|
22
|
+
|
|
23
|
+
# 1. 转换星期逻辑 (Python 0=Mon, 6=Sun -> Cron 0或7=Sun, 1=Mon...)
|
|
24
|
+
# 转换公式:(now.weekday() + 1) % 7 -> 结果 0=Sun, 1=Mon, ..., 6=Sat
|
|
25
|
+
cron_weekday = (now.weekday() + 1) % 7
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
# 2. 基础字段匹配
|
|
29
|
+
sec_match = CronBase._match_field(sec_part, now.second)
|
|
30
|
+
min_match = CronBase._match_field(min_part, now.minute)
|
|
31
|
+
hour_match = CronBase._match_field(hour_part, now.hour)
|
|
32
|
+
month_match = CronBase._match_field(month_part, now.month)
|
|
33
|
+
|
|
34
|
+
day_matches = CronBase._match_field(day_part, now.day)
|
|
35
|
+
weekday_matches = CronBase._match_field(weekday_part, cron_weekday)
|
|
36
|
+
|
|
37
|
+
# 3. 处理 Day 和 Weekday 的特殊关系 (Standard Cron Logic)
|
|
38
|
+
# 如果两个字段都有限制(不是 *),则为 OR 关系;否则为 AND 关系。
|
|
39
|
+
day_is_star = (day_part == "*")
|
|
40
|
+
weekday_is_star = (weekday_part == "*")
|
|
41
|
+
|
|
42
|
+
if not day_is_star and not weekday_is_star:
|
|
43
|
+
day_weekday_ok = (day_matches or weekday_matches)
|
|
44
|
+
else:
|
|
45
|
+
day_weekday_ok = (day_matches and weekday_matches)
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
sec_match and
|
|
49
|
+
min_match and
|
|
50
|
+
hour_match and
|
|
51
|
+
month_match and
|
|
52
|
+
day_weekday_ok
|
|
53
|
+
)
|
|
54
|
+
except Exception:
|
|
55
|
+
# 如果表达式解析失败(如格式错误),返回 False 避免程序崩溃
|
|
56
|
+
return False
|
|
57
|
+
|
|
58
|
+
@staticmethod
|
|
59
|
+
def _match_field(pattern: str, value: int) -> bool:
|
|
60
|
+
"""解析单个 Cron 字段"""
|
|
61
|
+
if pattern == "*":
|
|
62
|
+
return True
|
|
63
|
+
|
|
64
|
+
# 处理列表: "1,2,3"
|
|
65
|
+
if "," in pattern:
|
|
66
|
+
return any(CronBase._match_field(p, value) for p in pattern.split(","))
|
|
67
|
+
|
|
68
|
+
# 处理步长: "*/5" 或 "1-10/2"
|
|
69
|
+
if "/" in pattern:
|
|
70
|
+
r, s = pattern.split("/")
|
|
71
|
+
step = int(s)
|
|
72
|
+
if r in ["*", ""]:
|
|
73
|
+
return value % step == 0
|
|
74
|
+
if "-" in r:
|
|
75
|
+
start, end = map(int, r.split("-"))
|
|
76
|
+
return start <= value <= end and (value - start) % step == 0
|
|
77
|
+
# 固定点开始的步长: "5/10"
|
|
78
|
+
return value >= int(r) and (value - int(r)) % step == 0
|
|
79
|
+
|
|
80
|
+
# 处理范围: "10-20"
|
|
81
|
+
if "-" in pattern:
|
|
82
|
+
start, end = map(int, pattern.split("-"))
|
|
83
|
+
return start <= value <= end
|
|
84
|
+
|
|
85
|
+
# 处理精确数值: "5"
|
|
86
|
+
try:
|
|
87
|
+
target_val = int(pattern)
|
|
88
|
+
# 兼容性处理:Cron 中 7 经常作为周日的另一种写法
|
|
89
|
+
if target_val == 7:
|
|
90
|
+
target_val = 0
|
|
91
|
+
return target_val == value
|
|
92
|
+
except ValueError:
|
|
93
|
+
return False
|
faster_cron/sync_cron.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import threading
|
|
2
|
+
import time
|
|
3
|
+
import datetime
|
|
4
|
+
import logging
|
|
5
|
+
import inspect
|
|
6
|
+
from typing import List, Dict, Any, Callable
|
|
7
|
+
from .base import CronBase
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class FasterCron:
|
|
11
|
+
def __init__(self, log_level=logging.INFO):
|
|
12
|
+
self.tasks: List[Dict[str, Any]] = []
|
|
13
|
+
self.logger = logging.getLogger("FasterCron.Sync")
|
|
14
|
+
self.logger.setLevel(log_level)
|
|
15
|
+
self._running = False
|
|
16
|
+
self._monitors: List[threading.Thread] = []
|
|
17
|
+
|
|
18
|
+
def schedule(self, expression: str, allow_overlap: bool = True):
|
|
19
|
+
"""
|
|
20
|
+
注册同步任务。
|
|
21
|
+
allow_overlap 为 True 时,会通过开启新线程来实现并发执行。
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def decorator(func: Callable):
|
|
25
|
+
self.tasks.append({
|
|
26
|
+
"expression": expression,
|
|
27
|
+
"func": func,
|
|
28
|
+
"allow_overlap": allow_overlap,
|
|
29
|
+
"name": func.__name__,
|
|
30
|
+
"last_worker": None # 用于追踪此任务的上一个执行线程
|
|
31
|
+
})
|
|
32
|
+
return func
|
|
33
|
+
|
|
34
|
+
return decorator
|
|
35
|
+
|
|
36
|
+
def run(self):
|
|
37
|
+
"""阻塞启动所有任务监控器"""
|
|
38
|
+
self._running = True
|
|
39
|
+
self.logger.info(f"FasterCron (Sync Mode) started with {len(self.tasks)} tasks.")
|
|
40
|
+
|
|
41
|
+
for task in self.tasks:
|
|
42
|
+
t = threading.Thread(
|
|
43
|
+
target=self._monitor_loop,
|
|
44
|
+
args=(task,),
|
|
45
|
+
name=f"Monitor-{task['name']}",
|
|
46
|
+
daemon=True
|
|
47
|
+
)
|
|
48
|
+
t.start()
|
|
49
|
+
self._monitors.append(t)
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
while self._running:
|
|
53
|
+
time.sleep(1)
|
|
54
|
+
except KeyboardInterrupt:
|
|
55
|
+
self.logger.info("FasterCron stopping...")
|
|
56
|
+
self._running = False
|
|
57
|
+
|
|
58
|
+
def _monitor_loop(self, task: Dict[str, Any]):
|
|
59
|
+
"""每个任务独立的监听循环"""
|
|
60
|
+
last_trigger_ts = 0
|
|
61
|
+
|
|
62
|
+
while self._running:
|
|
63
|
+
now = datetime.datetime.now()
|
|
64
|
+
current_ts = int(now.timestamp())
|
|
65
|
+
|
|
66
|
+
# 1. 时间匹配检查 (确保每秒只触发一次)
|
|
67
|
+
if current_ts != last_trigger_ts and CronBase.is_time_match(task["expression"], now):
|
|
68
|
+
last_trigger_ts = current_ts
|
|
69
|
+
|
|
70
|
+
# 2. 并发控制
|
|
71
|
+
if not task["allow_overlap"]:
|
|
72
|
+
# 单例模式:检查上一个工作线程是否还在跑
|
|
73
|
+
prev_worker = task.get("last_worker")
|
|
74
|
+
if prev_worker and prev_worker.is_alive():
|
|
75
|
+
self.logger.warning(f"Task '{task['name']}' is still running. Skipping this cycle.")
|
|
76
|
+
continue
|
|
77
|
+
|
|
78
|
+
# 3. 执行任务
|
|
79
|
+
# 无论是并发还是单例,都开启新线程执行工作函数,避免阻塞监控循环
|
|
80
|
+
context = {"scheduled_at": now, "task_name": task["name"]}
|
|
81
|
+
worker_thread = threading.Thread(
|
|
82
|
+
target=self._execute_task,
|
|
83
|
+
args=(task["func"], context),
|
|
84
|
+
name=f"Worker-{task['name']}-{current_ts}",
|
|
85
|
+
daemon=True
|
|
86
|
+
)
|
|
87
|
+
task["last_worker"] = worker_thread # 记录引用以便下次检查
|
|
88
|
+
worker_thread.start()
|
|
89
|
+
|
|
90
|
+
# 4. 精确对齐到下一秒
|
|
91
|
+
sleep_time = 1.0 - (now.microsecond / 1_000_000) + 0.01
|
|
92
|
+
time.sleep(sleep_time)
|
|
93
|
+
|
|
94
|
+
def _execute_task(self, func: Callable, context: Dict):
|
|
95
|
+
"""具体的任务执行包装器"""
|
|
96
|
+
try:
|
|
97
|
+
# 智能参数注入
|
|
98
|
+
sig = inspect.signature(func)
|
|
99
|
+
if 'context' in sig.parameters:
|
|
100
|
+
func(context=context)
|
|
101
|
+
else:
|
|
102
|
+
func()
|
|
103
|
+
except Exception as e:
|
|
104
|
+
self.logger.error(f"Error in task '{func.__name__}': {e}", exc_info=True)
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: faster-cron
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: 一个轻量、直观、支持异步与同步双模式的定时任务调度器
|
|
5
|
+
Author-email: Bernard Simon <bernardziyi@gmail.com>
|
|
6
|
+
Project-URL: Homepage, https://github.com/BernardSimon/faster-cron
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Requires-Python: >=3.7
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# FasterCron
|
|
14
|
+
|
|
15
|
+
[English](./README_EN.md) | 中文版
|
|
16
|
+
|
|
17
|
+
**FasterCron** 是一个轻量级、直观且功能强大的 Python 定时任务调度工具库。它完美支持 **Asyncio (异步)** 和 **Threading (多线程)** 双模式,专为需要高可靠性、简单配置和任务并发控制的场景设计。
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## 🌟 核心特性
|
|
22
|
+
|
|
23
|
+
* **双模式支持**:一套逻辑同时提供 `AsyncFasterCron`(异步)和 `FasterCron`(同步多线程)两种实现。
|
|
24
|
+
* **任务级并发控制**:通过 `allow_overlap` 参数精准控制同一个任务是否允许重叠执行(单例模式 vs 并发模式)。
|
|
25
|
+
* **智能参数注入**:自动检测任务函数签名,按需注入包含调度时间、任务名称的 `context` 上下文。
|
|
26
|
+
* **标准 Cron 支持**:兼容 5 位(分时日月周)和 6 位(秒分时日月周)Cron 表达式。
|
|
27
|
+
* **健壮性**:内置异常捕获机制,单个任务崩溃不影响调度器运行。
|
|
28
|
+
* **无外部依赖**:仅使用 Python 标准库实现,轻量无负担。
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## 📦 安装
|
|
33
|
+
|
|
34
|
+
您可以直接通过 pip 安装:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install faster-cron
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
或者直接将源码放入您的项目中。
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## 🚀 快速上手
|
|
46
|
+
|
|
47
|
+
### 1. 异步模式 (Async Mode)
|
|
48
|
+
|
|
49
|
+
适用于使用了 `aiohttp`, `httpx` 或 `tortoise-orm` 等异步库的项目。
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
import asyncio
|
|
53
|
+
from faster_cron import AsyncFasterCron
|
|
54
|
+
|
|
55
|
+
cron = AsyncFasterCron()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# 示例:每 5 秒执行一次,禁止重叠(若上一个任务没跑完,则跳过本次)
|
|
59
|
+
@cron.schedule("*/5 * * * * *", allow_overlap=False)
|
|
60
|
+
async def my_async_job(context):
|
|
61
|
+
print(f"正在执行任务: {context['task_name']}, 计划时间: {context['scheduled_at']}")
|
|
62
|
+
await asyncio.sleep(6) # 模拟长耗时任务
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
async def main():
|
|
66
|
+
await cron.start()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
if __name__ == "__main__":
|
|
70
|
+
asyncio.run(main())
|
|
71
|
+
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### 2. 同步模式 (Sync Mode)
|
|
75
|
+
|
|
76
|
+
适用于传统的阻塞式脚本或爬虫。
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
from faster_cron import FasterCron
|
|
80
|
+
import time
|
|
81
|
+
|
|
82
|
+
cron = FasterCron()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# 示例:每秒执行一次,允许并发执行
|
|
86
|
+
@cron.schedule("* * * * * *", allow_overlap=True)
|
|
87
|
+
def my_sync_job():
|
|
88
|
+
print("滴答,同步任务正在运行...")
|
|
89
|
+
time.sleep(2)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
if __name__ == "__main__":
|
|
93
|
+
cron.run()
|
|
94
|
+
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
---
|
|
98
|
+
|
|
99
|
+
## 🛠 核心 API 说明
|
|
100
|
+
|
|
101
|
+
### 调度装饰器 `schedule`
|
|
102
|
+
|
|
103
|
+
| 参数 | 类型 | 默认值 | 说明 |
|
|
104
|
+
| --- | --- | --- | --- |
|
|
105
|
+
| `expression` | `str` | - | Cron 表达式。支持 `*`, `,`, `-`, `/`。 |
|
|
106
|
+
| `allow_overlap` | `bool` | `True` | **关键参数**。`True`: 时间点到达即执行;`False`: 若该任务的上一个实例未结束,则跳过本次执行循环。 |
|
|
107
|
+
|
|
108
|
+
### 上下文参数 `context`
|
|
109
|
+
|
|
110
|
+
如果您的任务函数接收名为 `context` 的参数,FasterCron 会自动注入以下字典:
|
|
111
|
+
|
|
112
|
+
* `task_name`: 函数名称。
|
|
113
|
+
* `scheduled_at`: 任务触发的精确 `datetime` 对象。
|
|
114
|
+
* `expression`: 该任务使用的 Cron 表达式。
|
|
115
|
+
|
|
116
|
+
---
|
|
117
|
+
|
|
118
|
+
## 📅 Cron 表达式参考
|
|
119
|
+
|
|
120
|
+
FasterCron 支持灵活的表达式定义:
|
|
121
|
+
|
|
122
|
+
* `* * * * * *` : 每秒执行。
|
|
123
|
+
* `*/5 * * * * *` : 每 5 秒执行。
|
|
124
|
+
* `0 0 * * * *` : 每整小时执行。
|
|
125
|
+
* `0 30 9-17 * * *` : 每天 9:00 到 17:00 之间的每半小时执行。
|
|
126
|
+
* `0 0 0 * * 0` : 每周日凌晨执行。
|
|
127
|
+
|
|
128
|
+
---
|
|
129
|
+
|
|
130
|
+
## 🧪 运行测试
|
|
131
|
+
|
|
132
|
+
本项目包含完善的单元测试。您可以使用 `pytest` 来验证功能:
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
# 安装测试依赖
|
|
136
|
+
pip install pytest pytest-asyncio
|
|
137
|
+
|
|
138
|
+
# 运行所有测试
|
|
139
|
+
pytest
|
|
140
|
+
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
## 📄 开源协议
|
|
146
|
+
|
|
147
|
+
MIT License.
|
|
148
|
+
|
|
149
|
+
---
|
|
150
|
+
|
|
151
|
+
**如果您觉得好用,欢迎点一个 Star!🌟**
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
faster_cron/__init__.py,sha256=9TQeFkow--g4AlkZpemak_vODrkQI6E2zIrbIdgDg80,243
|
|
2
|
+
faster_cron/async_cron.py,sha256=VMD5BqagBvbfhKI4Eo_T1nyYUlcpdmCEKYwnfoLETeE,2084
|
|
3
|
+
faster_cron/base.py,sha256=lIK0kOnt33DD_H9bXT0pEONbPZthBzinivETbrek5EM,3525
|
|
4
|
+
faster_cron/sync_cron.py,sha256=xbyRDJFo2wUvpmlXI9VB6UWUet3mZngHd5XRrTOKR3k,3775
|
|
5
|
+
faster_cron-0.1.1.dist-info/METADATA,sha256=3kMUQMDawwuDKvfPM8beqswm6SK6GPc7I0DpujCw0BQ,4004
|
|
6
|
+
faster_cron-0.1.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
7
|
+
faster_cron-0.1.1.dist-info/top_level.txt,sha256=lQbktVSdGKKyocrLDqk8V_bFLBuWML4squauwEdZuik,12
|
|
8
|
+
faster_cron-0.1.1.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
faster_cron
|