onestep 0.4.2__tar.gz → 0.4.4__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.
Potentially problematic release.
This version of onestep might be problematic. Click here for more details.
- {onestep-0.4.2 → onestep-0.4.4}/PKG-INFO +3 -2
- {onestep-0.4.2 → onestep-0.4.4}/pyproject.toml +1 -1
- {onestep-0.4.2 → onestep-0.4.4}/src/onestep/__init__.py +6 -2
- {onestep-0.4.2 → onestep-0.4.4}/src/onestep/broker/__init__.py +10 -0
- {onestep-0.4.2 → onestep-0.4.4}/src/onestep/broker/base.py +10 -2
- {onestep-0.4.2 → onestep-0.4.4}/src/onestep/broker/cron.py +14 -6
- {onestep-0.4.2 → onestep-0.4.4}/src/onestep/broker/memory.py +4 -1
- {onestep-0.4.2 → onestep-0.4.4}/src/onestep/broker/rabbitmq.py +37 -11
- {onestep-0.4.2 → onestep-0.4.4}/src/onestep/broker/redis/stream.py +10 -5
- {onestep-0.4.2 → onestep-0.4.4}/src/onestep/broker/webhook.py +6 -4
- {onestep-0.4.2 → onestep-0.4.4}/src/onestep/cli.py +8 -2
- onestep-0.4.4/src/onestep/cron.py +211 -0
- {onestep-0.4.2 → onestep-0.4.4}/src/onestep/message.py +18 -7
- {onestep-0.4.2 → onestep-0.4.4}/src/onestep/middleware/unique.py +1 -1
- {onestep-0.4.2 → onestep-0.4.4}/src/onestep/onestep.py +3 -3
- {onestep-0.4.2 → onestep-0.4.4}/src/onestep/worker.py +4 -1
- {onestep-0.4.2 → onestep-0.4.4}/README.md +0 -0
- {onestep-0.4.2 → onestep-0.4.4}/src/onestep/_utils.py +0 -0
- {onestep-0.4.2 → onestep-0.4.4}/src/onestep/broker/redis/__init__.py +0 -0
- {onestep-0.4.2 → onestep-0.4.4}/src/onestep/broker/redis/pubsub.py +0 -0
- {onestep-0.4.2 → onestep-0.4.4}/src/onestep/exception.py +0 -0
- {onestep-0.4.2 → onestep-0.4.4}/src/onestep/middleware/__init__.py +0 -0
- {onestep-0.4.2 → onestep-0.4.4}/src/onestep/middleware/base.py +0 -0
- {onestep-0.4.2 → onestep-0.4.4}/src/onestep/middleware/config.py +0 -0
- {onestep-0.4.2 → onestep-0.4.4}/src/onestep/retry.py +0 -0
- {onestep-0.4.2 → onestep-0.4.4}/src/onestep/signal.py +0 -0
- {onestep-0.4.2 → onestep-0.4.4}/src/onestep/state.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: onestep
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.4
|
|
4
4
|
Summary: 仅需一步,轻松实现分布式异步任务。
|
|
5
5
|
Author: miclon
|
|
6
6
|
Author-email: jcnd@163.com
|
|
@@ -12,6 +12,7 @@ Classifier: Programming Language :: Python :: 3.10
|
|
|
12
12
|
Classifier: Programming Language :: Python :: 3.11
|
|
13
13
|
Classifier: Programming Language :: Python :: 3.12
|
|
14
14
|
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
15
16
|
Provides-Extra: redis
|
|
16
17
|
Requires-Dist: asgiref (>=3.6.0,<4.0.0)
|
|
17
18
|
Requires-Dist: blinker (>=1.5,<2.0)
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
from .onestep import step
|
|
2
|
+
from .cron import Cron
|
|
2
3
|
from .retry import (
|
|
3
4
|
BaseRetry, BaseErrorCallback, NackErrorCallBack,
|
|
4
5
|
NeverRetry, AlwaysRetry, TimesRetry, RetryIfException, AdvancedRetry
|
|
@@ -11,6 +12,7 @@ from .middleware import (
|
|
|
11
12
|
BaseMiddleware, BaseConfigMiddleware,
|
|
12
13
|
NacosPublishConfigMiddleware, NacosConsumeConfigMiddleware,
|
|
13
14
|
RedisPublishConfigMiddleware, RedisConsumeConfigMiddleware,
|
|
15
|
+
UniqueMiddleware, MemoryUniqueMiddleware,
|
|
14
16
|
)
|
|
15
17
|
from .exception import (
|
|
16
18
|
StopMiddleware, DropMessage,
|
|
@@ -18,7 +20,7 @@ from .exception import (
|
|
|
18
20
|
)
|
|
19
21
|
|
|
20
22
|
__all__ = [
|
|
21
|
-
'step',
|
|
23
|
+
'step', 'Cron',
|
|
22
24
|
|
|
23
25
|
# broker
|
|
24
26
|
'BaseBroker',
|
|
@@ -48,6 +50,8 @@ __all__ = [
|
|
|
48
50
|
'NacosConsumeConfigMiddleware',
|
|
49
51
|
'RedisPublishConfigMiddleware',
|
|
50
52
|
'RedisConsumeConfigMiddleware',
|
|
53
|
+
'UniqueMiddleware',
|
|
54
|
+
'MemoryUniqueMiddleware',
|
|
51
55
|
|
|
52
56
|
# exception
|
|
53
57
|
'StopMiddleware',
|
|
@@ -59,4 +63,4 @@ __all__ = [
|
|
|
59
63
|
'__version__'
|
|
60
64
|
]
|
|
61
65
|
|
|
62
|
-
__version__ = '0.4.
|
|
66
|
+
__version__ = '0.4.4'
|
|
@@ -6,3 +6,13 @@ from .webhook import WebHookBroker
|
|
|
6
6
|
from .rabbitmq import RabbitMQBroker
|
|
7
7
|
from .redis import RedisStreamBroker, RedisPubSubBroker
|
|
8
8
|
from .cron import CronBroker
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"BaseBroker", "BaseConsumer",
|
|
13
|
+
"MemoryBroker", "MemoryConsumer",
|
|
14
|
+
"WebHookBroker",
|
|
15
|
+
"RabbitMQBroker",
|
|
16
|
+
"RedisStreamBroker", "RedisPubSubBroker",
|
|
17
|
+
"CronBroker"
|
|
18
|
+
]
|
|
@@ -119,9 +119,17 @@ class BaseConsumer:
|
|
|
119
119
|
|
|
120
120
|
def __next__(self):
|
|
121
121
|
try:
|
|
122
|
-
|
|
122
|
+
q = self.queue
|
|
123
|
+
if q is None:
|
|
124
|
+
return None
|
|
125
|
+
timeout_ms = self.timeout
|
|
126
|
+
if isinstance(timeout_ms, (int, float)) and timeout_ms > 0:
|
|
127
|
+
data = q.get(timeout=timeout_ms / 1000.0)
|
|
128
|
+
else:
|
|
129
|
+
# 当超时为0、负数或非数字时,使用非阻塞获取以避免ValueError
|
|
130
|
+
data = q.get_nowait()
|
|
123
131
|
return self.message_cls.from_broker(broker_message=data)
|
|
124
|
-
except Empty:
|
|
132
|
+
except (Empty, ValueError):
|
|
125
133
|
return None
|
|
126
134
|
|
|
127
135
|
def __iter__(self):
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
"""
|
|
2
|
-
使用CRON表达式触发任务执行
|
|
2
|
+
使用 CRON 表达式触发任务执行
|
|
3
|
+
|
|
4
|
+
支持人性化 DSL 与宏:
|
|
5
|
+
- DSL:Cron.every(minutes=5)、Cron.daily(at="09:00")、Cron.weekly(on=["mon","fri"], at="10:30") 等
|
|
6
|
+
- 宏:@hourly/@daily/@weekly/@monthly/@yearly 以及扩展 @workdays/@weekends/@every 5m/2h/3d/1mo
|
|
3
7
|
"""
|
|
4
8
|
import logging
|
|
5
9
|
import threading
|
|
@@ -7,6 +11,7 @@ from datetime import datetime
|
|
|
7
11
|
from typing import Any
|
|
8
12
|
|
|
9
13
|
from croniter import croniter
|
|
14
|
+
from ..cron import resolve_cron
|
|
10
15
|
|
|
11
16
|
from .memory import MemoryBroker, MemoryConsumer
|
|
12
17
|
|
|
@@ -14,14 +19,16 @@ logger = logging.getLogger(__name__)
|
|
|
14
19
|
|
|
15
20
|
|
|
16
21
|
class CronBroker(MemoryBroker):
|
|
17
|
-
_thread = None
|
|
18
22
|
|
|
19
|
-
def __init__(self, cron, name=None, middlewares=None, body: Any = None, *args, **kwargs):
|
|
23
|
+
def __init__(self, cron, name=None, middlewares=None, body: Any = None, start_time=None, *args, **kwargs):
|
|
20
24
|
super().__init__(name=name, middlewares=middlewares, *args, **kwargs)
|
|
21
|
-
|
|
22
|
-
self.
|
|
25
|
+
# 支持 DSL/宏/原始字符串:统一解析为标准表达式
|
|
26
|
+
self.cron = resolve_cron(cron)
|
|
27
|
+
self.start_time = start_time or datetime.now()
|
|
28
|
+
self.itr = croniter(self.cron, self.start_time)
|
|
23
29
|
self.next_fire_time = self.itr.get_next(datetime)
|
|
24
30
|
self.body = body
|
|
31
|
+
self._thread = None
|
|
25
32
|
|
|
26
33
|
def _scheduler(self):
|
|
27
34
|
if self.next_fire_time <= datetime.now():
|
|
@@ -36,7 +43,8 @@ class CronBroker(MemoryBroker):
|
|
|
36
43
|
return CronConsumer(self, *args, **kwargs)
|
|
37
44
|
|
|
38
45
|
def shutdown(self):
|
|
39
|
-
self._thread
|
|
46
|
+
if self._thread:
|
|
47
|
+
self._thread.cancel()
|
|
40
48
|
|
|
41
49
|
|
|
42
50
|
class CronConsumer(MemoryConsumer):
|
|
@@ -26,7 +26,10 @@ class MemoryMessage(Message):
|
|
|
26
26
|
if "body" not in message:
|
|
27
27
|
# 来自 外部的消息 可能没有 body, 故直接认为都是 message.body
|
|
28
28
|
message = {"body": message}
|
|
29
|
-
|
|
29
|
+
extra_val = message.get("extra")
|
|
30
|
+
if not isinstance(extra_val, dict):
|
|
31
|
+
extra_val = None
|
|
32
|
+
return cls(body=message.get("body"), extra=extra_val, message=broker_message)
|
|
30
33
|
|
|
31
34
|
|
|
32
35
|
class MemoryBroker(BaseBroker):
|
|
@@ -3,17 +3,21 @@ import threading
|
|
|
3
3
|
from queue import Queue
|
|
4
4
|
from typing import Optional, Dict, Any
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
try:
|
|
7
|
+
import amqpstorm
|
|
8
|
+
from use_rabbitmq import useRabbitMQ as RabbitMQStore
|
|
9
|
+
except ImportError:
|
|
10
|
+
amqpstorm = None
|
|
11
|
+
RabbitMQStore = None
|
|
7
12
|
|
|
8
13
|
from .base import BaseBroker, BaseConsumer
|
|
9
|
-
from use_rabbitmq import useRabbitMQ as RabbitMQStore
|
|
10
14
|
from ..message import Message
|
|
11
15
|
|
|
12
16
|
|
|
13
17
|
class _RabbitMQMessage(Message):
|
|
14
18
|
|
|
15
19
|
@classmethod
|
|
16
|
-
def from_broker(cls, broker_message
|
|
20
|
+
def from_broker(cls, broker_message):
|
|
17
21
|
try:
|
|
18
22
|
message = json.loads(broker_message.body)
|
|
19
23
|
except json.JSONDecodeError:
|
|
@@ -60,6 +64,9 @@ class RabbitMQBroker(BaseBroker):
|
|
|
60
64
|
threads (list): List of threads.
|
|
61
65
|
"""
|
|
62
66
|
|
|
67
|
+
if RabbitMQStore is None:
|
|
68
|
+
raise ImportError("RabbitMQ dependencies not installed. Please install 'use-rabbitmq' package.")
|
|
69
|
+
|
|
63
70
|
super().__init__(*args, **kwargs)
|
|
64
71
|
self.queue_name = queue_name
|
|
65
72
|
self.queue = Queue()
|
|
@@ -71,7 +78,7 @@ class RabbitMQBroker(BaseBroker):
|
|
|
71
78
|
self.threads = []
|
|
72
79
|
|
|
73
80
|
def _consume(self, *args, **kwargs):
|
|
74
|
-
def callback(message
|
|
81
|
+
def callback(message):
|
|
75
82
|
self.queue.put(message)
|
|
76
83
|
|
|
77
84
|
prefetch = kwargs.pop("prefetch", self.prefetch)
|
|
@@ -79,7 +86,7 @@ class RabbitMQBroker(BaseBroker):
|
|
|
79
86
|
|
|
80
87
|
def consume(self, *args, **kwargs):
|
|
81
88
|
daemon = kwargs.pop('daemon', True)
|
|
82
|
-
thread = threading.Thread(target=self._consume,
|
|
89
|
+
thread = threading.Thread(target=self._consume, args=args, kwargs=kwargs)
|
|
83
90
|
thread.daemon = daemon
|
|
84
91
|
thread.start()
|
|
85
92
|
self.threads.append(thread)
|
|
@@ -91,11 +98,21 @@ class RabbitMQBroker(BaseBroker):
|
|
|
91
98
|
|
|
92
99
|
def confirm(self, message: Message):
|
|
93
100
|
"""确认消息"""
|
|
94
|
-
message
|
|
101
|
+
broker_msg = getattr(message, "message", None)
|
|
102
|
+
if broker_msg is not None and hasattr(broker_msg, "ack"):
|
|
103
|
+
broker_msg.ack()
|
|
104
|
+
else:
|
|
105
|
+
# 无可确认的原始消息,忽略确认
|
|
106
|
+
return
|
|
95
107
|
|
|
96
108
|
def reject(self, message: Message):
|
|
97
109
|
"""拒绝消息"""
|
|
98
|
-
message
|
|
110
|
+
broker_msg = getattr(message, "message", None)
|
|
111
|
+
if broker_msg is not None and hasattr(broker_msg, "reject"):
|
|
112
|
+
broker_msg.reject(requeue=False)
|
|
113
|
+
else:
|
|
114
|
+
# 无法拒绝(无原始消息对象),忽略
|
|
115
|
+
return
|
|
99
116
|
|
|
100
117
|
def requeue(self, message: Message, is_source=False):
|
|
101
118
|
"""
|
|
@@ -104,11 +121,20 @@ class RabbitMQBroker(BaseBroker):
|
|
|
104
121
|
:param message: 消息
|
|
105
122
|
:param is_source: 是否是原始消息,True: 使用原始消息重入当前队列,False: 使用消息的最新数据重入当前队列
|
|
106
123
|
"""
|
|
107
|
-
|
|
108
|
-
|
|
124
|
+
broker_msg = getattr(message, "message", None)
|
|
125
|
+
if broker_msg is not None and hasattr(broker_msg, "reject"):
|
|
126
|
+
if is_source:
|
|
127
|
+
broker_msg.reject(requeue=True)
|
|
128
|
+
else:
|
|
129
|
+
broker_msg.reject(requeue=False)
|
|
130
|
+
self.send(message)
|
|
109
131
|
else:
|
|
110
|
-
|
|
111
|
-
|
|
132
|
+
# 没有原始消息控制能力,回退为直接发送当前消息状态
|
|
133
|
+
if is_source and broker_msg is not None and hasattr(broker_msg, "body"):
|
|
134
|
+
# 尝试使用原始消息体重入队列
|
|
135
|
+
self.client.send(self.queue_name, broker_msg.body)
|
|
136
|
+
else:
|
|
137
|
+
self.send(message)
|
|
112
138
|
|
|
113
139
|
def shutdown(self):
|
|
114
140
|
self.client.shutdown()
|
|
@@ -69,7 +69,7 @@ class RedisStreamBroker(BaseBroker):
|
|
|
69
69
|
|
|
70
70
|
def consume(self, *args, **kwargs):
|
|
71
71
|
daemon = kwargs.pop('daemon', True)
|
|
72
|
-
thread = threading.Thread(target=self._consume,
|
|
72
|
+
thread = threading.Thread(target=self._consume, args=args, kwargs=kwargs)
|
|
73
73
|
thread.daemon = daemon
|
|
74
74
|
thread.start()
|
|
75
75
|
self.threads.append(thread)
|
|
@@ -85,10 +85,14 @@ class RedisStreamBroker(BaseBroker):
|
|
|
85
85
|
publish = send
|
|
86
86
|
|
|
87
87
|
def confirm(self, message: Message):
|
|
88
|
-
|
|
88
|
+
broker_msg = getattr(message, "message", None)
|
|
89
|
+
if broker_msg is not None:
|
|
90
|
+
self.client.ack(broker_msg)
|
|
89
91
|
|
|
90
92
|
def reject(self, message: Message):
|
|
91
|
-
|
|
93
|
+
broker_msg = getattr(message, "message", None)
|
|
94
|
+
if broker_msg is not None:
|
|
95
|
+
self.client.reject(broker_msg)
|
|
92
96
|
|
|
93
97
|
def requeue(self, message: Message, is_source=False):
|
|
94
98
|
"""
|
|
@@ -99,8 +103,9 @@ class RedisStreamBroker(BaseBroker):
|
|
|
99
103
|
"""
|
|
100
104
|
self.reject(message)
|
|
101
105
|
|
|
102
|
-
|
|
103
|
-
|
|
106
|
+
broker_msg = getattr(message, "message", None)
|
|
107
|
+
if is_source and broker_msg is not None and hasattr(broker_msg, "body"):
|
|
108
|
+
self.client.send(broker_msg.body)
|
|
104
109
|
else:
|
|
105
110
|
self.send(message)
|
|
106
111
|
|
|
@@ -57,13 +57,15 @@ class WebHookBroker(MemoryBroker):
|
|
|
57
57
|
WebHookServer
|
|
58
58
|
)
|
|
59
59
|
self._servers[(self.host, self.port)] = hs
|
|
60
|
+
# 只有在创建新服务器时才启动线程
|
|
61
|
+
thread = threading.Thread(target=hs.serve_forever)
|
|
62
|
+
thread.daemon = True
|
|
63
|
+
thread.start()
|
|
64
|
+
self.threads.append(thread)
|
|
60
65
|
else:
|
|
61
66
|
hs = self._servers[(self.host, self.port)]
|
|
62
67
|
|
|
63
68
|
WebHookServer.servers[(self.host, self.port)].append(Server(self.path, self.queue))
|
|
64
|
-
thread = threading.Thread(target=hs.serve_forever)
|
|
65
|
-
thread.start()
|
|
66
|
-
self.threads.append(thread)
|
|
67
69
|
|
|
68
70
|
def consume(self, *args, **kwargs):
|
|
69
71
|
self._create_server()
|
|
@@ -71,7 +73,7 @@ class WebHookBroker(MemoryBroker):
|
|
|
71
73
|
return WebHookConsumer(self, *args, **kwargs)
|
|
72
74
|
|
|
73
75
|
def shutdown(self):
|
|
74
|
-
hs = self._servers
|
|
76
|
+
hs = self._servers.get((self.host, self.port))
|
|
75
77
|
if hs:
|
|
76
78
|
hs.shutdown()
|
|
77
79
|
for thread in self.threads:
|
|
@@ -8,11 +8,17 @@ LOGFORMAT = "[%(asctime)s] [%(name)s] [%(levelname)s] %(message)s"
|
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
def setup_logging():
|
|
11
|
-
|
|
11
|
+
# 设置全局日志级别为INFO,避免第三方库的DEBUG日志输出
|
|
12
|
+
logging.basicConfig(level=logging.INFO, format=LOGFORMAT, stream=sys.stdout)
|
|
12
13
|
|
|
13
14
|
# exclude amqpstorm logs
|
|
14
15
|
logging.getLogger("amqpstorm").setLevel(logging.CRITICAL)
|
|
15
|
-
|
|
16
|
+
|
|
17
|
+
# 获取onestep的logger并设置为DEBUG级别以便调试
|
|
18
|
+
onestep_logger = logging.getLogger("onestep")
|
|
19
|
+
onestep_logger.setLevel(logging.DEBUG)
|
|
20
|
+
|
|
21
|
+
return onestep_logger
|
|
16
22
|
|
|
17
23
|
|
|
18
24
|
logger = setup_logging()
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
Cron DSL/Builder 与宏解析
|
|
4
|
+
|
|
5
|
+
提供人性化的 API 来生成 cron 表达式,并支持常用别名/宏:
|
|
6
|
+
- Cron.every(minutes=5) / Cron.every(seconds=10) / Cron.every(hours=2) / Cron.every(days=3) / Cron.every(months=1)
|
|
7
|
+
- Cron.daily(at="09:00")
|
|
8
|
+
- Cron.weekly(on="mon" or ["mon","fri"], at="10:30")
|
|
9
|
+
- Cron.monthly(on_day=1, at="00:00")
|
|
10
|
+
- Cron.yearly(on="01-01", at="00:00")
|
|
11
|
+
- 宏别名:@hourly/@daily/@weekly/@monthly/@yearly
|
|
12
|
+
- 扩展宏:@workdays(1-5)/@weekends(0,6)/@every <n><unit>(unit: s/m/h/d/mo)
|
|
13
|
+
|
|
14
|
+
注意:
|
|
15
|
+
- 标准 5 字段:minute hour day month day_of_week
|
|
16
|
+
- 当包含 seconds(如 `at` 使用 HH:MM:SS 或 `every(seconds=...)`)时输出 6 字段:second minute hour day month day_of_week
|
|
17
|
+
- `@every <n>d` 等按“日/月字段取模”的语义生成表达式,并非滚动间隔。
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from typing import List, Optional, Tuple, Union
|
|
22
|
+
|
|
23
|
+
DOW_MAP = {
|
|
24
|
+
"sun": 0, "mon": 1, "tue": 2, "wed": 3, "thu": 4, "fri": 5, "sat": 6,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
ALIAS_MAP = {
|
|
28
|
+
"@hourly": "0 * * * *",
|
|
29
|
+
"@daily": "0 0 * * *",
|
|
30
|
+
"@weekly": "0 0 * * 0",
|
|
31
|
+
"@monthly": "0 0 1 * *",
|
|
32
|
+
"@yearly": "0 0 1 1 *",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _parse_at(at: Optional[str]) -> Tuple[Optional[int], int, int]:
|
|
37
|
+
"""解析 at 参数,支持 HH:MM 或 HH:MM:SS。
|
|
38
|
+
返回 (second, minute, hour)。如果不传 at,默认 minute=0, hour=0。
|
|
39
|
+
"""
|
|
40
|
+
if not at:
|
|
41
|
+
return None, 0, 0
|
|
42
|
+
parts = at.strip().split(":")
|
|
43
|
+
if len(parts) == 2:
|
|
44
|
+
h, m = parts
|
|
45
|
+
return None, int(m), int(h)
|
|
46
|
+
elif len(parts) == 3:
|
|
47
|
+
h, m, s = parts
|
|
48
|
+
return int(s), int(m), int(h)
|
|
49
|
+
raise ValueError(f"Invalid time format for at='{at}', expected HH:MM or HH:MM:SS")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _to_dow_expr(on: Union[str, int, List[Union[str, int]]]) -> str:
|
|
53
|
+
"""将星期输入转换为 day_of_week 字段表达式。
|
|
54
|
+
支持:字符串缩写(mon...sun)、数字 0-6、列表。
|
|
55
|
+
"""
|
|
56
|
+
def _to_num(v: Union[str, int]) -> int:
|
|
57
|
+
if isinstance(v, int):
|
|
58
|
+
if v < 0 or v > 6:
|
|
59
|
+
raise ValueError("day_of_week must be in 0-6")
|
|
60
|
+
return v
|
|
61
|
+
v = str(v).lower()
|
|
62
|
+
if v.isdigit():
|
|
63
|
+
num = int(v)
|
|
64
|
+
if num < 0 or num > 6:
|
|
65
|
+
raise ValueError("day_of_week must be in 0-6")
|
|
66
|
+
return num
|
|
67
|
+
if v not in DOW_MAP:
|
|
68
|
+
raise ValueError(f"Unknown day_of_week '{v}'")
|
|
69
|
+
return DOW_MAP[v]
|
|
70
|
+
|
|
71
|
+
if isinstance(on, (list, tuple, set)):
|
|
72
|
+
vals = sorted({_to_num(v) for v in on})
|
|
73
|
+
return ",".join(str(x) for x in vals)
|
|
74
|
+
else:
|
|
75
|
+
return str(_to_num(on))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _join_fields(second: Optional[int], minute: Union[int, str], hour: Union[int, str], day: Union[int, str], month: Union[int, str], dow: Union[int, str]) -> str:
|
|
79
|
+
"""组合 5/6 字段。若 second 为 None,输出 5 字段,否则输出 6 字段(秒在最后)。"""
|
|
80
|
+
if second is None:
|
|
81
|
+
return f"{minute} {hour} {day} {month} {dow}"
|
|
82
|
+
return f"{minute} {hour} {day} {month} {dow} {second}"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class Cron:
|
|
86
|
+
@staticmethod
|
|
87
|
+
def every(*, seconds: Optional[int] = None, minutes: Optional[int] = None, hours: Optional[int] = None, days: Optional[int] = None, months: Optional[int] = None) -> str:
|
|
88
|
+
"""每隔指定单位执行。
|
|
89
|
+
只能指定一个非 None 的单位。
|
|
90
|
+
- seconds: 6 字段:*/s * * * * *
|
|
91
|
+
- minutes: 5 字段:*/m * * * *
|
|
92
|
+
- hours: 5 字段:0 */h * * *
|
|
93
|
+
- days: 5 字段:0 0 */d * *
|
|
94
|
+
- months: 5 字段:0 0 1 */mo *
|
|
95
|
+
"""
|
|
96
|
+
units = [("seconds", seconds), ("minutes", minutes), ("hours", hours), ("days", days), ("months", months)]
|
|
97
|
+
chosen = [(k, v) for k, v in units if v is not None]
|
|
98
|
+
if len(chosen) != 1:
|
|
99
|
+
raise ValueError("Cron.every() must specify exactly one unit")
|
|
100
|
+
k, v = chosen[0]
|
|
101
|
+
if not isinstance(v, int) or v <= 0:
|
|
102
|
+
raise ValueError("Interval must be a positive integer")
|
|
103
|
+
if k == "seconds":
|
|
104
|
+
return f"* * * * * */{v}"
|
|
105
|
+
if k == "minutes":
|
|
106
|
+
return "*/%d * * * *" % v
|
|
107
|
+
if k == "hours":
|
|
108
|
+
return "0 */%d * * *" % v
|
|
109
|
+
if k == "days":
|
|
110
|
+
return "0 0 */%d * *" % v
|
|
111
|
+
if k == "months":
|
|
112
|
+
return "0 0 1 */%d *" % v
|
|
113
|
+
raise RuntimeError("unreachable")
|
|
114
|
+
|
|
115
|
+
@staticmethod
|
|
116
|
+
def daily(*, at: Optional[str] = None) -> str:
|
|
117
|
+
s, m, h = _parse_at(at)
|
|
118
|
+
day, month, dow = "*", "*", "*"
|
|
119
|
+
return _join_fields(s, m, h, day, month, dow)
|
|
120
|
+
|
|
121
|
+
@staticmethod
|
|
122
|
+
def weekly(*, on: Union[str, int, List[Union[str, int]]] = "mon", at: Optional[str] = None) -> str:
|
|
123
|
+
s, m, h = _parse_at(at)
|
|
124
|
+
dow = _to_dow_expr(on)
|
|
125
|
+
day, month = "*", "*"
|
|
126
|
+
return _join_fields(s, m, h, day, month, dow)
|
|
127
|
+
|
|
128
|
+
@staticmethod
|
|
129
|
+
def monthly(*, on_day: Union[int, List[int]] = 1, at: Optional[str] = None) -> str:
|
|
130
|
+
s, m, h = _parse_at(at)
|
|
131
|
+
month, dow = "*", "*"
|
|
132
|
+
def _to_day(v: Union[int, str]) -> int:
|
|
133
|
+
iv = int(v)
|
|
134
|
+
if iv < 1 or iv > 31:
|
|
135
|
+
raise ValueError("day_of_month must be in 1-31")
|
|
136
|
+
return iv
|
|
137
|
+
if isinstance(on_day, (list, tuple, set)):
|
|
138
|
+
day = ",".join(str(_to_day(v)) for v in sorted({int(x) for x in on_day}))
|
|
139
|
+
else:
|
|
140
|
+
day = str(_to_day(on_day))
|
|
141
|
+
return _join_fields(s, m, h, day, month, dow)
|
|
142
|
+
|
|
143
|
+
@staticmethod
|
|
144
|
+
def yearly(*, on: str = "01-01", at: Optional[str] = None) -> str:
|
|
145
|
+
s, m, h = _parse_at(at)
|
|
146
|
+
dow = "*"
|
|
147
|
+
try:
|
|
148
|
+
mon_str, day_str = on.split("-")
|
|
149
|
+
month = str(int(mon_str))
|
|
150
|
+
day = str(int(day_str))
|
|
151
|
+
except Exception:
|
|
152
|
+
raise ValueError("yearly(on=...) expects 'MM-DD', e.g., '01-01'")
|
|
153
|
+
return _join_fields(s, m, h, day, month, dow)
|
|
154
|
+
|
|
155
|
+
@staticmethod
|
|
156
|
+
def alias(name: str) -> str:
|
|
157
|
+
"""返回内置宏别名对应的表达式(如果已知)。未知别名原样返回,以便 croniter 自行处理。"""
|
|
158
|
+
return ALIAS_MAP.get(name.strip().lower(), name)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def resolve_cron(cron_like: str) -> str:
|
|
162
|
+
"""将类似 cron 的输入(字符串/宏)解析为标准 cron 表达式。
|
|
163
|
+
- 内置关键字直接映射或保留(croniter 也支持);
|
|
164
|
+
- 扩展宏:@workdays/@weekends/@every <n><unit>(unit: s/m/h/d/mo)。
|
|
165
|
+
"""
|
|
166
|
+
s = str(cron_like).strip()
|
|
167
|
+
if not s.startswith("@"):
|
|
168
|
+
return s
|
|
169
|
+
lower = s.lower()
|
|
170
|
+
# 内置别名
|
|
171
|
+
if lower in ALIAS_MAP:
|
|
172
|
+
return ALIAS_MAP[lower]
|
|
173
|
+
# 扩展:工作日/周末(默认 00:00 执行)
|
|
174
|
+
if lower == "@workdays":
|
|
175
|
+
return "0 0 * * 1-5"
|
|
176
|
+
if lower == "@weekends":
|
|
177
|
+
return "0 0 * * 0,6"
|
|
178
|
+
# 扩展:@every <n><unit>
|
|
179
|
+
if lower.startswith("@every"):
|
|
180
|
+
tail = lower.replace("@every", "", 1).strip()
|
|
181
|
+
if not tail:
|
|
182
|
+
raise ValueError("@every requires an interval, e.g., '@every 5m'")
|
|
183
|
+
# 支持类似 "5m" / "10 h" / "2d" / "3mo" / "30s"
|
|
184
|
+
tail = tail.replace(" ", "")
|
|
185
|
+
# 处理 'mo' 与单位优先级
|
|
186
|
+
if tail.endswith("mo"):
|
|
187
|
+
num = int(tail[:-2])
|
|
188
|
+
if num <= 0:
|
|
189
|
+
raise ValueError("@every months must be > 0")
|
|
190
|
+
return "0 0 1 */%d *" % num
|
|
191
|
+
unit = tail[-1]
|
|
192
|
+
try:
|
|
193
|
+
num = int(tail[:-1])
|
|
194
|
+
except Exception:
|
|
195
|
+
raise ValueError("@every expects '<n><unit>', units: s/m/h/d/mo")
|
|
196
|
+
if num <= 0:
|
|
197
|
+
raise ValueError("@every interval must be > 0")
|
|
198
|
+
if unit == "s":
|
|
199
|
+
return "* * * * * */%d" % num
|
|
200
|
+
if unit == "m":
|
|
201
|
+
return "*/%d * * * *" % num
|
|
202
|
+
if unit == "h":
|
|
203
|
+
return "0 */%d * * *" % num
|
|
204
|
+
if unit == "d":
|
|
205
|
+
return "0 0 */%d * *" % num
|
|
206
|
+
if unit == "w":
|
|
207
|
+
# Cron 不支持“每 N 周”,仅建议直接使用 weekly(on=..., at=...)
|
|
208
|
+
raise ValueError("@every <n>w is not supported; use weekly(on=..., at=...) instead")
|
|
209
|
+
raise ValueError("Unknown unit for @every; use s/m/h/d/mo")
|
|
210
|
+
# 未知宏直接返回,交由 croniter 处理(可能抛错)
|
|
211
|
+
return s
|
|
@@ -5,20 +5,21 @@ import uuid
|
|
|
5
5
|
from dataclasses import dataclass
|
|
6
6
|
from traceback import TracebackException
|
|
7
7
|
from typing import Optional, Any, Union
|
|
8
|
+
from types import TracebackType
|
|
8
9
|
|
|
9
10
|
from onestep._utils import catch_error
|
|
10
11
|
|
|
11
12
|
|
|
12
13
|
class MessageTracebackException(TracebackException):
|
|
13
|
-
def __init__(self, exc_type, exc_value, exc_traceback, **kwargs):
|
|
14
|
+
def __init__(self, exc_type: type[BaseException], exc_value: BaseException, exc_traceback: Optional[TracebackType], **kwargs):
|
|
14
15
|
super().__init__(exc_type, exc_value, exc_traceback, **kwargs)
|
|
15
16
|
self.exc_value = exc_value
|
|
16
17
|
|
|
17
18
|
|
|
18
19
|
@dataclass
|
|
19
20
|
class Extra:
|
|
20
|
-
task_id: str = None
|
|
21
|
-
publish_time: float = None
|
|
21
|
+
task_id: Optional[str] = None
|
|
22
|
+
publish_time: Optional[float] = None
|
|
22
23
|
failure_count: int = 0
|
|
23
24
|
|
|
24
25
|
def __post_init__(self):
|
|
@@ -70,7 +71,11 @@ class Message:
|
|
|
70
71
|
|
|
71
72
|
def set_exception(self):
|
|
72
73
|
"""设置异常信息,会自动获取"""
|
|
73
|
-
|
|
74
|
+
exc_type, exc_value, exc_tb = sys.exc_info()
|
|
75
|
+
if exc_type is None or exc_value is None:
|
|
76
|
+
exc_type = Exception
|
|
77
|
+
exc_value = Exception("No exception info")
|
|
78
|
+
self._exception = MessageTracebackException(exc_type, exc_value, exc_tb)
|
|
74
79
|
self.failure_count = self.failure_count + 1
|
|
75
80
|
|
|
76
81
|
@property
|
|
@@ -112,12 +117,16 @@ class Message:
|
|
|
112
117
|
@catch_error()
|
|
113
118
|
def confirm(self):
|
|
114
119
|
"""确认消息"""
|
|
115
|
-
|
|
120
|
+
broker = getattr(self, 'broker', None)
|
|
121
|
+
if broker and hasattr(broker, 'confirm'):
|
|
122
|
+
broker.confirm(self)
|
|
116
123
|
|
|
117
124
|
@catch_error()
|
|
118
125
|
def reject(self):
|
|
119
126
|
"""拒绝消息"""
|
|
120
|
-
|
|
127
|
+
broker = getattr(self, 'broker', None)
|
|
128
|
+
if broker and hasattr(broker, 'reject'):
|
|
129
|
+
broker.reject(self)
|
|
121
130
|
|
|
122
131
|
@catch_error()
|
|
123
132
|
def requeue(self, is_source=False):
|
|
@@ -126,7 +135,9 @@ class Message:
|
|
|
126
135
|
|
|
127
136
|
:param is_source: 是否是源消息,True: 使用消息的最新数据重入当前队列,False: 使用消息的最新数据重入当前队列
|
|
128
137
|
"""
|
|
129
|
-
|
|
138
|
+
broker = getattr(self, 'broker', None)
|
|
139
|
+
if broker and hasattr(broker, 'requeue'):
|
|
140
|
+
broker.requeue(self, is_source=is_source)
|
|
130
141
|
|
|
131
142
|
def __getattr__(self, item):
|
|
132
143
|
return None
|
|
@@ -28,7 +28,7 @@ class BaseOneStep:
|
|
|
28
28
|
|
|
29
29
|
def __init__(self, fn,
|
|
30
30
|
group: str = "OneStep",
|
|
31
|
-
name: str = None,
|
|
31
|
+
name: Optional[str] = None,
|
|
32
32
|
from_broker: Union[BaseBroker, List[BaseBroker], None] = None,
|
|
33
33
|
to_broker: Union[BaseBroker, List[BaseBroker], None] = None,
|
|
34
34
|
workers: Optional[int] = None,
|
|
@@ -42,7 +42,7 @@ class BaseOneStep:
|
|
|
42
42
|
self.workers = workers or DEFAULT_WORKERS
|
|
43
43
|
self.worker_class = worker_class or DEFAULT_WORKER_CLASS
|
|
44
44
|
if self.workers > MAX_WORKERS:
|
|
45
|
-
logger.warning(f"workers[{self.workers}]
|
|
45
|
+
logger.warning(f"workers[{self.workers}] greater than {MAX_WORKERS}")
|
|
46
46
|
self.workers = MAX_WORKERS
|
|
47
47
|
self.middlewares = middlewares or []
|
|
48
48
|
|
|
@@ -220,7 +220,7 @@ class step:
|
|
|
220
220
|
def __init__(self,
|
|
221
221
|
*,
|
|
222
222
|
group: str = "OneStep",
|
|
223
|
-
name: str = None,
|
|
223
|
+
name: Optional[str] = None,
|
|
224
224
|
from_broker: Union[BaseBroker, List[BaseBroker], None] = None,
|
|
225
225
|
to_broker: Union[BaseBroker, List[BaseBroker], None] = None,
|
|
226
226
|
workers: Optional[int] = None,
|
|
@@ -169,6 +169,8 @@ class ThreadWorker(BaseWorker):
|
|
|
169
169
|
|
|
170
170
|
|
|
171
171
|
class ThreadPoolWorker(BaseWorker):
|
|
172
|
+
broker_exit: Dict[BaseBroker, bool] = {}
|
|
173
|
+
broker_exit_lock = threading.Lock()
|
|
172
174
|
|
|
173
175
|
def __init__(self, onestep, broker: BaseBroker, workers=None, *args, **kwargs):
|
|
174
176
|
super().__init__(onestep, broker, *args, **kwargs)
|
|
@@ -191,7 +193,8 @@ class ThreadPoolWorker(BaseWorker):
|
|
|
191
193
|
self.shutdown()
|
|
192
194
|
break
|
|
193
195
|
for message in self.receive_messages():
|
|
194
|
-
|
|
196
|
+
# 将消息处理提交到线程池中并发执行
|
|
197
|
+
self.executor.submit(self.handle_message, message)
|
|
195
198
|
|
|
196
199
|
def shutdown(self):
|
|
197
200
|
"""关闭线程池 Worker"""
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|