From 2baef7cf2f82b0eded441d7b1462043cf4f7aa7b Mon Sep 17 00:00:00 2001 From: chakcy <947105045@qq.com> Date: Thu, 27 Aug 2026 17:06:03 +0800 Subject: [PATCH] =?UTF-8?q?modify:=20=E5=88=86=E5=B8=83=E5=BC=8F=E6=96=B9?= =?UTF-8?q?=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 39 +++- pyproject.toml | 8 - src/chongming/app/__init__.py | 25 +- src/chongming/app/core/lifespan_setup.py | 280 +++++++++++++++++------ test/test_mqtt_bridge_heal.py | 146 ++++++++++++ test/test_mqtt_broker_lock.py | 158 +++++++++++++ uv.lock | 223 +----------------- 7 files changed, 572 insertions(+), 307 deletions(-) create mode 100644 test/test_mqtt_bridge_heal.py create mode 100644 test/test_mqtt_broker_lock.py diff --git a/README.md b/README.md index d285e5b..0fc7804 100644 --- a/README.md +++ b/README.md @@ -288,6 +288,25 @@ worker///__admin__ - **进程插件(external)不受该开关影响**:网关只对 **inline** 插件用 cache 解析启动副本数(见「启动流程」第 5 步 `load_workers`);external 插件由外部编排按其自身配置 `config/workers//config.toml` 的 `count` 启动,不经网关、也不读 cache。运行时 `scale` 对 external 仍走「向存活副本下发 rescale」路径,同样与该开关无关。 +### inline worker 宿主级领导选举(gunicorn 多 worker 兼容) + +inline worker 的生命周期交给**基于分布式锁的宿主级领导选举**(取代“每 gunicorn worker 各自拉起”),key `inline_owner:` 按网关隔离: + +- **多 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`,深色主题): @@ -524,7 +543,7 @@ start.sh 容器入口:uv sync + chongming-worker --type ### Broker 来源(内嵌 vs 外部) -网关默认启动内嵌 amqtt Broker(子进程 + 文件锁单例),`[.mqtt].use_embedded_broker` 控制 Broker 来源: +网关默认启动内嵌 amqtt Broker(子进程 + **宿主级分布式排他锁**单例),`[.mqtt].use_embedded_broker` 控制 Broker 来源: ```toml [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 = false`:网关**不启动** Broker,MQTT Bridge 直接连接 `host:port` 指向的外部 MQTT Broker(如独立部署的 EMQX/Mosquitto)。`host` 需改为外部 Broker 地址,`enabled = false` 时整个 MQTT 通道关闭。 +**单例与生命周期控制**(取代旧文件锁): + +- 内嵌 Broker 用 `chongming-cache` 的 `DistributedLock`(key `mqtt_broker:`,**按宿主隔离**——内嵌 Broker 绑定本机端口),多 worker/多实例下同一宿主机只启动一个 Broker;`cache` 未配置时跳过并告警。 +- 持锁的网关在缓存登记其 Broker 子进程 PID(`mqtt_broker_owner:`)。前任持有者崩溃/被强杀而留下**孤儿 Broker 子进程**时,接管的网关据此 PID 识别并终止之,再启动**由自己托管**的新子进程(可停、可接管);否则端口被不可托管进程占用时仅复用、不托管。 +- 关停时仅真正托管子进程的网关终止进程、清理登记并释放锁,绝不误伤他人。 + ### Broker 认证(用户名/密码) `[.mqtt].username` / `password` 配置 Broker 认证(可选,默认匿名): @@ -579,6 +604,7 @@ MQTT Bridge 是**网关进程内**的 MQTT 客户端组件,负责订阅主题 - **抢锁机制**:启动时 `set_nx`(SET NX + TTL 租约)原子抢锁,key 为 `mqtt_bridge`;抢到锁的网关才启动 Bridge,其余网关跳过。 - **租约续约**:持锁网关后台定期 `touch` 续约;若持锁网关宕机/失联,租约到期后锁自动释放,其他网关可重新抢到(重启后生效)。 - **受保护释放**:关闭时仅当 token 仍匹配(仍是自己持有)才删除,绝不误删其他网关抢到的锁。 +- **持有者崩溃自愈接管**:仅靠 gunicorn 复活死掉 slot 无法 failover(新进程可能在旧锁租约未过期时抢锁失败而照样跳过)。各网关进程内有**周期性巡检**(`mqtt_bridge_supervisor`,周期经 `[.mqtt].bridge_supervise_interval` 配置,默认 15s):非持有者进程若发现锁已空闲(原持有者租约过期=已死/失联),即经 `set_nx` 抢得唯一持有权并**自动重建 Bridge**;接管时同步按需拉起/接管内嵌 Broker。分布式锁保证任意时刻只有一方胜出,无分裂。 ```toml [cache] @@ -589,6 +615,17 @@ backend = "redis" # 跨主机收敛必须用 redis(或共享卷 dis - **`use_embedded_broker = true`(各机内嵌独立 Broker)**:各机 Broker 互不相连、本身不产生重复响应;此时全局单 Bridge 只会连到持锁网关那台机器的 Broker。 - **`use_embedded_broker = false`(共享外部 Broker)**:原先 N 台网关的 N 个 Bridge 会共同订阅同一批主题,导致一条请求被各消费一次、各回一份响应;改用分布式锁后全局只有一个 Bridge 消费,彻底规避重复响应。 +**部署结论**(内嵌 Broker 单例同样成立,`mqtt_broker:` 按宿主隔离): + +| 部署形态 | 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 请求负载为信封结构,控制字段与业务数据分离: diff --git a/pyproject.toml b/pyproject.toml index 982f97a..d8afba0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,14 +56,6 @@ url = "http://124.71.68.6:3000/api/packages/chongming/pypi/simple/" [[tool.uv.index]] 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] gunicorn = "chongming:gunicorn_serve" serve = "chongming:serve" diff --git a/src/chongming/app/__init__.py b/src/chongming/app/__init__.py index 4570a39..b8a04f7 100644 --- a/src/chongming/app/__init__.py +++ b/src/chongming/app/__init__.py @@ -42,13 +42,12 @@ async def lifespan(app: FastAPI): # 2. Zenoh session = lifespan_setup.setup_zenoh(app) - # 3. mqtt broker - # lifespan_setup.setup_mqtt_broker(app, host=config[env]["mqtt"]["host"], port=config[env]["mqtt"]["port"]) - lifespan_setup.setup_mqtt_broker(app) - - # 4. WorkerManager + # 3. WorkerManager 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 manager.register_gateway() @@ -83,6 +82,11 @@ async def lifespan(app: FastAPI): # 7. 启动 MQTT Bridge(内嵌 Broker + Worker 桥接) 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 状态同步 await asyncio.sleep(0.5) @@ -99,7 +103,16 @@ async def lifespan(app: FastAPI): # ---- 关闭阶段 ---- 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) # 1.5 停止路由注册表订阅(常驻,随应用生命周期启停) diff --git a/src/chongming/app/core/lifespan_setup.py b/src/chongming/app/core/lifespan_setup.py index cb2e5dc..c6de62b 100644 --- a/src/chongming/app/core/lifespan_setup.py +++ b/src/chongming/app/core/lifespan_setup.py @@ -1,6 +1,7 @@ import os import inspect import time +import signal import tomllib import glob 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 -def _acquire_singleton_lock(name: str) -> bool: - """原子文件锁:成功创建锁文件返回 True,已被占用返回 False。""" - exe_dir = os.path.dirname(os.path.abspath(sys.executable)) - lock_dir = os.path.join(exe_dir, ".chongming_locks") - os.makedirs(lock_dir, exist_ok=True) - lock_path = os.path.join(lock_dir, f"{name}.lock") - my_pid = os.getpid() +def _broker_lock_key() -> str: + """内嵌 Broker 排他锁 key。内嵌 Broker 绑定本机端口,故按宿主隔离 + (多实例共享同一 cache/redis 时,各宿主仍各起各的 Broker)。""" + return f"mqtt_broker:{socket.gethostname()}" + + +def _broker_owner_key() -> str: + """记录内嵌 Broker 子进程实际 PID 的缓存 key(按宿主隔离)。 + + 持有者在启动 Broker 子进程后登记其 PID;前任持有者崩溃时,接管的进程据此 + 识别并终止孤儿 Broker 子进程,从而真正掌握其生命周期。""" + return f"mqtt_broker_owner:{socket.gethostname()}" + + +def _pid_alive(pid) -> bool: try: - fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_RDWR, 0o644) - os.write(fd, str(my_pid).encode()) - os.close(fd) - logger.info(f"[LOCK] {name}: 获得锁 (PID {my_pid})") + os.kill(int(pid), 0) return True - except FileExistsError: - 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})") + except (ProcessLookupError, OSError, ValueError, TypeError): return False -def _release_singleton_lock(name: str): - """释放单例锁""" - 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") +def _terminate_pid(pid): + """尽力终止进程:先 SIGTERM,宽限后仍存活则 SIGKILL。""" try: - os.remove(lock_path) - except FileNotFoundError: + os.kill(int(pid), signal.SIGTERM) + 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 +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, password: str | None = None): """子进程入口:启动 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"] _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/多实例仅本宿主启动一个)。 由配置 [.mqtt].use_embedded_broker 控制: true (默认)→ 网关启动 amqtt Broker 子进程,host/port 为监听地址 false → 使用外部 MQTT Broker,网关仅运行 Bridge 客户端,跳过本步 配置了 username/password 时 Broker 启用认证,否则保持匿名。 + + 排他锁基于 chongming-cache 的 DistributedLock,key 按宿主隔离(内嵌 Broker 绑定 + 本机端口)。持有者在缓存登记其 Broker 子进程 PID;前任持有者崩溃/被强杀而留下 + 孤儿 Broker 子进程时,接管的进程借此 PID 识别并终止之,再启动自己托管的子进程, + 从而真正掌握 Broker 生命周期(可关停、可接管),而非留下不可管控的残留下端点。 """ env_config = config.get(env, {}) mqtt_config = env_config.get("mqtt", {}) @@ -563,38 +573,70 @@ def setup_mqtt_broker(app: FastAPI): f"跳过内嵌 amqtt Broker 启动" ) 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") port = mqtt_config.get("port", 1883) username = mqtt_config.get("username") 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): if is_port_in_use(port, host): - logger.info(f"MQTT Broker 已在 {host}:{port} 运行,跳过") - return + logger.info(f"MQTT Broker 已在 {host}:{port} 运行(由其他进程托管)") + return None 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 + # 端口已被监听:优先识别并终止前任残留的孤儿 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): - logger.info(f"MQTT Broker 已在 {host}:{port} 运行,跳过") - _release_singleton_lock("mqtt_broker") - app.state.mqtt_broker_lock_acquired = False - return + if prev_pid and _pid_alive(prev_pid) and prev_pid != os.getpid(): + logger.warning( + f"[mqtt-broker] 检出孤儿 Broker 子进程 PID {prev_pid},终止并接管重启" + ) + _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( target=_run_mqtt_broker, args=(host, port, username, password), daemon=False ) proc.start() app.state.mqtt_broker_process = proc + worker_manager.cache.set(_broker_owner_key(), str(proc.pid)) 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): if is_port_in_use(port, host): logger.info(f"MQTT Broker 已监听 {port} 端口") - return + return None if not proc.is_alive(): raise RuntimeError("MQTT Broker 进程异常退出") time.sleep(0.1) @@ -602,7 +644,7 @@ def setup_mqtt_broker(app: FastAPI): async def shutdown_mqtt_broker(app: FastAPI): - """终止 MQTT Broker 子进程(仅锁持有者释放锁)""" + """终止本进程托管的 MQTT Broker 子进程,并释放宿主级分布式排他锁、清理登记。""" proc = getattr(app.state, "mqtt_broker_process", None) if proc and proc.is_alive(): proc.terminate() @@ -610,28 +652,28 @@ async def shutdown_mqtt_broker(app: FastAPI): if proc.is_alive(): proc.kill() logger.info("MQTT Broker 子进程已终止") - if getattr(app.state, "mqtt_broker_lock_acquired", False): - _release_singleton_lock("mqtt_broker") + lock = getattr(app.state, "mqtt_broker_lock", None) + 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: - """ - 启动 MQTT Bridge(纯客户端模式)。 +async def _start_mqtt(app: FastAPI, worker_manager: WorkerManager, + bridge_lock) -> 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) - 订阅主题 = 全局 [.mqtt].custom_topics(可选兜底) - + 各插件在其 config/workers//config.toml 中声明的 [[workers.mqtt_topics]]。 - 使用文件锁确保多个 gunicorn worker 中只有一个启动 Bridge。 + 该逻辑同时被 setup_mqtt(启动时)与 mqtt_bridge_supervisor(持有者自愈接管时) + 复用。假定调用方已 acquire() 到 "mqtt_bridge" 分布式锁。 """ env_config = config.get(env, {}) mqtt_config = env_config.get("mqtt", {}) - if not mqtt_config.get("enabled", True): - logger.info("MQTT 已禁用") - return None - host = mqtt_config.get("host", "localhost") port = mqtt_config.get("port", 1883) 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(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( worker_manager=worker_manager, host=host, @@ -665,6 +692,7 @@ async def setup_mqtt(app: FastAPI, worker_manager: WorkerManager) -> MQTTBridge app.state.mqtt_bridge = bridge await bridge.start() bridge_lock.start() # 后台续约租约,防止持有方宕机/长暂停导致锁被错误抢走 + app.state.mqtt_bridge_lock = bridge_lock # 把 Bridge 挂接给常驻路由注册表(动态主题 + 补推已到达的元信息) registry = getattr(app.state, "route_registry", None) if registry is not None: @@ -675,6 +703,106 @@ async def setup_mqtt(app: FastAPI, worker_manager: WorkerManager) -> MQTTBridge 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) + 订阅主题 = 全局 [.mqtt].custom_topics(可选兜底) + + 各插件在其 config/workers//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 客户端。 + + 运行周期经 [.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): """停止 MQTT Bridge 和 Broker 子进程(仅锁持有者释放锁)""" bridge: MQTTBridge | None = getattr(app.state, "mqtt_bridge", None) diff --git a/test/test_mqtt_bridge_heal.py b/test/test_mqtt_bridge_heal.py new file mode 100644 index 0000000..5eab698 --- /dev/null +++ b/test/test_mqtt_bridge_heal.py @@ -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())) \ No newline at end of file diff --git a/test/test_mqtt_broker_lock.py b/test/test_mqtt_broker_lock.py new file mode 100644 index 0000000..9127394 --- /dev/null +++ b/test/test_mqtt_broker_lock.py @@ -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())) \ No newline at end of file diff --git a/uv.lock b/uv.lock index 7810a0a..8977cbd 100644 --- a/uv.lock +++ b/uv.lock @@ -6,12 +6,6 @@ resolution-markers = [ "python_full_version < '3.14'", ] -[manifest] -members = [ - "chongming-cli", - "chongming-zenoh", -] - [[package]] name = "aiofiles" version = "25.1.0" @@ -450,31 +444,19 @@ wheels = [ [[package]] name = "chongming-cli" -version = "0.1.2" -source = { editable = "utils/cli" } +version = "0.2.0" +source = { registry = "http://124.71.68.6:3000/api/packages/chongming/pypi/simple/" } dependencies = [ { name = "chongming-config" }, { name = "module-bank" }, { name = "sqlite-vfs" }, { name = "tomli" }, ] - -[package.dev-dependencies] -dev = [ - { name = "twine" }, +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" } +wheels = [ + { 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" }, ] -[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]] name = "chongming-config" version = "0.1.0" @@ -622,7 +604,7 @@ requires-dist = [ { name = "amqtt" }, { name = "bytedance" }, { name = "chongming-cache" }, - { name = "chongming-cli", editable = "utils/cli" }, + { name = "chongming-cli" }, { name = "chongming-config" }, { name = "chongming-database" }, { name = "chongming-jwt" }, @@ -649,7 +631,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ - { name = "chongming-cli", editable = "utils/cli" }, + { name = "chongming-cli" }, { name = "nuitka" }, { name = "pyarmor" }, { 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" }, ] -[[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]] name = "eclipse-zenoh" 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" }, ] -[[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]] name = "idna" 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" }, ] -[[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]] name = "jiter" 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" }, ] -[[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]] name = "loguru" 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" }, ] -[[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]] name = "nuitka" 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" }, ] -[[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]] name = "redis" 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" }, ] -[[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]] name = "rich" 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" }, ] -[[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]] name = "setuptools" 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" }, ] -[[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]] name = "typer" version = "0.27.1"