why-tools 0.2.26__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.
- venv/bin/activate_this.py +36 -0
- venv314/bin/activate_this.py +38 -0
- why_tools-0.2.26.dist-info/METADATA +558 -0
- why_tools-0.2.26.dist-info/RECORD +51 -0
- why_tools-0.2.26.dist-info/WHEEL +5 -0
- why_tools-0.2.26.dist-info/top_level.txt +3 -0
- ytools/VERSION +1 -0
- ytools/__init__.py +35 -0
- ytools/alert/__init__.py +11 -0
- ytools/alert/wechat.py +132 -0
- ytools/arq/__init__.py +11 -0
- ytools/arq/client/__init__.py +11 -0
- ytools/arq/client/agent.py +160 -0
- ytools/arq/client/base.py +129 -0
- ytools/arq/client/client.py +99 -0
- ytools/arq/setting.py +23 -0
- ytools/arq/task/__init__.py +11 -0
- ytools/arq/task/task.py +114 -0
- ytools/auto_driver/__init__.py +10 -0
- ytools/auto_driver/dp/__init__.py +10 -0
- ytools/auto_driver/dp/route.py +439 -0
- ytools/auto_driver/dp/route_by_fetch.py +403 -0
- ytools/auto_driver/dp/rpa.py +270 -0
- ytools/auto_driver/rpa_base.py +194 -0
- ytools/auto_driver/track.py +356 -0
- ytools/error.py +28 -0
- ytools/log.py +14 -0
- ytools/network/__init__.py +14 -0
- ytools/network/header.py +248 -0
- ytools/network/request.py +295 -0
- ytools/network/response.py +406 -0
- ytools/others/__init__.py +11 -0
- ytools/others/no_eol.py +213 -0
- ytools/utils/__init__.py +10 -0
- ytools/utils/cache.py +87 -0
- ytools/utils/cache_utils.py +210 -0
- ytools/utils/counter.py +76 -0
- ytools/utils/date.py +102 -0
- ytools/utils/encrypt.py +91 -0
- ytools/utils/extractors.py +438 -0
- ytools/utils/file.py +24 -0
- ytools/utils/host_ip.py +28 -0
- ytools/utils/magic.py +609 -0
- ytools/utils/magic_for_class.py +134 -0
- ytools/utils/nodes.py +158 -0
- ytools/utils/package.py +630 -0
- ytools/utils/quiter.py +71 -0
- ytools/utils/sign.py +20 -0
- ytools/utils/track.py +383 -0
- ytools/utils/variable.py +668 -0
- ytools/version.py +97 -0
ytools/__init__.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
@File : __init__.py.py
|
|
4
|
+
@Date : 2024/4/15 下午7:28
|
|
5
|
+
@Author : yintian
|
|
6
|
+
@Desc :
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from ytools.log import logger
|
|
10
|
+
from ytools.version import get_version
|
|
11
|
+
from ytools.utils.magic import empty
|
|
12
|
+
|
|
13
|
+
__version__ = get_version(path=__file__)
|
|
14
|
+
|
|
15
|
+
G = T = C = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def init_var(name: str):
|
|
19
|
+
global G, T, C
|
|
20
|
+
match name:
|
|
21
|
+
case 'G':
|
|
22
|
+
from ytools.utils.variable import VariableG
|
|
23
|
+
G = VariableG()
|
|
24
|
+
case 'T':
|
|
25
|
+
from ytools.utils.variable import VariableT
|
|
26
|
+
T = VariableT()
|
|
27
|
+
case 'C':
|
|
28
|
+
from ytools.utils.variable import VariableC
|
|
29
|
+
C = VariableC()
|
|
30
|
+
case _:
|
|
31
|
+
print(f"请输入: G/T/C")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
if __name__ == '__main__':
|
|
35
|
+
pass
|
ytools/alert/__init__.py
ADDED
ytools/alert/wechat.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
@File : wechat.py
|
|
4
|
+
@Author : yintian
|
|
5
|
+
@Date : 2026/1/8 15:00
|
|
6
|
+
@Software: PyCharm
|
|
7
|
+
@Desc :
|
|
8
|
+
"""
|
|
9
|
+
from typing import Literal
|
|
10
|
+
|
|
11
|
+
from ytools import logger
|
|
12
|
+
from ytools.utils.magic import require
|
|
13
|
+
|
|
14
|
+
msg_type_literal = Literal["text", "markdown", "image", "news", "file", "voice", "template_card", ""]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
async def async_send_wechat(content: str, to: list, at: list = None, msg_type: msg_type_literal = "text", **kwargs):
|
|
18
|
+
"""发送企业微信告警消息
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
content: 消息内容(text类型为纯文本,markdown类型为markdown格式)
|
|
22
|
+
to: 机器人webhook URL列表或key列表
|
|
23
|
+
at: at 列表
|
|
24
|
+
msg_type: 消息类型,支持 "text" 和 "markdown",默认为 "text"
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
require("httpx", action="fix")
|
|
28
|
+
import httpx # noqa
|
|
29
|
+
for webhook in to:
|
|
30
|
+
# 如果to是key,需要拼接完整URL,否则直接使用
|
|
31
|
+
if not webhook.startswith("http"):
|
|
32
|
+
# 这里假设webhook是完整URL,如果是key需要根据实际情况拼接
|
|
33
|
+
webhook_url = webhook
|
|
34
|
+
else:
|
|
35
|
+
webhook_url = webhook
|
|
36
|
+
|
|
37
|
+
# 根据消息类型构建payload
|
|
38
|
+
payload = {
|
|
39
|
+
"msgtype": msg_type,
|
|
40
|
+
msg_type: {
|
|
41
|
+
"content": content,
|
|
42
|
+
"mentioned_list": at or [],
|
|
43
|
+
**kwargs
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
try:
|
|
47
|
+
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
48
|
+
response = await client.post(webhook_url, json=payload)
|
|
49
|
+
response.raise_for_status()
|
|
50
|
+
result = response.json()
|
|
51
|
+
|
|
52
|
+
if result.get("errcode") == 0:
|
|
53
|
+
logger.info(f"[企业微信]发送成功!-> {content[:50]}...")
|
|
54
|
+
else:
|
|
55
|
+
logger.error(f"[企业微信]发送失败!错误码:{result.get('errcode')},错误信息:{result.get('errmsg')}")
|
|
56
|
+
except Exception as e:
|
|
57
|
+
logger.error(f"[企业微信]发送失败!原因:{e}")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def send_wechat(content: str, to: list, at: list = None, msg_type: msg_type_literal = "text", **kwargs):
|
|
61
|
+
"""发送企业微信告警消息
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
content: 消息内容(text类型为纯文本,markdown类型为markdown格式)
|
|
65
|
+
to: 机器人webhook URL列表或key列表
|
|
66
|
+
at: at 列表
|
|
67
|
+
msg_type: 消息类型,支持 "text" 和 "markdown",默认为 "text"
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
require("requests", action="fix")
|
|
71
|
+
import requests # noqa
|
|
72
|
+
payload = {
|
|
73
|
+
"msgtype": msg_type,
|
|
74
|
+
msg_type: {
|
|
75
|
+
"content": content,
|
|
76
|
+
"mentioned_list": at or [],
|
|
77
|
+
**kwargs
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
for webhook in to:
|
|
81
|
+
# 如果to是key,需要拼接完整URL,否则直接使用
|
|
82
|
+
if not webhook.startswith("http"):
|
|
83
|
+
# 这里假设webhook是完整URL,如果是key需要根据实际情况拼接
|
|
84
|
+
webhook_url = webhook
|
|
85
|
+
else:
|
|
86
|
+
webhook_url = webhook
|
|
87
|
+
|
|
88
|
+
# 根据消息类型构建payload
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
with requests.Session() as client:
|
|
92
|
+
response = client.post(webhook_url, json=payload, timeout=10)
|
|
93
|
+
response.raise_for_status()
|
|
94
|
+
result = response.json()
|
|
95
|
+
|
|
96
|
+
if result.get("errcode") == 0:
|
|
97
|
+
logger.info(f"[企业微信]发送成功!-> {content[:50]}...")
|
|
98
|
+
else:
|
|
99
|
+
logger.error(f"[企业微信]发送失败!错误码:{result.get('errcode')},错误信息:{result.get('errmsg')}")
|
|
100
|
+
except Exception as e:
|
|
101
|
+
logger.error(f"[企业微信]发送失败!原因:{e}")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def fmt_msg(*rows, msg: str | list = "", title: str = "微信通知", at: list = None, index=True, level: Literal["comment", "warning", "info"] = "info"):
|
|
105
|
+
msg = msg if isinstance(msg, list) else [msg]
|
|
106
|
+
msg = [f"# <font color=\"{level}\">{title}</font>\n", "\n", *msg, "\n"]
|
|
107
|
+
|
|
108
|
+
for row in rows:
|
|
109
|
+
msg.append("\n" +
|
|
110
|
+
"\n".join(
|
|
111
|
+
[f">{f'{_index + 1}. ' if index else ''}{item[0]}: <font color=\"comment\">{item[1]}</font>" for _index, item in
|
|
112
|
+
enumerate(row.items())]
|
|
113
|
+
)
|
|
114
|
+
+ "\n")
|
|
115
|
+
if at:
|
|
116
|
+
msg.append(f"\n\n {' '.join([f'<@{_at}>' for _at in at])}")
|
|
117
|
+
content = ''.join(msg)
|
|
118
|
+
return content
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def send_msg(*rows, to: list, msg: str | list = "", title: str = "微信通知", at: list = None, index=True, level: Literal["comment", "warning", "info"] = "info"):
|
|
122
|
+
content = fmt_msg(*rows, msg=msg, title=title, at=at, index=index, level=level)
|
|
123
|
+
send_wechat(content=content, to=to, msg_type="markdown")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
async def async_send_msg(*rows, to: list, msg: str | list = "", title: str = "微信通知", at: list = None, index=True, level: Literal["comment", "warning", "info"] = "info"):
|
|
127
|
+
content = fmt_msg(*rows, msg=msg, title=title, at=at, index=index, level=level)
|
|
128
|
+
await async_send_wechat(content=content, to=to, msg_type="markdown")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
if __name__ == '__main__':
|
|
132
|
+
pass
|
ytools/arq/__init__.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
@File : agent.py
|
|
4
|
+
@Author : yintian
|
|
5
|
+
@Date : 2025/10/21 11:33
|
|
6
|
+
@Software: PyCharm
|
|
7
|
+
@Desc :
|
|
8
|
+
"""
|
|
9
|
+
import asyncio
|
|
10
|
+
import contextlib
|
|
11
|
+
import inspect
|
|
12
|
+
from asyncio import Event
|
|
13
|
+
from typing import Callable, Any
|
|
14
|
+
|
|
15
|
+
from ytools.arq import setting
|
|
16
|
+
from ytools.arq.client.base import BaseClient
|
|
17
|
+
from ytools.arq.task.task import Task
|
|
18
|
+
from ytools.utils import magic
|
|
19
|
+
from ytools.utils.counter import FastWriteCounter
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Agent(BaseClient):
|
|
23
|
+
def __init__(self, worker: Callable[[Task], Any] | None = None, max_concurrency=None, **kwargs):
|
|
24
|
+
super().__init__(**kwargs)
|
|
25
|
+
self.worker = worker or self.run_task
|
|
26
|
+
self.success_tasks = FastWriteCounter()
|
|
27
|
+
self.extra = {
|
|
28
|
+
"success_tasks": self.success_tasks.value,
|
|
29
|
+
}
|
|
30
|
+
if max_concurrency:
|
|
31
|
+
self.context = asyncio.Semaphore(max_concurrency)
|
|
32
|
+
else:
|
|
33
|
+
self.context = contextlib.nullcontext()
|
|
34
|
+
|
|
35
|
+
async def do(self, task):
|
|
36
|
+
async with self.context:
|
|
37
|
+
try:
|
|
38
|
+
res = await self.run_callable(self.worker, task)
|
|
39
|
+
except Exception as e:
|
|
40
|
+
self.log(f"执行任务失败 task_id={task.task_id}: {type(e).__name__}: {e}", level="error")
|
|
41
|
+
res = f"ERROR::{type(e)}|{str(e)}"
|
|
42
|
+
await self.put_result(res, task)
|
|
43
|
+
self.success_tasks.increment()
|
|
44
|
+
self.extra["success_tasks"] = self.success_tasks.value
|
|
45
|
+
if task.callback:
|
|
46
|
+
asyncio.create_task(self.callback(task, res))
|
|
47
|
+
|
|
48
|
+
@staticmethod
|
|
49
|
+
def is_async_callable(func):
|
|
50
|
+
return inspect.iscoroutinefunction(func) or inspect.iscoroutinefunction(getattr(func, "__call__", None))
|
|
51
|
+
|
|
52
|
+
async def run_callable(self, func, *args, **kwargs):
|
|
53
|
+
pre = magic.prepare(func, *args, **kwargs)
|
|
54
|
+
if self.is_async_callable(pre.func):
|
|
55
|
+
return await pre()
|
|
56
|
+
res = await asyncio.to_thread(pre)
|
|
57
|
+
if inspect.isawaitable(res):
|
|
58
|
+
return await res
|
|
59
|
+
return res
|
|
60
|
+
|
|
61
|
+
async def run_task(self, task: Task):
|
|
62
|
+
payload = task.data
|
|
63
|
+
if isinstance(payload, (str, bytes)):
|
|
64
|
+
with contextlib.suppress(Exception):
|
|
65
|
+
payload = task.decode_data()
|
|
66
|
+
self.log(f"收到任务 task_id={task.task_id}: {payload}")
|
|
67
|
+
result = await self.execute_task(task, payload)
|
|
68
|
+
self.log(f"任务结果 task_id={task.task_id}: {result}")
|
|
69
|
+
return result
|
|
70
|
+
|
|
71
|
+
@staticmethod
|
|
72
|
+
def normalize_task_payload(payload):
|
|
73
|
+
if not isinstance(payload, dict):
|
|
74
|
+
return None
|
|
75
|
+
func = payload.get("func")
|
|
76
|
+
if func is None:
|
|
77
|
+
return None
|
|
78
|
+
func = magic.load_object(func)
|
|
79
|
+
args = payload.get("args", ())
|
|
80
|
+
kwargs = payload.get("kwargs", {})
|
|
81
|
+
if not isinstance(args, (list, tuple)):
|
|
82
|
+
args = (args,)
|
|
83
|
+
if kwargs is None:
|
|
84
|
+
kwargs = {}
|
|
85
|
+
if not isinstance(kwargs, dict):
|
|
86
|
+
raise TypeError("task kwargs 必须为 dict")
|
|
87
|
+
return func, tuple(args), kwargs
|
|
88
|
+
|
|
89
|
+
async def execute_task(self, task: Task, payload):
|
|
90
|
+
normalized = self.normalize_task_payload(payload)
|
|
91
|
+
if normalized is None:
|
|
92
|
+
return payload
|
|
93
|
+
func, args, kwargs = normalized
|
|
94
|
+
return await self.run_callable(func, *args, **kwargs, task=task)
|
|
95
|
+
|
|
96
|
+
async def callback(self, task: Task, res):
|
|
97
|
+
try:
|
|
98
|
+
pre = magic.prepare(task.callback, task=task, res=res, result=res)
|
|
99
|
+
if pre.is_async:
|
|
100
|
+
await pre()
|
|
101
|
+
else:
|
|
102
|
+
pre()
|
|
103
|
+
|
|
104
|
+
except Exception as e:
|
|
105
|
+
self.log(f"执行 callback 报错: {e}", level="error")
|
|
106
|
+
|
|
107
|
+
async def run(self, event: Event = None):
|
|
108
|
+
while True:
|
|
109
|
+
try:
|
|
110
|
+
if event and event.is_set():
|
|
111
|
+
await asyncio.sleep(setting.INTERVAL)
|
|
112
|
+
continue
|
|
113
|
+
task: Task = await self.get_task()
|
|
114
|
+
if not task:
|
|
115
|
+
continue
|
|
116
|
+
self.task_count.increment()
|
|
117
|
+
asyncio.create_task(self.do(task))
|
|
118
|
+
finally:
|
|
119
|
+
await asyncio.sleep(setting.INTERVAL)
|
|
120
|
+
|
|
121
|
+
async def get_task(self):
|
|
122
|
+
task_id = await self.zpop(self.tasks_queue)
|
|
123
|
+
if not task_id:
|
|
124
|
+
return None
|
|
125
|
+
data = await self.redis.get(self.get_queue(task_id, base=self.data_queue))
|
|
126
|
+
if data is None:
|
|
127
|
+
self.log(f"task_id:{task_id} 未获取到数据", level="error")
|
|
128
|
+
return None
|
|
129
|
+
return Task(
|
|
130
|
+
data=data,
|
|
131
|
+
client=self,
|
|
132
|
+
task_id=task_id,
|
|
133
|
+
fmt=True
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
async def zpop(self, key: str):
|
|
137
|
+
result = None
|
|
138
|
+
if (await self.redis.info("server")).get("redis_version", "0.0.0") >= "5.0.0":
|
|
139
|
+
result = await self.redis.zpopmin(self.tasks_queue, count=1)
|
|
140
|
+
result = result and result[0]
|
|
141
|
+
else:
|
|
142
|
+
async with self.redis.pipeline(transaction=True) as pipe:
|
|
143
|
+
# 取出最小分数的任务
|
|
144
|
+
await pipe.zrange(key, 0, 0, withscores=False)
|
|
145
|
+
# 删除它
|
|
146
|
+
await pipe.zremrangebyrank(key, 0, 0)
|
|
147
|
+
result, _ = await pipe.execute()
|
|
148
|
+
|
|
149
|
+
if result:
|
|
150
|
+
return result[0].decode() if isinstance(result[0], bytes) else result[0]
|
|
151
|
+
return None
|
|
152
|
+
|
|
153
|
+
async def put_result(self, result, task):
|
|
154
|
+
result_queue = self.get_queue(task.task_id, base=self.result_queue)
|
|
155
|
+
data = task.encode_data(result)
|
|
156
|
+
await self.redis.publish(result_queue, data)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
if __name__ == '__main__':
|
|
160
|
+
pass
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
@File : base.py
|
|
4
|
+
@Author : yintian
|
|
5
|
+
@Date : 2025/10/21 10:07
|
|
6
|
+
@Software: PyCharm
|
|
7
|
+
@Desc :
|
|
8
|
+
"""
|
|
9
|
+
import asyncio
|
|
10
|
+
import json
|
|
11
|
+
|
|
12
|
+
from ytools import logger as default_logger
|
|
13
|
+
from ytools.utils.counter import FastWriteCounter
|
|
14
|
+
from ytools.utils.host_ip import get_local_ip
|
|
15
|
+
from ytools.utils.magic import require
|
|
16
|
+
|
|
17
|
+
require("redis")
|
|
18
|
+
from redis.asyncio import Redis # noqa
|
|
19
|
+
from ytools.arq import setting
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class BaseClient:
|
|
23
|
+
redis: Redis
|
|
24
|
+
split = ':'
|
|
25
|
+
queue_name: str
|
|
26
|
+
tasks_queue: str
|
|
27
|
+
data_queue: str
|
|
28
|
+
result_queue: str
|
|
29
|
+
status_queue: str
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
queue_name=None,
|
|
34
|
+
redis=None,
|
|
35
|
+
logger=None,
|
|
36
|
+
level="info",
|
|
37
|
+
):
|
|
38
|
+
self.task_count = FastWriteCounter()
|
|
39
|
+
self.extra = {}
|
|
40
|
+
self.logger = logger or default_logger
|
|
41
|
+
self.level = (level or "info").lower()
|
|
42
|
+
self._host_ip = None
|
|
43
|
+
self.set_queue(queue_name)
|
|
44
|
+
if isinstance(redis, dict):
|
|
45
|
+
self.redis = self.make_redis(**redis)
|
|
46
|
+
elif isinstance(redis, str):
|
|
47
|
+
self.redis = Redis.from_url(redis)
|
|
48
|
+
elif redis:
|
|
49
|
+
self.redis = redis
|
|
50
|
+
asyncio.create_task(self.heartbeat())
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def info(self):
|
|
54
|
+
return {
|
|
55
|
+
"host_ip": self.get_host_ip(),
|
|
56
|
+
"task_count": self.task_count.value,
|
|
57
|
+
**self.extra
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
def set_queue(self, queue_name):
|
|
61
|
+
self.queue_name = queue_name or setting.DEFAULT_QUEUE_NAME
|
|
62
|
+
self.tasks_queue = self.get_queue("tasks")
|
|
63
|
+
self.data_queue = self.get_queue("data")
|
|
64
|
+
self.result_queue = self.get_queue("result")
|
|
65
|
+
self.status_queue = self.get_queue("status")
|
|
66
|
+
|
|
67
|
+
def get_queue(self, *queue: str, base=None):
|
|
68
|
+
return self.split.join([base or self.queue_name, *queue])
|
|
69
|
+
|
|
70
|
+
def log(self, message, level=None, *args, **kwargs):
|
|
71
|
+
level = (level or self.level or "info").lower()
|
|
72
|
+
logger_func = getattr(self.logger, level, None)
|
|
73
|
+
if callable(logger_func):
|
|
74
|
+
return logger_func(message, *args, **kwargs)
|
|
75
|
+
logger_func = getattr(self.logger, "info", None)
|
|
76
|
+
if callable(logger_func):
|
|
77
|
+
return logger_func(message, *args, **kwargs)
|
|
78
|
+
|
|
79
|
+
def get_host_ip(self):
|
|
80
|
+
if self._host_ip:
|
|
81
|
+
return self._host_ip
|
|
82
|
+
try:
|
|
83
|
+
self._host_ip = get_local_ip()
|
|
84
|
+
except Exception as e:
|
|
85
|
+
self.log(f"获取内网IP失败: {e}", level="error")
|
|
86
|
+
self._host_ip = "unknown"
|
|
87
|
+
return self._host_ip
|
|
88
|
+
|
|
89
|
+
@classmethod
|
|
90
|
+
def make_redis(
|
|
91
|
+
cls,
|
|
92
|
+
host: str,
|
|
93
|
+
port: int,
|
|
94
|
+
db=0,
|
|
95
|
+
password=None,
|
|
96
|
+
**kwargs
|
|
97
|
+
):
|
|
98
|
+
return Redis(host=host, port=port, db=db, password=password, **kwargs)
|
|
99
|
+
|
|
100
|
+
@classmethod
|
|
101
|
+
def from_redis(cls, queue_name=None, **redis_config):
|
|
102
|
+
return cls(queue_name, redis=redis_config)
|
|
103
|
+
|
|
104
|
+
async def set_status(self, status_id, data=None, expire_time=None):
|
|
105
|
+
status_queue = self.get_queue(status_id, base=self.status_queue)
|
|
106
|
+
expire_time = expire_time or setting.EXPIRE_TIME
|
|
107
|
+
if data is not None:
|
|
108
|
+
await self.redis.set(status_queue, data, ex=expire_time)
|
|
109
|
+
if await self.redis.exists(status_id):
|
|
110
|
+
await self.redis.expire(status_queue, expire_time)
|
|
111
|
+
|
|
112
|
+
async def get_status(self, status_id):
|
|
113
|
+
status_queue = self.get_queue(status_id, base=self.status_queue)
|
|
114
|
+
return await self.redis.get(status_queue)
|
|
115
|
+
|
|
116
|
+
async def del_status(self, status_id):
|
|
117
|
+
status_queue = self.get_queue(status_id, base=self.status_queue)
|
|
118
|
+
return await self.redis.delete(status_queue)
|
|
119
|
+
|
|
120
|
+
async def heartbeat(self):
|
|
121
|
+
interval = 5
|
|
122
|
+
while True:
|
|
123
|
+
h_key = self.get_queue(self.__class__.__name__.lower(), self.get_host_ip(), base=self.queue_name)
|
|
124
|
+
await self.redis.set(h_key, value=json.dumps(self.info), ex=interval + 1)
|
|
125
|
+
await asyncio.sleep(interval)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
if __name__ == '__main__':
|
|
129
|
+
pass
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
@File : Client.py
|
|
4
|
+
@Author : yintian
|
|
5
|
+
@Date : 2025/10/21 10:20
|
|
6
|
+
@Software: PyCharm
|
|
7
|
+
@Desc :
|
|
8
|
+
"""
|
|
9
|
+
import asyncio
|
|
10
|
+
import inspect
|
|
11
|
+
from typing import Literal
|
|
12
|
+
from uuid import uuid4
|
|
13
|
+
|
|
14
|
+
from redis.asyncio.client import Pipeline
|
|
15
|
+
|
|
16
|
+
from ytools.arq import setting
|
|
17
|
+
from ytools.arq.client.base import BaseClient
|
|
18
|
+
from ytools.arq.task.task import Task
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Client(BaseClient):
|
|
22
|
+
max_sleep_time = 5
|
|
23
|
+
|
|
24
|
+
def __init__(self, *args, max_task_num: int = None, max_action: Literal["sleep", "break", "raise"] = "sleep", **kwargs):
|
|
25
|
+
self.max_task_num = max_task_num
|
|
26
|
+
self.max_action = max_action
|
|
27
|
+
super().__init__(*args, **kwargs)
|
|
28
|
+
|
|
29
|
+
async def put(self, data, task_id=None, **kwargs):
|
|
30
|
+
task_id = task_id or str(uuid4())
|
|
31
|
+
task = Task(data=data, client=self, task_id=task_id, result_queue=self.get_queue(task_id, base=self.result_queue), **kwargs)
|
|
32
|
+
await self.put_task(task, kwargs.get('auto_ensure', False))
|
|
33
|
+
return task
|
|
34
|
+
|
|
35
|
+
async def put_task(self, task: Task, auto_ensure=False):
|
|
36
|
+
data_queue = self.get_queue(task.task_id, base=self.data_queue)
|
|
37
|
+
|
|
38
|
+
auto_ensure and await task.ensure()
|
|
39
|
+
async with self.redis.pipeline(transaction=True) as pipe:
|
|
40
|
+
if self.max_task_num and await self.check_max(pipe):
|
|
41
|
+
return
|
|
42
|
+
await pipe.zadd(self.tasks_queue, {task.task_id: task.score})
|
|
43
|
+
await pipe.set(data_queue, task.encode_data(), ex=setting.EXPIRE_TIME)
|
|
44
|
+
results = await pipe.execute()
|
|
45
|
+
if not all(results): # 检查是否有命令失败
|
|
46
|
+
raise ValueError(f"投放任务至队列失败: {results}")
|
|
47
|
+
self.task_count.increment()
|
|
48
|
+
|
|
49
|
+
async def check_max(self, pipe: Pipeline):
|
|
50
|
+
while True:
|
|
51
|
+
now_task_num = await pipe.zcard(self.tasks_queue)
|
|
52
|
+
if now_task_num >= self.max_task_num:
|
|
53
|
+
if self.max_action == "sleep":
|
|
54
|
+
self.log(f"当前任务数量: {now_task_num} 超出最大任务数: {self.max_task_num}, 等待任务消费, 休眠 {self.max_sleep_time}s...")
|
|
55
|
+
await asyncio.sleep(self.max_sleep_time)
|
|
56
|
+
elif self.max_action == "break":
|
|
57
|
+
return True
|
|
58
|
+
else:
|
|
59
|
+
raise ValueError("超出最大任务数")
|
|
60
|
+
else:
|
|
61
|
+
return
|
|
62
|
+
|
|
63
|
+
@staticmethod
|
|
64
|
+
async def get_result(task: Task, timeout=None, timeout_back=None):
|
|
65
|
+
res = await task.get_result(timeout=timeout, timeout_back=timeout_back)
|
|
66
|
+
return res
|
|
67
|
+
|
|
68
|
+
async def get_result_by_id(self, task_id, timeout=None, timeout_back=None):
|
|
69
|
+
result_queue = self.get_queue(task_id, base=self.result_queue)
|
|
70
|
+
pubsub = self.redis.pubsub()
|
|
71
|
+
await pubsub.subscribe(result_queue)
|
|
72
|
+
|
|
73
|
+
async def get_result():
|
|
74
|
+
async for msg in pubsub.listen():
|
|
75
|
+
if msg["type"] == "message":
|
|
76
|
+
return msg["data"]
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
done_future = asyncio.ensure_future(get_result())
|
|
80
|
+
try:
|
|
81
|
+
res = await asyncio.wait_for(done_future, timeout=timeout)
|
|
82
|
+
return res
|
|
83
|
+
except asyncio.TimeoutError:
|
|
84
|
+
if inspect.iscoroutinefunction(timeout_back):
|
|
85
|
+
await timeout_back()
|
|
86
|
+
elif inspect.isawaitable(timeout_back):
|
|
87
|
+
await timeout_back
|
|
88
|
+
elif timeout_back:
|
|
89
|
+
timeout_back()
|
|
90
|
+
raise
|
|
91
|
+
finally:
|
|
92
|
+
done_future.cancel()
|
|
93
|
+
finally:
|
|
94
|
+
await pubsub.unsubscribe(result_queue)
|
|
95
|
+
await pubsub.close()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
if __name__ == '__main__':
|
|
99
|
+
pass
|
ytools/arq/setting.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
@File : setting.py
|
|
4
|
+
@Author : yintian
|
|
5
|
+
@Date : 2025/10/21 10:30
|
|
6
|
+
@Software: PyCharm
|
|
7
|
+
@Desc :
|
|
8
|
+
"""
|
|
9
|
+
# 默认队列名
|
|
10
|
+
DEFAULT_QUEUE_NAME = "ytools:arq"
|
|
11
|
+
# 是否启用加密, 若传入方法则使用传入方法
|
|
12
|
+
ENCRYPT = False
|
|
13
|
+
# 默认编码
|
|
14
|
+
DEFAULT_ENCODING = "utf-8"
|
|
15
|
+
# 轮询休眠
|
|
16
|
+
INTERVAL = 0.01
|
|
17
|
+
# 传入的是否是可序列化数据
|
|
18
|
+
OBJ_DATA = False
|
|
19
|
+
# key 超时删除时间
|
|
20
|
+
EXPIRE_TIME = 300
|
|
21
|
+
|
|
22
|
+
if __name__ == '__main__':
|
|
23
|
+
pass
|