modify: 分布式方案

This commit is contained in:
2026-08-27 17:06:03 +08:00
parent 53f2ac236e
commit 2baef7cf2f
7 changed files with 572 additions and 307 deletions
+38 -1
View File
@@ -288,6 +288,25 @@ worker/<type>/<worker_id>/__admin__
- **进程插件(external)不受该开关影响**:网关只对 **inline** 插件用 cache 解析启动副本数(见「启动流程」第 5 步 `load_workers`);external 插件由外部编排按其自身配置 `config/workers/<type>/config.toml` 的 `count` 启动,不经网关、也不读 cache。运行时 `scale` 对 external 仍走「向存活副本下发 rescale」路径,同样与该开关无关。 - **进程插件(external)不受该开关影响**:网关只对 **inline** 插件用 cache 解析启动副本数(见「启动流程」第 5 步 `load_workers`);external 插件由外部编排按其自身配置 `config/workers/<type>/config.toml` 的 `count` 启动,不经网关、也不读 cache。运行时 `scale` 对 external 仍走「向存活副本下发 rescale」路径,同样与该开关无关。
### inline worker 宿主级领导选举(gunicorn 多 worker 兼容)
inline worker 的生命周期交给**基于分布式锁的宿主级领导选举**(取代“每 gunicorn worker 各自拉起”),key `inline_owner:<gateway_id>` 按网关隔离:
- **多 worker(同网关)**:同一时刻只有一个网关进程托管 inline worker(副本数精确等于 `count`,不再是 `worker 数 × count`);其余进程只做 HTTP,经 Zenoh liveliness 发现并调用 inline worker。HTTP 层仍由所有 gunicorn worker 分担吞吐。
- **多实例(不同网关)**:各网关按自己的 `gateway_id` 各自选举,互不抢占。
- **持有者崩溃自愈**:非持有者进程经 `refresh_workers` 周期性巡检(`_maybe_heal_inline`)——若发现某 inline 类型既无线活副本又无本地副本,就经分布式锁抢得领导权并拉起这些 worker(防 gunicorn 复活死 slot 却因租约未过期而跳过的问题)。接管同样 `set_nx` 唯一胜出,无双持有。
- 前提:同一共享 `[cache]`(见「全局副本数协调」);单实例本机 `diskcache` 即可,多实例需共享后端。
### 多 worker 的边界(转发 vs 处理)
在 gunicorn 多 worker 模式下,结论是:**多 worker 提升的是网关"HTTP 接入/转发"能力,不提升"worker 处理"能力**——网关角色是代理,真正的 RPC 处理在 worker 端。
| 瓶颈所在 | 多 gunicorn worker 是否有效 | 正确扩法 |
| -------- | --------------------------- | -------- |
| HTTP 接入/转发(高 QPS 转发、并发慢客户端、鉴权/路由) | ✅ 有效(上限≈核数) | 加 gunicorn `-w` |
| inline 插件实际计算 | ⚠️ 有限(多 worker 只是更快把请求塞给同一个 inline worker,inline 仍单点) | 加 worker 副本数 `count` / 改跑 external 进程 |
| external 进程插件处理 | ✅ 有效(多 worker 可并行转发给独立进程) | 加 gunicorn `-w` + 对等扩 external |
### 前端管理页 ### 前端管理页
`Worker 管理`(`/workers`,深色主题): `Worker 管理`(`/workers`,深色主题):
@@ -524,7 +543,7 @@ start.sh 容器入口:uv sync + chongming-worker --type
### Broker 来源(内嵌 vs 外部) ### Broker 来源(内嵌 vs 外部)
网关默认启动内嵌 amqtt Broker(子进程 + 文件锁单例),`[<env>.mqtt].use_embedded_broker` 控制 Broker 来源: 网关默认启动内嵌 amqtt Broker(子进程 + **宿主级分布式排他锁**单例),`[<env>.mqtt].use_embedded_broker` 控制 Broker 来源:
```toml ```toml
[development.mqtt] [development.mqtt]
@@ -537,6 +556,12 @@ use_embedded_broker = true # true(默认)网关启动 amqtt Broker;false
- `use_embedded_broker = true`(默认):网关启动内嵌 amqtt Broker 子进程,MQTT Bridge 作为客户端连回自身。 - `use_embedded_broker = true`(默认):网关启动内嵌 amqtt Broker 子进程,MQTT Bridge 作为客户端连回自身。
- `use_embedded_broker = false`:网关**不启动** Broker,MQTT Bridge 直接连接 `host:port` 指向的外部 MQTT Broker(如独立部署的 EMQX/Mosquitto)。`host` 需改为外部 Broker 地址,`enabled = false` 时整个 MQTT 通道关闭。 - `use_embedded_broker = false`:网关**不启动** Broker,MQTT Bridge 直接连接 `host:port` 指向的外部 MQTT Broker(如独立部署的 EMQX/Mosquitto)。`host` 需改为外部 Broker 地址,`enabled = false` 时整个 MQTT 通道关闭。
**单例与生命周期控制**(取代旧文件锁):
- 内嵌 Broker 用 `chongming-cache` 的 `DistributedLock`(key `mqtt_broker:<hostname>`,**按宿主隔离**——内嵌 Broker 绑定本机端口),多 worker/多实例下同一宿主机只启动一个 Broker;`cache` 未配置时跳过并告警。
- 持锁的网关在缓存登记其 Broker 子进程 PID(`mqtt_broker_owner:<hostname>`)。前任持有者崩溃/被强杀而留下**孤儿 Broker 子进程**时,接管的网关据此 PID 识别并终止之,再启动**由自己托管**的新子进程(可停、可接管);否则端口被不可托管进程占用时仅复用、不托管。
- 关停时仅真正托管子进程的网关终止进程、清理登记并释放锁,绝不误伤他人。
### Broker 认证(用户名/密码) ### Broker 认证(用户名/密码)
`[<env>.mqtt].username` / `password` 配置 Broker 认证(可选,默认匿名): `[<env>.mqtt].username` / `password` 配置 Broker 认证(可选,默认匿名):
@@ -579,6 +604,7 @@ MQTT Bridge 是**网关进程内**的 MQTT 客户端组件,负责订阅主题
- **抢锁机制**:启动时 `set_nx`(SET NX + TTL 租约)原子抢锁,key 为 `mqtt_bridge`;抢到锁的网关才启动 Bridge,其余网关跳过。 - **抢锁机制**:启动时 `set_nx`(SET NX + TTL 租约)原子抢锁,key 为 `mqtt_bridge`;抢到锁的网关才启动 Bridge,其余网关跳过。
- **租约续约**:持锁网关后台定期 `touch` 续约;若持锁网关宕机/失联,租约到期后锁自动释放,其他网关可重新抢到(重启后生效)。 - **租约续约**:持锁网关后台定期 `touch` 续约;若持锁网关宕机/失联,租约到期后锁自动释放,其他网关可重新抢到(重启后生效)。
- **受保护释放**:关闭时仅当 token 仍匹配(仍是自己持有)才删除,绝不误删其他网关抢到的锁。 - **受保护释放**:关闭时仅当 token 仍匹配(仍是自己持有)才删除,绝不误删其他网关抢到的锁。
- **持有者崩溃自愈接管**:仅靠 gunicorn 复活死掉 slot 无法 failover(新进程可能在旧锁租约未过期时抢锁失败而照样跳过)。各网关进程内有**周期性巡检**(`mqtt_bridge_supervisor`,周期经 `[<env>.mqtt].bridge_supervise_interval` 配置,默认 15s):非持有者进程若发现锁已空闲(原持有者租约过期=已死/失联),即经 `set_nx` 抢得唯一持有权并**自动重建 Bridge**;接管时同步按需拉起/接管内嵌 Broker。分布式锁保证任意时刻只有一方胜出,无分裂。
```toml ```toml
[cache] [cache]
@@ -589,6 +615,17 @@ backend = "redis" # 跨主机收敛必须用 redis(或共享卷 dis
- **`use_embedded_broker = true`(各机内嵌独立 Broker)**:各机 Broker 互不相连、本身不产生重复响应;此时全局单 Bridge 只会连到持锁网关那台机器的 Broker。 - **`use_embedded_broker = true`(各机内嵌独立 Broker)**:各机 Broker 互不相连、本身不产生重复响应;此时全局单 Bridge 只会连到持锁网关那台机器的 Broker。
- **`use_embedded_broker = false`(共享外部 Broker)**:原先 N 台网关的 N 个 Bridge 会共同订阅同一批主题,导致一条请求被各消费一次、各回一份响应;改用分布式锁后全局只有一个 Bridge 消费,彻底规避重复响应。 - **`use_embedded_broker = false`(共享外部 Broker)**:原先 N 台网关的 N 个 Bridge 会共同订阅同一批主题,导致一条请求被各消费一次、各回一份响应;改用分布式锁后全局只有一个 Bridge 消费,彻底规避重复响应。
**部署结论**(内嵌 Broker 单例同样成立,`mqtt_broker:<hostname>` 按宿主隔离):
| 部署形态 | MQTT Broker | `[cache]` 后端 | 状态 |
| -------- | ----------- | ------------- | ---- |
| 单机(含多 worker) | 内嵌 amqtt | 本地 `diskcache` | ✅ 完全够用 |
| 多实例分布式 | **外部 Broker**(必须,内嵌各机不聚合、接管会断设备) | **redis**(或共享卷 `diskcache`) | ✅ 推荐 |
> 关键:多实例是否收敛,**MQTT 只是其中一环**,真正的收敛前提是**所有网关共享同一 `[cache]` 后端**。Bridge 全局锁、inline 领导选举锁、在线网关清单/quota 全部工作在同一共享 cache 上;若各实例用各自本机 cache,`mqtt_bridge` 锁跨不了主机,每台都能抢到 Bridge,又会退化成 N 个 Bridge 各连各的 Broker → 单请求多响应,问题回归。
>
> 即:**单机 = 内嵌 Broker + 本地 diskcache;多实例 = 外部 Broker(必须)+ redis/共享卷 diskcache(选一)**。
### 请求负载格式(信封协议) ### 请求负载格式(信封协议)
MQTT 请求负载为信封结构,控制字段与业务数据分离: MQTT 请求负载为信封结构,控制字段与业务数据分离:
-8
View File
@@ -56,14 +56,6 @@ url = "http://124.71.68.6:3000/api/packages/chongming/pypi/simple/"
[[tool.uv.index]] [[tool.uv.index]]
url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple" url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple"
[tool.uv.workspace]
members = [
"utils/cli",
]
[tool.uv.sources]
chongming-cli = { workspace = true }
[project.scripts] [project.scripts]
gunicorn = "chongming:gunicorn_serve" gunicorn = "chongming:gunicorn_serve"
serve = "chongming:serve" serve = "chongming:serve"
+19 -6
View File
@@ -42,13 +42,12 @@ async def lifespan(app: FastAPI):
# 2. Zenoh # 2. Zenoh
session = lifespan_setup.setup_zenoh(app) session = lifespan_setup.setup_zenoh(app)
# 3. mqtt broker # 3. WorkerManager
# lifespan_setup.setup_mqtt_broker(app, host=config[env]["mqtt"]["host"], port=config[env]["mqtt"]["port"])
lifespan_setup.setup_mqtt_broker(app)
# 4. WorkerManager
manager = lifespan_setup.setup_worker_manager(app, session) manager = lifespan_setup.setup_worker_manager(app, session)
# 3.1 内嵌 amqtt Broker(宿主级分布式锁;需在 WorkerManager 就绪后提供共享 cache)
await lifespan_setup.setup_mqtt_broker(app, manager)
# 4.1 全局副本数协调:把当前网关登记进在线列表,供按在线网关分摊 quota # 4.1 全局副本数协调:把当前网关登记进在线列表,供按在线网关分摊 quota
manager.register_gateway() manager.register_gateway()
@@ -83,6 +82,11 @@ async def lifespan(app: FastAPI):
# 7. 启动 MQTT Bridge(内嵌 Broker + Worker 桥接) # 7. 启动 MQTT Bridge(内嵌 Broker + Worker 桥接)
await lifespan_setup.setup_mqtt(app, manager) await lifespan_setup.setup_mqtt(app, manager)
# 7.1 启动 MQTT Bridge 持有者自愈巡检(跨所有网关进程,崩溃时自动接管重建)
app.state.mqtt_bridge_supervise_task = asyncio.create_task(
lifespan_setup.mqtt_bridge_supervisor(app, manager)
)
# 8. 短暂等待,确保 worker 状态同步 # 8. 短暂等待,确保 worker 状态同步
await asyncio.sleep(0.5) await asyncio.sleep(0.5)
@@ -99,7 +103,16 @@ async def lifespan(app: FastAPI):
# ---- 关闭阶段 ---- # ---- 关闭阶段 ----
logger.info("正在关闭应用...") logger.info("正在关闭应用...")
# 1. 停止 MQTT Bridge # 1. 停止 MQTT Bridge 持有者自愈巡检,先于 Bridge 关停,避免关停期间再次接管
_sup = getattr(app.state, "mqtt_bridge_supervise_task", None)
if _sup:
_sup.cancel()
try:
await _sup
except (asyncio.CancelledError, Exception):
pass
# 1.0 停止 MQTT Bridge
await lifespan_setup.shutdown_mqtt(app) await lifespan_setup.shutdown_mqtt(app)
# 1.5 停止路由注册表订阅(常驻,随应用生命周期启停) # 1.5 停止路由注册表订阅(常驻,随应用生命周期启停)
+204 -76
View File
@@ -1,6 +1,7 @@
import os import os
import inspect import inspect
import time import time
import signal
import tomllib import tomllib
import glob import glob
import multiprocessing import multiprocessing
@@ -37,46 +38,50 @@ def is_port_in_use(port: int, host: str = "127.0.0.1") -> bool:
return s.connect_ex((host, port)) == 0 return s.connect_ex((host, port)) == 0
def _acquire_singleton_lock(name: str) -> bool: def _broker_lock_key() -> str:
"""原子文件锁:成功创建锁文件返回 True,已被占用返回 False。""" """内嵌 Broker 排他锁 key。内嵌 Broker 绑定本机端口,故按宿主隔离
exe_dir = os.path.dirname(os.path.abspath(sys.executable)) (多实例共享同一 cache/redis 时,各宿主仍各起各的 Broker)。"""
lock_dir = os.path.join(exe_dir, ".chongming_locks") return f"mqtt_broker:{socket.gethostname()}"
os.makedirs(lock_dir, exist_ok=True)
lock_path = os.path.join(lock_dir, f"{name}.lock")
my_pid = os.getpid() def _broker_owner_key() -> str:
"""记录内嵌 Broker 子进程实际 PID 的缓存 key(按宿主隔离)。
持有者在启动 Broker 子进程后登记其 PID;前任持有者崩溃时,接管的进程据此
识别并终止孤儿 Broker 子进程,从而真正掌握其生命周期。"""
return f"mqtt_broker_owner:{socket.gethostname()}"
def _pid_alive(pid) -> bool:
try: try:
fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_RDWR, 0o644) os.kill(int(pid), 0)
os.write(fd, str(my_pid).encode())
os.close(fd)
logger.info(f"[LOCK] {name}: 获得锁 (PID {my_pid})")
return True return True
except FileExistsError: except (ProcessLookupError, OSError, ValueError, TypeError):
try:
with open(lock_path) as f:
old_pid = int(f.read().strip())
try:
os.kill(old_pid, 0)
logger.info(f"[LOCK] {name}: 已被占用 (holder PID {old_pid})")
except OSError:
logger.info(f"[LOCK] {name}: 清理僵尸锁 (dead PID {old_pid})")
os.remove(lock_path)
return _acquire_singleton_lock(name)
except (ValueError, FileNotFoundError) as e:
logger.info(f"[LOCK] {name}: 读取失败 ({e})")
return False return False
def _release_singleton_lock(name: str): def _terminate_pid(pid):
"""释放单例锁""" """尽力终止进程:先 SIGTERM,宽限后仍存活则 SIGKILL。"""
exe_dir = os.path.dirname(os.path.abspath(sys.executable))
lock_dir = os.path.join(exe_dir, ".chongming_locks")
lock_path = os.path.join(lock_dir, f"{name}.lock")
try: try:
os.remove(lock_path) os.kill(int(pid), signal.SIGTERM)
except FileNotFoundError: except (ProcessLookupError, OSError, ValueError, TypeError):
return
for _ in range(20):
if not _pid_alive(pid):
return
time.sleep(0.1)
try:
os.kill(int(pid), signal.SIGKILL)
except (ProcessLookupError, OSError, ValueError, TypeError):
pass pass
def _wait_port_free(port: int, host: str = "127.0.0.1", timeout: float = 3.0):
end = time.time() + timeout
while time.time() < end and is_port_in_use(port, host):
time.sleep(0.1)
def _run_mqtt_broker(host: str, port: int, username: str | None = None, def _run_mqtt_broker(host: str, port: int, username: str | None = None,
password: str | None = None): password: str | None = None):
"""子进程入口:启动 amqtt Broker(配置了账号密码时启用认证)""" """子进程入口:启动 amqtt Broker(配置了账号密码时启用认证)"""
@@ -542,14 +547,19 @@ def validate_contracts(app: FastAPI | None = None):
inline_plugins = [p for p in plugins if p.get("load_mode", "inline") != "external"] inline_plugins = [p for p in plugins if p.get("load_mode", "inline") != "external"]
_validate(inline_plugins) _validate(inline_plugins)
def setup_mqtt_broker(app: FastAPI): async def setup_mqtt_broker(app: FastAPI, worker_manager: WorkerManager):
""" """
启动内嵌 amqtt Broker 子进程(原子锁确保多 worker 仅启动一个)。 启动内嵌 amqtt Broker 子进程(宿主级分布式锁确保多 worker/多实例仅本宿主启动一个)。
由配置 [<env>.mqtt].use_embedded_broker 控制: 由配置 [<env>.mqtt].use_embedded_broker 控制:
true (默认)→ 网关启动 amqtt Broker 子进程,host/port 为监听地址 true (默认)→ 网关启动 amqtt Broker 子进程,host/port 为监听地址
false → 使用外部 MQTT Broker,网关仅运行 Bridge 客户端,跳过本步 false → 使用外部 MQTT Broker,网关仅运行 Bridge 客户端,跳过本步
配置了 username/password 时 Broker 启用认证,否则保持匿名。 配置了 username/password 时 Broker 启用认证,否则保持匿名。
排他锁基于 chongming-cache 的 DistributedLock,key 按宿主隔离(内嵌 Broker 绑定
本机端口)。持有者在缓存登记其 Broker 子进程 PID;前任持有者崩溃/被强杀而留下
孤儿 Broker 子进程时,接管的进程借此 PID 识别并终止之,再启动自己托管的子进程,
从而真正掌握 Broker 生命周期(可关停、可接管),而非留下不可管控的残留下端点。
""" """
env_config = config.get(env, {}) env_config = config.get(env, {})
mqtt_config = env_config.get("mqtt", {}) mqtt_config = env_config.get("mqtt", {})
@@ -563,38 +573,70 @@ def setup_mqtt_broker(app: FastAPI):
f"跳过内嵌 amqtt Broker 启动" f"跳过内嵌 amqtt Broker 启动"
) )
return None return None
if worker_manager.cache is None:
logger.warning(
"MQTT 内嵌 Broker 启用但未配置共享 cache([cache]),无法获取排他锁;"
"请配置 redis 或共享卷 diskcache,或改用外部 Broker"
)
return None
host = mqtt_config.get("host", "localhost") host = mqtt_config.get("host", "localhost")
port = mqtt_config.get("port", 1883) port = mqtt_config.get("port", 1883)
username = mqtt_config.get("username") or None username = mqtt_config.get("username") or None
password = mqtt_config.get("password") or None password = mqtt_config.get("password") or None
if not _acquire_singleton_lock("mqtt_broker"):
lock = DistributedLock(worker_manager.cache, _broker_lock_key())
if not lock.acquire():
# 非持有者:等待持有者把 Broker 拉起并监听端口
app.state.mqtt_broker_lock_acquired = False
app.state.mqtt_broker_lock = None
for _ in range(50): for _ in range(50):
if is_port_in_use(port, host): if is_port_in_use(port, host):
logger.info(f"MQTT Broker 已在 {host}:{port} 运行,跳过") logger.info(f"MQTT Broker 已在 {host}:{port} 运行(由其他进程托管)")
return return None
time.sleep(0.1) time.sleep(0.1)
raise RuntimeError("MQTT Broker 端口未就绪(已被其他 worker 锁定)") logger.warning(f"等待内嵌 MQTT Broker 端口 {port} 就绪超时")
return None
# 记录本进程持有锁,关闭时由持有者释放(避免误删其他 worker 的锁) # 本进程赢得宿主锁:持有租约续期,取得 Broker 托管权
lock.start()
app.state.mqtt_broker_lock = lock
app.state.mqtt_broker_lock_acquired = True app.state.mqtt_broker_lock_acquired = True
# 端口已被监听:优先识别并终止前任残留的孤儿 Broker 子进程,以真正接管其
# 生命周期;否则视为不可托管的既有进程,复用(本进程不持有句柄)。
try:
prev_pid = int(worker_manager.cache.get(_broker_owner_key()) or 0)
except (TypeError, ValueError):
prev_pid = None
if is_port_in_use(port, host): if is_port_in_use(port, host):
logger.info(f"MQTT Broker 已在 {host}:{port} 运行,跳过") if prev_pid and _pid_alive(prev_pid) and prev_pid != os.getpid():
_release_singleton_lock("mqtt_broker") logger.warning(
app.state.mqtt_broker_lock_acquired = False f"[mqtt-broker] 检出孤儿 Broker 子进程 PID {prev_pid},终止并接管重启"
return )
_terminate_pid(prev_pid)
_wait_port_free(port, host)
if is_port_in_use(port, host):
logger.info(
f"MQTT Broker 端口 {port} 在终止孤儿后仍被占用,属不可托管进程,复用"
)
return None
else:
logger.info(f"MQTT Broker 已在 {host}:{port} 运行,复用(不可托管)")
return None
proc = multiprocessing.Process( proc = multiprocessing.Process(
target=_run_mqtt_broker, args=(host, port, username, password), daemon=False target=_run_mqtt_broker, args=(host, port, username, password), daemon=False
) )
proc.start() proc.start()
app.state.mqtt_broker_process = proc app.state.mqtt_broker_process = proc
worker_manager.cache.set(_broker_owner_key(), str(proc.pid))
auth_note = f",认证用户 {username}" if username else "(匿名)" auth_note = f",认证用户 {username}" if username else "(匿名)"
logger.info(f"MQTT Broker 子进程已启动 (PID: {proc.pid}){auth_note}") logger.info(f"MQTT Broker 子进程已启动并登记托管 (PID: {proc.pid}){auth_note}")
for _ in range(50): for _ in range(50):
if is_port_in_use(port, host): if is_port_in_use(port, host):
logger.info(f"MQTT Broker 已监听 {port} 端口") logger.info(f"MQTT Broker 已监听 {port} 端口")
return return None
if not proc.is_alive(): if not proc.is_alive():
raise RuntimeError("MQTT Broker 进程异常退出") raise RuntimeError("MQTT Broker 进程异常退出")
time.sleep(0.1) time.sleep(0.1)
@@ -602,7 +644,7 @@ def setup_mqtt_broker(app: FastAPI):
async def shutdown_mqtt_broker(app: FastAPI): async def shutdown_mqtt_broker(app: FastAPI):
"""终止 MQTT Broker 子进程(仅锁持有者释放锁)""" """终止本进程托管的 MQTT Broker 子进程,并释放宿主级分布式排他锁、清理登记。"""
proc = getattr(app.state, "mqtt_broker_process", None) proc = getattr(app.state, "mqtt_broker_process", None)
if proc and proc.is_alive(): if proc and proc.is_alive():
proc.terminate() proc.terminate()
@@ -610,28 +652,28 @@ async def shutdown_mqtt_broker(app: FastAPI):
if proc.is_alive(): if proc.is_alive():
proc.kill() proc.kill()
logger.info("MQTT Broker 子进程已终止") logger.info("MQTT Broker 子进程已终止")
if getattr(app.state, "mqtt_broker_lock_acquired", False): lock = getattr(app.state, "mqtt_broker_lock", None)
_release_singleton_lock("mqtt_broker") if lock is not None:
if proc: # 仅真正托管了子进程的进程才清理孤儿登记,避免误删他人登记
try:
lock._cache.delete(_broker_owner_key())
except Exception as e: # noqa: BLE001
logger.debug(f"清理 MQTT Broker 登记失败: {e}")
await lock.stop()
lock.release()
app.state.mqtt_broker_lock = None
app.state.mqtt_broker_lock_acquired = False
async def setup_mqtt(app: FastAPI, worker_manager: WorkerManager) -> MQTTBridge | None: async def _start_mqtt(app: FastAPI, worker_manager: WorkerManager,
""" bridge_lock) -> MQTTBridge | None:
启动 MQTT Bridge(纯客户端模式)。 """在已持有分布式锁的前提下构建并启动一个 MQTT Bridge 客户端。
从 config.toml 的环境段读取 host/port/enabled;若 enabled 为 false 则跳过。 该逻辑同时被 setup_mqtt(启动时)与 mqtt_bridge_supervisor(持有者自愈接管时)
Broker 来源由 use_embedded_broker 控制: 复用。假定调用方已 acquire() 到 "mqtt_bridge" 分布式锁。
true (默认)= 内嵌 amqtt Broker(lifespan 第 3 步已启动子进程)
false = 外部 MQTT Broker(host/port 指向外部地址,网关不启动 Broker)
订阅主题 = 全局 [<env>.mqtt].custom_topics(可选兜底)
+ 各插件在其 config/workers/<type>/config.toml 中声明的 [[workers.mqtt_topics]]。
使用文件锁确保多个 gunicorn worker 中只有一个启动 Bridge。
""" """
env_config = config.get(env, {}) env_config = config.get(env, {})
mqtt_config = env_config.get("mqtt", {}) mqtt_config = env_config.get("mqtt", {})
if not mqtt_config.get("enabled", True):
logger.info("MQTT 已禁用")
return None
host = mqtt_config.get("host", "localhost") host = mqtt_config.get("host", "localhost")
port = mqtt_config.get("port", 1883) port = mqtt_config.get("port", 1883)
username = mqtt_config.get("username") or None username = mqtt_config.get("username") or None
@@ -639,21 +681,6 @@ async def setup_mqtt(app: FastAPI, worker_manager: WorkerManager) -> MQTTBridge
custom_topics = list(mqtt_config.get("custom_topics", []) or []) custom_topics = list(mqtt_config.get("custom_topics", []) or [])
custom_topics += list(getattr(app.state, "plugin_mqtt_topics", []) or []) custom_topics += list(getattr(app.state, "plugin_mqtt_topics", []) or [])
# Broker 来源:use_embedded_broker=true 时内嵌 amqtt Broker 子进程已在
# lifespan 第 3 步启动(setup_mqtt_broker + 文件锁单例);false 时连接外部 Broker。
# 排他锁:基于共享缓存(redis / 共享卷 diskcache),保证全局只有一个 Bridge 消费。
# 相比原文件锁,可跨多台主机收敛——多实例共享同一个外部 Broker 时,
# 避免一条 MQTT 请求被 N 台网关上各自消费、各回一份响应。
bridge_lock = DistributedLock(worker_manager.cache, "mqtt_bridge")
if not bridge_lock.acquire():
logger.info(
"MQTT Bridge 已由其他网关实例持有(分布式锁),本网关跳过,"
"以规避共享 Broker 下单请求多响应"
)
return None
app.state.mqtt_bridge_lock = bridge_lock
bridge = MQTTBridge( bridge = MQTTBridge(
worker_manager=worker_manager, worker_manager=worker_manager,
host=host, host=host,
@@ -665,6 +692,7 @@ async def setup_mqtt(app: FastAPI, worker_manager: WorkerManager) -> MQTTBridge
app.state.mqtt_bridge = bridge app.state.mqtt_bridge = bridge
await bridge.start() await bridge.start()
bridge_lock.start() # 后台续约租约,防止持有方宕机/长暂停导致锁被错误抢走 bridge_lock.start() # 后台续约租约,防止持有方宕机/长暂停导致锁被错误抢走
app.state.mqtt_bridge_lock = bridge_lock
# 把 Bridge 挂接给常驻路由注册表(动态主题 + 补推已到达的元信息) # 把 Bridge 挂接给常驻路由注册表(动态主题 + 补推已到达的元信息)
registry = getattr(app.state, "route_registry", None) registry = getattr(app.state, "route_registry", None)
if registry is not None: if registry is not None:
@@ -675,6 +703,106 @@ async def setup_mqtt(app: FastAPI, worker_manager: WorkerManager) -> MQTTBridge
return bridge return bridge
async def setup_mqtt(app: FastAPI, worker_manager: WorkerManager) -> MQTTBridge | None:
"""
启动 MQTT Bridge(纯客户端模式)。
从 config.toml 的环境段读取 host/port/enabled;若 enabled 为 false 则跳过。
Broker 来源由 use_embedded_broker 控制:
true (默认)= 内嵌 amqtt Broker(lifespan 第 3 步已启动子进程)
false = 外部 MQTT Broker(host/port 指向外部地址,网关不启动 Broker)
订阅主题 = 全局 [<env>.mqtt].custom_topics(可选兜底)
+ 各插件在其 config/workers/<type>/config.toml 中声明的 [[workers.mqtt_topics]]。
使用基于共享 cache 的分布式锁确保多进程/多网关中全局只有一个 Bridge 消费。
"""
env_config = config.get(env, {})
mqtt_config = env_config.get("mqtt", {})
if not mqtt_config.get("enabled", True):
logger.info("MQTT 已禁用")
return None
# 排他锁:基于共享缓存(redis / 共享卷 diskcache),保证全局只有一个 Bridge 消费。
# 相比原文件锁,可跨多台主机收敛——多实例共享同一个外部 Broker 时,
# 避免一条 MQTT 请求被 N 台网关上各自消费、各回一份响应。
bridge_lock = DistributedLock(worker_manager.cache, "mqtt_bridge")
if not bridge_lock.acquire():
logger.info(
"MQTT Bridge 已由其他网关实例持有(分布式锁),本网关跳过,"
"以规避共享 Broker 下单请求多响应"
)
return None
try:
return await _start_mqtt(app, worker_manager, bridge_lock)
except Exception as e: # noqa: BLE001
logger.error(f"MQTT Bridge 启动失败: {e}")
await bridge_lock.stop()
bridge_lock.release()
app.state.mqtt_bridge = None
app.state.mqtt_bridge_lock = None
return None
async def mqtt_bridge_supervisor(app: FastAPI, worker_manager: WorkerManager):
"""MQTT Bridge 持有者崩溃自愈巡检(跨所有网关进程运行)。
与 inline 持有者同源的问题:仅靠 gunicorn 复活死掉 slot 无法 failover——新进程
可能在旧锁租约未过期时抢锁失败而照样跳过。本巡检在每个网关进程周期性运行:
- 本进程已持有一存活 Bridge → 正常托管,跳过。
- 否则尝试获得 "mqtt_bridge" 分布式锁(set_nx 保证全局唯一胜出,无分裂);
若锁仍被存活持有者把持 → 跳过;若空闲(原持有者租约过期=已死/失联)→ 接管。
- 接管时按需拉起内嵌 Broker(setup_mqtt_broker 幂等:端口被占则复用现有进程),
再重建 Bridge 客户端。
运行周期经 [<env>.mqtt].bridge_supervise_interval 配置,默认 15s。
"""
env_config = config.get(env, {})
mqtt_config = env_config.get("mqtt", {})
if not mqtt_config.get("enabled", True):
return # MQTT 禁用:无需巡检
interval = mqtt_config.get("bridge_supervise_interval", 15) or 15
while True:
await asyncio.sleep(interval)
try:
bridge = getattr(app.state, "mqtt_bridge", None)
# 本进程持有一存活 Bridge:正常托管
if bridge is not None and getattr(bridge, "is_running", True):
continue
newly_acquired = False
lock = getattr(app.state, "mqtt_bridge_lock", None)
if lock is None:
# 非持有者:仅当锁空闲(原持有者租约过期)时才能抢得
if worker_manager.cache is None:
continue
candidate = DistributedLock(worker_manager.cache, "mqtt_bridge")
if not candidate.acquire():
continue # 仍被存活持有者把持
candidate.start()
app.state.mqtt_bridge_lock = candidate
lock = candidate
newly_acquired = True
logger.warning("[mqtt-bridge] 检出持有者失效,接管重建 Broker+Bridge")
# 按需拉起内嵌 Broker(幂等;外部 Broker 时 setup_mqtt_broker 内直接返回)
try:
await setup_mqtt_broker(app, worker_manager)
except Exception as e: # noqa: BLE001
logger.error(f"[mqtt-bridge] 接管时拉起 Broker 失败: {e}")
try:
await _start_mqtt(app, worker_manager, lock)
logger.info("[mqtt-bridge] 接管完成,Bridge 已重建")
except Exception as e: # noqa: BLE001
logger.error(f"[mqtt-bridge] 接管时重建 Bridge 失败: {e}")
app.state.mqtt_bridge = None
if newly_acquired:
await lock.stop()
lock.release()
app.state.mqtt_bridge_lock = None
except Exception as e: # noqa: BLE001
logger.error(f"MQTT Bridge 自愈巡检失败: {e}")
async def shutdown_mqtt(app: FastAPI): async def shutdown_mqtt(app: FastAPI):
"""停止 MQTT Bridge 和 Broker 子进程(仅锁持有者释放锁)""" """停止 MQTT Bridge 和 Broker 子进程(仅锁持有者释放锁)"""
bridge: MQTTBridge | None = getattr(app.state, "mqtt_bridge", None) bridge: MQTTBridge | None = getattr(app.state, "mqtt_bridge", None)
+146
View File
@@ -0,0 +1,146 @@
"""
MQTT Bridge 持有者崩溃自愈巡检测试
验证 mqtt_bridge_supervisor 的接管语义(基于共享 cache 的分布式锁 "mqtt_bridge"):
1. mqtt 启用、本进程非持有者、锁空闲(原持有者租约过期=已死)→ 抢锁接管,
重新拉起 Broker(setup_mqtt_broker)并重建 Bridge(_start_mqtt)
2. 已持有一存活 Bridge(本进程即持有者)→ 正常托管,不重复拉起
3. 本进程已持锁但 Bridge 停止运行 → 不重新抢锁,直接重建 Bridge(自愈自身池)
4. mqtt 禁用 → 巡检直接退出,不做任何动作
注:通过 monkeypatch setup_mqtt_broker / _start_mqtt 隔离真实 Broker/Bridge;
分布式锁本体用真实 diskcache,验证 set_nx 唯一性与租约语义。
运行: uv run python test/test_mqtt_bridge_heal.py
"""
import asyncio
import sys
import tempfile
from pathlib import Path
from types import SimpleNamespace
_HERE = Path(__file__).resolve().parent # chongming-zenoh/
_PROJ = _HERE
for _p in (_PROJ / "src", _PROJ / "utils" / "worker" / "src"):
sys.path.insert(0, str(_p))
import chongming.app.core.lifespan_setup as ls
from chongming_cache import Cache
from chongming.app.core.worker_manager import WorkerManager
failures = []
def check(name, cond, detail=""):
status = "PASS" if cond else "FAIL"
print(f"[{status}] {name}" + (f" — {detail}" if detail else ""))
if not cond:
failures.append(name)
class DummySession:
pass
def make_app():
return SimpleNamespace(state=SimpleNamespace(
mqtt_bridge=None, mqtt_bridge_lock=None, plugin_mqtt_topics=[],
route_registry=None, mqtt_broker_lock_acquired=False,
mqtt_broker_process=None,
))
async def drive_supervisor(app, manager, steps, enabled=True, interval=0.02):
"""运行 supervisor,让其控制权让出 steps 次(每让出可经过 interval 睡眠)后取消。"""
ls.env = "testenv"
ls.config = {"testenv": {"mqtt": {
"enabled": enabled, "bridge_supervise_interval": interval,
}}}
counts = {"broker": 0, "start": 0}
async def fake_start(aw, wm, lock):
counts["start"] += 1
aw.state.mqtt_bridge = "RUNNING" # 模拟 Bridge 已运行
aw.state.mqtt_bridge_lock = lock
return "RUNNING"
async def _broker_stub(aw, wm):
counts["broker"] += 1
ls.setup_mqtt_broker = _broker_stub # async:supervisor 会 await 拉起 Broker
ls._start_mqtt = fake_start
task = asyncio.create_task(ls.mqtt_bridge_supervisor(app, manager))
try:
for _ in range(steps):
await asyncio.sleep(interval * 2)
finally:
task.cancel()
try:
await task
except (asyncio.CancelledError, Exception):
pass
return counts
async def main():
c = Cache(backend="diskcache", cache_store_path=tempfile.mkdtemp())
# ── 1. mqtt 禁用:巡检直接退出,无任何动作 ──────────────
mgr_dis = WorkerManager(session=DummySession(), cache=c)
app_dis = make_app()
cnt = await drive_supervisor(app_dis, mgr_dis, 3, enabled=False)
check("1.1 mqtt 禁用:不拉起 Broker/Bridge", cnt["broker"] == 0 and cnt["start"] == 0)
# ── 2. 非持有者 + 锁空闲 → 接管 ─────────────────────────
mgr = WorkerManager(session=DummySession(), cache=c)
app = make_app()
cnt = await drive_supervisor(app, mgr, 8)
check("2.1 锁空闲时接管:拉起 Broker", cnt["broker"] >= 1)
check("2.2 锁空闲时接管:重建 Bridge", cnt["start"] >= 1)
check("2.3 接管后本进程变为持有者(持锁)", app.state.mqtt_bridge_lock is not None)
# 释放 scenario 2 抢得的那把分布式锁(含其续约任务),避免污染后续场景
_lk = getattr(app.state, "mqtt_bridge_lock", None)
if _lk is not None:
await _lk.stop()
_lk.release()
# ── 3. 本进程已是存活持有者:不再重复拉起 ────────────────
app2 = make_app()
app2.state.mqtt_bridge = "RUNNING" # 模拟已持有存活 Bridge
app2.state.mqtt_bridge_lock = object() # 持锁标记
cnt2 = await drive_supervisor(app2, mgr, 8)
check("3.1 存活持有者不重复接管", cnt2["broker"] == 0 and cnt2["start"] == 0)
# ── 4. 本进程已持锁但 Bridge 停止 → 不带锁重建(池自愈)─
app3 = make_app()
app3.state.mqtt_bridge = SimpleNamespace(is_running=False)
app3.state.mqtt_bridge_lock = object()
cnt3 = await drive_supervisor(app3, mgr, 8)
check("4.1 持锁但 Bridge 停:重建 Bridge", cnt3["start"] >= 1,
detail=f"{cnt3}")
check("4.2 持锁但 Bridge 停:拉起 Broker", cnt3["broker"] >= 1,
detail=f"{cnt3}")
# ── 5. 持有者在场时(锁被把持),非持有者不接管 ──────────
app5 = make_app()
from chongming_cache import DistributedLock as _DL
held = _DL(c, "mqtt_bridge")
held.acquire() # 模拟一个存活的持有者把持着桥锁
cnt5 = await drive_supervisor(app5, mgr, 5)
check("5.1 持有者存活:非持有者不拉起", cnt5["broker"] == 0 and cnt5["start"] == 0,
detail=f"{cnt5}")
await held.stop()
held.release()
c.close()
print()
if failures:
print(f"FAILED {len(failures)}: {failures}")
return 1
print("ALL PASS")
return 0
if __name__ == "__main__":
sys.exit(asyncio.run(main()))
+158
View File
@@ -0,0 +1,158 @@
"""
内嵌 MQTT Broker 宿主级分布式锁 + 孤儿接管测试
验证 setup_mqtt_broker 用 chongming-cache DistruibutedLock 取代文件锁后的语义:
1. 外部 Broker(use_embedded_broker=false)→ 跳过锁,直接返回
2. 未配置共享 cache → 无法取锁,跳过(并给出警告)
3. 非持有者:锁被他人持有 → 等待期间端口已就绪即复用,不启动子进程
4. 持有者 & 端口空闲 → 启动子进程并登记 PID
5. 持有者 & 端口被占 & 无孤儿登记 → 复用(不托管、不杀进程)
6. 持有者 & 端口被占 & 孤儿 PID 存活 → 终止孤儿、接管重启、登记新 PID
7. 关停 → 终止子进程、释放锁、清理登记
通过 monkeypatch is_port_in_use / _pid_alive / _terminate_pid / _wait_port_free 隔离
真实端口与进程;子进程目标替换为休眠桩,验证编排逻辑而非真实 amqtt 收发。
运行: uv run python test/test_mqtt_broker_lock.py
"""
import asyncio
import sys
import tempfile
import time
from pathlib import Path
from types import SimpleNamespace
_HERE = Path(__file__).resolve().parent # chongming-zenoh/
_PROJ = _HERE
for _p in (_PROJ / "src", _PROJ / "utils" / "worker" / "src"):
sys.path.insert(0, str(_p))
import chongming.app.core.lifespan_setup as ls
from chongming_cache import Cache, DistributedLock
failures = []
def check(name, cond, detail=""):
status = "PASS" if cond else "FAIL"
print(f"[{status}] {name}" + (f" — {detail}" if detail else ""))
if not cond:
failures.append(name)
def _dummy_broker(*_a, **_k):
"""休眠桩子进程,替代真实 amqtt Broker(验证编排,不做 MQTT 收发)。"""
time.sleep(60)
def make_manager(cache):
return SimpleNamespace(cache=cache)
def make_app():
return SimpleNamespace(state=SimpleNamespace(
mqtt_broker_process=None, mqtt_broker_lock=None,
mqtt_broker_lock_acquired=False,
))
def _port_script(*seq):
"""一次性的 is_port_in_use 脚本(耗尽后一律 True,防止等待循环无限跑)。"""
it = iter(seq)
def fake(port, host="127.0.0.1"):
try:
return next(it)
except StopIteration:
return True
return fake
async def run_setup(cache, conf, port_script, patch=()):
"""用给定配置跑一遍 setup_mqtt_broker,返回 app。"""
ls.env = "testenv"
ls.config = {"testenv": {"mqtt": conf}}
ls._run_mqtt_broker = _dummy_broker
ls.is_port_in_use = port_script
ls._pid_alive = lambda pid: True
ls._terminate_pid = lambda pid: None
ls._wait_port_free = lambda *a, **k: None
for name, val in patch:
setattr(ls, name, val)
app = make_app()
mgr = make_manager(cache)
await ls.setup_mqtt_broker(app, mgr)
return app
async def main():
c = Cache(backend="diskcache", cache_store_path=tempfile.mkdtemp())
base = {"enabled": True, "host": "127.0.0.1", "port": 18883,
"use_embedded_broker": True}
# ── 1. 外部 Broker:跳过 ────────────────────────────────
ext = dict(base, use_embedded_broker=False)
app = await run_setup(c, ext, _port_script(False))
check("1.1 外部 Broker 跳过(不启动)", app.state.mqtt_broker_process is None
and app.state.mqtt_broker_lock is None)
# ── 2. 未配 cache:跳过 ─────────────────────────────────
app = await run_setup(None, base, _port_script(False))
check("2.1 未配 cache 跳过(不取锁)", app.state.mqtt_broker_lock is None
and app.state.mqtt_broker_process is None)
# ── 3. 非持有者:锁被他人持有 → 已监听则复用 ────────────
actor_lock = DistributedLock(c, ls._broker_lock_key()) # 先被别人抢到
actor_lock.acquire()
app = await run_setup(c, base, _port_script(True)) # 端口已被"占用"
check("3.1 非持有者复用(不取锁、不启动)",
app.state.mqtt_broker_lock is None
and app.state.mqtt_broker_process is None)
await actor_lock.stop()
actor_lock.release()
# ── 4. 持有者 & 端口空闲 → 启动并登记 PID ──────────────
app = await run_setup(c, base, _port_script(False, True))
check("4.1 持锁启动子进程", app.state.mqtt_broker_process is not None)
check("4.2 已登记子进程 PID",
c.get(ls._broker_owner_key()) == str(app.state.mqtt_broker_process.pid))
await ls.shutdown_mqtt_broker(app)
check("4.3 关停后释放锁", app.state.mqtt_broker_lock is None)
check("4.4 关停后清理登记", c.get(ls._broker_owner_key()) is None)
# ── 5. 持有者 & 端口被占 & 无孤儿登记 → 复用 ───────────
app = await run_setup(c, base, _port_script(True))
check("5.1 端口被占无孤儿:复用(不托管不杀)",
app.state.mqtt_broker_process is None
and app.state.mqtt_broker_lock is not None)
await ls.shutdown_mqtt_broker(app)
check("5.2 复用路径关停释放锁", app.state.mqtt_broker_lock is None)
# ── 6. 持有者 & 端口被占 & 孤儿 PID 存活 → 终止并接管 ──
# 端口脚本:入口=占用(True) → 终止后=空闲(False) → 新进程监听(True)
c.set(ls._broker_owner_key(), "99999") # 模拟前任登记的孤儿 PID
terminated = []
app = await run_setup(
c, base, _port_script(True, False, True),
patch=[("_pid_alive", lambda pid: pid == 99999),
("_terminate_pid", lambda pid: terminated.append(pid))],
)
check("6.1 检出并终止孤儿 PID 99999", 99999 in terminated, detail=f"{terminated}")
check("6.2 接管后启动并登记新 PID",
app.state.mqtt_broker_process is not None
and c.get(ls._broker_owner_key()) == str(app.state.mqtt_broker_process.pid))
await ls.shutdown_mqtt_broker(app)
check("6.3 接管后关停释放并清理", app.state.mqtt_broker_lock is None
and c.get(ls._broker_owner_key()) is None)
c.close()
print()
if failures:
print(f"FAILED {len(failures)}: {failures}")
return 1
print("ALL PASS")
return 0
if __name__ == "__main__":
sys.exit(asyncio.run(main()))
Generated
+7 -216
View File
@@ -6,12 +6,6 @@ resolution-markers = [
"python_full_version < '3.14'", "python_full_version < '3.14'",
] ]
[manifest]
members = [
"chongming-cli",
"chongming-zenoh",
]
[[package]] [[package]]
name = "aiofiles" name = "aiofiles"
version = "25.1.0" version = "25.1.0"
@@ -450,31 +444,19 @@ wheels = [
[[package]] [[package]]
name = "chongming-cli" name = "chongming-cli"
version = "0.1.2" version = "0.2.0"
source = { editable = "utils/cli" } source = { registry = "http://124.71.68.6:3000/api/packages/chongming/pypi/simple/" }
dependencies = [ dependencies = [
{ name = "chongming-config" }, { name = "chongming-config" },
{ name = "module-bank" }, { name = "module-bank" },
{ name = "sqlite-vfs" }, { name = "sqlite-vfs" },
{ name = "tomli" }, { name = "tomli" },
] ]
sdist = { url = "http://124.71.68.6:3000/api/packages/chongming/pypi/files/chongming-cli/0.2.0/chongming_cli-0.2.0.tar.gz", hash = "sha256:ff59d25366e0e9c396b4be504afcfdb2db4821246c23d5710efc2f8bc257b77d" }
[package.dev-dependencies] wheels = [
dev = [ { url = "http://124.71.68.6:3000/api/packages/chongming/pypi/files/chongming-cli/0.2.0/chongming_cli-0.2.0-py3-none-any.whl", hash = "sha256:76aabcebcfd6807a25d9fa6be2b85f92863e56e6a271e99990024ec174051710" },
{ name = "twine" },
] ]
[package.metadata]
requires-dist = [
{ name = "chongming-config" },
{ name = "module-bank" },
{ name = "sqlite-vfs" },
{ name = "tomli" },
]
[package.metadata.requires-dev]
dev = [{ name = "twine", specifier = ">=7.0.0" }]
[[package]] [[package]]
name = "chongming-config" name = "chongming-config"
version = "0.1.0" version = "0.1.0"
@@ -622,7 +604,7 @@ requires-dist = [
{ name = "amqtt" }, { name = "amqtt" },
{ name = "bytedance" }, { name = "bytedance" },
{ name = "chongming-cache" }, { name = "chongming-cache" },
{ name = "chongming-cli", editable = "utils/cli" }, { name = "chongming-cli" },
{ name = "chongming-config" }, { name = "chongming-config" },
{ name = "chongming-database" }, { name = "chongming-database" },
{ name = "chongming-jwt" }, { name = "chongming-jwt" },
@@ -649,7 +631,7 @@ requires-dist = [
[package.metadata.requires-dev] [package.metadata.requires-dev]
dev = [ dev = [
{ name = "chongming-cli", editable = "utils/cli" }, { name = "chongming-cli" },
{ name = "nuitka" }, { name = "nuitka" },
{ name = "pyarmor" }, { name = "pyarmor" },
{ name = "pyinstaller" }, { name = "pyinstaller" },
@@ -742,15 +724,6 @@ wheels = [
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/3f/27/4570e78fc0bf5ea0ca45eb1de3818a23787af9b390c0b0a0033a1b8236f9/diskcache-5.6.3-py3-none-any.whl", hash = "sha256:5e31b2d5fbad117cc363ebaf6b689474db18a1f6438bc82358b024abd4c2ca19" }, { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/3f/27/4570e78fc0bf5ea0ca45eb1de3818a23787af9b390c0b0a0033a1b8236f9/diskcache-5.6.3-py3-none-any.whl", hash = "sha256:5e31b2d5fbad117cc363ebaf6b689474db18a1f6438bc82358b024abd4c2ca19" },
] ]
[[package]]
name = "docutils"
version = "0.23"
source = { registry = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple" }
sdist = { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/39/a4/5180d9afc57e8fca05601dd652bdff19604c218814037fe90ffc7625a50a/docutils-0.23.tar.gz", hash = "sha256:746f5060322511280a1e50eb76846ed6bf2342984b2ac04dc42caa1a8d78799e", size = 2303823, upload-time = "2026-05-27T17:41:06.934Z" }
wheels = [
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/32/91/30151a39f7570f448ed84529390628a651d7f27c87d73c9b887f8189695e/docutils-0.23-py3-none-any.whl", hash = "sha256:25d013af9bf23bc1c7b2b093dff4208166c53a94786c9e447808335ef1185fea", size = 634701, upload-time = "2026-05-27T17:40:58.442Z" },
]
[[package]] [[package]]
name = "eclipse-zenoh" name = "eclipse-zenoh"
version = "1.10.0" version = "1.10.0"
@@ -959,18 +932,6 @@ wheels = [
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" }, { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" },
] ]
[[package]]
name = "id"
version = "1.6.1"
source = { registry = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple" }
dependencies = [
{ name = "urllib3" },
]
sdist = { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/6d/04/c2156091427636080787aac190019dc64096e56a23b7364d3c1764ee3a06/id-1.6.1.tar.gz", hash = "sha256:d0732d624fb46fd4e7bc4e5152f00214450953b9e772c182c1c22964def1a069", size = 18088, upload-time = "2026-02-04T16:19:41.26Z" }
wheels = [
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/42/77/de194443bf38daed9452139e960c632b0ef9f9a5dd9ce605fdf18ca9f1b1/id-1.6.1-py3-none-any.whl", hash = "sha256:f5ec41ed2629a508f5d0988eda142e190c9c6da971100612c4de9ad9f9b237ca", size = 14689, upload-time = "2026-02-04T16:19:40.051Z" },
]
[[package]] [[package]]
name = "idna" name = "idna"
version = "3.19" version = "3.19"
@@ -980,48 +941,6 @@ wheels = [
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" }, { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" },
] ]
[[package]]
name = "jaraco-classes"
version = "3.4.0"
source = { registry = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple" }
dependencies = [
{ name = "more-itertools" },
]
sdist = { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780, upload-time = "2024-03-31T07:27:36.643Z" }
wheels = [
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777, upload-time = "2024-03-31T07:27:34.792Z" },
]
[[package]]
name = "jaraco-context"
version = "6.1.2"
source = { registry = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple" }
sdist = { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/af/50/4763cd07e722bb6285316d390a164bc7e479db9d90daa769f22578f698b4/jaraco_context-6.1.2.tar.gz", hash = "sha256:f1a6c9d391e661cc5b8d39861ff077a7dc24dc23833ccee564b234b81c82dfe3", size = 16801, upload-time = "2026-03-20T22:13:33.922Z" }
wheels = [
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/f2/58/bc8954bda5fcda97bd7c19be11b85f91973d67a706ed4a3aec33e7de22db/jaraco_context-6.1.2-py3-none-any.whl", hash = "sha256:bf8150b79a2d5d91ae48629d8b427a8f7ba0e1097dd6202a9059f29a36379535", size = 7871, upload-time = "2026-03-20T22:13:32.808Z" },
]
[[package]]
name = "jaraco-functools"
version = "4.6.0"
source = { registry = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple" }
dependencies = [
{ name = "more-itertools" },
]
sdist = { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/6c/1f/c23395957d41ccf27c4e535c3d334c4051e5395b3752057ba4cbaec35c56/jaraco_functools-4.6.0.tar.gz", hash = "sha256:880c577ec9720b3a052d5bc611fb9f2269b3d87902ef42440df443b88e443280", size = 20837, upload-time = "2026-07-14T01:28:02.544Z" }
wheels = [
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/02/36/ecc85bc96c273dc8a11273ed4782272975e6338d4a3e9228621175edf0e3/jaraco_functools-4.6.0-py3-none-any.whl", hash = "sha256:99e3dc0060c5cbe8fcd1cdb36258e2a65ca40f1566b2033b12abb1bb44dd3c30", size = 11677, upload-time = "2026-07-14T01:28:01.59Z" },
]
[[package]]
name = "jeepney"
version = "0.9.0"
source = { registry = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple" }
sdist = { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758, upload-time = "2025-02-27T18:51:01.684Z" }
wheels = [
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" },
]
[[package]] [[package]]
name = "jiter" name = "jiter"
version = "0.16.0" version = "0.16.0"
@@ -1103,23 +1022,6 @@ wheels = [
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/9e/54/b819eb2f8caedb1bccef0bf6188c051f0d898a177b271e1ed3a5cc0a8b66/jwcrypto-1.5.9-py3-none-any.whl", hash = "sha256:30b1c9afc898eefd112c31d836eb2d7a4c6febcac95d4aa727e963cd42911d8e", size = 120772, upload-time = "2026-08-26T12:54:08.637Z" }, { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/9e/54/b819eb2f8caedb1bccef0bf6188c051f0d898a177b271e1ed3a5cc0a8b66/jwcrypto-1.5.9-py3-none-any.whl", hash = "sha256:30b1c9afc898eefd112c31d836eb2d7a4c6febcac95d4aa727e963cd42911d8e", size = 120772, upload-time = "2026-08-26T12:54:08.637Z" },
] ]
[[package]]
name = "keyring"
version = "25.7.0"
source = { registry = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple" }
dependencies = [
{ name = "jaraco-classes" },
{ name = "jaraco-context" },
{ name = "jaraco-functools" },
{ name = "jeepney", marker = "sys_platform == 'linux'" },
{ name = "pywin32-ctypes", marker = "sys_platform == 'win32'" },
{ name = "secretstorage", marker = "sys_platform == 'linux'" },
]
sdist = { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b", size = 63516, upload-time = "2025-11-16T16:26:09.482Z" }
wheels = [
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" },
]
[[package]] [[package]]
name = "loguru" name = "loguru"
version = "0.7.3" version = "0.7.3"
@@ -1194,49 +1096,6 @@ wheels = [
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/8a/d3/0a7a8297ec3041e0352f313e020276a04c59bc9200138094df2a6d374f51/module_bank-0.3.2-py3-none-any.whl", hash = "sha256:524faaa86258ebbdc07df493a041566adfbdf57461487a63bf7161e6aa79a817", size = 19051, upload-time = "2026-05-04T16:21:19.827Z" }, { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/8a/d3/0a7a8297ec3041e0352f313e020276a04c59bc9200138094df2a6d374f51/module_bank-0.3.2-py3-none-any.whl", hash = "sha256:524faaa86258ebbdc07df493a041566adfbdf57461487a63bf7161e6aa79a817", size = 19051, upload-time = "2026-05-04T16:21:19.827Z" },
] ]
[[package]]
name = "more-itertools"
version = "11.1.0"
source = { registry = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple" }
sdist = { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/de/1d/f4da6f02cdffe04d6362210b807146a26044c88d839208aec273bb0d9184/more_itertools-11.1.0.tar.gz", hash = "sha256:48e8f4d9e7e5878571ecf6f2b4e57634f93cd474cc8cfbd2376f2d11b396e30d", size = 145772, upload-time = "2026-05-22T14:14:29.909Z" }
wheels = [
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/e8/3d/1087453384dbde46a8c7f9356eead2c58be8a7bf156bca40243377c85715/more_itertools-11.1.0-py3-none-any.whl", hash = "sha256:4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192", size = 72226, upload-time = "2026-05-22T14:14:28.824Z" },
]
[[package]]
name = "nh3"
version = "0.3.7"
source = { registry = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple" }
sdist = { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/18/2f/022b27146d52d24b1b353b003359134788ecbcd6fcdf6283adbd57c0fbc8/nh3-0.3.7.tar.gz", hash = "sha256:71860d01c16f4d8c72e334e0674beb2b0899dbd0bf760de18932ef4390303848", size = 25662, upload-time = "2026-08-23T14:26:30.728Z" }
wheels = [
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/ce/88/b594f0e86856b37e182fb663283da419eea6424972506e640e890885467f/nh3-0.3.7-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:91a4dab4e94d9fc54b9f67b1adfb23e81fab7ab43f33c3b8c97be9aa38f789ba", size = 1471147, upload-time = "2026-08-23T14:25:55.259Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/1e/60/847a21339f095c4d4c655af31fa2d18b174585bcc210709facacc7ce205c/nh3-0.3.7-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eae64328e46a25785535afcb6885b6f182ecaf5ee8c88f8c075422db8aacc65b", size = 820463, upload-time = "2026-08-23T14:25:56.803Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/7b/7f/1a103e00aaf5e59f2dee4c2709aac609bb2d4bb74fddaf0dcfade11ed87b/nh3-0.3.7-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4968fe8d2db97c6f047659bf46a449fd8ec377f44ebf3e0a1b96c0d3a333ae32", size = 861456, upload-time = "2026-08-23T14:25:58.087Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/d8/4a/e9c436089a0c80b928011ead0efd156aa7639a19b6064ef58dcedcab8369/nh3-0.3.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:be53a4825585f701955cb9baf49f478f56eb81e20294329fe4bc689dd5dd81fa", size = 1023930, upload-time = "2026-08-23T14:25:59.465Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/04/5c/aa1468e3e281e78d2b3b7d762ccba59f681af355e971dbd255d5903f7b86/nh3-0.3.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:94fd6e59553fbb9ffd8ba71bbd5a54e3126ba01799a097ae30d5341d750bc6ac", size = 1102614, upload-time = "2026-08-23T14:26:00.869Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/6a/9f/57d186d9d3dd38905dc12dddb3484406cdf6aa0b1ce33639a2d277d4ee1c/nh3-0.3.7-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:18f4278ecd157d43cb35acd5aae9f35cfa79f546b4922bd86536adc0f6312102", size = 1059915, upload-time = "2026-08-23T14:26:02.388Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/6b/53/097a5ad0b34b15d67a472ef849165a54209fa5fbd3e639801c6fe439ba28/nh3-0.3.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:808def0c8c07843e6e50dc84f532457bfa2cfd17417b219a5d9e7c773709331a", size = 1047402, upload-time = "2026-08-23T14:26:03.897Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/9a/a7/c57a2c70534418310889a65ccfac3525e62f0bc0a8613225903403755ce7/nh3-0.3.7-cp314-cp314t-win32.whl", hash = "sha256:874b7d67a067bd29a59223f6270fc30da4edd8e6d87fd219fc93bcbaa662c946", size = 619895, upload-time = "2026-08-23T14:26:05.105Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/e6/b7/efda1d0a611d940bdfde6893bde1ea6b7b7d48c31273aea48e35b822fd58/nh3-0.3.7-cp314-cp314t-win_amd64.whl", hash = "sha256:614dac4a4c36ad084e78447d16fe898dedd762e354a7ab9cda2984e82f67883d", size = 633456, upload-time = "2026-08-23T14:26:06.661Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/1d/18/3ab564595cb88196f50d26e163ed0fd2acc731ab26ac615df91981885887/nh3-0.3.7-cp314-cp314t-win_arm64.whl", hash = "sha256:157ec1eb7a62f3d9a7badb8d82d89aa810e3e24e097eedfa481a25d0c8a99877", size = 611003, upload-time = "2026-08-23T14:26:07.813Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/94/0d/c257754bf57f829f307aa226bbe136d3a1356b5a0d08324c7b6bd2a8aacd/nh3-0.3.7-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:6c3aa50eb26e9228238271db9f983cbc3b006dfbfeca2d4dc34c33ddc6ac5ea5", size = 1493959, upload-time = "2026-08-23T14:26:09.025Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/07/42/a687e7091928806e514f89fa2666f25ec9bfe0a902fc4402b25e51ce408b/nh3-0.3.7-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f266d3f1b3647449923a8e406524632220dd5d8b647078dfe45b885d33d10479", size = 859615, upload-time = "2026-08-23T14:26:10.606Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/85/05/b0e6bef633549a23347d5462aa288fcc42381e7918482062ca3cb456242a/nh3-0.3.7-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e8fd1ab205258b29254f72db377d99e2c96aa7653ef3b015ccab0420b094b506", size = 839872, upload-time = "2026-08-23T14:26:12.037Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/17/40/2a0921d45b20828708bcb56887e47dcf8cae13818de5bf9a01308d348712/nh3-0.3.7-cp38-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:19f288c938ec6eef1f5d2c6cab47838e71fef8097e1c1233802be5a6230ba086", size = 1091325, upload-time = "2026-08-23T14:26:13.34Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/e4/d1/9d70e0e418a48280ec0ddc6c1b08b4b1136ebcc31a1625e57ff5c665fa51/nh3-0.3.7-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de2b2aab32ea303405debefdcfc58043d3e635fa3f67b9eb140d2b0e0c0d2563", size = 1042482, upload-time = "2026-08-23T14:26:14.667Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/93/a7/02dd159d4e71f98607d8d4249cddb7561e77be1a8e4dec77d76e1b68fc99/nh3-0.3.7-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b7279d43323a25225df23576af6594a16693f61431170848b8b2ac21ad4f174", size = 946868, upload-time = "2026-08-23T14:26:16.094Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/a6/ed/c5510c615dce55b6fcc364aa1838142f938beed64f5e4927490dfcaf4405/nh3-0.3.7-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70f5ac8626e899a4bab0ef74ca2f5bd602f49c7b739e6e5026b4afc6d63dac42", size = 832161, upload-time = "2026-08-23T14:26:17.272Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/7b/e3/3212c1a5b5745245d7f18885207bbddb34c56075f34dd682bd539aad55cc/nh3-0.3.7-cp38-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:5ffdfcb9a686ffb12765376bcfb6b5b55728516d3c0ee317d29982381ded3df8", size = 849791, upload-time = "2026-08-23T14:26:18.498Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/20/64/9e36594efad6c290de4240d02cb2bd80c339a4ab1c4de66e599ffa6d9d81/nh3-0.3.7-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bc42bb1193c1e28a1e74c2cabaca178e118a7103e8832699fef8a2b3e2496493", size = 875473, upload-time = "2026-08-23T14:26:19.908Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/00/0c/1a8985fd43fea5530c0ac890b6f0b423770ee72f111b70b7a77f2dec243a/nh3-0.3.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:d56e76bd3cadb09b6b0cef364850811663734b348a25f5f587a2819c495367bd", size = 1036463, upload-time = "2026-08-23T14:26:21.536Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/b2/5d/891e533b716cf00df76ad0ba6485dcfd14d59a6430a3cc99057c4c04004e/nh3-0.3.7-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:fd4a70efb45d5372174f718878eb7a35c12677626a63b2f103b23b833457dcac", size = 1116029, upload-time = "2026-08-23T14:26:22.907Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/42/e5/ae8c0782fce74fb6fcf7234bb3d4017f37ce181b4f9d29369eab21c50a04/nh3-0.3.7-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:15f5fbf090f5c88d61c820e1fc1fceecb6520cca9fe85649c06b57ef9dc9ff62", size = 1076589, upload-time = "2026-08-23T14:26:24.302Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/26/a4/c3423351e8d864ad756e85e15f0c01433361f14d34e4ed156482c0518f2a/nh3-0.3.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6698a822132beedab80f131c08d8d0ac5a178ddeb488d02ca4b67716ecfac7af", size = 1058871, upload-time = "2026-08-23T14:26:25.674Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/4b/6a/478f153f1d7c0baaa3d1e8bb5fdcee3a6235f90fe44ea969a9d4e2b8c47a/nh3-0.3.7-cp38-abi3-win32.whl", hash = "sha256:6e4280115d44c3b278eef712a86748c1a723105cd79feec46952383117ab4e59", size = 630729, upload-time = "2026-08-23T14:26:26.932Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/b4/b9/34433ccb1f0fe6968dabbb7d4bf5721c6221878ef07832748c06655a6a80/nh3-0.3.7-cp38-abi3-win_amd64.whl", hash = "sha256:618e3059caf41ccdf5dcccb3fa9df4cf6e4efe23d1382a8bbfca272a8a4f8bfc", size = 644462, upload-time = "2026-08-23T14:26:28.294Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/f9/70/e140dffff6e808dc6343598df76e7e2407fd0f581de3524c75fba2e0cf24/nh3-0.3.7-cp38-abi3-win_arm64.whl", hash = "sha256:f04b7d333b27f13ca439da3cf1c75c2fba34f104969f6ce4ac8e7079699c2f4a", size = 621867, upload-time = "2026-08-23T14:26:29.547Z" },
]
[[package]] [[package]]
name = "nuitka" name = "nuitka"
version = "4.1.3" version = "4.1.3"
@@ -1710,20 +1569,6 @@ wheels = [
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
] ]
[[package]]
name = "readme-renderer"
version = "45.0"
source = { registry = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple" }
dependencies = [
{ name = "docutils" },
{ name = "nh3" },
{ name = "pygments" },
]
sdist = { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/02/51/d3a6ea424652c60f05600d8c2e01a55c913755e7cdad64afabbd1aa16f44/readme_renderer-45.0.tar.gz", hash = "sha256:030a8fac74904f8fba11ad1bb6964e3f76e896dc7e5e71f16af190c9056696d1", size = 36172, upload-time = "2026-06-09T21:05:17.37Z" }
wheels = [
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/97/1b/295bf2fa3e740131778065e5ffa2c481f0e7210182d408e9a2c244ff5b0c/readme_renderer-45.0-py3-none-any.whl", hash = "sha256:3385ed220117104a2bceb4a9dac8c5fdf6d1f96890d7ea2a9c7174fd5c84091f", size = 14134, upload-time = "2026-06-09T21:05:15.85Z" },
]
[[package]] [[package]]
name = "redis" name = "redis"
version = "8.1.0" version = "8.1.0"
@@ -1748,27 +1593,6 @@ wheels = [
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
] ]
[[package]]
name = "requests-toolbelt"
version = "1.0.0"
source = { registry = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple" }
dependencies = [
{ name = "requests" },
]
sdist = { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888, upload-time = "2023-05-01T04:11:33.229Z" }
wheels = [
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" },
]
[[package]]
name = "rfc3986"
version = "2.0.0"
source = { registry = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple" }
sdist = { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/85/40/1520d68bfa07ab5a6f065a186815fb6610c86fe957bc065754e47f7b0840/rfc3986-2.0.0.tar.gz", hash = "sha256:97aacf9dbd4bfd829baad6e6309fa6573aaf1be3f6fa735c8ab05e46cecb261c", size = 49026, upload-time = "2022-01-10T00:52:30.832Z" }
wheels = [
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/ff/9a/9afaade874b2fa6c752c36f1548f718b5b83af81ed9b76628329dab81c1b/rfc3986-2.0.0-py2.py3-none-any.whl", hash = "sha256:50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd", size = 31326, upload-time = "2022-01-10T00:52:29.594Z" },
]
[[package]] [[package]]
name = "rich" name = "rich"
version = "15.0.0" version = "15.0.0"
@@ -1807,19 +1631,6 @@ wheels = [
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/0e/fe/da8b9e1347696bb22120b77280ec5ce25d500ca5cb39d5ad6e5c18de19c1/ruff-0.16.4-py3-none-win_arm64.whl", hash = "sha256:a3a61621c9b6f6a89573e938a080e648f1695baa3f58570a3a707bc51ff65a21", size = 10451579, upload-time = "2026-08-20T17:43:57.135Z" }, { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/0e/fe/da8b9e1347696bb22120b77280ec5ce25d500ca5cb39d5ad6e5c18de19c1/ruff-0.16.4-py3-none-win_arm64.whl", hash = "sha256:a3a61621c9b6f6a89573e938a080e648f1695baa3f58570a3a707bc51ff65a21", size = 10451579, upload-time = "2026-08-20T17:43:57.135Z" },
] ]
[[package]]
name = "secretstorage"
version = "3.5.0"
source = { registry = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple" }
dependencies = [
{ name = "cryptography" },
{ name = "jeepney" },
]
sdist = { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" }
wheels = [
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" },
]
[[package]] [[package]]
name = "setuptools" name = "setuptools"
version = "84.0.0" version = "84.0.0"
@@ -2002,26 +1813,6 @@ wheels = [
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" },
] ]
[[package]]
name = "twine"
version = "7.0.0"
source = { registry = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple" }
dependencies = [
{ name = "id" },
{ name = "keyring", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x'" },
{ name = "packaging" },
{ name = "readme-renderer" },
{ name = "requests" },
{ name = "requests-toolbelt" },
{ name = "rfc3986" },
{ name = "rich" },
{ name = "urllib3" },
]
sdist = { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/92/3c/58f808a359700f39a967dffede33efeac809262c03303fa3eec6afff8f49/twine-7.0.0.tar.gz", hash = "sha256:85cdb29c518efef867360ae4acd4b0dfd61c8654a22fca08e6f8539f05022177", size = 215032, upload-time = "2026-07-27T15:59:00.825Z" }
wheels = [
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/96/08/ddcdc06225eaad6de0e48e1002b06d919dbde20582d0662c7af51308e5d6/twine-7.0.0-py3-none-any.whl", hash = "sha256:b854164df26db268af05f49aa5c0344b10e27a494343ff05b1e0bad3b135f5a7", size = 43204, upload-time = "2026-07-27T15:58:59.26Z" },
]
[[package]] [[package]]
name = "typer" name = "typer"
version = "0.27.1" version = "0.27.1"