onestep 0.5.0__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.
onestep/cron.py ADDED
@@ -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
onestep/exception.py ADDED
@@ -0,0 +1,35 @@
1
+ class StopMiddleware(Exception):
2
+ ...
3
+
4
+
5
+ class RetryException(Exception):
6
+ def __init__(self, message=None, times=None, **kwargs):
7
+ self.message = message
8
+ self.times = times
9
+ self.kwargs = kwargs
10
+
11
+
12
+ class RetryInQueue(RetryException):
13
+ """消息重试-通过重试队列
14
+
15
+ 抛出此异常,消息将被重新放入队列,等待下次消费。
16
+ """
17
+
18
+
19
+ class RetryInLocal(RetryException):
20
+ """消息重试-本地
21
+
22
+ 不经过队列,直接在本地重试,直到达到重试次数。
23
+ """
24
+
25
+
26
+ class DropMessage(Exception):
27
+ """从 Brokers 中 丢弃该消息"""
28
+
29
+
30
+ class RejectMessage(DropMessage):
31
+ """从 Brokers 中 丢弃该消息"""
32
+
33
+
34
+ class RequeueMessage(Exception):
35
+ """重新入队该消息"""
onestep/message.py ADDED
@@ -0,0 +1,169 @@
1
+ import json
2
+ import sys
3
+ import time
4
+ import uuid
5
+ from dataclasses import dataclass
6
+ from traceback import TracebackException
7
+ from typing import Optional, Any, Union, Type
8
+ from types import TracebackType
9
+
10
+ from onestep._utils import catch_error
11
+
12
+
13
+ class MessageTracebackException(TracebackException):
14
+ def __init__(self, exc_type: Type[BaseException], exc_value: BaseException, exc_traceback: Optional[TracebackType], **kwargs):
15
+ super().__init__(exc_type, exc_value, exc_traceback, **kwargs)
16
+ self.exc_value = exc_value
17
+
18
+
19
+ @dataclass
20
+ class Extra:
21
+ task_id: Optional[str] = None
22
+ publish_time: Optional[float] = None
23
+ failure_count: int = 0
24
+
25
+ def __post_init__(self):
26
+ self.task_id = self.task_id or str(uuid.uuid4())
27
+ self.publish_time = self.publish_time or round(time.time(), 3)
28
+
29
+ def to_dict(self):
30
+ return {
31
+ 'task_id': self.task_id,
32
+ 'publish_time': self.publish_time,
33
+ 'failure_count': self.failure_count,
34
+ }
35
+
36
+ def __str__(self):
37
+ return str(self.to_dict())
38
+
39
+
40
+ class Message:
41
+
42
+ def __init__(
43
+ self,
44
+ body: Optional[Union[dict, Any]] = None,
45
+ extra: Optional[Union[dict, Extra]] = None,
46
+ message: Optional[Any] = None,
47
+ broker=None
48
+ ):
49
+ """ Message
50
+
51
+ :param body: 解析后的消息体
52
+ :param extra: 额外信息
53
+ :param message: 原始消息体
54
+ :param broker: 当前消息所属的 broker
55
+ """
56
+ self.body = body
57
+ self.extra = self._set_extra(extra)
58
+ self.message = message
59
+
60
+ self.broker = broker
61
+ self._exception = None
62
+
63
+ @staticmethod
64
+ def _set_extra(extra):
65
+ if isinstance(extra, Extra):
66
+ return extra
67
+ elif isinstance(extra, dict):
68
+ return Extra(**extra)
69
+ else:
70
+ return Extra()
71
+
72
+ def set_exception(self):
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)
79
+ self.failure_count = self.failure_count + 1
80
+
81
+ @property
82
+ def exception(self) -> Optional[MessageTracebackException]:
83
+ return self._exception
84
+
85
+ @property
86
+ def fail(self):
87
+ return self.exception is not None
88
+
89
+ @property
90
+ def failure_count(self):
91
+ return self.extra.failure_count
92
+
93
+ @failure_count.setter
94
+ def failure_count(self, value):
95
+ self.extra.failure_count = value
96
+
97
+ def replace(self, **kwargs):
98
+ """替换当前message的属性"""
99
+ for key, value in kwargs.items():
100
+ if not hasattr(self, key):
101
+ continue
102
+ if key == 'extra':
103
+ value = self._set_extra(value)
104
+ setattr(self, key, value)
105
+ return self
106
+
107
+ def to_dict(self, include_exception=False) -> dict:
108
+ data = {'body': self.body, 'extra': self.extra.to_dict()}
109
+ if include_exception and self.exception:
110
+ data['exception'] = "".join(self.exception.format(chain=True)) # noqa
111
+
112
+ return data
113
+
114
+ def to_json(self, include_exception=False) -> str:
115
+ return json.dumps(self.to_dict(include_exception))
116
+
117
+ @catch_error()
118
+ def confirm(self, **kwargs):
119
+ """确认消息"""
120
+ broker = getattr(self, 'broker', None)
121
+ if broker and hasattr(broker, 'confirm'):
122
+ if hasattr(broker, 'before_emit'):
123
+ broker.before_emit('confirm', message=self, step=kwargs.get('step'))
124
+ broker.confirm(self)
125
+ if hasattr(broker, 'after_emit'):
126
+ broker.after_emit('confirm', message=self, step=kwargs.get('step'))
127
+
128
+ @catch_error()
129
+ def reject(self, **kwargs):
130
+ """拒绝消息"""
131
+ broker = getattr(self, 'broker', None)
132
+ if broker and hasattr(broker, 'reject'):
133
+ if hasattr(broker, 'before_emit'):
134
+ broker.before_emit('reject', message=self, step=kwargs.get('step'))
135
+ broker.reject(self)
136
+ if hasattr(broker, 'after_emit'):
137
+ broker.after_emit('reject', message=self, step=kwargs.get('step'))
138
+
139
+ @catch_error()
140
+ def requeue(self, is_source=False, **kwargs):
141
+ """
142
+ 重发消息:先拒绝 再 重入
143
+
144
+ :param is_source: 是否是源消息,True: 使用消息的最新数据重入当前队列,False: 使用消息的最新数据重入当前队列
145
+ """
146
+ broker = getattr(self, 'broker', None)
147
+ if broker and hasattr(broker, 'requeue'):
148
+ if hasattr(broker, 'before_emit'):
149
+ broker.before_emit('requeue', message=self, step=kwargs.get('step'))
150
+ broker.requeue(self, is_source=is_source)
151
+ if hasattr(broker, 'after_emit'):
152
+ broker.after_emit('requeue', message=self, step=kwargs.get('step'))
153
+
154
+ def __getattr__(self, item):
155
+ return None
156
+
157
+ def __delattr__(self, item):
158
+ if hasattr(self, item):
159
+ setattr(self, item, None)
160
+
161
+ def __str__(self):
162
+ return str(self.to_dict())
163
+
164
+ def __repr__(self):
165
+ return f"<{self.__class__.__name__} {self.body}>"
166
+
167
+ @classmethod
168
+ def from_broker(cls, broker_message: Any):
169
+ return cls(body=broker_message, extra=None, message=broker_message, broker=None)
@@ -0,0 +1,7 @@
1
+ from .base import BaseMiddleware
2
+ from .config import (
3
+ BaseConfigMiddleware, PublishConfigMixin, ConsumeConfigMixin,
4
+ NacosConfigMixin, NacosPublishConfigMiddleware, NacosConsumeConfigMiddleware,
5
+ RedisConfigMixin, RedisPublishConfigMiddleware, RedisConsumeConfigMiddleware
6
+ )
7
+ from .unique import UniqueMiddleware, MemoryUniqueMiddleware
@@ -0,0 +1,32 @@
1
+ class DeprecationMeta(type):
2
+
3
+ def __new__(cls, name, bases, attrs):
4
+ if "before_receive" in attrs or "after_receive" in attrs:
5
+ raise DeprecationWarning(
6
+ f"`{name}` "
7
+ "The before_receive and after_receive methods are deprecated, "
8
+ "please use before_consume and after_consume instead"
9
+ )
10
+ return super().__new__(cls, name, bases, attrs)
11
+
12
+
13
+ class BaseMiddleware(metaclass=DeprecationMeta):
14
+
15
+ def before_send(self, step, message, *args, **kwargs):
16
+ """消息发送之前"""
17
+ pass
18
+
19
+ def after_send(self, step, message, *args, **kwargs):
20
+ """消息发送之后"""
21
+ pass
22
+
23
+ def before_consume(self, step, message, *args, **kwargs):
24
+ """消费消息之前"""
25
+ pass
26
+
27
+ def after_consume(self, step, message, *args, **kwargs):
28
+ """消费消息之后"""
29
+ pass
30
+
31
+ def __repr__(self):
32
+ return f"<{self.__class__.__name__}>"
@@ -0,0 +1,77 @@
1
+ from .base import BaseMiddleware
2
+
3
+
4
+ class BaseConfigMiddleware(BaseMiddleware):
5
+
6
+ def __init__(self, client, *args, **kwargs):
7
+ self.config_key = kwargs.pop('config_key', None)
8
+ self.client = client
9
+
10
+ def process(self, step, message, *args, **kwargs):
11
+ """实际获取配置的逻辑"""
12
+ config = self.get(self.config_key)
13
+ return config
14
+
15
+ def get(self, key):
16
+ raise NotImplementedError
17
+
18
+
19
+ class PublishConfigMixin:
20
+ def before_send(self, step, message, *args, **kwargs):
21
+ """消息发送之前,给消息添加配置"""
22
+ config = self.process(step, message, *args, **kwargs) # noqa
23
+ # 持久性,会跟随消息传递到其他 broker
24
+ message.extra['config'] = config
25
+
26
+
27
+ class ConsumeConfigMixin:
28
+ def before_consume(self, step, message, *args, **kwargs):
29
+ """消息消费之前,给消息附加配置"""
30
+ config = self.process(step, message, *args, **kwargs) # noqa
31
+ # 一次性,不跟随消息传递到其他 broker
32
+ message.config = config
33
+
34
+
35
+ class NacosConfigMixin:
36
+
37
+ def __init__(self, client, *args, **kwargs):
38
+ super().__init__(client, *args, **kwargs)
39
+ self.config_group = kwargs.pop("config_group", "DEFAULT_GROUP")
40
+
41
+ def get(self, key):
42
+ pass
43
+
44
+ def set(self, key, value):
45
+ pass
46
+
47
+
48
+ class NacosPublishConfigMiddleware(NacosConfigMixin, PublishConfigMixin, BaseConfigMiddleware):
49
+ """发布前附加来自 Nacos 的配置"""
50
+
51
+
52
+ class NacosConsumeConfigMiddleware(NacosConfigMixin, ConsumeConfigMixin, BaseConfigMiddleware):
53
+ """消费前附加来自 Nacos 的配置"""
54
+
55
+
56
+ class RedisConfigMixin:
57
+
58
+ def __init__(self, client, *args, **kwargs):
59
+ super().__init__(client, *args, **kwargs)
60
+ self.config_group = kwargs.pop('config_group', 'onestep:config')
61
+
62
+ def get(self, key):
63
+ value = self.client.hget(name=self.config_group, key=key) # noqa
64
+ if isinstance(value, bytes):
65
+ value = value.decode()
66
+ return value
67
+
68
+ def set(self, key, value):
69
+ return self.client.hset(name=self.config_group, key=key, value=value) # noqa
70
+
71
+
72
+ class RedisPublishConfigMiddleware(RedisConfigMixin, PublishConfigMixin, BaseConfigMiddleware):
73
+ """发布前附加来自 Redis 的配置"""
74
+
75
+
76
+ class RedisConsumeConfigMiddleware(RedisConfigMixin, ConsumeConfigMixin, BaseConfigMiddleware):
77
+ """消费前附加来自 Redis 的配置"""
@@ -0,0 +1,48 @@
1
+ import hashlib
2
+ import json
3
+
4
+ from .base import BaseMiddleware
5
+ from ..exception import DropMessage
6
+
7
+
8
+ class hash_func:
9
+ """ Default message to hash function """
10
+
11
+ def __call__(self, body):
12
+ data = json.dumps(body) if isinstance(body, dict) else str(body)
13
+ return hashlib.sha1(data.encode("utf-8")).hexdigest()
14
+
15
+
16
+ class UniqueMiddleware(BaseMiddleware):
17
+ default_hash_func = hash_func()
18
+
19
+ def before_consume(self, step, message, *args, **kwargs):
20
+ message_hash = self.default_hash_func(message.body)
21
+ if self.has_seen(message_hash):
22
+ raise DropMessage(f"Message<{message}> has been seen before")
23
+
24
+ def after_consume(self, step, message, *args, **kwargs):
25
+ if message.fail or message.body is None:
26
+ return
27
+ message_hash = self.default_hash_func(message.body)
28
+ self.mark_seen(message_hash)
29
+
30
+ def has_seen(self, message):
31
+ raise NotImplementedError
32
+
33
+ def mark_seen(self, message):
34
+ raise NotImplementedError
35
+
36
+
37
+ class MemoryUniqueMiddleware(UniqueMiddleware):
38
+ """基于内存的去重中间件"""
39
+
40
+ def __init__(self, *args, **kwargs):
41
+ super().__init__(*args, **kwargs)
42
+ self.seen = set()
43
+
44
+ def has_seen(self, hash_value):
45
+ return hash_value in self.seen
46
+
47
+ def mark_seen(self, hash_value):
48
+ self.seen.add(hash_value)