sycommon-python-lib 0.1.59__py3-none-any.whl → 0.2.0b1__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.
- sycommon/notice/uvicorn_monitor.py +70 -82
- sycommon/rabbitmq/rabbitmq_client.py +157 -46
- sycommon/rabbitmq/rabbitmq_pool.py +114 -64
- sycommon/rabbitmq/rabbitmq_service_client_manager.py +20 -5
- sycommon/synacos/nacos_service_discovery.py +9 -6
- sycommon/tests/test_email.py +172 -0
- sycommon/tools/syemail.py +173 -0
- {sycommon_python_lib-0.1.59.dist-info → sycommon_python_lib-0.2.0b1.dist-info}/METADATA +2 -1
- {sycommon_python_lib-0.1.59.dist-info → sycommon_python_lib-0.2.0b1.dist-info}/RECORD +12 -10
- {sycommon_python_lib-0.1.59.dist-info → sycommon_python_lib-0.2.0b1.dist-info}/WHEEL +1 -1
- {sycommon_python_lib-0.1.59.dist-info → sycommon_python_lib-0.2.0b1.dist-info}/entry_points.txt +0 -0
- {sycommon_python_lib-0.1.59.dist-info → sycommon_python_lib-0.2.0b1.dist-info}/top_level.txt +0 -0
|
@@ -1,10 +1,9 @@
|
|
|
1
|
-
from datetime import datetime
|
|
2
1
|
import sys
|
|
3
2
|
import traceback
|
|
4
3
|
import aiohttp
|
|
5
4
|
import asyncio
|
|
6
|
-
import json
|
|
7
5
|
from typing import Optional
|
|
6
|
+
from datetime import datetime
|
|
8
7
|
from urllib.parse import urlparse, parse_qs, urlencode, urlunparse
|
|
9
8
|
from sycommon.config.Config import Config
|
|
10
9
|
from sycommon.logging.kafka_log import SYLogger
|
|
@@ -16,19 +15,7 @@ async def send_wechat_markdown_msg(
|
|
|
16
15
|
) -> Optional[dict]:
|
|
17
16
|
"""
|
|
18
17
|
异步发送企业微信Markdown格式的WebHook消息
|
|
19
|
-
|
|
20
|
-
Args:
|
|
21
|
-
content: Markdown格式的消息内容(支持企业微信支持的markdown语法)
|
|
22
|
-
webhook: 完整的企业微信WebHook URL(默认值包含key)
|
|
23
|
-
|
|
24
|
-
Returns:
|
|
25
|
-
接口返回的JSON数据(dict),失败返回None
|
|
26
18
|
"""
|
|
27
|
-
# 设置请求头
|
|
28
|
-
headers = {
|
|
29
|
-
"Content-Type": "application/json; charset=utf-8"
|
|
30
|
-
}
|
|
31
|
-
|
|
32
19
|
# 构造请求体(Markdown格式)
|
|
33
20
|
payload = {
|
|
34
21
|
"msgtype": "markdown",
|
|
@@ -38,54 +25,54 @@ async def send_wechat_markdown_msg(
|
|
|
38
25
|
}
|
|
39
26
|
|
|
40
27
|
try:
|
|
28
|
+
# 使用 json 参数自动处理序列化和 Content-Type
|
|
41
29
|
async with aiohttp.ClientSession() as session:
|
|
42
30
|
async with session.post(
|
|
43
31
|
url=webhook,
|
|
44
|
-
|
|
45
|
-
headers=headers,
|
|
32
|
+
json=payload,
|
|
46
33
|
timeout=aiohttp.ClientTimeout(total=10)
|
|
47
34
|
) as response:
|
|
48
|
-
|
|
49
|
-
response_text = await response.text()
|
|
50
|
-
response_data = json.loads(
|
|
51
|
-
response_text) if response_text else {}
|
|
35
|
+
response_data = await response.json()
|
|
52
36
|
|
|
53
|
-
if status == 200 and response_data.get("errcode") == 0:
|
|
37
|
+
if response.status == 200 and response_data.get("errcode") == 0:
|
|
54
38
|
SYLogger.info(f"消息发送成功: {response_data}")
|
|
55
39
|
return response_data
|
|
56
40
|
else:
|
|
57
|
-
SYLogger.
|
|
58
|
-
f"消息发送失败 - 状态码: {status}, 响应: {response_data}")
|
|
41
|
+
SYLogger.warning(
|
|
42
|
+
f"消息发送失败 - 状态码: {response.status}, 响应: {response_data}")
|
|
59
43
|
return None
|
|
60
44
|
except Exception as e:
|
|
61
|
-
SYLogger.
|
|
45
|
+
SYLogger.error(f"发送企业微信消息异常: {str(e)}")
|
|
62
46
|
return None
|
|
63
47
|
|
|
64
48
|
|
|
65
49
|
async def send_webhook(error_info: dict = None, webhook: str = None):
|
|
66
50
|
"""
|
|
67
51
|
发送服务启动结果的企业微信通知
|
|
68
|
-
Args:
|
|
69
|
-
error_info: 错误信息字典(启动失败时必填),包含:error_type, error_msg, stack_trace, elapsed_time
|
|
70
|
-
webhook: 完整的企业微信WebHook URL(覆盖默认值)
|
|
71
52
|
"""
|
|
72
|
-
#
|
|
53
|
+
# 获取服务名和环境(增加默认值保护)
|
|
73
54
|
try:
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
55
|
+
config = Config().config
|
|
56
|
+
service_name = config.get('Name', "未知服务")
|
|
57
|
+
env = config.get('Nacos', {}).get('namespaceId', '未知环境')
|
|
58
|
+
# 注意:这里使用了大写开头的 WebHook,请确保配置文件中键名一致
|
|
59
|
+
webHook = config.get('llm', {}).get('WebHook')
|
|
77
60
|
except Exception as e:
|
|
78
61
|
service_name = "未知服务"
|
|
79
62
|
env = "未知环境"
|
|
80
63
|
webHook = None
|
|
81
|
-
SYLogger.
|
|
64
|
+
SYLogger.warning(f"读取配置失败: {str(e)}")
|
|
82
65
|
|
|
83
66
|
start_time = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
|
84
67
|
|
|
68
|
+
# 如果没有错误信息,就不发送失败告警
|
|
69
|
+
if not error_info:
|
|
70
|
+
return
|
|
71
|
+
|
|
85
72
|
# 启动失败的通知内容(包含详细错误信息)
|
|
86
73
|
error_type = error_info.get("error_type", "未知错误")
|
|
87
74
|
error_msg = error_info.get("error_msg", "无错误信息")
|
|
88
|
-
stack_trace = error_info.get("stack_trace", "无堆栈信息")[:
|
|
75
|
+
stack_trace = error_info.get("stack_trace", "无堆栈信息")[:1000] # 适当增加长度限制
|
|
89
76
|
elapsed_time = error_info.get("elapsed_time", 0)
|
|
90
77
|
|
|
91
78
|
markdown_content = f"""### {service_name}服务启动失败告警 ⚠️
|
|
@@ -103,22 +90,20 @@ async def send_webhook(error_info: dict = None, webhook: str = None):
|
|
|
103
90
|
)
|
|
104
91
|
SYLogger.info(f"通知发送结果: {result}")
|
|
105
92
|
else:
|
|
106
|
-
SYLogger.info("未设置企业微信WebHook")
|
|
93
|
+
SYLogger.info("未设置企业微信WebHook,跳过告警发送")
|
|
107
94
|
|
|
108
95
|
|
|
109
96
|
def run(*args, webhook: str = None, **kwargs):
|
|
110
97
|
"""
|
|
111
98
|
带企业微信告警的Uvicorn启动监控
|
|
112
|
-
调用方式1(默认配置):uvicorn_monitor.run("app:app", **app.state.config)
|
|
113
|
-
调用方式2(自定义webhook):uvicorn_monitor.run("app:app", webhook="完整URL", **app.state.config)
|
|
114
|
-
|
|
115
|
-
Args:
|
|
116
|
-
*args: 传递给uvicorn.run的位置参数(如"app:app")
|
|
117
|
-
webhook: 完整的企业微信WebHook URL(可选,覆盖默认值)
|
|
118
|
-
**kwargs: 传递给uvicorn.run的关键字参数(如app.state.config)
|
|
119
99
|
"""
|
|
120
100
|
# 判断环境
|
|
121
|
-
|
|
101
|
+
try:
|
|
102
|
+
env = Config().config.get('Nacos', {}).get('namespaceId', 'dev')
|
|
103
|
+
except:
|
|
104
|
+
env = 'dev'
|
|
105
|
+
|
|
106
|
+
# 如果是生产环境,通常不需要这种启动脚本进行告警干扰,或者你需要根据实际情况调整
|
|
122
107
|
if env == "prod":
|
|
123
108
|
import uvicorn
|
|
124
109
|
uvicorn.run(*args, **kwargs)
|
|
@@ -128,42 +113,44 @@ def run(*args, webhook: str = None, **kwargs):
|
|
|
128
113
|
start_time = datetime.now()
|
|
129
114
|
|
|
130
115
|
if webhook:
|
|
131
|
-
# 脱敏展示webhook
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
key
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
116
|
+
# 脱敏展示webhook
|
|
117
|
+
try:
|
|
118
|
+
parsed = urlparse(webhook)
|
|
119
|
+
query = parse_qs(parsed.query)
|
|
120
|
+
if 'key' in query and query['key'][0]:
|
|
121
|
+
key = query['key'][0]
|
|
122
|
+
masked_key = key[:8] + "****" if len(key) > 8 else key + "****"
|
|
123
|
+
query['key'] = [masked_key]
|
|
124
|
+
masked_query = urlencode(query, doseq=True)
|
|
125
|
+
masked_webhook = urlunparse(
|
|
126
|
+
(parsed.scheme, parsed.netloc, parsed.path, parsed.params, masked_query, parsed.fragment))
|
|
127
|
+
SYLogger.info(f"自定义企业微信WebHook: {masked_webhook}")
|
|
128
|
+
except Exception:
|
|
129
|
+
SYLogger.info("自定义Webhook格式解析失败,但会尝试使用")
|
|
142
130
|
|
|
143
131
|
# 初始化错误信息
|
|
144
132
|
error_info = None
|
|
145
133
|
|
|
146
134
|
try:
|
|
147
135
|
import uvicorn
|
|
148
|
-
#
|
|
136
|
+
# 执行启动
|
|
149
137
|
uvicorn.run(*args, **kwargs)
|
|
150
138
|
|
|
151
139
|
except KeyboardInterrupt:
|
|
152
|
-
#
|
|
140
|
+
# 处理用户手动中断
|
|
153
141
|
elapsed = (datetime.now() - start_time).total_seconds()
|
|
154
142
|
SYLogger.info(f"\n{'='*50}")
|
|
155
143
|
SYLogger.info(f"ℹ️ 应用被用户手动中断")
|
|
156
144
|
SYLogger.info(f"启动耗时: {elapsed:.2f} 秒")
|
|
157
145
|
SYLogger.info(f"{'='*50}\n")
|
|
146
|
+
# 手动中断不需要错误告警,也不需要 sys.exit(1)
|
|
158
147
|
sys.exit(0)
|
|
159
148
|
|
|
160
149
|
except Exception as e:
|
|
161
|
-
#
|
|
150
|
+
# 捕获启动失败异常
|
|
162
151
|
elapsed = (datetime.now() - start_time).total_seconds()
|
|
163
|
-
# 捕获堆栈信息
|
|
164
152
|
stack_trace = traceback.format_exc()
|
|
165
153
|
|
|
166
|
-
# 构造错误信息字典
|
|
167
154
|
error_info = {
|
|
168
155
|
"error_type": type(e).__name__,
|
|
169
156
|
"error_msg": str(e),
|
|
@@ -171,30 +158,31 @@ def run(*args, webhook: str = None, **kwargs):
|
|
|
171
158
|
"elapsed_time": elapsed
|
|
172
159
|
}
|
|
173
160
|
|
|
174
|
-
#
|
|
175
|
-
SYLogger.
|
|
176
|
-
SYLogger.
|
|
177
|
-
SYLogger.
|
|
178
|
-
SYLogger.
|
|
179
|
-
SYLogger.
|
|
180
|
-
SYLogger.
|
|
181
|
-
SYLogger.
|
|
161
|
+
# 打印详细错误
|
|
162
|
+
SYLogger.error(f"\n{'='*50}")
|
|
163
|
+
SYLogger.error(f"🚨 应用启动失败!")
|
|
164
|
+
SYLogger.error(f"失败时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
|
165
|
+
SYLogger.error(f"错误类型: {type(e).__name__}")
|
|
166
|
+
SYLogger.error(f"错误信息: {str(e)}")
|
|
167
|
+
SYLogger.error(f"\n📝 错误堆栈:")
|
|
168
|
+
SYLogger.error(f"-"*50)
|
|
182
169
|
traceback.print_exc(file=sys.stdout)
|
|
183
|
-
SYLogger.
|
|
184
|
-
SYLogger.
|
|
170
|
+
SYLogger.error(f"\n⏱️ 启动耗时: {elapsed:.2f} 秒")
|
|
171
|
+
SYLogger.error(f"{'='*50}\n")
|
|
185
172
|
|
|
186
173
|
finally:
|
|
187
|
-
#
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
174
|
+
# 创建新的事件循环来发送通知,防止与 Uvicorn 的循环冲突
|
|
175
|
+
if error_info:
|
|
176
|
+
try:
|
|
177
|
+
loop = asyncio.new_event_loop()
|
|
178
|
+
asyncio.set_event_loop(loop)
|
|
179
|
+
loop.run_until_complete(send_webhook(
|
|
180
|
+
error_info=error_info,
|
|
181
|
+
webhook=webhook
|
|
182
|
+
))
|
|
183
|
+
loop.close()
|
|
184
|
+
except Exception as e:
|
|
185
|
+
SYLogger.error(f"错误:异步通知发送失败 - {str(e)}")
|
|
186
|
+
|
|
187
|
+
# 只有确实有错误时才以状态码 1 退出
|
|
188
|
+
sys.exit(1)
|
|
@@ -117,37 +117,37 @@ class RabbitMQClient:
|
|
|
117
117
|
logger.info(f"队列重建成功: {self.queue_name}")
|
|
118
118
|
|
|
119
119
|
async def connect(self) -> None:
|
|
120
|
-
"""
|
|
120
|
+
"""连接方法(消费者独立通道 + 移除无效属性检查 + 强制重建队列)"""
|
|
121
121
|
if self._closed:
|
|
122
122
|
raise RuntimeError("客户端已关闭,无法重新连接")
|
|
123
123
|
|
|
124
|
-
# 1. 获取 Condition
|
|
124
|
+
# 1. 获取 Condition 锁,用于管理连接并发和等待
|
|
125
125
|
await self._connect_condition.acquire()
|
|
126
126
|
|
|
127
127
|
try:
|
|
128
|
-
# ===== 阶段 A:
|
|
128
|
+
# ===== 阶段 A: 检查状态与排队 =====
|
|
129
129
|
if await self.is_connected:
|
|
130
|
-
self._connect_condition.
|
|
130
|
+
if self._connect_condition.locked():
|
|
131
|
+
self._connect_condition.release()
|
|
131
132
|
return
|
|
132
133
|
|
|
134
|
+
# 如果已有协程正在连接,等待其完成
|
|
133
135
|
if self._connecting:
|
|
134
136
|
try:
|
|
135
137
|
logger.debug("连接正在进行中,等待现有连接完成...")
|
|
136
138
|
await asyncio.wait_for(self._connect_condition.wait(), timeout=60.0)
|
|
137
139
|
except asyncio.TimeoutError:
|
|
138
|
-
|
|
139
|
-
raise RuntimeError("等待连接超时")
|
|
140
|
+
logger.warning("等待前序连接超时,当前协程将尝试强制接管并重连...")
|
|
140
141
|
|
|
142
|
+
# 唤醒后再次检查状态,防止重复连接
|
|
141
143
|
if await self.is_connected:
|
|
142
|
-
self._connect_condition.
|
|
144
|
+
if self._connect_condition.locked():
|
|
145
|
+
self._connect_condition.release()
|
|
143
146
|
return
|
|
144
|
-
else:
|
|
145
|
-
self._connect_condition.release()
|
|
146
|
-
raise RuntimeError("等待重连后,连接状态依然无效")
|
|
147
147
|
|
|
148
|
-
# ===== 阶段 B:
|
|
148
|
+
# ===== 阶段 B: 标记开始连接并释放锁 =====
|
|
149
|
+
# 释放锁是为了让耗时的连接过程不阻塞其他协程
|
|
149
150
|
self._connecting = True
|
|
150
|
-
# 【关键】释放锁,允许其他协程进入等待逻辑
|
|
151
151
|
self._connect_condition.release()
|
|
152
152
|
|
|
153
153
|
except Exception as e:
|
|
@@ -155,13 +155,19 @@ class RabbitMQClient:
|
|
|
155
155
|
self._connect_condition.release()
|
|
156
156
|
raise
|
|
157
157
|
|
|
158
|
-
# === 阶段 C: 执行耗时的连接逻辑 (
|
|
158
|
+
# === 阶段 C: 执行耗时的连接逻辑 (无锁状态) ===
|
|
159
|
+
connection_failed = False
|
|
160
|
+
was_consuming = False
|
|
161
|
+
|
|
162
|
+
# 判断当前是否为消费者模式(通过是否有消息处理函数判断)
|
|
163
|
+
is_consumer = self._message_handler is not None
|
|
164
|
+
old_channel = self._channel
|
|
165
|
+
|
|
159
166
|
try:
|
|
160
|
-
# --- 步骤 1:
|
|
161
|
-
# 必须在清理前记录状态
|
|
167
|
+
# --- 步骤 1: 记录状态并清理旧资源 ---
|
|
162
168
|
was_consuming = self._consumer_tag is not None
|
|
163
169
|
|
|
164
|
-
#
|
|
170
|
+
# 清理旧连接的 close_callbacks,防止重连触发多次
|
|
165
171
|
if self._channel_conn:
|
|
166
172
|
try:
|
|
167
173
|
if self._channel_conn.close_callbacks:
|
|
@@ -169,60 +175,125 @@ class RabbitMQClient:
|
|
|
169
175
|
except Exception:
|
|
170
176
|
pass
|
|
171
177
|
|
|
172
|
-
#
|
|
178
|
+
# 显式关闭旧 Channel
|
|
179
|
+
# 注意:无论是生产者复用的主通道,还是消费者的独立通道,断开时都应显式关闭以释放服务端资源
|
|
180
|
+
if old_channel and not old_channel.is_closed:
|
|
181
|
+
try:
|
|
182
|
+
await old_channel.close()
|
|
183
|
+
except Exception:
|
|
184
|
+
pass
|
|
185
|
+
|
|
186
|
+
# 【修复点】强制重置所有核心资源引用
|
|
187
|
+
# 因为我们即将获取一个新的 Channel,旧的 Exchange 和 Queue 对象(基于旧 Channel)将全部失效。
|
|
188
|
+
# 必须置为 None,强制后续逻辑基于新 Channel 重建这些对象。
|
|
173
189
|
self._channel = None
|
|
174
190
|
self._channel_conn = None
|
|
175
191
|
self._exchange = None
|
|
176
192
|
self._queue = None
|
|
177
193
|
self._consumer_tag = None
|
|
178
194
|
|
|
179
|
-
# --- 步骤 2:
|
|
180
|
-
|
|
195
|
+
# --- 步骤 2: 根据角色获取新连接 ---
|
|
196
|
+
# 生产者:复用连接池的主通道(性能高)
|
|
197
|
+
# 消费者:从连接池获取独立的通道(稳定性高,避免并发冲突)
|
|
198
|
+
if is_consumer:
|
|
199
|
+
logger.debug("获取消费者独立通道...")
|
|
200
|
+
self._channel = await self.connection_pool.acquire_consumer_channel()
|
|
201
|
+
self._channel_conn = self.connection_pool._connection
|
|
202
|
+
else:
|
|
203
|
+
logger.debug("获取生产者主通道...")
|
|
204
|
+
self._channel, self._channel_conn = await self.connection_pool.acquire_channel()
|
|
205
|
+
|
|
206
|
+
# --- 步骤 3: 设置连接关闭回调 ---
|
|
207
|
+
loop = asyncio.get_running_loop()
|
|
181
208
|
|
|
182
|
-
# 设置连接关闭回调
|
|
183
209
|
def on_conn_closed(conn, exc):
|
|
210
|
+
if self._closed:
|
|
211
|
+
return
|
|
184
212
|
logger.warning(f"检测到底层连接关闭: {exc}")
|
|
185
|
-
|
|
186
|
-
|
|
213
|
+
# 确保在循环中安全调用协程
|
|
214
|
+
asyncio.run_coroutine_threadsafe(self._safe_reconnect(), loop)
|
|
187
215
|
|
|
188
216
|
if self._channel_conn:
|
|
189
217
|
self._channel_conn.close_callbacks.add(on_conn_closed)
|
|
190
218
|
|
|
191
|
-
# --- 步骤
|
|
219
|
+
# --- 步骤 4: 重建基础资源 ---
|
|
220
|
+
# 这会在新的 self._channel 上声明 Exchange 和 Queue,并执行绑定
|
|
192
221
|
await self._rebuild_resources()
|
|
193
222
|
|
|
194
|
-
# --- 步骤 4: 恢复消费 ---
|
|
195
|
-
if was_consuming and self._message_handler:
|
|
196
|
-
logger.info("🔄 检测到重连前处于消费状态,尝试自动恢复消费...")
|
|
197
|
-
try:
|
|
198
|
-
# 直接调用 start_consuming 来恢复,它内部包含了完整的队列检查和绑定逻辑
|
|
199
|
-
self._consumer_tag = await self.start_consuming()
|
|
200
|
-
logger.info(f"✅ 消费已自动恢复: {self._consumer_tag}")
|
|
201
|
-
except Exception as e:
|
|
202
|
-
logger.error(f"❌ 自动恢复消费失败: {e}")
|
|
203
|
-
self._consumer_tag = None
|
|
204
|
-
|
|
205
|
-
logger.info("客户端连接初始化完成")
|
|
206
|
-
|
|
207
223
|
except Exception as e:
|
|
224
|
+
connection_failed = True
|
|
208
225
|
logger.error(f"客户端连接失败: {str(e)}", exc_info=True)
|
|
209
|
-
|
|
226
|
+
|
|
227
|
+
# 发生异常时清理引用
|
|
210
228
|
if self._channel_conn and self._channel_conn.close_callbacks:
|
|
211
229
|
self._channel_conn.close_callbacks.clear()
|
|
230
|
+
|
|
212
231
|
self._channel = None
|
|
213
232
|
self._channel_conn = None
|
|
233
|
+
self._exchange = None
|
|
214
234
|
self._queue = None
|
|
215
235
|
self._consumer_tag = None
|
|
236
|
+
|
|
216
237
|
raise
|
|
217
238
|
|
|
218
239
|
finally:
|
|
219
|
-
# === 阶段 D:
|
|
220
|
-
await self._connect_condition.acquire()
|
|
240
|
+
# === 阶段 D: 恢复消费与收尾 (重新加锁) ===
|
|
221
241
|
try:
|
|
242
|
+
await self._connect_condition.acquire()
|
|
243
|
+
except Exception:
|
|
244
|
+
pass
|
|
245
|
+
|
|
246
|
+
try:
|
|
247
|
+
# 只有连接完全成功,且之前处于消费状态,才尝试自动恢复消费
|
|
248
|
+
if not connection_failed and was_consuming and self._message_handler:
|
|
249
|
+
logger.info("🔄 检测到重连前处于消费状态,尝试自动恢复消费...")
|
|
250
|
+
|
|
251
|
+
# 【修复核心】
|
|
252
|
+
# 由于在步骤 1 中 self._queue 已被置为 None,
|
|
253
|
+
# 如果 _rebuild_resources 因为某种原因(例如配置条件)没有成功创建队列,
|
|
254
|
+
# 这里需要再次尝试在当前新 Channel 上创建并绑定队列。
|
|
255
|
+
# 不再检查 is_closed(因为该属性不存在),直接检查是否为 None。
|
|
256
|
+
|
|
257
|
+
if self.queue_name and not self._queue:
|
|
258
|
+
try:
|
|
259
|
+
logger.info(f"重连恢复过程中重新声明队列: {self.queue_name}")
|
|
260
|
+
# 在当前新 Channel 上声明队列
|
|
261
|
+
self._queue = await self._channel.declare_queue(
|
|
262
|
+
name=self.queue_name,
|
|
263
|
+
durable=self.durable,
|
|
264
|
+
auto_delete=self.auto_delete,
|
|
265
|
+
passive=not self.create_if_not_exists,
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
# 【关键步骤】显式绑定队列
|
|
269
|
+
# 即使队列已存在,也必须在新 Channel 上重新绑定,否则服务端路由状态可能不更新
|
|
270
|
+
if self._exchange:
|
|
271
|
+
await self._queue.bind(exchange=self._exchange, routing_key=self.routing_key)
|
|
272
|
+
logger.info(
|
|
273
|
+
f"✅ 重连绑定成功: {self.queue_name} -> {self.routing_key}")
|
|
274
|
+
except Exception as bind_err:
|
|
275
|
+
logger.error(f"❌ 重连恢复队列/绑定失败: {bind_err}")
|
|
276
|
+
# 绑定失败,无法恢复消费
|
|
277
|
+
self._queue = None
|
|
278
|
+
|
|
279
|
+
# 队列对象有效才启动消费
|
|
280
|
+
if self._queue:
|
|
281
|
+
try:
|
|
282
|
+
self._consumer_tag = await self.start_consuming()
|
|
283
|
+
logger.info(f"✅ 消费已自动恢复: {self._consumer_tag}")
|
|
284
|
+
except Exception as e:
|
|
285
|
+
logger.error(f"❌ 自动恢复消费失败: {e}")
|
|
286
|
+
self._consumer_tag = None
|
|
287
|
+
else:
|
|
288
|
+
logger.warning("⚠️ 队列对象无效,无法恢复消费")
|
|
289
|
+
|
|
290
|
+
finally:
|
|
291
|
+
# 最终状态复位
|
|
222
292
|
self._connecting = False
|
|
223
293
|
self._connect_condition.notify_all()
|
|
224
|
-
|
|
225
|
-
self._connect_condition.
|
|
294
|
+
|
|
295
|
+
if self._connect_condition.locked():
|
|
296
|
+
self._connect_condition.release()
|
|
226
297
|
|
|
227
298
|
async def _safe_reconnect(self):
|
|
228
299
|
"""安全重连任务(仅用于被动监听连接关闭)"""
|
|
@@ -313,6 +384,11 @@ class RabbitMQClient:
|
|
|
313
384
|
self._consumer_tag = None
|
|
314
385
|
|
|
315
386
|
async def _handle_publish_failure(self):
|
|
387
|
+
# 如果当前正在重连,或者已经关闭,直接返回,避免冲突
|
|
388
|
+
if self._connecting or self._closed:
|
|
389
|
+
logger.warning("⚠️ 正在重连或已关闭,跳过故障转移触发")
|
|
390
|
+
return
|
|
391
|
+
|
|
316
392
|
try:
|
|
317
393
|
logger.info("检测到发布异常,强制连接池切换节点...")
|
|
318
394
|
await self.connection_pool.force_reconnect()
|
|
@@ -387,9 +463,12 @@ class RabbitMQClient:
|
|
|
387
463
|
raise RuntimeError(f"消息发布最终失败: {last_exception}")
|
|
388
464
|
|
|
389
465
|
async def close(self) -> None:
|
|
466
|
+
"""关闭客户端(支持独立通道的清理与死锁修复)"""
|
|
467
|
+
# 1. 先标记关闭
|
|
390
468
|
self._closed = True
|
|
391
469
|
logger.info("开始关闭RabbitMQ客户端...")
|
|
392
470
|
|
|
471
|
+
# 2. 取消可能存在的后台重连任务
|
|
393
472
|
if self._current_reconnect_task and not self._current_reconnect_task.done():
|
|
394
473
|
self._current_reconnect_task.cancel()
|
|
395
474
|
try:
|
|
@@ -397,21 +476,53 @@ class RabbitMQClient:
|
|
|
397
476
|
except asyncio.CancelledError:
|
|
398
477
|
pass
|
|
399
478
|
|
|
479
|
+
# 3. 停止消费
|
|
400
480
|
await self.stop_consuming()
|
|
401
481
|
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
482
|
+
# 4. 处理 _connect_condition 锁
|
|
483
|
+
try:
|
|
484
|
+
await asyncio.wait_for(self._connect_condition.acquire(), timeout=2.0)
|
|
485
|
+
except asyncio.TimeoutError:
|
|
486
|
+
logger.warning("获取连接锁超时,强制清理资源...")
|
|
487
|
+
|
|
488
|
+
try:
|
|
489
|
+
# 清理回调
|
|
490
|
+
if self._channel_conn:
|
|
491
|
+
try:
|
|
492
|
+
if self._channel_conn.close_callbacks:
|
|
493
|
+
self._channel_conn.close_callbacks.clear()
|
|
494
|
+
except Exception:
|
|
495
|
+
pass
|
|
496
|
+
|
|
497
|
+
# 【关键修改】显式关闭持有的通道
|
|
498
|
+
# 无论是生产者(主通道,但 Client 只是持有者,通常不关 Pool 管理的主通道),
|
|
499
|
+
# 还是消费者(独立通道,必须显式关闭),这里都需要处理。
|
|
500
|
+
# 由于我们引入了独立消费者通道,这里必须显式关闭 self._channel
|
|
501
|
+
if self._channel and not self._channel.is_closed:
|
|
502
|
+
try:
|
|
503
|
+
# 注意:如果是主通道,这里关闭可能会影响其他 Producer。
|
|
504
|
+
# 但由于我们的架构中,Consumer 用独立通道,这里大概率是 Consumer 关闭。
|
|
505
|
+
# 为了安全,可以增加判断:如果 shared_channel 标志为 False 才关?
|
|
506
|
+
# 简化策略:统一关闭,因为 Client 被销毁意味着不再需要该通道。
|
|
507
|
+
await self._channel.close()
|
|
508
|
+
logger.debug("客户端通道已关闭")
|
|
509
|
+
except Exception as e:
|
|
510
|
+
logger.warning(f"关闭客户端通道异常: {e}")
|
|
406
511
|
|
|
512
|
+
# 置空资源引用
|
|
407
513
|
self._channel = None
|
|
408
514
|
self._channel_conn = None
|
|
409
515
|
self._exchange = None
|
|
410
516
|
self._queue = None
|
|
411
517
|
self._message_handler = None
|
|
518
|
+
self._conn_close_callback = None
|
|
412
519
|
|
|
413
|
-
|
|
520
|
+
finally:
|
|
521
|
+
# 强制重置状态并唤醒所有等待者
|
|
414
522
|
self._connecting = False
|
|
415
523
|
self._connect_condition.notify_all()
|
|
416
524
|
|
|
525
|
+
if self._connect_condition.locked():
|
|
526
|
+
self._connect_condition.release()
|
|
527
|
+
|
|
417
528
|
logger.info("客户端已关闭")
|
|
@@ -141,21 +141,38 @@ class RabbitMQConnectionPool:
|
|
|
141
141
|
|
|
142
142
|
async def _ensure_main_channel(self) -> RobustChannel:
|
|
143
143
|
"""
|
|
144
|
-
确保主通道有效
|
|
145
|
-
修复逻辑:
|
|
146
|
-
1. 如果连接对象不存在或已关闭 -> 重建连接
|
|
147
|
-
2. 如果连接对象存在但处于重连中 -> 等待其 ready
|
|
148
|
-
3. 如果通道不存在或已关闭 -> 重建通道
|
|
144
|
+
确保主通道有效 (修复:将探活逻辑移入锁内,防止死锁)
|
|
149
145
|
"""
|
|
150
|
-
async with self._lock:
|
|
146
|
+
async with self._lock: # 持有锁贯穿整个方法
|
|
151
147
|
if self._is_shutdown:
|
|
152
148
|
raise RuntimeError("客户端已关闭")
|
|
153
149
|
|
|
154
|
-
# --- 阶段A
|
|
155
|
-
|
|
156
|
-
|
|
150
|
+
# --- 阶段 A: 连接检查与重建 ---
|
|
151
|
+
connection_is_dead = False
|
|
152
|
+
if self._connection is None:
|
|
153
|
+
connection_is_dead = True
|
|
154
|
+
else:
|
|
155
|
+
try:
|
|
156
|
+
if self._connection.is_closed:
|
|
157
|
+
connection_is_dead = True
|
|
158
|
+
else:
|
|
159
|
+
# 【修复】显式探活,保持在锁内
|
|
160
|
+
# 这样如果探活失败,可以直接在锁内进入重建流程,无需重新竞争锁
|
|
161
|
+
try:
|
|
162
|
+
await asyncio.wait_for(self._connection.connect(timeout=1), timeout=15)
|
|
163
|
+
except Exception:
|
|
164
|
+
logger.warning("⚠️ 连接探活失败,判定为死连接,强制重建...")
|
|
165
|
+
connection_is_dead = True
|
|
166
|
+
except Exception:
|
|
167
|
+
connection_is_dead = True
|
|
168
|
+
|
|
169
|
+
# 如果连接死掉,执行清理和重建
|
|
170
|
+
if connection_is_dead:
|
|
157
171
|
await self._cleanup_resources()
|
|
172
|
+
# ... (重建连接逻辑保持不变: 遍历 hosts -> connect_robust -> 赋值 self._connection) ...
|
|
173
|
+
# 确保重建成功,否则抛出异常
|
|
158
174
|
|
|
175
|
+
# 为了代码完整性,这里补全重建逻辑的核心部分(基于你之前的代码)
|
|
159
176
|
retry_hosts = self.hosts.copy()
|
|
160
177
|
random.shuffle(retry_hosts)
|
|
161
178
|
last_error = None
|
|
@@ -164,110 +181,110 @@ class RabbitMQConnectionPool:
|
|
|
164
181
|
for _ in range(max_attempts):
|
|
165
182
|
if not retry_hosts:
|
|
166
183
|
break
|
|
167
|
-
|
|
168
184
|
host = retry_hosts.pop()
|
|
169
185
|
self._current_host = host
|
|
170
186
|
temp_conn = None
|
|
171
|
-
|
|
172
187
|
try:
|
|
173
|
-
|
|
188
|
+
conn_url = (
|
|
189
|
+
f"amqp://{self.username}:{self.password}@{host}:{self.port}/"
|
|
190
|
+
f"{self.virtualhost}?name={self.app_name}&heartbeat={self.heartbeat}"
|
|
191
|
+
f"&reconnect_interval={self.reconnect_interval}&fail_fast=1"
|
|
192
|
+
)
|
|
193
|
+
temp_conn = await asyncio.wait_for(
|
|
194
|
+
connect_robust(conn_url),
|
|
195
|
+
timeout=self.connection_timeout + 5
|
|
196
|
+
)
|
|
174
197
|
self._connection = temp_conn
|
|
175
|
-
temp_conn = None
|
|
176
198
|
self._initialized = True
|
|
177
199
|
last_error = None
|
|
178
|
-
logger.info(f"
|
|
200
|
+
logger.info(f"✅ [CONNECT_OK] 新连接建立成功: {host}")
|
|
179
201
|
break
|
|
180
202
|
except Exception as e:
|
|
181
|
-
logger.warning(
|
|
182
|
-
|
|
203
|
+
logger.warning(
|
|
204
|
+
f"⚠️ [RECONNECT_RETRY] 节点 {host} 连接失败: {e}")
|
|
205
|
+
if temp_conn:
|
|
183
206
|
try:
|
|
184
207
|
await temp_conn.close()
|
|
185
|
-
except
|
|
208
|
+
except:
|
|
186
209
|
pass
|
|
187
210
|
last_error = e
|
|
188
211
|
await asyncio.sleep(self.reconnect_interval)
|
|
189
212
|
|
|
190
213
|
if last_error:
|
|
191
|
-
self._connection = None
|
|
192
|
-
self._initialized = False
|
|
193
|
-
logger.error("💥 [RECONNECT_FATAL] 所有节点重试失败")
|
|
194
214
|
raise ConnectionError("所有 RabbitMQ 节点连接失败") from last_error
|
|
195
|
-
else:
|
|
196
|
-
# 情况2:连接对象存在,但可能处于“连接丢失,正在重连”的状态
|
|
197
|
-
# 【关键修复】显式调用 connect() 确保连接真正就绪
|
|
198
|
-
# 如果连接已经好,这会立即返回;如果正在重连,这会等待完成
|
|
199
|
-
try:
|
|
200
|
-
logger.debug(f"⏳ [WAIT_CONN] 等待连接就绪...")
|
|
201
|
-
await self._connection.connect(timeout=5) # 设置一个短暂超时避免卡死太久
|
|
202
|
-
logger.debug(f"✅ [WAIT_CONN_OK] 连接已就绪")
|
|
203
|
-
except asyncio.TimeoutError:
|
|
204
|
-
logger.warning("⚠️ [WAIT_CONN_TIMEOUT] 等待连接就绪超时,强制重连")
|
|
205
|
-
await self._cleanup_resources()
|
|
206
|
-
raise
|
|
207
|
-
except Exception as e:
|
|
208
|
-
logger.error(f"❌ [WAIT_CONN_FAIL] 连接不可用: {e}")
|
|
209
|
-
await self._cleanup_resources()
|
|
210
|
-
raise
|
|
211
215
|
|
|
212
|
-
# --- 阶段B
|
|
216
|
+
# --- 阶段 B: 通道恢复逻辑 ---
|
|
217
|
+
# 此时 self._connection 必须是有效的
|
|
213
218
|
if self._channel is None or self._channel.is_closed:
|
|
219
|
+
max_channel_attempts = 2
|
|
220
|
+
for attempt in range(max_channel_attempts):
|
|
221
|
+
try:
|
|
222
|
+
self._channel = await self._connection.channel()
|
|
223
|
+
await self._channel.set_qos(prefetch_count=self.prefetch_count)
|
|
224
|
+
logger.info(f"✅ [CHANNEL_OK] 主通道已就绪")
|
|
225
|
+
break
|
|
226
|
+
except Exception as e:
|
|
227
|
+
logger.warning(
|
|
228
|
+
f"⚠️ [CHANNEL_RETRY] 第 {attempt + 1} 次尝试创建通道失败: {e}")
|
|
229
|
+
if attempt < max_channel_attempts - 1:
|
|
230
|
+
await self._cleanup_resources() # 通道失败导致连接可能也坏了,重置
|
|
231
|
+
# 简单重试逻辑:这里抛出异常让外层重试,或者在这里递归调用
|
|
232
|
+
# 鉴于复杂度,建议抛出异常
|
|
233
|
+
raise e
|
|
234
|
+
else:
|
|
235
|
+
raise e
|
|
236
|
+
else:
|
|
237
|
+
# 通道存在,进行一次轻量级探活 (保持一致性)
|
|
214
238
|
try:
|
|
215
|
-
|
|
239
|
+
await self._channel.set_qos(prefetch_count=self.prefetch_count)
|
|
240
|
+
except Exception:
|
|
241
|
+
logger.warning("⚠️ 通道探活失败,重建通道...")
|
|
216
242
|
self._channel = await self._connection.channel()
|
|
217
243
|
await self._channel.set_qos(prefetch_count=self.prefetch_count)
|
|
218
|
-
logger.info(f"✅ [CHANNEL_OK] 主通道已恢复")
|
|
219
|
-
except Exception as e:
|
|
220
|
-
logger.error(f"❌ [CHANNEL_FAIL] 通道创建失败: {e}")
|
|
221
|
-
# 如果连通道都创建不了,说明连接状态有问题,回滚清理
|
|
222
|
-
await self._cleanup_resources()
|
|
223
|
-
raise
|
|
224
244
|
|
|
225
245
|
return self._channel
|
|
226
246
|
|
|
227
247
|
async def init_pools(self):
|
|
228
248
|
"""初始化入口"""
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
if self._initialized:
|
|
233
|
-
return
|
|
249
|
+
# 快速检查
|
|
250
|
+
if self._initialized:
|
|
251
|
+
return
|
|
234
252
|
|
|
235
|
-
# 在 try 之前声明变量,确保 except 块能访问
|
|
236
253
|
conn_created_in_this_try = None
|
|
237
254
|
|
|
238
255
|
try:
|
|
239
256
|
# 锁外创建连接,减少锁持有时间
|
|
240
257
|
init_host = random.choice(self.hosts)
|
|
241
258
|
conn = await self._create_connection_impl(init_host)
|
|
242
|
-
|
|
243
|
-
# 记录本次创建的连接
|
|
244
259
|
conn_created_in_this_try = conn
|
|
245
260
|
|
|
246
261
|
async with self._lock:
|
|
247
262
|
if self._is_shutdown:
|
|
248
263
|
raise RuntimeError("客户端已关闭")
|
|
249
264
|
|
|
265
|
+
# 双重检查:防止在锁外等待时,状态已改变或被其他协程初始化
|
|
266
|
+
if self._initialized:
|
|
267
|
+
logger.info("🚀 [INIT_SKIP] 其他协程已完成初始化")
|
|
268
|
+
if conn_created_in_this_try:
|
|
269
|
+
await conn_created_in_this_try.close()
|
|
270
|
+
return
|
|
271
|
+
|
|
250
272
|
# 提交新资源
|
|
251
273
|
self._connection = conn
|
|
252
274
|
self._channel = await self._connection.channel()
|
|
253
275
|
await self._channel.set_qos(prefetch_count=self.prefetch_count)
|
|
254
276
|
self._initialized = True
|
|
255
277
|
|
|
256
|
-
# 所有权转移成功,清空临时引用,防止 finally 重复关闭
|
|
257
278
|
conn_created_in_this_try = None
|
|
258
|
-
|
|
259
279
|
logger.info(f"🚀 [INIT_OK] 连接池初始化完成: {init_host}")
|
|
260
280
|
|
|
261
281
|
except Exception as e:
|
|
262
282
|
logger.error(f"💥 [INIT_FAIL] 初始化异常: {str(e)}")
|
|
263
|
-
|
|
264
|
-
# 这里现在可以合法访问 conn_created_in_this_try
|
|
265
283
|
if conn_created_in_this_try is not None:
|
|
266
284
|
try:
|
|
267
285
|
await conn_created_in_this_try.close()
|
|
268
286
|
except Exception:
|
|
269
287
|
pass
|
|
270
|
-
|
|
271
288
|
if not self._is_shutdown:
|
|
272
289
|
await self.close()
|
|
273
290
|
raise
|
|
@@ -299,6 +316,29 @@ class RabbitMQConnectionPool:
|
|
|
299
316
|
logger.error(f"❌ [FORCE_RECONNECT_FAIL] 强制重连失败: {e}")
|
|
300
317
|
raise
|
|
301
318
|
|
|
319
|
+
async def acquire_consumer_channel(self) -> RobustChannel:
|
|
320
|
+
"""
|
|
321
|
+
专门为消费者获取独立的通道。
|
|
322
|
+
遵循 aio_pika 最佳实践:消费者不应与发布者或其他消费者共享同一个 Channel 对象。
|
|
323
|
+
"""
|
|
324
|
+
# 确保连接池已初始化且连接是活的
|
|
325
|
+
if not self._initialized:
|
|
326
|
+
await self.init_pools()
|
|
327
|
+
|
|
328
|
+
# 确保 self._connection 是有效的(复用 _ensure_main_channel 的连接恢复逻辑)
|
|
329
|
+
await self._ensure_main_channel()
|
|
330
|
+
|
|
331
|
+
# 基于有效连接创建一个新的独立通道
|
|
332
|
+
try:
|
|
333
|
+
# 注意:这里直接使用 self._connection,而不是返回缓存的 self._channel
|
|
334
|
+
consumer_ch = await self._connection.channel()
|
|
335
|
+
await consumer_ch.set_qos(prefetch_count=self.prefetch_count)
|
|
336
|
+
logger.debug("✅ [CONSUMER_CH] 消费者独立通道已创建")
|
|
337
|
+
return consumer_ch
|
|
338
|
+
except Exception as e:
|
|
339
|
+
logger.error(f"❌ [CONSUMER_CH_FAIL] 创建消费者独立通道失败: {e}")
|
|
340
|
+
raise
|
|
341
|
+
|
|
302
342
|
async def acquire_channel(self) -> Tuple[RobustChannel, AbstractRobustConnection]:
|
|
303
343
|
"""获取主通道"""
|
|
304
344
|
if not self._initialized and not self._is_shutdown:
|
|
@@ -383,18 +423,28 @@ class RabbitMQConnectionPool:
|
|
|
383
423
|
|
|
384
424
|
async def close(self):
|
|
385
425
|
"""资源销毁"""
|
|
386
|
-
|
|
426
|
+
try:
|
|
427
|
+
# 设置超时,防止因锁异常导致无法关闭
|
|
428
|
+
await asyncio.wait_for(self._lock.acquire(), timeout=5.0)
|
|
429
|
+
except asyncio.TimeoutError:
|
|
430
|
+
logger.error("⚠️ [CLOSE_TIMEOUT] 获取锁超时,强制标记关闭")
|
|
431
|
+
# 如果拿不到锁,我们只能标记状态,无法安全清理连接
|
|
432
|
+
self._is_shutdown = True
|
|
433
|
+
self._initialized = False
|
|
434
|
+
return
|
|
435
|
+
|
|
436
|
+
try:
|
|
387
437
|
if self._is_shutdown:
|
|
388
438
|
return
|
|
389
439
|
self._is_shutdown = True
|
|
390
440
|
self._initialized = False
|
|
391
441
|
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
442
|
+
logger.info("🛑 [CLOSE] 开始关闭连接池...")
|
|
443
|
+
await self._cleanup_resources()
|
|
444
|
+
logger.info("🏁 [CLOSE] 连接池已关闭")
|
|
445
|
+
finally:
|
|
446
|
+
if self._lock.locked():
|
|
447
|
+
self._lock.release()
|
|
398
448
|
|
|
399
449
|
async def declare_queue(self, queue_name: str, **kwargs) -> AbstractQueue:
|
|
400
450
|
channel, _ = await self.acquire_channel()
|
|
@@ -28,16 +28,31 @@ class RabbitMQClientManager(RabbitMQCoreService):
|
|
|
28
28
|
|
|
29
29
|
@classmethod
|
|
30
30
|
async def _clean_client_resources(cls, client: RabbitMQClient) -> None:
|
|
31
|
-
"""
|
|
31
|
+
"""清理客户端无效资源(支持显式关闭独立通道)"""
|
|
32
32
|
try:
|
|
33
|
-
#
|
|
33
|
+
# 1. 先尝试正常停止消费
|
|
34
34
|
if client._consumer_tag:
|
|
35
35
|
await client.stop_consuming()
|
|
36
|
-
logger.debug("客户端无效资源清理完成(单通道无需归还)")
|
|
37
36
|
except Exception as e:
|
|
38
|
-
logger.warning(f"
|
|
37
|
+
logger.warning(f"停止消费逻辑异常: {str(e)}")
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
# 2. 【关键】显式关闭 Channel
|
|
41
|
+
# 在我们的新架构中,消费者持有独立通道,必须在这里显式关闭。
|
|
42
|
+
# 生产者持有的主通道虽然通常由 Pool 管理,但这里为了彻底清理,
|
|
43
|
+
# 可以尝试关闭(Pool 内部会有保护,或者接受主通道被关闭后重新创建)。
|
|
44
|
+
if client._channel and not client._channel.is_closed:
|
|
45
|
+
try:
|
|
46
|
+
await client._channel.close()
|
|
47
|
+
logger.debug(f"清理资源:已关闭客户端通道")
|
|
48
|
+
except Exception as e:
|
|
49
|
+
logger.warning(f"清理资源:关闭通道异常: {e}")
|
|
50
|
+
except Exception as e:
|
|
51
|
+
logger.warning(f"清理资源过程发生异常: {str(e)}")
|
|
39
52
|
finally:
|
|
40
|
-
# 强制重置客户端状态
|
|
53
|
+
# 3. 强制重置客户端状态
|
|
54
|
+
# 注意:不要将 _channel_conn 置空,因为它是连接池的引用,置空后可能导致逻辑判断出错
|
|
55
|
+
# 但为了彻底断开,置空也是安全的,因为 connect 会重新获取。
|
|
41
56
|
client._channel = None
|
|
42
57
|
client._channel_conn = None
|
|
43
58
|
client._exchange = None
|
|
@@ -53,20 +53,23 @@ class NacosServiceDiscovery:
|
|
|
53
53
|
instance for instance in all_instances
|
|
54
54
|
if instance.get('enabled', True)
|
|
55
55
|
]
|
|
56
|
+
|
|
57
|
+
if not all_instances:
|
|
58
|
+
SYLogger.info(f"nacos:筛选后 {service_name} 无可用实例")
|
|
59
|
+
return []
|
|
60
|
+
|
|
56
61
|
SYLogger.info(
|
|
57
62
|
f"nacos:共发现 {len(all_instances)} 个 {service_name} 服务实例")
|
|
58
63
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
if version_to_use:
|
|
64
|
+
if target_version:
|
|
62
65
|
same_version_instances = [
|
|
63
66
|
instance for instance in all_instances
|
|
64
|
-
if instance.get('metadata', {}).get('version') ==
|
|
67
|
+
if instance.get('metadata', {}).get('version') == target_version
|
|
65
68
|
]
|
|
66
69
|
|
|
67
70
|
if same_version_instances:
|
|
68
71
|
SYLogger.info(
|
|
69
|
-
f"nacos:筛选出 {len(same_version_instances)} 个与当前版本({
|
|
72
|
+
f"nacos:筛选出 {len(same_version_instances)} 个与当前版本({target_version})匹配的实例")
|
|
70
73
|
return same_version_instances
|
|
71
74
|
|
|
72
75
|
no_version_instances = [
|
|
@@ -76,7 +79,7 @@ class NacosServiceDiscovery:
|
|
|
76
79
|
|
|
77
80
|
if no_version_instances:
|
|
78
81
|
SYLogger.info(
|
|
79
|
-
f"nacos:未找到相同版本({
|
|
82
|
+
f"nacos:未找到相同版本({target_version})的实例,筛选出 {len(no_version_instances)} 个无版本号的实例")
|
|
80
83
|
return no_version_instances
|
|
81
84
|
else:
|
|
82
85
|
no_version_instances = [
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import smtplib
|
|
3
|
+
import asyncio
|
|
4
|
+
from typing import List, Optional
|
|
5
|
+
from email.mime.text import MIMEText
|
|
6
|
+
from email.mime.multipart import MIMEMultipart
|
|
7
|
+
from email.header import Header
|
|
8
|
+
from email.mime.base import MIMEBase
|
|
9
|
+
from email import encoders
|
|
10
|
+
from src.sycommon.config.Config import Config
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SYEmail:
|
|
14
|
+
"""盛业邮件发送工具类"""
|
|
15
|
+
|
|
16
|
+
@staticmethod
|
|
17
|
+
def _get_config() -> dict:
|
|
18
|
+
"""获取邮件配置"""
|
|
19
|
+
return Config().config.get("AIEmailConfig", {})
|
|
20
|
+
|
|
21
|
+
@staticmethod
|
|
22
|
+
async def send(
|
|
23
|
+
to: List[str],
|
|
24
|
+
subject: str,
|
|
25
|
+
content: str,
|
|
26
|
+
attachments: Optional[List[str]] = None,
|
|
27
|
+
verbose: bool = False
|
|
28
|
+
) -> bool:
|
|
29
|
+
"""
|
|
30
|
+
异步发送邮件
|
|
31
|
+
|
|
32
|
+
:param to: 收件人列表
|
|
33
|
+
:param subject: 邮件主题
|
|
34
|
+
:param content: 邮件正文
|
|
35
|
+
:param attachments: 附件路径列表 (可选)
|
|
36
|
+
:param verbose: 是否打印详细日志
|
|
37
|
+
:return: 是否发送成功
|
|
38
|
+
"""
|
|
39
|
+
try:
|
|
40
|
+
# 1. 构建邮件对象
|
|
41
|
+
message = SYEmail._build_message(
|
|
42
|
+
to, subject, content, attachments
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
# 2. 发送邮件 (网络阻塞型,扔到线程池运行)
|
|
46
|
+
loop = asyncio.get_running_loop()
|
|
47
|
+
success = await loop.run_in_executor(
|
|
48
|
+
None,
|
|
49
|
+
SYEmail._send_smtp,
|
|
50
|
+
message,
|
|
51
|
+
to,
|
|
52
|
+
verbose
|
|
53
|
+
)
|
|
54
|
+
return success
|
|
55
|
+
|
|
56
|
+
except Exception as e:
|
|
57
|
+
print(f"❌ 异步邮件调度异常: {e}")
|
|
58
|
+
return False
|
|
59
|
+
|
|
60
|
+
@staticmethod
|
|
61
|
+
def _build_message(to: List[str], subject: str, content: str, attachments: Optional[List[str]] = None):
|
|
62
|
+
"""构建邮件对象"""
|
|
63
|
+
config = SYEmail._get_config()
|
|
64
|
+
|
|
65
|
+
message = MIMEMultipart()
|
|
66
|
+
message['From'] = Header(config.get('sender_name', ''), 'utf-8')
|
|
67
|
+
message['To'] = Header(", ".join(to), 'utf-8')
|
|
68
|
+
message['Subject'] = Header(subject, 'utf-8')
|
|
69
|
+
message.attach(MIMEText(content, 'plain', 'utf-8'))
|
|
70
|
+
|
|
71
|
+
if attachments:
|
|
72
|
+
for file_path in attachments:
|
|
73
|
+
if os.path.exists(file_path):
|
|
74
|
+
try:
|
|
75
|
+
with open(file_path, 'rb') as f:
|
|
76
|
+
# 使用 octet-stream 通用流类型
|
|
77
|
+
attachment = MIMEBase(
|
|
78
|
+
'application', 'octet-stream')
|
|
79
|
+
attachment.set_payload(f.read())
|
|
80
|
+
encoders.encode_base64(attachment)
|
|
81
|
+
|
|
82
|
+
# 解决文件名中文乱码问题
|
|
83
|
+
filename = os.path.basename(file_path)
|
|
84
|
+
attachment.add_header(
|
|
85
|
+
'Content-Disposition',
|
|
86
|
+
'attachment',
|
|
87
|
+
filename=('utf-8', '', filename)
|
|
88
|
+
)
|
|
89
|
+
message.attach(attachment)
|
|
90
|
+
except Exception as e:
|
|
91
|
+
print(f"⚠️ 读取附件失败 [{file_path}]: {e}")
|
|
92
|
+
else:
|
|
93
|
+
print(f"⚠️ 附件文件不存在: {file_path}")
|
|
94
|
+
return message
|
|
95
|
+
|
|
96
|
+
@staticmethod
|
|
97
|
+
def _send_smtp(message, to_list: List[str], verbose: bool) -> bool:
|
|
98
|
+
"""执行 SMTP 发送 (在线程池中运行)"""
|
|
99
|
+
config = SYEmail._get_config()
|
|
100
|
+
smtp_obj = None
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
if verbose:
|
|
104
|
+
print(
|
|
105
|
+
f"正在连接 {config.get('smtp_server')}:{config.get('smtp_port')} ...")
|
|
106
|
+
|
|
107
|
+
# 1. 创建连接
|
|
108
|
+
smtp_obj = smtplib.SMTP(
|
|
109
|
+
config.get('smtp_server'),
|
|
110
|
+
config.get('smtp_port'),
|
|
111
|
+
timeout=10
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
if verbose:
|
|
115
|
+
smtp_obj.set_debuglevel(1)
|
|
116
|
+
|
|
117
|
+
# 2. 直接进行登录认证
|
|
118
|
+
if verbose:
|
|
119
|
+
print(f"正在使用账号 {config.get('smtp_user')} 登录...")
|
|
120
|
+
|
|
121
|
+
smtp_obj.login(config.get('smtp_user'), config.get('smtp_pass'))
|
|
122
|
+
|
|
123
|
+
if verbose:
|
|
124
|
+
print("✅ 登录成功")
|
|
125
|
+
|
|
126
|
+
# 3. 发送邮件
|
|
127
|
+
smtp_obj.sendmail(
|
|
128
|
+
config.get('sender_email'),
|
|
129
|
+
to_list,
|
|
130
|
+
message.as_string()
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
if verbose:
|
|
134
|
+
print("✅ 邮件发送成功!")
|
|
135
|
+
return True
|
|
136
|
+
|
|
137
|
+
except smtplib.SMTPAuthenticationError:
|
|
138
|
+
print("❌ 认证失败:请检查账号密码。")
|
|
139
|
+
except smtplib.SMTPException as e:
|
|
140
|
+
print(f"❌ SMTP 协议错误: {e}")
|
|
141
|
+
except Exception as e:
|
|
142
|
+
print(f"❌ 发送邮件时发生未知错误: {e}")
|
|
143
|
+
finally:
|
|
144
|
+
if smtp_obj:
|
|
145
|
+
try:
|
|
146
|
+
smtp_obj.quit()
|
|
147
|
+
except:
|
|
148
|
+
pass
|
|
149
|
+
return False
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# 使用示例
|
|
153
|
+
async def main():
|
|
154
|
+
"""异步主函数测试"""
|
|
155
|
+
print("开始发送邮件...")
|
|
156
|
+
|
|
157
|
+
success = await SYEmail.send(
|
|
158
|
+
to=["Osulcode.xiao@syholdings.com"],
|
|
159
|
+
subject="异步邮件测试",
|
|
160
|
+
content="这是一封通过异步方式发送的测试邮件。",
|
|
161
|
+
attachments=["test_file.txt"],
|
|
162
|
+
verbose=True
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
if success:
|
|
166
|
+
print("\n🎉 任务完成")
|
|
167
|
+
else:
|
|
168
|
+
print("\n⚠️ 任务失败")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
if __name__ == "__main__":
|
|
172
|
+
asyncio.run(main())
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import smtplib
|
|
3
|
+
import asyncio
|
|
4
|
+
from typing import List, Optional
|
|
5
|
+
from email.mime.text import MIMEText
|
|
6
|
+
from email.mime.multipart import MIMEMultipart
|
|
7
|
+
from email.header import Header
|
|
8
|
+
from email.mime.base import MIMEBase
|
|
9
|
+
from email import encoders
|
|
10
|
+
from src.sycommon.config.Config import Config
|
|
11
|
+
from src.sycommon.logging.kafka_log import SYLogger
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SYEmail:
|
|
15
|
+
"""盛业邮件发送工具类"""
|
|
16
|
+
|
|
17
|
+
@staticmethod
|
|
18
|
+
def _get_config() -> dict:
|
|
19
|
+
"""获取邮件配置"""
|
|
20
|
+
return Config().config.get("AIEmailConfig", {})
|
|
21
|
+
|
|
22
|
+
@staticmethod
|
|
23
|
+
async def send(
|
|
24
|
+
to: List[str],
|
|
25
|
+
subject: str,
|
|
26
|
+
content: str,
|
|
27
|
+
attachments: Optional[List[str]] = None,
|
|
28
|
+
verbose: bool = False
|
|
29
|
+
) -> bool:
|
|
30
|
+
"""
|
|
31
|
+
异步发送邮件
|
|
32
|
+
|
|
33
|
+
:param to: 收件人列表
|
|
34
|
+
:param subject: 邮件主题
|
|
35
|
+
:param content: 邮件正文
|
|
36
|
+
:param attachments: 附件路径列表 (可选)
|
|
37
|
+
:param verbose: 是否打印详细日志
|
|
38
|
+
:return: 是否发送成功
|
|
39
|
+
"""
|
|
40
|
+
try:
|
|
41
|
+
# 1. 构建邮件对象
|
|
42
|
+
message = SYEmail._build_message(
|
|
43
|
+
to, subject, content, attachments
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# 2. 发送邮件 (网络阻塞型,扔到线程池运行)
|
|
47
|
+
loop = asyncio.get_running_loop()
|
|
48
|
+
success = await loop.run_in_executor(
|
|
49
|
+
None,
|
|
50
|
+
SYEmail._send_smtp,
|
|
51
|
+
message,
|
|
52
|
+
to,
|
|
53
|
+
verbose
|
|
54
|
+
)
|
|
55
|
+
return success
|
|
56
|
+
|
|
57
|
+
except Exception as e:
|
|
58
|
+
SYLogger.error(f"❌ 异步邮件调度异常: {e}")
|
|
59
|
+
return False
|
|
60
|
+
|
|
61
|
+
@staticmethod
|
|
62
|
+
def _build_message(to: List[str], subject: str, content: str, attachments: Optional[List[str]] = None):
|
|
63
|
+
"""构建邮件对象"""
|
|
64
|
+
config = SYEmail._get_config()
|
|
65
|
+
|
|
66
|
+
message = MIMEMultipart()
|
|
67
|
+
message['From'] = Header(config.get('sender_name', ''), 'utf-8')
|
|
68
|
+
message['To'] = Header(", ".join(to), 'utf-8')
|
|
69
|
+
message['Subject'] = Header(subject, 'utf-8')
|
|
70
|
+
message.attach(MIMEText(content, 'plain', 'utf-8'))
|
|
71
|
+
|
|
72
|
+
if attachments:
|
|
73
|
+
for file_path in attachments:
|
|
74
|
+
if os.path.exists(file_path):
|
|
75
|
+
try:
|
|
76
|
+
with open(file_path, 'rb') as f:
|
|
77
|
+
# 使用 octet-stream 通用流类型
|
|
78
|
+
attachment = MIMEBase(
|
|
79
|
+
'application', 'octet-stream')
|
|
80
|
+
attachment.set_payload(f.read())
|
|
81
|
+
encoders.encode_base64(attachment)
|
|
82
|
+
|
|
83
|
+
# 解决文件名中文乱码问题
|
|
84
|
+
filename = os.path.basename(file_path)
|
|
85
|
+
attachment.add_header(
|
|
86
|
+
'Content-Disposition',
|
|
87
|
+
'attachment',
|
|
88
|
+
filename=('utf-8', '', filename)
|
|
89
|
+
)
|
|
90
|
+
message.attach(attachment)
|
|
91
|
+
except Exception as e:
|
|
92
|
+
SYLogger.error(f"⚠️ 读取附件失败 [{file_path}]: {e}")
|
|
93
|
+
else:
|
|
94
|
+
SYLogger.error(f"⚠️ 附件文件不存在: {file_path}")
|
|
95
|
+
return message
|
|
96
|
+
|
|
97
|
+
@staticmethod
|
|
98
|
+
def _send_smtp(message, to_list: List[str], verbose: bool) -> bool:
|
|
99
|
+
"""执行 SMTP 发送 (在线程池中运行)"""
|
|
100
|
+
config = SYEmail._get_config()
|
|
101
|
+
smtp_obj = None
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
if verbose:
|
|
105
|
+
SYLogger.info(
|
|
106
|
+
f"正在连接 {config.get('smtp_server')}:{config.get('smtp_port')} ...")
|
|
107
|
+
|
|
108
|
+
# 1. 创建连接
|
|
109
|
+
smtp_obj = smtplib.SMTP(
|
|
110
|
+
config.get('smtp_server'),
|
|
111
|
+
config.get('smtp_port'),
|
|
112
|
+
timeout=10
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
if verbose:
|
|
116
|
+
smtp_obj.set_debuglevel(1)
|
|
117
|
+
|
|
118
|
+
# 2. 直接进行登录认证
|
|
119
|
+
if verbose:
|
|
120
|
+
SYLogger.info(f"正在使用账号 {config.get('smtp_user')} 登录...")
|
|
121
|
+
|
|
122
|
+
smtp_obj.login(config.get('smtp_user'), config.get('smtp_pass'))
|
|
123
|
+
|
|
124
|
+
if verbose:
|
|
125
|
+
SYLogger.info("✅ 登录成功")
|
|
126
|
+
|
|
127
|
+
# 3. 发送邮件
|
|
128
|
+
smtp_obj.sendmail(
|
|
129
|
+
config.get('sender_email'),
|
|
130
|
+
to_list,
|
|
131
|
+
message.as_string()
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
if verbose:
|
|
135
|
+
SYLogger.info("✅ 邮件发送成功!")
|
|
136
|
+
return True
|
|
137
|
+
|
|
138
|
+
except smtplib.SMTPAuthenticationError:
|
|
139
|
+
SYLogger.error("❌ 认证失败:请检查账号密码。")
|
|
140
|
+
except smtplib.SMTPException as e:
|
|
141
|
+
SYLogger.error(f"❌ SMTP 协议错误: {e}")
|
|
142
|
+
except Exception as e:
|
|
143
|
+
SYLogger.error(f"❌ 发送邮件时发生未知错误: {e}")
|
|
144
|
+
finally:
|
|
145
|
+
if smtp_obj:
|
|
146
|
+
try:
|
|
147
|
+
smtp_obj.quit()
|
|
148
|
+
except:
|
|
149
|
+
pass
|
|
150
|
+
return False
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# 使用示例
|
|
154
|
+
# async def main():
|
|
155
|
+
# """异步主函数测试"""
|
|
156
|
+
# print("开始发送邮件...")
|
|
157
|
+
|
|
158
|
+
# success = await SYEmail.send(
|
|
159
|
+
# to=["Osulcode.xiao@syholdings.com"],
|
|
160
|
+
# subject="异步邮件测试",
|
|
161
|
+
# content="这是一封通过异步方式发送的测试邮件。",
|
|
162
|
+
# attachments=["test_file.txt"],
|
|
163
|
+
# verbose=True
|
|
164
|
+
# )
|
|
165
|
+
|
|
166
|
+
# if success:
|
|
167
|
+
# print("\n🎉 任务完成")
|
|
168
|
+
# else:
|
|
169
|
+
# print("\n⚠️ 任务失败")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# if __name__ == "__main__":
|
|
173
|
+
# asyncio.run(main())
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sycommon-python-lib
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0b1
|
|
4
4
|
Summary: Add your description here
|
|
5
5
|
Requires-Python: >=3.11
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -21,6 +21,7 @@ Requires-Dist: nacos-sdk-python<3.0,>=2.0.9
|
|
|
21
21
|
Requires-Dist: psutil>=7.2.1
|
|
22
22
|
Requires-Dist: pydantic>=2.12.5
|
|
23
23
|
Requires-Dist: python-dotenv>=1.2.1
|
|
24
|
+
Requires-Dist: python-multipart>=0.0.21
|
|
24
25
|
Requires-Dist: pyyaml>=6.0.3
|
|
25
26
|
Requires-Dist: sentry-sdk[fastapi]>=2.49.0
|
|
26
27
|
Requires-Dist: sqlalchemy[asyncio]>=2.0.45
|
|
@@ -50,11 +50,11 @@ sycommon/models/mqmsg_model.py,sha256=Zo-LsDMFuF1Vkx9ZmwBC9E7TrCw-7nAQD2TE4v9-6F
|
|
|
50
50
|
sycommon/models/mqsend_config.py,sha256=NQX9dc8PpuquMG36GCVhJe8omAW1KVXXqr6lSRU6D7I,268
|
|
51
51
|
sycommon/models/sso_user.py,sha256=i1WAN6k5sPcPApQEdtjpWDy7VrzWLpOrOQewGLGoGIw,2702
|
|
52
52
|
sycommon/notice/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
53
|
-
sycommon/notice/uvicorn_monitor.py,sha256=
|
|
54
|
-
sycommon/rabbitmq/rabbitmq_client.py,sha256=
|
|
55
|
-
sycommon/rabbitmq/rabbitmq_pool.py,sha256=
|
|
53
|
+
sycommon/notice/uvicorn_monitor.py,sha256=PrC-OFRE71mL8TcbfdkJRKbjwGAbgsWtyBPWIw1qs08,6753
|
|
54
|
+
sycommon/rabbitmq/rabbitmq_client.py,sha256=MC4QNSZpfYSPHY8-iW3RRuZAs0MZcNGWJD9EzCLc68k,22115
|
|
55
|
+
sycommon/rabbitmq/rabbitmq_pool.py,sha256=igQ4Yh96oZyM8UEhNa_rljhZcitHwske3UsjPtBuj8c,18946
|
|
56
56
|
sycommon/rabbitmq/rabbitmq_service.py,sha256=XSHo9HuIJ_lq-vizRh4xJVdZr_2zLqeLhot09qb0euA,2025
|
|
57
|
-
sycommon/rabbitmq/rabbitmq_service_client_manager.py,sha256=
|
|
57
|
+
sycommon/rabbitmq/rabbitmq_service_client_manager.py,sha256=O4zVL7lY2eaN_hUajwB5Ai6nnW-bevRBXysxSMhfVqw,9005
|
|
58
58
|
sycommon/rabbitmq/rabbitmq_service_connection_monitor.py,sha256=uvoMuJDzJ9i63uVRq1NKFV10CvkbGnTMyEoq2rgjQx8,3013
|
|
59
59
|
sycommon/rabbitmq/rabbitmq_service_consumer_manager.py,sha256=489r1RKd5WrTNMAcWCxUZpt9yWGrNunZlLCCp-M_rzM,11497
|
|
60
60
|
sycommon/rabbitmq/rabbitmq_service_core.py,sha256=6RMvIf78DmEOZmN8dA0duA9oy4ieNswdGrOeyJdD6tU,4753
|
|
@@ -73,17 +73,19 @@ sycommon/synacos/nacos_client_base.py,sha256=KZgQAg9Imfr_TfM-4LXdtrnTdJ-beu6bcNJ
|
|
|
73
73
|
sycommon/synacos/nacos_config_manager.py,sha256=Cff-4gpp0aD7sQVi-nEvDO4BWqK9abEDDDJ9qXKFQgs,4399
|
|
74
74
|
sycommon/synacos/nacos_heartbeat_manager.py,sha256=G80_pOn37WdO_HpYUiAfpwMqAxW0ff0Bnw0NEuge9v0,5568
|
|
75
75
|
sycommon/synacos/nacos_service.py,sha256=BezQ1eDIYwBPE567Po_Qh1Ki_z9WmhZy1J1NiTPbdHY,6118
|
|
76
|
-
sycommon/synacos/nacos_service_discovery.py,sha256=
|
|
76
|
+
sycommon/synacos/nacos_service_discovery.py,sha256=rcnMTX8e6VJmu2LwW5lrubt2UQNpu4e8P7GVcOi79Cg,6926
|
|
77
77
|
sycommon/synacos/nacos_service_registration.py,sha256=plg2PmH8CWgmVnPtiIXBxtj-3BpyMdSzKr1wyWRdzh4,10968
|
|
78
78
|
sycommon/synacos/param.py,sha256=KcfSkxnXOa0TGmCjY8hdzU9pzUsA8-4PeyBKWI2-568,1765
|
|
79
|
+
sycommon/tests/test_email.py,sha256=-oPtYVGQzJ3Cv-op3ZNRMeyYOF-UNidGJC35CHRgjGQ,5442
|
|
79
80
|
sycommon/tools/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
80
81
|
sycommon/tools/docs.py,sha256=OPj2ETheuWjXLyaXtaZPbwmJKfJaYXV5s4XMVAUNrms,1607
|
|
81
82
|
sycommon/tools/env.py,sha256=Ah-tBwG2C0_hwLGFebVQgKdWWXCjTzBuF23gCkLHYy4,2437
|
|
82
83
|
sycommon/tools/merge_headers.py,sha256=u9u8_1ZIuGIminWsw45YJ5qnsx9MB-Fot0VPge7itPw,4941
|
|
83
84
|
sycommon/tools/snowflake.py,sha256=xQlYXwYnI85kSJ1rZ89gMVBhzemP03xrMPVX9vVa3MY,9228
|
|
85
|
+
sycommon/tools/syemail.py,sha256=BDFhgf7WDOQeTcjxJEQdu0dQhnHFPO_p3eI0-Ni3LhQ,5612
|
|
84
86
|
sycommon/tools/timing.py,sha256=OiiE7P07lRoMzX9kzb8sZU9cDb0zNnqIlY5pWqHcnkY,2064
|
|
85
|
-
sycommon_python_lib-0.
|
|
86
|
-
sycommon_python_lib-0.
|
|
87
|
-
sycommon_python_lib-0.
|
|
88
|
-
sycommon_python_lib-0.
|
|
89
|
-
sycommon_python_lib-0.
|
|
87
|
+
sycommon_python_lib-0.2.0b1.dist-info/METADATA,sha256=5MhM3eTt-f3-du7nf8tlpH8wDKIU3_4z6nIRURL6Rg4,7372
|
|
88
|
+
sycommon_python_lib-0.2.0b1.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
89
|
+
sycommon_python_lib-0.2.0b1.dist-info/entry_points.txt,sha256=q_h2nbvhhmdnsOUZEIwpuoDjaNfBF9XqppDEmQn9d_A,46
|
|
90
|
+
sycommon_python_lib-0.2.0b1.dist-info/top_level.txt,sha256=98CJ-cyM2WIKxLz-Pf0AitWLhJyrfXvyY8slwjTXNuc,17
|
|
91
|
+
sycommon_python_lib-0.2.0b1.dist-info/RECORD,,
|
{sycommon_python_lib-0.1.59.dist-info → sycommon_python_lib-0.2.0b1.dist-info}/entry_points.txt
RENAMED
|
File without changes
|
{sycommon_python_lib-0.1.59.dist-info → sycommon_python_lib-0.2.0b1.dist-info}/top_level.txt
RENAMED
|
File without changes
|