feat: 集成 chongming-license 机器码授权门禁(打包可选、授权版不可关)

三处收口,未授权插件不加载、不暴露端点、不可管理:
- core/licensing:授权管理器单例 + 构建期烘焙决策(chongming/_build_flags.py,
  随代码编译、部署侧不可改);开发/测试默认放行,CHONGMING_LICENSE_MODE 可临时开
- lifespan_setup:_scan_plugins(盘扫)与 _discover_plugins(授权收口)分离,
  未授权插件连同 TOML 路由/MQTT 主题/副本数一起从启动流程消失(契约校验因此
  不会硬失败),启动打印授权横幅
- route_registry.set_denied_types:未授权类型经 Zenoh 推来的描述符一律忽略
  (否则把 load_mode 改成 external 即可绕过)
- worker_admin:扩缩容/重启/读改配置对未授权插件一律 403,实例盘点附 licensed 标注
- routes/licensing:/license/{info,reload,file}(仅超管),导入先验签后原子落盘,
  坏文件不覆盖现有授权;导入不即时加载插件,如实告知需重启

打包可选:不加 --license 出"非授权版"(不校验、插件照常加载);授权版强制校验
且公钥随代码内置(现场换 config.toml 公钥自签也无效)。未烘焙的旧打包产物沿用
"打包即强制"的旧语义;烘焙标记只在产物内生效,不影响源码运行。

补依赖 greenlet(chongming-auth→database 的 sqlalchemy asyncio import 期即要求)
与 python-multipart(上传端点注册路由即要求);新增 test/test_license_gate.py
(52 项:门禁/契约存活/描述符拦截/管理接口/上传安全/烘焙三态,自造夹具直接运行)。
This commit is contained in:
2026-10-14 10:57:09 +08:00
parent dd1e6ed846
commit 93fed12d14
12 changed files with 1456 additions and 15 deletions
+3
View File
@@ -29,6 +29,9 @@ venv/
.installed.cfg
*.egg
# 授权版标记:打包脚本构建期生成(可编辑安装时落在 src/chongming/ 下),非源码
src/chongming/_build_flags.py
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
+70 -4
View File
@@ -3,7 +3,8 @@
chongming 网关本体:基于 **FastAPI + Zenoh** 的配置驱动微服务网关。
把宿主项目里的插件(inline 进程内 / external 独立进程)发布的路由统一挂载为 HTTP 端点,
并提供 Worker 服务发现、负载均衡、超时重试、实例定向、MQTT Bridge 与内置登录鉴权。
并提供 Worker 服务发现、负载均衡、超时重试、实例定向、MQTT Bridge、内置登录鉴权
与机器码授权(chongming-license)。
网关只提供**代码**:配置、插件与打包骨架由宿主项目提供(见「运行时约定」)。
@@ -42,12 +43,70 @@ uvicorn chongming.app:app --host 0.0.0.0 --port 8000
## 运行时约定
- **配置**:cwd 下 `config.toml`(环境、服务器、安全、MQTT、缓存等)与
- **配置**:cwd 下 `config.toml`(环境、服务器、安全、MQTT、缓存、授权等)与
`config/workers/**`(插件注册与路由定义),运行期读取,随宿主项目部署。
- **插件**:网关按 `plugins.<type>` 约定导入插件包,来源不限——`.mbank` 模块银行、
源码目录(开发态 `src/plugins`)或已安装包均可。
- **工作目录**:日志、上传、数据等相对路径均以 cwd 为基准。
## 机器码授权(chongming-license)
厂商用**私钥**离线签发 `license.lic`(绑定机器指纹 + 插件白名单 + 有效期),
网关内置**公钥**验签,全程无需联网。未授权的插件不加载、不注册路由
(external 插件经 Zenoh 推来的描述符同样被忽略)、管理接口一律 403。
是否启用校验见下方「打包」一节:非授权版产物完全不校验,授权版产物强制校验。
宿主 `config.toml` 的 `[license]` 段(缺省视为未配置,不启用校验):
```toml
[license]
# 是否执行拒绝。**只在非打包态生效**(uv run serve / 测试)——打包产物的
# 开关由构建期烘焙决定(部署目录的 config.toml 明文可写,以它为准等于放行
# 现场改一行就绕过授权)
enabled = false
path = "license.lic" # 授权文件路径(相对网关工作目录)
public_key = "" # 厂商公钥(base64 裸 32 字节或 PEM);由构建脚本注入,
# 授权版还会把公钥烘焙进产物代码,此处仅作镜像/兜底
min_match = 3 # 指纹命中阈值(N-of-M),容忍单块网卡/eMMC 更换
```
非打包态也可用 `CHONGMING_LICENSE_MODE=on`(或 `off`)临时开启/关闭,
便于在开发机上验证门禁逻辑。
### 打包:license 是可选构建项
"是否强制校验"由**构建期烘焙**决定,而不是运行时可写的配置 —— 打包脚本在编译前
生成 `chongming/_build_flags.py`(随代码编译进产物,部署侧不可改):
| 构建方式 | 产物 | 行为 |
| --- | --- | --- |
| 不加 `--license` | 非授权版 | 不校验授权,插件照常加载(与开发态一致) |
| `--license` + 厂商公钥 | 授权版 | 强制校验;公钥随代码内置,现场换 `config.toml` 里的公钥也无法自签绕过 |
| 旧打包脚本(未烘焙) | 兼容旧语义 | 打包即强制校验 |
即**不配 license 也能正常出包**(非授权版不是"拒绝所有插件"的砖头包),
授权版则无法被现场改配置/环境变量关掉。宿主打包命令:
```bash
uv run build --encrypt --license # 授权版产物(读 CHONGMING_LICENSE_PUBKEY / license_public.key)
uv run build --encrypt # 非授权版产物:不校验、插件照常加载
```
烘焙标记只对打包产物生效——源码运行(含可编辑安装)仍只由上面的配置/环境变量
决定,不会因为上次打了授权版包就把开发态也锁上。
授权管理端点(`/api/v1/license/...`,仅超级管理员):
| 端点 | 用途 |
| --- | --- |
| `GET /license/info` | 机器码、申请码、授权状态、各插件授权情况与硬件来源明细 |
| `POST /license/reload` | 重新读取磁盘上的授权文件(不改插件加载状态) |
| `POST /license/file` | 导入 `license.lic`:先验签/机器码/有效期,通过才原子替换 |
导入接口**先校验后落盘**:坏文件一律拒绝且不覆盖现有可用授权;通过校验但
白名单含此前未授权的插件时,需**重启网关**才会加载(响应里的 `restart_required`
如实告知)。申请授权时把 `/license/info` 的 `request_code` 发给厂商即可。
## 目录结构
```text
@@ -55,8 +114,11 @@ src/chongming/
├── __init__.py # 启动入口(serve / granian / gunicorn)与服务器参数转换
└── app/
├── __init__.py # FastAPI 应用装配(lifespan、中间件、异常处理)
├── core/ # WorkerManager / RouteGenerator / MQTT Bridge / 日志 / 鉴权
└── routes/ # 内置管理端点(stats / worker_admin / auth)
├── core/ # WorkerManager / RouteGenerator / MQTT Bridge / 日志 / 鉴权 / 授权门禁
└── routes/ # 内置管理端点(stats / worker_admin / auth / license)
test/
└── test_license_gate.py # 授权门禁测试(自造夹具,直接 python 运行)
```
## 开发与发布
@@ -65,6 +127,9 @@ src/chongming/
# 在 chongming-core 仓库内改代码后,让 venv 用本地源码(联调用)
uv pip install -e utils/gateway
# 授权门禁测试(自造夹具,可在任意目录直接运行)
python test/test_license_gate.py
# 发版:构建 wheel 并推私有源
uv build # dist/chongming_gateway-<version>-py3-none-any.whl
uv run twine upload --repository <私有源> dist/*
@@ -77,3 +142,4 @@ uv run twine upload --repository <私有源> dist/*
- [项目主 README](../../README.md):整体架构、使用与构建部署
- [Worker 管理与多实例运维](../../docs/worker-management.md):管理端点、扩缩容、实例定向
- [机器码授权指南](../../docs/license.md):密钥管理、签发/激活流程、指纹容错与命令行参考
+8 -5
View File
@@ -4,7 +4,7 @@ requires = ["hatchling"]
[project]
name = "chongming-gateway"
version = "0.1.0"
version = "0.1.1"
description = "chongming 网关:FastAPI + Zenoh 的配置驱动微服务网关(插件路由动态挂载、Worker 服务发现与负载均衡)"
readme = "README.md"
requires-python = ">=3.12"
@@ -15,9 +15,10 @@ dependencies = [
"chongming-config",
"chongming-jwt",
"chongming-worker",
"chongming-license",
# Web / 服务器
"fastapi",
"granian[reload,uvloop]>=2.8.4",
"granian[reload,uvloop]",
"gunicorn",
"httptools",
"starlette",
@@ -26,9 +27,13 @@ dependencies = [
# 运行时
"amqtt",
"eclipse-zenoh",
# chongming-auth → chongming-database 的 sqlalchemy[asyncio] 在 import 期即要求
"greenlet",
"orjson",
"pwdlib",
"pydantic",
# /license/file 上传端点(File/UploadFile)在注册路由时即要求
"python-multipart",
"sqlite-vfs",
]
@@ -47,6 +52,4 @@ url = "http://124.71.68.6:3000/api/packages/chongming/pypi/simple/"
url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple"
[dependency-groups]
dev = [
"twine>=7.0.0",
]
dev = ["twine>=7.0.0"]
+5 -1
View File
@@ -24,7 +24,7 @@ from .core.logger import get_logger
from sqlite_vfs.contrib.fastapi_static import SVFSStaticFiles
from .core import lifespan_setup, call_stats
from .core.parent_death import guard_parent_death
from .routes import stats_router, worker_admin_router, auth_router
from .routes import stats_router, worker_admin_router, auth_router, license_router
config = get_config()
@@ -69,6 +69,9 @@ async def lifespan(app: FastAPI):
is_inline_owner = manager.acquire_inline_owner()
app.state.inline_owner_locked = is_inline_owner
# 4.3 机器码授权状态横幅(需 WorkerManager 就绪:时钟水位存共享 cache)
lifespan_setup.log_license_banner(app)
# 5. 插件加载(混合模式:inline 进程内加载;external 由独立进程运行,
# 其路由经 Zenoh 动态注册——进程编排交给 Docker/K8s,网关不拉起进程)
worker_tasks, custom_routers, inline_types, external_types = (
@@ -352,3 +355,4 @@ if vfs_db_path := config[env].get("file_system", {}).get("path", None):
app.include_router(stats_router)
app.include_router(worker_admin_router)
app.include_router(auth_router)
app.include_router(license_router)
+264
View File
@@ -0,0 +1,264 @@
"""网关侧机器码授权门禁。
职责:把 `chongming_license` 的判定接进网关生命周期 —— 读配置、建管理器、
在插件发现阶段拒绝未授权插件、输出启动横幅。
**"是否执行拒绝"由构建期决定,而不是运行时配置**:部署目录的 `config.toml`
是明文可写的,任何 `enabled = false` 都能被一键绕开。打包脚本在编译前生成
``chongming/_build_flags.py``(见 :func:`write_build_flags`),把决策和厂商公钥
一起**编译进产物**,部署侧改不了(**只在打包产物内生效**,源码运行不采纳):
- 烘焙 ``LICENSE_ENFORCE = True``(授权版构建)→ 强制校验,且公钥随代码内置,
现场改 config.toml 里的公钥也无法伪造授权;
- 烘焙 ``LICENSE_ENFORCE = False``(无授权版构建)→ 与开发态一致、不校验 ——
**打包不含 license 因此始终是合法选项**,不会产出"拒绝所有插件"的砖头包;
- 未烘焙(开发/测试 / 旧打包脚本产物)→ 打包态沿用"打包即强制"的旧语义;
非打包态由 ``[license] enabled`` 或 ``CHONGMING_LICENSE_MODE`` 决定,默认放行。
安全边界说明:授权信任根是**厂商私钥**(Ed25519 签名)。公钥明文放置本身无害
(验签失败即拒绝),但若公钥来自可写配置,现场可"换成自己的公钥 + 自签一份
license"绕过授权 —— 所以授权版构建要把公钥烘焙进代码(上述 True 分支)。
注意宿主根仓 `resources/*.key` 那套 `.mbank` 验签是 HMAC **对称**且密钥随包发布,
只能算防篡改,**不能**作为授权信任根。
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
from chongming_cache import Cache
from chongming_config import get_config
from chongming_license import (
DEFAULT_MIN_MATCH,
LicenseManager,
LicenseStatus,
PluginDecision,
)
from .logger import get_logger
logger = get_logger("license")
# 默认授权文件路径(相对网关工作目录,与 config/、data/ 同级)
DEFAULT_LICENSE_PATH = "license.lic"
# 强制开启/关闭的环境变量(仅非打包态或未烘焙时期生效)
ENV_MODE = "CHONGMING_LICENSE_MODE"
_TRUTHY = {"on", "enforce", "enabled", "true", "1", "yes"}
_FALSY = {"off", "disable", "disabled", "false", "0", "no"}
_manager: LicenseManager | None = None
def is_packaged() -> bool:
"""当前是否运行在打包产物中(Nuitka / PyInstaller)。
Nuitka 在模块全局注入 ``__compiled__``,PyInstaller 设置 ``sys.frozen``。
"""
return bool(getattr(sys, "frozen", False) or globals().get("__compiled__"))
def build_flags_path() -> Path:
"""本包内 ``_build_flags.py`` 的落盘位置(打包脚本在此写烘焙开关)。"""
return Path(__file__).resolve().parents[2] / "_build_flags.py"
def _baked_flags() -> tuple[bool | None, str]:
"""读取构建期烘焙的授权决策,返回 ``(enforce, public_key)``。
``enforce=None`` 表示未烘焙(开发/测试,或旧打包脚本的产物)。此处用**静态
相对 import**(而非 importlib)以便 Nuitka/PyInstaller 沿依赖分析把该模块
编译进产物;模块缺失时静默回退。不缓存:每次现读,测试可注入伪模块。
"""
try:
from ... import _build_flags as flags
except ImportError:
return None, ""
enforce = getattr(flags, "LICENSE_ENFORCE", None)
return (
None if enforce is None else bool(enforce),
str(getattr(flags, "LICENSE_PUBLIC_KEY", "") or ""),
)
def write_build_flags(
*,
enforce: bool,
public_key: str = "",
target: str | Path | None = None,
) -> Path:
"""构建脚本调用:把授权决策烘焙成模块源码(编译进产物,部署侧不可改)。
由打包脚本在**编译前**调用一次(chongming-cli 的 build 已接好):
- 有厂商公钥(授权版)→ ``enforce=True`` 且公钥一并烘焙:产物强制校验,
现场改 config.toml 里的公钥也无法伪造授权(自签 + 换公钥这条路被堵死);
- 无公钥(内部/演示/无授权构建)→ ``enforce=False``:产物不校验授权 ——
**打包不配 license 因此始终是合法选项**,且不会产出"拒绝所有插件"的砖头包。
Args:
enforce: 该产物是否为授权版(是否强制校验)。
public_key: 厂商公钥(base64 裸 32 字节或 PEM);非空时会校验格式并记录指纹。
target: 生成路径,缺省为本包 ``chongming/_build_flags.py``。
"""
fingerprint = ""
if public_key:
# 失败即构建期配置错误:趁早报错,避免打出一个"谁都验不过"的授权版产物
from chongming_license import load_public_key, public_key_fingerprint
try:
load_public_key(public_key)
except Exception as exc: # noqa: BLE001 - 统一转成构建脚本可读的错误
raise ValueError(f"厂商授权公钥无效:{exc}") from exc
fingerprint = public_key_fingerprint(public_key)
path = Path(target) if target is not None else build_flags_path()
lines = [
'"""构建期烘焙的授权决策 —— 由打包脚本生成,请勿手改。',
"",
"编译进产物后部署侧不可修改:部署目录的 config.toml / 环境变量都影响不了",
"这份决策。生成方:chongming_cli.build(见 chongming.app.core.licensing."
"write_build_flags)。",
'"""',
f"LICENSE_ENFORCE = {enforce!r}",
f'LICENSE_PUBLIC_KEY = "{public_key}"',
f'PUBLIC_KEY_FINGERPRINT = "{fingerprint}"',
]
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
return path
def license_conf() -> dict:
"""``[license]`` 配置段(缺省为空 dict)。"""
return get_config().get("license", {}) or {}
def should_enforce() -> bool:
"""是否真正执行拒绝。
优先级(见模块文档):**打包产物内**以构建期烘焙为准(未烘焙时沿用"打包即
强制"的旧语义);非打包(开发/测试)由环境变量 > 配置决定,默认 False ——
烘焙标记只对产物生效,源码运行(含可编辑安装)不受上次打包结果影响。
"""
conf = license_conf()
env_mode = os.environ.get(ENV_MODE, "").strip().lower()
if is_packaged():
baked, _ = _baked_flags()
if baked is not None:
if baked:
if conf.get("enabled") is False or env_mode in _FALSY:
# 陷阱日志:授权版产物却看到"关闭授权"的意图 —— 不予采纳,但要留痕
logger.warning(
"[license] 本产物为授权版构建(构建期已烘焙),"
"配置/环境变量关闭授权不生效。若确需关闭请联系厂商重新出包。"
)
return True
if conf.get("enabled") or env_mode in _TRUTHY:
logger.warning(
"[license] 本产物为非授权版构建(打包时未配 license),"
"忽略开启授权校验的配置/环境变量(也缺少可校验的公钥)。"
)
return False
if conf.get("enabled") is False or env_mode in _FALSY:
# 陷阱日志:打包态却看到"关闭授权"的意图 —— 不予采纳,但要留痕
logger.warning(
"[license] 检测到关闭授权的配置/环境变量,但当前为打包产物且构建期"
"未烘焙非授权版标记,授权校验强制开启(该配置不生效)。"
"若确需关闭请联系研发重新出包。"
)
return True
if env_mode:
return env_mode in _TRUTHY
return bool(conf.get("enabled", False))
def enforce_source() -> str:
"""当前 enforce 的判定来源:``build`` / ``packaging`` / ``env`` / ``config``。"""
if is_packaged():
baked, _ = _baked_flags()
return "build" if baked is not None else "packaging"
if os.environ.get(ENV_MODE, "").strip():
return "env"
return "config"
def _build_manager() -> LicenseManager:
conf = license_conf()
# 公钥优先取构建期烘焙(授权版产物随代码内置、现场不可改);未烘焙时回退
# 配置/环境注入。与 enforce 一致,只在打包产物内采纳烘焙值。
conf_key = str(conf.get("public_key") or "")
baked_key = ""
if is_packaged():
_baked, baked_key = _baked_flags()
if baked_key and conf_key and conf_key != baked_key:
logger.warning(
"[license] 配置内公钥与产物内置公钥不一致,以产物内置公钥为准"
"(内置公钥随代码编译,不可篡改)"
)
return LicenseManager(
license_path=str(conf.get("path") or DEFAULT_LICENSE_PATH),
public_key=baked_key or conf_key,
min_match=int(conf.get("min_match", DEFAULT_MIN_MATCH)),
# 时钟水位存共享缓存(sqlite 本机 / redis 跨主机),不加 TTL:
# 水位一旦过期被淘汰,回拨检测就失效了
clock_store=Cache.from_config(get_config()["cache"]),
enforce=should_enforce(),
logger=logger,
)
def get_manager() -> LicenseManager:
"""授权管理器单例(惰性创建)。"""
global _manager
if _manager is None:
_manager = _build_manager()
return _manager
def reset_manager() -> None:
"""丢弃单例(测试用;换配置或换 license 文件后重新判定)。"""
global _manager
_manager = None
def reload() -> LicenseStatus:
"""重新读取 license 文件(管理接口导入新文件后调用)。"""
return get_manager().reload()
def check_plugin(plugin_type: str) -> PluginDecision:
"""该插件是否获授权运行。"""
return get_manager().check_plugin(plugin_type)
def denied_types(plugin_types) -> set[str]:
"""从给定插件类型里筛出未获授权的(即不允许加载的)。"""
return get_manager().denied_types(plugin_types)
def describe() -> dict:
"""给 ``GET /license/info`` 与前端「授权」页的完整信息。"""
data = get_manager().describe()
data["enforce_source"] = enforce_source()
data["is_packaged"] = is_packaged()
data["license_path"] = get_manager().license_path
return data
def log_banner() -> None:
"""启动横幅:机器码、授权状态、授权插件、公钥指纹。"""
try:
for line in get_manager().banner():
logger.info(line)
if get_manager().enforce and not get_manager().status.ok:
logger.error(
"[license] 未授权状态下所有插件均不会加载;"
"请在管理页「授权」导入 license 文件,或联系厂商签发(需提供本机机器码)。"
)
except Exception as exc: # 授权模块异常不应阻断启动流程
logger.error("[license] 授权状态检查异常: %s", exc)
+81 -2
View File
@@ -332,6 +332,16 @@ async def setup_route_registry(
registry = RouteRegistry(app, manager, prefix=prefix)
if inline_types:
registry.set_inline_types(set(inline_types))
# 授权门禁:未授权类型一律忽略其路由描述符。此处独立重算(不依赖 load_workers
# 的调用顺序),避免模块级状态带来的隐式时序耦合。
from .licensing import denied_types as _denied_types, get_manager as _get_license
if _get_license().enforce:
denied = _denied_types({p["type"] for p in _scan_plugins()})
registry.set_denied_types(denied)
if denied:
logger.warning(
"路由注册表已拒绝未授权插件描述符: %s", sorted(denied)
)
await registry.start(session)
app.state.route_registry = registry
logger.info(
@@ -341,9 +351,9 @@ async def setup_route_registry(
return registry
def _discover_plugins() -> list[dict]:
def _scan_plugins() -> list[dict]:
"""
发现所有插件配置。
扫描全部插件配置(**不做授权过滤**,授权门禁见 _discover_plugins)。
优先从各插件的独立配置文件 config/workers/<type>/config.toml 的 [workers] 节读取,
兼容旧的 config/workers/config.toml 全局配置。
@@ -431,6 +441,75 @@ def _discover_plugins() -> list[dict]:
return plugins
# 最近一次授权门禁的拒绝明细:插件类型 -> 拒绝原因
_denied_plugin_types: dict[str, str] = {}
def denied_plugin_types() -> dict[str, str]:
"""最近一次插件发现中被授权门禁拒绝的类型 -> 原因(供管理接口展示)。"""
return dict(_denied_plugin_types)
def log_license_banner(app: FastAPI | None = None) -> None:
"""启动横幅:机器码、授权状态、授权插件、公钥指纹。
在 WorkerManager 就绪后调用(时钟水位写入共享 cache)。异常不阻断启动 ——
授权模块自身出问题时仍应让网关起来并留下日志,而不是变成一个起不来的黑盒。
"""
from .licensing import log_banner
try:
log_banner()
except Exception as exc:
logger.error("[license] 授权状态横幅输出失败: %s", exc)
def _discover_plugins() -> list[dict]:
"""发现插件并应用**授权门禁**,返回可加载的插件列表。
这里是机器码授权的**唯一收口**:load_workers / validate_contracts /
cleanup_workers 全部经由本函数取插件列表,因此在此剔除未授权插件即可让
各处自动保持一致。
必须收口于此而不是"跳过 import":契约校验(``validate_contracts``)会把
该插件的 TOML 路由视为"缺少 @rpc_method 注册"的硬性错误并**终止启动** ——
只跳过 import 会让设备直接起不来。在此剔除后,该插件连同其 TOML 路由、
自定义路由、MQTT 主题、副本数一起从启动流程中消失,契约校验自然跳过。
external 插件的路由由 RouteRegistry 动态挂载,不经本函数,故另有
``RouteRegistry.set_denied_types`` 封堵(否则把 load_mode 改成 external
即可绕过门禁)。
"""
plugins = _scan_plugins()
from .licensing import check_plugin, get_manager
if not get_manager().enforce:
# 未启用(开发/测试):不剔除任何插件
_denied_plugin_types.clear()
return plugins
allowed: list[dict] = []
denied: dict[str, str] = {}
for p in plugins:
decision = check_plugin(p["type"])
if decision.allowed:
allowed.append(p)
else:
denied[p["type"]] = decision.message
_denied_plugin_types.clear()
_denied_plugin_types.update(denied)
for plugin_type in sorted(denied):
logger.error("[license] %s", denied[plugin_type])
if denied:
logger.warning(
"授权门禁已拒绝 %d 个插件(不加载、不注册路由、管理接口禁止操作): %s",
len(denied), sorted(denied),
)
return allowed
def _import_plugin_module(module_name: str):
"""导入插件模块(`plugins.<type>` 约定)。
+28
View File
@@ -74,6 +74,10 @@ class RouteRegistry:
self._mqtt_bridge = None
# 网关进程内加载(inline)的插件类型:忽略其描述符,防止与盘扫注册重复挂载
self._inline_types: set = set()
# 被授权门禁拒绝的插件类型:忽略其描述符(未获授权的插件不得暴露任何 HTTP 端点)
self._denied_types: set = set()
# 已告警过的拒绝类型(避免 worker 周期性重发布描述符导致日志刷屏)
self._denied_logged: set = set()
# 将动态 schema 注入 OpenAPI(provider 实时读取本注册表)
_inject_schemas_to_openapi(
@@ -87,6 +91,20 @@ class RouteRegistry:
"""设置 inline 插件类型集合(混合模式防双注册)。"""
self._inline_types = set(types)
def set_denied_types(self, types: set):
"""设置被授权门禁拒绝的插件类型集合,其路由描述符一律忽略。
必要性:门禁在网关侧只能拦住"盘扫加载"这条路径。若把某插件 config.toml
的 ``load_mode`` 改成 ``external``,网关就不再加载它、``set_inline_types``
也不再抑制它,其独立进程推来的路由描述符就会被正常挂载 —— 等于绕过了
授权。因此这里对未授权类型再做一道拦截,且**与 load_mode 无关**。
局限(如实记录):这只拦截 HTTP 暴露,被直接拉起的 external 进程自身
仍会运行。彻底封堵需在 chongming-worker SDK 侧加门禁(另行规划)。
"""
self._denied_types = set(types)
self._denied_logged &= self._denied_types
async def start(self, session: zenoh.Session):
"""订阅 chongming/routes/**,开始接收路由描述符。"""
self._loop = asyncio.get_running_loop()
@@ -151,6 +169,16 @@ class RouteRegistry:
# 该插件由网关进程内加载(盘扫注册路由),忽略外部描述符防双注册
return
if wtype in self._denied_types:
# 授权门禁拒绝的插件:不下发任何 HTTP 端点(与 load_mode 无关)
if wtype not in self._denied_logged:
self._denied_logged.add(wtype)
logger.error(
"拒绝未授权插件 %s 的路由描述符(key=%s):该插件不在授权白名单内,"
"其端点不会被挂载", wtype, key,
)
return
if kind == zenoh.SampleKind.DELETE:
self._remove(wtype, route_id)
return
+2 -1
View File
@@ -1,5 +1,6 @@
from .stats import router as stats_router
from .worker_admin import router as worker_admin_router
from .auth import router as auth_router
from .licensing import router as license_router
__all__ = ["stats_router", "worker_admin_router", "auth_router"]
__all__ = ["stats_router", "worker_admin_router", "auth_router", "license_router"]
+208
View File
@@ -0,0 +1,208 @@
"""机器码授权管理端点(查看授权状态 / 展示机器码 / 导入 license 文件)。
设计取舍:
- **仅超级管理员可用**:机器码与各硬件来源指纹属设备标识信息,且导入 license 会
改变整个网关的插件加载行为,与 `worker_admin` 同级风险,统一按超管门禁。
设备上默认已 seed admin/admin,现场装机人员登录后即可取机器码,不额外加门槛。
- **导入先验证后落盘**:非法/验签失败/机器码不符的文件一律拒绝且不覆盖现有文件,
避免"上传了一个坏文件导致原本可用的授权也没了"。
- **导入不即时加载插件**:`_discover_plugins` 在启动期完成,新授权的插件要等
网关重启才会真正加载;响应里的 `restart_required` 如实告知,前端据此提示。
路径为 ``/api/v1/license/...``(跟随 ``[default] prefix``)。
"""
from __future__ import annotations
import json
import os
import shutil
import tempfile
from pathlib import Path
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile
from chongming_config import get_config
from ..core import licensing
from ..core.auth import jwt_auth_dependency
from ..core.lifespan_setup import _scan_plugins, denied_plugin_types
from ..core.logger import get_logger
config = get_config()
logger = get_logger("license")
router = APIRouter(prefix=config["default"]["prefix"], tags=["license"])
# license 文件大小上限:正常文件仅数 KB,给足余量的同时避免被当作上传通道
MAX_LICENSE_BYTES = 64 * 1024
def require_superuser(
payload: dict = Depends(jwt_auth_dependency),
) -> dict:
"""仅超级管理员可访问(与 worker_admin 一致的门禁口径)。"""
if not payload.get("is_superuser"):
raise HTTPException(status_code=403, detail="需要超级管理员权限 (is_superuser)")
return payload
def _loaded_types(request: Request) -> set[str]:
"""当前已实际加载的插件类型(inline + external 观测集合)。"""
return set(getattr(request.app.state, "inline_types", None) or []) | set(
getattr(request.app.state, "external_types", None) or []
)
def _plugin_overview(request: Request) -> dict:
"""插件视角的授权全景:盘扫到的 / 已授权 / 被拒 / 当前已加载。"""
discovered = [p["type"] for p in _scan_plugins()]
manager = licensing.get_manager()
enforce = manager.enforce
licensed, denied = [], {}
for plugin_type in discovered:
decision = manager.check_plugin(plugin_type)
if decision.allowed:
licensed.append(plugin_type)
else:
denied[plugin_type] = {"code": decision.code, "message": decision.message}
return {
"discovered": sorted(discovered),
# 未启用校验时不存在"授权与否",如实按"全部可用"展示,避免误导
"licensed": sorted(licensed) if enforce else sorted(discovered),
"denied": denied if enforce else {},
"loaded": sorted(_loaded_types(request)),
"denied_from_last_scan": denied_plugin_types(),
}
@router.get("/license/info", summary="授权状态 / 机器码 / 申请码")
async def license_info(
request: Request,
_admin: dict = Depends(require_superuser),
):
"""返回本机机器码、授权状态、各插件授权情况与硬件来源明细。
现场申请授权时,把响应中的 ``request_code``(或 ``machine_code``)发给厂商即可。
"""
info = licensing.describe()
# 申请码:把各来源指纹打包成一段可抄写文本,方便直接贴进邮件 ——
# 只报机器码只能签出"精确匹配"的授权(无换硬件容错),故优先给申请码
request_code = ""
try:
from chongming_license import encode_request_code
request_code = encode_request_code()
except Exception as exc:
logger.warning("生成申请码失败:%s", exc)
overview = _plugin_overview(request)
return {
**info,
"request_code": request_code,
"plugins": overview,
}
@router.post("/license/reload", summary="重新读取授权文件")
async def license_reload(
_admin: dict = Depends(require_superuser),
):
"""从磁盘重新读取 license(手工替换文件后无需重启即可刷新状态)。
注意:插件是否加载仍以启动期判定为准,刷新状态不会加载新授权的插件。
"""
status = licensing.reload()
logger.info("授权文件已重新读取: %s", status.message)
return {"success": True, "status": status.as_dict()}
@router.post("/license/file", summary="导入 license 文件(覆盖现有授权)")
async def license_upload(
request: Request,
file: UploadFile = File(..., description="厂商签发的 license.lic"),
_admin: dict = Depends(require_superuser),
):
"""上传并安装 license 文件。
流程:读入 → 大小/JSON 校验 → **验签 + 机器码 + 有效期校验** → 通过才落盘
(临时文件 + 原子替换)→ 刷新授权状态。
未通过校验的文件一律拒绝,且**不会覆盖**现有可用授权 —— 现场误传坏文件不至于
把设备搞成未授权。校验通过但白名单含此前未授权的插件时,需**重启网关**才会
加载它们(`_discover_plugins` 只在启动期执行)。
"""
raw = await file.read()
if not raw:
raise HTTPException(status_code=422, detail="上传内容为空")
if len(raw) > MAX_LICENSE_BYTES:
raise HTTPException(
status_code=413,
detail=f"license 文件过大({len(raw)} 字节,上限 {MAX_LICENSE_BYTES})",
)
try:
json.loads(raw)
except ValueError as exc:
raise HTTPException(status_code=422, detail=f"不是合法的 JSON:{exc}") from exc
manager = licensing.get_manager()
target = Path(manager.license_path)
# 先落到同目录的临时文件再校验:read_license 需要真实路径,
# 且同目录保证后续 move 是原子 rename(不会读到半截文件)
tmp_dir = target.parent if str(target.parent) else Path(".")
tmp_dir.mkdir(parents=True, exist_ok=True)
fd, tmp_name = tempfile.mkstemp(prefix=".license-", suffix=".tmp", dir=str(tmp_dir))
try:
with os.fdopen(fd, "wb") as f:
f.write(raw)
# 用与运行期完全相同的判定逻辑校验(含机器码与有效期)
from chongming_license import read_license
status = read_license(
tmp_name,
manager.public_key,
min_match=manager.min_match,
sources=manager.collect_sources(),
)
if not status.ok:
raise HTTPException(
status_code=422,
detail=f"授权文件校验未通过:{status.message}(原因码 {status.code})",
)
shutil.move(tmp_name, target)
tmp_name = "" # 已移走,finally 不再删除
os.chmod(target, 0o644)
finally:
if tmp_name and os.path.exists(tmp_name):
os.unlink(tmp_name)
after = licensing.reload()
logger.info(
"已导入授权文件 %s:%s(授权插件 %s)",
target, after.message, sorted(after.licensed_plugins) or "无",
)
# 本次授权后"已授权但尚未加载"的插件 —— 这些需要重启网关才会生效
pending = sorted(
(set(_plugin_overview(request)["discovered"]) & after.licensed_plugins)
- _loaded_types(request)
)
return {
"success": True,
"license_path": str(target),
"status": after.as_dict(),
"licensed_plugins": sorted(after.licensed_plugins),
"restart_required": bool(pending),
"pending_plugins": pending,
"restart_hint": (
f"以下插件已获授权但尚未加载,重启网关后生效:{pending}"
if pending else "无新增插件需要加载,无需重启。"
),
}
+50 -2
View File
@@ -4,6 +4,7 @@ Worker 管理端点(插件 / 单副本重启)
提供插件实例盘点与"精细化重启单个 worker 副本"的能力:
- 经 Zenoh 下发管理指令到目标的 admin Queryable(worker/<type>/<id>/__admin__)
- 仅超级管理员(JWT payload.is_superuser)可访问
- 未授权插件(机器码授权白名单外)的任何管理操作一律 403
协议说明(见 chongming_worker.runtime 单副本管理协议):
- 每个 worker 副本在 worker/<type>/<worker_id>/__admin__ 注册内部 Queryable
@@ -17,6 +18,7 @@ import tomllib
from chongming_config import get_config
from ..core import licensing
from ..core.auth import jwt_auth_dependency
from ..core.logger import get_logger
@@ -39,6 +41,26 @@ def require_admin(
return payload
def _ensure_licensed(worker_type: str) -> None:
"""授权门禁:未授权的插件禁止任何管理操作(扩缩容 / 重启 / 读改配置)。
未授权插件在启动期就不加载(见 lifespan_setup._discover_plugins),因此在
离线设备上通常表现为"不存在";但网关可能因设备重启后
license 到期、或被手动改过配置而留下残存副本,故此处必须独立拦截 ——
否则可以借扩缩容把未授权插件重新拉起、或经 config 修改其行为。
每个管理端点在入口显式调用(而非做成依赖),便于审计"哪些入口受门禁保护"。
"""
if not licensing.get_manager().enforce:
return
decision = licensing.check_plugin(worker_type)
if not decision.allowed:
raise HTTPException(
status_code=403,
detail=f"{decision.message};该插件不在授权白名单内,管理操作已拒绝",
)
def _get_manager(request: Request):
"""从应用状态获取 WorkerManager;未初始化时(lifespan 外)报 503。"""
manager = getattr(request.app.state, "worker_manager", None)
@@ -52,9 +74,28 @@ async def list_workers(
request: Request,
_admin: dict = Depends(require_admin),
):
"""返回全部插件实例(worker_type / worker_id / mode / alive),供前端渲染与选择。"""
"""返回全部插件实例(worker_type / worker_id / mode / alive),供前端渲染与选择。
每个实例附 `licensed` 标注:未授权插件即使有残存副本也不应被操作,
前端据此禁用按钮。
"""
manager = _get_manager(request)
return {"workers": manager.list_workers(), "success": True}
workers = manager.list_workers()
enforce = licensing.get_manager().enforce
for w in workers:
w["licensed"] = (
True if not enforce
else licensing.check_plugin(w["worker_type"]).allowed
)
return {
"workers": workers,
"success": True,
"license_enforced": enforce,
"denied_plugin_types": (
sorted(licensing.denied_types({w["worker_type"] for w in workers}))
if enforce else []
),
}
@router.post("/admin/workers/restart", summary="重启单个 Worker 副本")
@@ -69,6 +110,7 @@ async def restart_worker(
- worker_type: 插件类型(如 example)
- worker_id: 副本 id(如 worker-0-1-12345),由 GET /admin/workers 提供
"""
_ensure_licensed(worker_type)
manager = _get_manager(request)
try:
result = await manager.restart_worker(worker_type, worker_id)
@@ -103,6 +145,7 @@ async def restart_all(
注意:网关进程内加载的 inline 插件副本经本接口重启,仅重建 worker 协程,
不会卸载插件;external 插件同理逐个副本重启。
"""
_ensure_licensed(worker_type)
manager = _get_manager(request)
candidates = [
w for w in manager.list_workers()
@@ -146,6 +189,7 @@ async def scale_workers(
- inline 直接调整本进程 Worker 池;external 经 Zenoh 下发 rescale 到存活副本,
由其进程内池扩缩并写回自身配置。
"""
_ensure_licensed(worker_type)
manager = _get_manager(request)
try:
result = await manager.scale_workers(worker_type, body.count)
@@ -186,6 +230,7 @@ async def list_worker_instances(
每个实例 = 一个可独立承载副本的执行单元(inline=本网关进程/host,
external=独立进程/容器),含当前副本数与可选中的 worker_id。
"""
_ensure_licensed(worker_type)
manager = _get_manager(request)
try:
instances = await manager.list_instances(worker_type)
@@ -210,6 +255,7 @@ async def scale_worker_instance(
经该实例任一副本下发 rescale{scale, instance_id, absolute:true},由其进程内
WorkerPool 直接扩缩并记入逐实例覆盖表;重启后仍保持该绝对数。
"""
_ensure_licensed(worker_type)
manager = _get_manager(request)
try:
result = await manager.scale_instance(
@@ -256,6 +302,7 @@ async def read_worker_config(
- worker_type: 插件类型(如 example)
"""
_ensure_licensed(worker_type)
manager = _get_manager(request)
try:
info = await manager.get_worker_config(worker_type)
@@ -305,6 +352,7 @@ async def update_worker_config(
- worker_type: 插件类型(如 example)
- body.updates: 待更新的键值,如 {"data_dir": "./data2"}
"""
_ensure_licensed(worker_type)
manager = _get_manager(request)
if not body.updates:
raise HTTPException(status_code=422, detail="updates 不能为空")
+625
View File
@@ -0,0 +1,625 @@
"""机器码授权门禁测试(网关侧)。
核心验证目标 —— 未授权插件被拒绝时,**网关必须仍能正常启动**:
1. 门禁收口在 `_discover_plugins()`:未授权插件不出现在返回列表中,
且 `denied_plugin_types()` 能给出拒绝原因;
2. **契约校验不硬失败**:这是最容易踩的坑 —— `validate_contracts()` 会把
"TOML 路由没有对应 @rpc_method 注册"视为硬性错误并终止启动。若把"不加载
未授权插件"实现成"跳过 import 但保留在插件列表里",该插件的 TOML 路由就会
变成孤儿 → 设备起不来。本测试确保收口位置正确(并反向验证夹具确实会硬失败,
防止测试因夹具写错而静默通过);
3. 路由描述符门禁:未授权类型的 external 路由描述符被忽略(防"改 load_mode
成 external 绕过门禁");
4. 管理接口门禁:未授权插件的扩缩容/重启/配置接口一律 403,实例盘点如实标注;
5. 授权管理接口:超管门禁、机器码/申请码输出,以及"导入坏文件不覆盖现有授权";
6. 有效期与开关:过期授权不放行任何插件;未启用校验时(开发/测试)全部放行。
测试在临时工作目录里自造夹具(config.toml + config/workers/**),不依赖宿主
项目文件。运行方式:`python test/test_license_gate.py`。
"""
import asyncio
import base64
import io
import json
import os
import sys
import tempfile
import types
from contextlib import contextmanager
from datetime import date, timedelta
from pathlib import Path
from types import SimpleNamespace
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "src"))
# ---- 夹具工作目录 ---------------------------------------------------------
# 网关全部相对路径(config.toml / config/workers/** / license.lic / 日志 / 缓存)
# 均以 cwd 为基准,故先切到自造的临时目录,再导入网关模块。
WORK = Path(tempfile.mkdtemp(prefix="gw-license-"))
os.chdir(WORK)
(WORK / "config.toml").write_text(
"""
[default]
app.debug = false
app.description = "license 门禁测试夹具"
app.name = "gw-license-test"
app.version = "0.0.1"
env = "development"
prefix = "/api/v1"
[cache]
backend = "sqlite"
[security]
algorithm = "HS256"
secret_key = "test-secret"
[license]
enabled = true
path = "license.lic"
public_key = ""
min_match = 1
[development]
debug = true
env = "development"
[development.server]
access_log = false
host = "127.0.0.1"
port = 8000
reload = false
workers = 1
[development.logging]
file = "logs"
format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
level = "WARNING"
""",
encoding="utf-8",
)
# 两个插件:alpha 在授权白名单内,beta 不在。beta 声明一条 TOML HTTP 路由而
# 没有任何 @rpc_method —— 若它未被门禁过滤,契约校验将硬失败终止启动。
for ptype in ("alpha", "beta"):
d = WORK / "config" / "workers" / ptype
d.mkdir(parents=True)
(d / "config.toml").write_text(
f"""
[workers]
type = "{ptype}"
module = "plugins.{ptype}"
factory = "create_workers"
cleanup = "cleanup"
count = 1
[worker]
name = "{ptype}"
version = "0.1.0"
[worker.registration]
router_prefix = "/{ptype}"
""",
encoding="utf-8",
)
(WORK / "config" / "workers" / "beta" / "ping.toml").write_text(
'[POST]\nsummary = "beta 端点(无 @rpc_method)"\n', encoding="utf-8"
)
# 必须在导入网关模块前设置:licensing 的 enforce 在首次建管理器时读取
os.environ["CHONGMING_LICENSE_MODE"] = "enforce"
from chongming_config import get_config # noqa: E402
from cryptography.hazmat.primitives import serialization # noqa: E402
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey # noqa: E402
from chongming_license import collect_fingerprints, machine_code, sign_payload # noqa: E402
import chongming # noqa: E402
from chongming.app.core import licensing # noqa: E402
from chongming.app.core.lifespan_setup import ( # noqa: E402
_discover_plugins,
_scan_plugins,
denied_plugin_types,
)
failures: list[str] = []
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)
# ---------------------------------------------------------------------------
# 测试夹具:临时密钥对 + license 文件
# ---------------------------------------------------------------------------
def _make_keypair() -> tuple[str, Ed25519PrivateKey]:
priv = Ed25519PrivateKey.generate()
pub = base64.b64encode(
priv.public_key().public_bytes(
encoding=serialization.Encoding.Raw,
format=serialization.PublicFormat.Raw,
)
).decode("ascii")
return pub, priv
def _issue(tmpdir: Path, priv: Ed25519PrivateKey, plugins: list[str],
expires_days: int = 365, sources: dict | None = None) -> str:
src = sources if sources is not None else collect_fingerprints()
payload = {
"v": 1,
"lic_id": "L-TEST-0001",
"customer": "测试客户",
"machine_code": machine_code(src),
"sources": src,
"plugins": plugins,
"not_before": None,
"expires_at": (date.today() + timedelta(days=expires_days)).isoformat(),
"issued_at": "2026-09-20T00:00:00Z",
"note": "",
}
doc = {
"payload": payload,
"alg": "ed25519",
"kid": "test",
"sig": sign_payload(payload, priv),
}
path = tmpdir / "license.lic"
path.write_text(json.dumps(doc), encoding="utf-8")
return str(path)
def _use_license(public_key: str, path: str, min_match: int = 1) -> None:
"""把测试用的公钥/路径写进运行期配置并重建管理器。
直接改 `get_config()` 返回的 dict(chongming_config 缓存同一对象)。
"""
cfg = get_config()
cfg.setdefault("license", {})
cfg["license"]["public_key"] = public_key
cfg["license"]["path"] = path
cfg["license"]["min_match"] = min_match
licensing.reset_manager()
def _request_stub() -> SimpleNamespace:
"""管理接口直调用的 Request 桩:只需要 app.state(inline/external 类型观测)。"""
return SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace()))
@contextmanager
def _baked_build_flags(enforce: bool, public_key: str = ""):
"""模拟打包脚本烘焙的 ``chongming/_build_flags.py``(编译进产物那份)。
直接塞进 sys.modules/父包属性:读取端是静态 import,等价于产物内已编译模块。
"""
module = types.ModuleType("chongming._build_flags")
module.LICENSE_ENFORCE = enforce
module.LICENSE_PUBLIC_KEY = public_key
prev_mod = sys.modules.get("chongming._build_flags")
had_attr = hasattr(chongming, "_build_flags")
sys.modules["chongming._build_flags"] = module
chongming._build_flags = module
licensing.reset_manager()
try:
yield module
finally:
if prev_mod is None:
sys.modules.pop("chongming._build_flags", None)
else:
sys.modules["chongming._build_flags"] = prev_mod
if not had_attr and hasattr(chongming, "_build_flags"):
del chongming._build_flags
licensing.reset_manager()
@contextmanager
def _packaged(value: bool = True):
"""模拟运行在打包产物中(Nuitka/PyInstaller 的 sys.frozen/__compiled__)。"""
original = licensing.is_packaged
licensing.is_packaged = lambda: value
licensing.reset_manager()
try:
yield
finally:
licensing.is_packaged = original
licensing.reset_manager()
# ---------------------------------------------------------------------------
# 1. 插件发现门禁
# ---------------------------------------------------------------------------
def test_gate_denies_unlicensed_plugins():
"""未授权插件被剔除,且原因可查。"""
tmp = Path(tempfile.mkdtemp())
pub, priv = _make_keypair()
_use_license(pub, _issue(tmp, priv, plugins=["alpha"]))
scanned = sorted(p["type"] for p in _scan_plugins())
allowed = sorted(p["type"] for p in _discover_plugins())
denied = denied_plugin_types()
check("盘扫到全部插件", scanned == ["alpha", "beta"], f"scanned={scanned}")
check("只放行已授权插件", allowed == ["alpha"], f"allowed={allowed}")
check("未授权插件给出拒绝原因",
"beta" in denied and "白名单" in denied["beta"], str(denied))
def test_contract_validation_survives_denial():
"""关键:有插件被拒时契约校验不得抛异常(否则设备起不来)。"""
tmp = Path(tempfile.mkdtemp())
pub, priv = _make_keypair()
_use_license(pub, _issue(tmp, priv, plugins=["alpha"]))
from chongming.app.core.contracts import validate_contracts
inline = [p for p in _discover_plugins() if p.get("load_mode", "inline") != "external"]
try:
validate_contracts(inline)
check("被拒插件的 TOML 路由不会导致契约校验硬失败", True)
except Exception as exc: # noqa: BLE001
check("被拒插件的 TOML 路由不会导致契约校验硬失败", False, repr(exc))
# 反向验证:夹具确实构成"硬失败"场景 —— 若 beta 未被剔除,校验必然抛错。
# 没有这一步,夹具写错(如路由文件缺失)会让上一条检查静默通过。
beta_entry = [p for p in _scan_plugins() if p["type"] == "beta"]
try:
validate_contracts(beta_entry)
check("夹具自身有效:beta 被纳入时会硬失败", False, "未抛异常")
except RuntimeError as exc:
check("夹具自身有效:beta 被纳入时会硬失败", True, str(exc)[:60])
def test_all_denied_still_validates():
"""极端情况:完全未授权(无 license 文件)时启动流程仍不硬失败。"""
tmp = Path(tempfile.mkdtemp())
pub, _priv = _make_keypair()
_use_license(pub, str(tmp / "not-exists.lic"))
from chongming.app.core.contracts import validate_contracts
allowed = _discover_plugins()
check("无授权文件时不放行任何插件", allowed == [], str(allowed))
try:
validate_contracts([])
check("完全未授权时契约校验不抛异常", True)
except Exception as exc: # noqa: BLE001
check("完全未授权时契约校验不抛异常", False, repr(exc))
# ---------------------------------------------------------------------------
# 2. 路由描述符门禁(external 插件)
# ---------------------------------------------------------------------------
def test_route_registry_denies_descriptors():
"""未授权类型的路由描述符被忽略(防改 load_mode 绕过)。"""
from fastapi import FastAPI
from chongming.app.core.route_registry import RouteRegistry
tmp = Path(tempfile.mkdtemp())
pub, priv = _make_keypair()
_use_license(pub, _issue(tmp, priv, plugins=["alpha"]))
denied = licensing.denied_types({p["type"] for p in _scan_plugins()})
check("denied_types 含未授权插件", "beta" in denied, str(denied))
async def run():
registry = RouteRegistry(FastAPI(), worker_manager=None, prefix="/api/v1")
registry.set_denied_types(denied)
descriptor = {
"schema_version": 1, "type": "beta",
"subject": "beta.ping", "method": "POST",
"path": "/beta/ping", "params": {},
}
await registry._handle_sample(
"chongming/routes/beta/ping",
__import__("zenoh").SampleKind.PUT,
json.dumps(descriptor).encode(),
)
return registry._groups, registry.route_types
groups, route_types = asyncio.run(run())
check("未授权类型的描述符未进入路由表", groups == {}, f"groups={list(groups)}")
check("未授权类型不出现在 route_types 中", route_types == [], str(route_types))
# ---------------------------------------------------------------------------
# 3. 管理接口门禁
# ---------------------------------------------------------------------------
def test_admin_gate_403_for_denied():
"""未授权插件的管理操作一律 403。"""
from fastapi import HTTPException
from chongming.app.routes.worker_admin import _ensure_licensed
tmp = Path(tempfile.mkdtemp())
pub, priv = _make_keypair()
_use_license(pub, _issue(tmp, priv, plugins=["alpha"]))
try:
_ensure_licensed("beta")
check("未授权插件的管理操作被拒", False, "未抛 403")
except HTTPException as exc:
check("未授权插件的管理操作被拒", exc.status_code == 403, f"status={exc.status_code}")
try:
_ensure_licensed("alpha")
check("已授权插件的管理操作放行", True)
except HTTPException as exc:
check("已授权插件的管理操作放行", False, f"status={exc.status_code}")
def test_list_workers_annotates_licensed():
"""实例盘点对每个副本标注 licensed,并汇总拒绝类型(供前端禁用按钮)。"""
from chongming.app.routes.worker_admin import list_workers
tmp = Path(tempfile.mkdtemp())
pub, priv = _make_keypair()
_use_license(pub, _issue(tmp, priv, plugins=["alpha"]))
class _StubManager:
def list_workers(self):
return [
{"worker_type": "alpha", "worker_id": "worker-0-1", "alive": True},
{"worker_type": "beta", "worker_id": "worker-0-2", "alive": True},
]
request = _request_stub()
request.app.state.worker_manager = _StubManager()
body = asyncio.run(list_workers(request, _admin={"is_superuser": True}))
by_type = {w["worker_type"]: w for w in body["workers"]}
check("已授权副本标注 licensed=True", by_type["alpha"]["licensed"] is True)
check("未授权副本标注 licensed=False", by_type["beta"]["licensed"] is False)
check("响应标明门禁启用", body["license_enforced"] is True)
check("响应汇总拒绝类型", body["denied_plugin_types"] == ["beta"],
str(body["denied_plugin_types"]))
# ---------------------------------------------------------------------------
# 4. 有效期与开关
# ---------------------------------------------------------------------------
def test_expired_license_denies_all():
"""过期授权 -> 全部拒绝。"""
tmp = Path(tempfile.mkdtemp())
pub, priv = _make_keypair()
_use_license(pub, _issue(tmp, priv, plugins=["alpha", "beta"], expires_days=-1))
allowed = _discover_plugins()
check("过期授权不放行任何插件", allowed == [], str(allowed))
check("拒绝原因为过期",
all("过期" in m for m in denied_plugin_types().values()),
str(denied_plugin_types()))
def test_disabled_mode_allows_everything():
"""未启用校验时(非打包态的默认行为)全部放行。"""
tmp = Path(tempfile.mkdtemp())
pub, priv = _make_keypair()
_use_license(pub, _issue(tmp, priv, plugins=[]))
os.environ.pop("CHONGMING_LICENSE_MODE", None)
get_config()["license"]["enabled"] = False
licensing.reset_manager()
try:
allowed = sorted(p["type"] for p in _discover_plugins())
check("未启用校验时全部放行", allowed == ["alpha", "beta"], str(allowed))
check("未启用校验时无拒绝记录", denied_plugin_types() == {},
str(denied_plugin_types()))
finally:
os.environ["CHONGMING_LICENSE_MODE"] = "enforce"
get_config()["license"]["enabled"] = True
licensing.reset_manager()
# ---------------------------------------------------------------------------
# 5. 授权管理接口(直调端点协程,不需要 TestClient)
# ---------------------------------------------------------------------------
def test_license_endpoints_guard_and_upload():
"""权限门禁、机器码/申请码、以及"坏文件不覆盖现有授权"。"""
from chongming.app.routes.licensing import (
license_info,
license_upload,
require_superuser,
)
from fastapi import HTTPException, UploadFile
tmp = Path(tempfile.mkdtemp())
pub, priv = _make_keypair()
lic_path = _issue(tmp, priv, plugins=["alpha"])
_use_license(pub, lic_path)
# 权限门禁:非超管 403,超管放行
try:
require_superuser({"is_superuser": False})
check("非超管访问被拒", False, "未抛 403")
except HTTPException as exc:
check("非超管访问被拒", exc.status_code == 403, f"status={exc.status_code}")
check("超管访问放行", require_superuser({"is_superuser": True})["is_superuser"] is True)
# 授权信息:机器码 / 申请码 / 插件全景 / 来源明细
info = asyncio.run(license_info(_request_stub(), _admin={"is_superuser": True}))
check("info 含机器码", info["status"]["machine_code"] != "")
check("info 含申请码", info["request_code"].startswith("CM1-"),
info["request_code"][:16])
check("info:未授权插件在 denied 中", "beta" in info["plugins"]["denied"],
str(list(info["plugins"]["denied"])))
check("info:已授权插件在 licensed 中", info["plugins"]["licensed"] == ["alpha"],
str(info["plugins"]["licensed"]))
check("info:来源明细含 weak 标注",
any(s["weak"] for s in info["sources_detail"]),
str([(s["name"], s["weak"]) for s in info["sources_detail"]]))
check("info:标明判定来源", info["enforce_source"] in
("build", "packaging", "env", "config"), info["enforce_source"])
# 签名错误的文件:必须拒绝,且不得覆盖现有授权
tampered = {
"v": 1, "lic_id": "L-TAMPER", "customer": "x",
"machine_code": "AAAA-BBBB", "sources": {}, "plugins": ["alpha", "beta"],
"not_before": None, "expires_at": None, "issued_at": None, "note": "",
}
bad = UploadFile(
filename="bad.lic",
file=io.BytesIO(json.dumps(
{"payload": tampered, "alg": "ed25519", "kid": "test", "sig": "AAAA"}
).encode()),
)
try:
asyncio.run(license_upload(_request_stub(), file=bad, _admin={"is_superuser": True}))
check("签名错误的文件被拒绝", False, "未抛 422")
except HTTPException as exc:
check("签名错误的文件被拒绝", exc.status_code == 422, f"status={exc.status_code}")
check("被拒文件未覆盖现有授权",
json.loads(Path(lic_path).read_text(encoding="utf-8"))["payload"]["lic_id"]
== "L-TEST-0001")
# 非 JSON 文件
not_json = UploadFile(filename="bad.lic", file=io.BytesIO(b"not json at all"))
try:
asyncio.run(license_upload(_request_stub(), file=not_json, _admin={"is_superuser": True}))
check("非 JSON 文件被拒绝", False, "未抛 422")
except HTTPException as exc:
check("非 JSON 文件被拒绝", exc.status_code == 422, f"status={exc.status_code}")
# 合法文件:安装成功,并提示需重启加载新授权的插件
src = collect_fingerprints()
payload2 = {
"v": 1, "lic_id": "L-TEST-0002", "customer": "测试客户",
"machine_code": machine_code(src), "sources": src,
"plugins": ["alpha", "beta"], "not_before": None,
"expires_at": (date.today() + timedelta(days=365)).isoformat(),
"issued_at": "2026-09-20T00:00:00Z", "note": "",
}
good = UploadFile(
filename="good.lic",
file=io.BytesIO(json.dumps(
{"payload": payload2, "alg": "ed25519", "kid": "test",
"sig": sign_payload(payload2, priv)}
).encode()),
)
# 模拟运行中的网关:alpha 已在启动期加载(inline),beta 此前未授权未加载
running = _request_stub()
running.app.state.inline_types = ["alpha"]
body = asyncio.run(license_upload(running, file=good, _admin={"is_superuser": True}))
check("合法文件导入成功", body["success"] is True)
check("导入后授权插件更新",
body["licensed_plugins"] == ["alpha", "beta"], str(body["licensed_plugins"]))
check("导入后提示需重启加载",
body["restart_required"] and body["pending_plugins"] == ["beta"],
str(body["pending_plugins"]))
# ---------------------------------------------------------------------------
# 6. 构建期烘焙开关("打包可选、授权版不可关")
# ---------------------------------------------------------------------------
def test_build_baked_licensed_artifact():
"""授权版产物:烘焙 enforce=True + 公钥 → 强制校验,现场改配置关不掉、换不了公钥。"""
tmp = Path(tempfile.mkdtemp())
baked_pub, baked_priv = _make_keypair() # 产物内置公钥(厂商签发方)
other_pub, _other_priv = _make_keypair() # 现场试图替换成的"自己的公钥"
lic = _issue(tmp, baked_priv, plugins=["alpha"])
with _baked_build_flags(True, baked_pub), _packaged(True):
_use_license(other_pub, lic) # 配置里被换成别的公钥
get_config()["license"]["enabled"] = False # 并试图关掉校验
os.environ[licensing.ENV_MODE] = "off"
try:
check("授权版产物强制校验", licensing.should_enforce() is True)
check("判定来源为构建期", licensing.enforce_source() == "build")
allowed = sorted(p["type"] for p in _discover_plugins())
check("授权版产物按内置公钥判定(换 config 公钥/自签均无效)",
allowed == ["alpha"], str(allowed))
check("未授权插件仍被拒", "beta" in denied_plugin_types())
finally:
get_config()["license"]["enabled"] = True
os.environ[licensing.ENV_MODE] = "enforce"
licensing.reset_manager()
def test_build_baked_unlicensed_artifact():
"""无授权版产物:烘焙 enforce=False → 不校验(打包不配 license 也是合法选项)。"""
with _baked_build_flags(False), _packaged(True):
get_config()["license"]["enabled"] = True # 配置试图开启也不生效(没有可校验的公钥)
try:
check("非授权版产物不校验", licensing.should_enforce() is False)
check("判定来源为构建期", licensing.enforce_source() == "build")
allowed = sorted(p["type"] for p in _discover_plugins())
check("非授权版产物全部插件照常加载", allowed == ["alpha", "beta"], str(allowed))
check("非授权版产物无拒绝记录", denied_plugin_types() == {},
str(denied_plugin_types()))
finally:
get_config()["license"]["enabled"] = True
def test_unbaked_packaged_keeps_enforcing():
"""未烘焙(旧打包脚本产物):沿用"打包即强制"的既有语义。"""
with _packaged(True):
check("未烘焙打包产物仍强制校验", licensing.should_enforce() is True)
check("判定来源为打包形态", licensing.enforce_source() == "packaging")
def test_baked_flags_ignored_outside_packaging():
"""烘焙标记只对产物生效:源码运行(含可编辑安装)不受上次打包结果影响。"""
pub, priv = _make_keypair()
tmp = Path(tempfile.mkdtemp())
lic = _issue(tmp, priv, plugins=["alpha"])
with _baked_build_flags(True, pub), _packaged(False):
_use_license(pub, lic) # 源码运行仍由配置/环境变量决定
check("源码运行不受烘焙标记影响(判定来源非 build)",
licensing.enforce_source() != "build", licensing.enforce_source())
os.environ[licensing.ENV_MODE] = "off"
try:
check("源码运行按环境变量/配置放行", licensing.should_enforce() is False)
finally:
os.environ[licensing.ENV_MODE] = "enforce"
licensing.reset_manager()
def test_write_build_flags_source():
"""write_build_flags 生成的模块源码可执行且值正确;非法公钥被拒绝。"""
from chongming.app.core.licensing import write_build_flags
pub, _priv = _make_keypair()
target = Path(tempfile.mkdtemp()) / "pkg" / "_build_flags.py"
path = write_build_flags(enforce=True, public_key=pub, target=target)
ns: dict = {}
exec(compile(path.read_text(encoding="utf-8"), str(path), "exec"), ns)
check("生成文件落盘于指定位置", path == target)
check("烘焙 enforce=True", ns["LICENSE_ENFORCE"] is True)
check("烘焙公钥", ns["LICENSE_PUBLIC_KEY"] == pub)
check("记录公钥指纹", bool(ns["PUBLIC_KEY_FINGERPRINT"]))
path2 = write_build_flags(enforce=False, target=target)
ns2: dict = {}
exec(compile(path2.read_text(encoding="utf-8"), str(path2), "exec"), ns2)
check("无授权版烘焙 enforce=False 且无公钥",
ns2["LICENSE_ENFORCE"] is False and ns2["LICENSE_PUBLIC_KEY"] == "")
try:
write_build_flags(enforce=True, public_key="not-a-key", target=target)
check("非法公钥被拒绝", False, "未抛异常")
except ValueError as exc:
check("非法公钥被拒绝", "公钥无效" in str(exc), str(exc)[:60])
def main():
tests = [v for k, v in sorted(globals().items()) if k.startswith("test_")]
for fn in tests:
print(f"\n--- {fn.__name__} ---")
fn()
print("\n" + "=" * 56)
if failures:
print(f"❌ {len(failures)} 项未通过: {failures}")
return 1
print("✅ 全部通过")
return 0
if __name__ == "__main__":
sys.exit(main())
Generated
+112
View File
@@ -474,15 +474,18 @@ dependencies = [
{ name = "chongming-cache" },
{ name = "chongming-config" },
{ name = "chongming-jwt" },
{ name = "chongming-license" },
{ name = "chongming-worker" },
{ name = "eclipse-zenoh" },
{ name = "fastapi" },
{ name = "granian", extra = ["reload", "uvloop"] },
{ name = "greenlet" },
{ name = "gunicorn" },
{ name = "httptools" },
{ name = "orjson" },
{ name = "pwdlib" },
{ name = "pydantic" },
{ name = "python-multipart" },
{ name = "sqlite-vfs" },
{ name = "starlette" },
{ name = "uvicorn" },
@@ -501,15 +504,18 @@ requires-dist = [
{ name = "chongming-cache" },
{ name = "chongming-config" },
{ name = "chongming-jwt" },
{ name = "chongming-license", specifier = ">=0.1.0" },
{ name = "chongming-worker" },
{ name = "eclipse-zenoh" },
{ name = "fastapi" },
{ name = "granian", extras = ["reload", "uvloop"], specifier = ">=2.8.4" },
{ name = "greenlet", specifier = ">=3.5.6" },
{ name = "gunicorn" },
{ name = "httptools" },
{ name = "orjson" },
{ name = "pwdlib" },
{ name = "pydantic" },
{ name = "python-multipart", specifier = ">=0.0.32" },
{ name = "sqlite-vfs" },
{ name = "starlette" },
{ name = "uvicorn" },
@@ -531,6 +537,20 @@ wheels = [
{ url = "http://124.71.68.6:3000/api/packages/chongming/pypi/files/chongming-jwt/0.1.0/chongming_jwt-0.1.0-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:d63cc24550b7084e55d4dd0cb9f99cbd0a9d77da39ad0685698fd484e4376e72" },
]
[[package]]
name = "chongming-license"
version = "0.1.0"
source = { registry = "http://124.71.68.6:3000/api/packages/chongming/pypi/simple/" }
dependencies = [
{ name = "cryptography" },
{ name = "orjson" },
{ name = "pydantic" },
]
sdist = { url = "http://124.71.68.6:3000/api/packages/chongming/pypi/files/chongming-license/0.1.0/chongming_license-0.1.0.tar.gz", hash = "sha256:30f3752a53ea76465c47ad324cbe1e4cfc656df175a9864f6d217d7b983d21c9" }
wheels = [
{ url = "http://124.71.68.6:3000/api/packages/chongming/pypi/files/chongming-license/0.1.0/chongming_license-0.1.0-py3-none-any.whl", hash = "sha256:6369da005874c83a279d35cbaffcd606c494b49514e4896b665e6f19ba2aa36a" },
]
[[package]]
name = "chongming-password"
version = "0.1.0"
@@ -587,42 +607,58 @@ dependencies = [
]
sdist = { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/9d/af/182eb91b0df3fe75c4d9f26fe70684569566745f6ba7e5c9c73a862c5252/cryptography-50.0.2.tar.gz", hash = "sha256:7b46165bb56eb4704e2eaaf86f3c940d19154535d9b0ca7d6d590b04060e00d5", size = 880623, upload-time = "2026-09-30T15:30:04.884Z" }
wheels = [
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/e5/56/d194340cc4a57535e82e1bee9e89667ac4b7c13b5d3f59686deae3094dd5/cryptography-50.0.2-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:fa8f5efb344d6908a1ce62f4a24e2e5780f825d6f53f5f50ec5ffacac72936cb", size = 3914904, upload-time = "2026-09-30T14:43:44.339Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/d9/69/c9bd862c3bf43d6399c433caf002df16e2dffd4be49bdf515cda38038711/cryptography-50.0.2-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:79def8d059362e7831389ed3be0ecdf58a89386e1271e35dd9f5af84e81bffd0", size = 4731146, upload-time = "2026-09-30T14:43:47.113Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/21/69/64cef1f702bf6657e0cc186ed1a2891d50d29fb41586b254e1c07adea261/cryptography-50.0.2-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:630ebfea3bf689d075f82316324ff7433dc447fe6bc1bfc76524b74b4a9567d2", size = 4719841, upload-time = "2026-09-30T14:43:49.01Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/38/6b/61a3f8d8c5e1e49a6cddccafc4015cc1c0021360ab0acb4080e7a423644a/cryptography-50.0.2-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f9f6143a8c75945eb960d9eb98905a441394abfa24afaae239d514ffb2586480", size = 4738340, upload-time = "2026-09-30T14:43:50.932Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/7b/2e/7212ca32fd43dc91f2f41db20160b268098874b4c9a0e7be94d6835f5b2e/cryptography-50.0.2-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:a582ab2ae1d34f67112cadc86702774c9ea4374df6bca6afe672817203c99134", size = 5367029, upload-time = "2026-09-30T14:43:52.911Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/1a/f1/b474e930c4d910328780e3940da76f5aa5cbc48ce1fc14e44d239d9ea9db/cryptography-50.0.2-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:4061c0079120205fb760c58acab6443e217307dcf05e3702cf970e0689972856", size = 4753050, upload-time = "2026-09-30T14:43:55.272Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/7c/52/9af10e80ac16b0fcc2123f9cbd5e7afbd0fd5075bb7a607c592258a39cda/cryptography-50.0.2-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:ac9ed99d81760c62fe89d5f0815cdfa1ba9a35141cf30f1c2d044f04b4803d2e", size = 4376724, upload-time = "2026-09-30T14:43:57.24Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/71/37/6202e488cc1eb625ea110c292c6bda92823176e023f427d8d5660ce8d632/cryptography-50.0.2-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:87e9ce85beb6b328ba370cc6e6aea483c92617b4c95b1d33a49297eb662bfb04", size = 4737859, upload-time = "2026-09-30T14:43:59.541Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/8f/30/e86d7d518489b0ae2497091a35287abcb1a2ce4037837a34afbe9b1d6964/cryptography-50.0.2-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f265528741e048bce55c3463ed721fb0aa45a5888d8add8cfeccb3035451bbdc", size = 5324103, upload-time = "2026-09-30T14:44:01.901Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/d3/69/2c833a049475e0a3444e94c7d0aca0aa51d166374a449b09e92ac98138de/cryptography-50.0.2-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dab55f57c74c3cad24c323bacbbd04be4705ba6eb0d92e920b1fc4837ed5079", size = 4752576, upload-time = "2026-09-30T14:44:04.545Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/6c/5d/906970b83bbfc1f5bbfb677a143c181f2801f23b6a7204a3b47c42c97e65/cryptography-50.0.2-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:25784ce8b9621c90c643efb9e1e2162ab3b0224cae446ad5e70e7fcb1ce18b51", size = 4870819, upload-time = "2026-09-30T14:44:06.884Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/68/e3/f2298d3bb55e0c4a91841ec4d01b3f020ba8c5fbf15ccdcc6dcf03f97025/cryptography-50.0.2-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:85d0d9a31b9098e98534226d5686b47264b95e62ce459dc2e62fdfc809f9fe93", size = 5030152, upload-time = "2026-09-30T14:44:09.443Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/9a/4f/adfc442765721292fff86d314ce385d3249d22db42295c0dd057727b60f3/cryptography-50.0.2-cp311-abi3-win_amd64.whl", hash = "sha256:7afa5a6602a9f29af1f3a2965f831bae7c9d5d597b7cbb716d41ab3b7d89879c", size = 3824692, upload-time = "2026-09-30T14:44:11.671Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/ce/cb/52eb3770c0d0be2702a98c6e96065ddc0a2877cf0845aa9c23397c142cd4/cryptography-50.0.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f785f6161f202ab04d8ca194158968798e480ca058943907972da5f12e2881e8", size = 3892731, upload-time = "2026-09-30T14:44:13.485Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/19/8e/aa1fc533d4546b127b45de8aa024eb5933d23eff9debfe25931e56861095/cryptography-50.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0ecbc5652bdb6fc9eaf89a7d196e20941adfe812f43bc4ca05d9150496821047", size = 4710431, upload-time = "2026-09-30T14:44:15.427Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/6a/64/72bc3f75176e7e406b748a3e3830432b8c51297b38368713df04dc04898a/cryptography-50.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ab50ee449bf968271e820086f10a33d101dd060370abc10bcd22279be2656539", size = 4694824, upload-time = "2026-09-30T14:44:17.69Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/4e/c6/62c77550edfa5ca3f14bf44a1e6739b9fa09d6e998a11d97ed8213bccc98/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a9f7355e6fab51f6c369b86fb7571cffa05edee2c2121e0380a37fb9ac1cd5c1", size = 4716967, upload-time = "2026-09-30T14:44:19.661Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/f4/37/cce70f150c432914460157a6ecc161752e053aa5ec0ef3b3f7dc6e31039a/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:94e5e9f108ee10471288214d3d233fbfbb492840a8457eb85178d643ddeb32c7", size = 5328676, upload-time = "2026-09-30T14:44:21.744Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/aa/9a/6f2f0304d634ceafdeaf23e84537336664ac419b5d07611675c2ad3f6b7a/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:241449bf940a5d27309bd317e6f9a2af6932113818bb2b8f5c59ddc7ef16da18", size = 4727698, upload-time = "2026-09-30T14:44:24.178Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/1d/de/66bcf9244d118663b2e1aaded8990f4640e3d7b7411870a5765f252074d2/cryptography-50.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d8947001be83df1394050758ce0e745dd74fb134eef0a4b5124208dfc3a68c37", size = 4354821, upload-time = "2026-09-30T14:44:26.263Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/bd/e6/db28a28c7b6c676addce89136de3d8db49ea825a8c863472e36e42ead4ad/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4a20ce1e5cb4284a86692fdcba7cb8754185c6b2e5c56fcef3751cf451d3cdc2", size = 4716748, upload-time = "2026-09-30T14:44:28.447Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/30/96/01546c7f69ea0e2ab790a2e4f0934a4052fb9b388147fbf83c2fd72f1e57/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:84f964e537f916e2cc85199e5a88742e964939b575ac8598b3f9d6cc416cdaf1", size = 5285085, upload-time = "2026-09-30T14:44:30.704Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/6c/01/03263395f74d50b071e9e66daace3f8bef80493e5d410726f2ba8554736b/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:828d49b0ff5a0e3975865571c5d91dbbdd0d38d8289b249a163e9425413a5e05", size = 4727268, upload-time = "2026-09-30T14:44:32.92Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/eb/94/2bfe8f29ec0cc9c0d99359c4161adf32858e4934b72c6d100d2ac0bbe962/cryptography-50.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:deb9fde5c60e437ee4821bc9bc39ff31b42135c27e1dc61ef0a629389c1de62e", size = 4849503, upload-time = "2026-09-30T14:44:34.969Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/54/44/e80651ecbf0e42b62e2bb5f5768916e07eea72e1297338956a61df361f88/cryptography-50.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8c71ba2cd31fc93748c38e1b613200ff1c2665cbfd5341fe3a61cfde35a1430e", size = 5004057, upload-time = "2026-09-30T14:44:37.064Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/f8/cc/1d33befb3cd7ea7e77d2d73f43f2066471da1b21f24a6156efcaabf6d2e8/cryptography-50.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:78198641e5be9521beea5aa782bb551a58068d10e6eb04c9c680c1b69f2e7d45", size = 3795868, upload-time = "2026-09-30T14:44:39.71Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/2d/49/93f6a6e7a87c9aa68d44d3e1cdb5fe8f60c90d5d2f46acae9a56892816b8/cryptography-50.0.2-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:edc3342adf8f697fc5f59c887a304356f147b397809440ed64e2fa6af2f50f37", size = 4133708, upload-time = "2026-09-30T14:44:41.807Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/8c/75/32ac2a56243d778805c16ca6a32b8f74fb757df7e28d7ecb560afafb59cf/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d370b8d1dfcdf7130178137f6fbee6140774a1acc6cacefc4b42643ec11d0a3a", size = 4956267, upload-time = "2026-09-30T14:44:43.693Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/aa/a4/2c8d734e43d97f0842ee9f1b7b4bfb3d0cf5e19edebf43c2afe6675c2320/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f2f9bd7f90c64fe89253f0a2c05e3c4856072660429ce8831b4235bf29403a67", size = 4966465, upload-time = "2026-09-30T14:44:45.769Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/c2/58/ee288c829a6f41f6235ae9dd33d82fd19b45442b65b4c8a3da36963d9f7a/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_aarch64.whl", hash = "sha256:e275096ea1e60cc595cda2836fd4a6c725d1125108b868be17f53684d164e2cc", size = 4959356, upload-time = "2026-09-30T14:44:48.211Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/92/20/9ded6d51ddd9897f6b6e81fb9ebea7951d7cc5d6c890b0ed8abf77a51a80/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_ppc64le.whl", hash = "sha256:b13478603dcd0a2479ff8e87e2c19a7d525734686fe3c49542472293a204212d", size = 5548822, upload-time = "2026-09-30T14:44:50.86Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/02/a8/8df951850d6b31d2a00218f19e2b3f999523437ed7a819df7fa427942fca/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_x86_64.whl", hash = "sha256:58a0c478eeca76fe5e07993c5a0703def34a6dc6a0cda4f5564639b33112ffe7", size = 5001199, upload-time = "2026-09-30T14:44:53.379Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/8b/f9/36b3022218ce75b7cdf068fb95f809f9bd0d820e4955ef43b90c255cc7ac/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_31_armv7l.whl", hash = "sha256:d38cdff612d06fa6a32840d5e1b1f7a27cee4a349aa9085d94a67789d6bfd408", size = 4629333, upload-time = "2026-09-30T14:44:55.635Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/8c/72/20f99a219f6af47cdd1cbd978c243b92d71496e168a746138af44ded4f29/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_aarch64.whl", hash = "sha256:fdd28f912fccfec1846a94e2e1e8f9b0012f557f0c46fe4f3eb0d7a87afcf90b", size = 4958822, upload-time = "2026-09-30T14:44:59.639Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/f2/20/196f112617fb08eb4d608a2a6c422373d46f9cc2857f38fc0667033c0899/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_ppc64le.whl", hash = "sha256:cbc8738fd8526d80f35cb3a40d41f41a2e7030bb3b18b09a6778ef63d291c2fd", size = 5506351, upload-time = "2026-09-30T14:45:02.267Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/24/95/83378121ef3eaaaf71d4b781577ff794acb39b9e1b87a3f156898c8497ed/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_x86_64.whl", hash = "sha256:e105ab60406787da31fccc883fc0f733af1efd78f0136a4599692c4083a73d0c", size = 5000859, upload-time = "2026-09-30T14:45:05.009Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/22/f7/70fd7ae4d1dbfa7ba29b02e1b9068771519a86027756510b700ce81086a8/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:6f8700550aa1474a91e5dc07049c46f98b423b5b1ddd0483e0b51362eeeaf5be", size = 5092151, upload-time = "2026-09-30T15:29:15.932Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/d4/be/688367b74de86984bd58d8efacfc7c9e68b89a6a22ced0fb4f38db50254a/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:c71be1cbfa5cd9a41ee452acf1eccd82b2c05950358b106ec8ceb83411d1a020", size = 5286120, upload-time = "2026-09-30T15:29:18.309Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/39/d1/55f8a3f2ef5d1529e16835ef10cf0fe3d559ce237b46dddc440c0bba3649/cryptography-50.0.2-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:c423ab384a46c4dff7217b2ea5ba2e11cffdeab6441acd04cf65a369caf0366c", size = 4111557, upload-time = "2026-09-30T15:29:20.155Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/23/ad/ac987755d00e1e64273760228d2635ae38dae2be83e3c6e0d3289d91dec3/cryptography-50.0.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:0ec5f09541743261e66e291b4a0cbf0fb2997aeaab6d9e9c740b9dba1b58d1c2", size = 3943588, upload-time = "2026-09-30T15:29:22.265Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/d5/8d/6d585339bedf85d45044c85d8412dac53f2bb6f918e8b7777efba1787844/cryptography-50.0.2-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c5e67125c7dca78d199ec4e116aa93dbb83494808ecbb8211a2cb09b1bf41dbd", size = 4756166, upload-time = "2026-09-30T15:29:24.58Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/bf/f1/1c1f6874e8550cfddd4b688ceb38cefb6ed15ceed224d56f133f3d88c214/cryptography-50.0.2-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ee247f5c245c9a2fe7c8e2214e295918838e44e00a45a6718451e4004219e767", size = 4749145, upload-time = "2026-09-30T15:29:26.807Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/c1/63/61b15dc1a8de03fe0adbe3fd7608b3ad5c73bf50993bbcb1faaa930afe33/cryptography-50.0.2-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dfe9763530994147d9af1def057a5b9658b00e8f8fe8743d144d1e0911c2e454", size = 4763638, upload-time = "2026-09-30T15:29:28.588Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/fc/35/b345bdfa40c9126df1a9d33236aa98418367931b8725f84fc3ae2b98dc59/cryptography-50.0.2-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:58ddb5a8e3179d12f19e4ea34d2d32e9d63a4baa142c875c1eb59f41b7243acd", size = 5382217, upload-time = "2026-09-30T15:29:30.589Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/4f/87/ef344a9e616871f2519c22d6afcda79ddd5d35e9592d95eb6e677608d055/cryptography-50.0.2-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f21e8a22c8605750c7af886bab299a363721264061b4ac0a30efb73cfd58efc5", size = 4781387, upload-time = "2026-09-30T15:29:32.605Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/90/5b/f2fdb13cd0b96f6f932c8627bb292a45f11c64d21620a8e120aee9a3b848/cryptography-50.0.2-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:9c8402a82ea0dc4ceeab793db05f0fafa8ca139ca34fcde5df0f596103c74107", size = 4403790, upload-time = "2026-09-30T15:29:34.374Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/bc/ce/7e4f662b1e3c393513569e402cfc85ac7da0bd3d5435e122a3140219eb2d/cryptography-50.0.2-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:0ddc924c04591c2811ca024d62ecad4f7f6f08af8939c211438f48a16bd23602", size = 4764319, upload-time = "2026-09-30T15:29:36.149Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/3c/3f/86ff33ce34cc0de6847fb96e035a1a760d81652e38643f617c02ad32ef7a/cryptography-50.0.2-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a6557e5f38e065ca9fbdaf7cfc7435ecb1d113aa81a022d1b51921ee7432e227", size = 5338560, upload-time = "2026-09-30T15:29:39.053Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/40/cf/6b5c8e2fd9202d98988ab7cb5cc5c991704c4ad55f492ff408e4969f83f1/cryptography-50.0.2-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:1981f1db4630889b9ef7803fadef12b056f428cb6b85c27ba57b774793b6093c", size = 4780973, upload-time = "2026-09-30T15:29:41.251Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/10/bf/8d6ebc7dded797bd0f0160d52188021211f011a2b164ef0ae1dac4587465/cryptography-50.0.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a8701d6b584d76e909e3d305b7d126b41439876a5aaf76cddc67fc230eafa2e", size = 4897738, upload-time = "2026-09-30T15:29:43.106Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/d4/aa/f3f6e0de7e6253b8baa8b2d8fb9d50924fa75cee3d4624bd4bc1208ee923/cryptography-50.0.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ce47f66801c20ec6c6632453bb5960fe38939e9306970b48b3a5a26de7745d94", size = 5058280, upload-time = "2026-09-30T15:29:44.827Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/f6/b6/a1faf3a27ae9405fb34b1713cc73b2d8a26b04d5c561578fa2e6ef3e5bb9/cryptography-50.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:4e81d95e5bafc2d6e34e4bed780e53e4d5b9a2f928573428aa4d35fbec1eb0de", size = 3854095, upload-time = "2026-09-30T15:29:46.782Z" },
]
[[package]]
@@ -755,6 +791,73 @@ uvloop = [
{ name = "uvloop", marker = "platform_python_implementation == 'CPython' and sys_platform != 'win32'" },
]
[[package]]
name = "greenlet"
version = "3.5.6"
source = { registry = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple" }
sdist = { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/3e/6e/0091f175ccd02b02bc8811bbcbcc6ac2e980be116e3b2f7a736ca322bf84/greenlet-3.5.6.tar.gz", hash = "sha256:8e67c43bdfc88d5fee6db0d3e40175b362fc95fb85f0412d233b9b203c53a575", size = 207653, upload-time = "2026-09-14T15:42:51.806Z" }
wheels = [
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/72/18/3fc6d951466ae9a2a688edcddde3b2e388da0a8244e0caf7117bbeb0eb95/greenlet-3.5.6-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:a5876d0a60355af98d535c47f6cd6eb0f8a432396dab26845d380b92f8412422", size = 295668, upload-time = "2026-09-14T14:22:33.241Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/27/89/366d2af5061eeefa5012f510d95a99c8620dcc457609838db4d538820318/greenlet-3.5.6-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e85880b538e59a59f55117b81f208a6660ad5ac328aad9305f812d9b8bc67a0f", size = 611700, upload-time = "2026-09-14T15:12:01.962Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/54/1c/07f133f865fd58ae593dd2bbec3144acaee9b04ffe2eb48c6e121747ceef/greenlet-3.5.6-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f0ba7c2a329d650628f4c8572fd1db29f0a59dd70a3e3e0710dcf18a35cce9d8", size = 624223, upload-time = "2026-09-14T15:20:42.459Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/a7/f2/844dc823ff2752ad049caa6b59d57e4572f9c445934b02d3518f4c67197c/greenlet-3.5.6-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ee7d9da3bf493909cf811a3f038840cb34fab5ae2956b8a263919f6e289ab188", size = 629529, upload-time = "2026-09-14T15:25:06.354Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/66/6a/1594f3869c57c149abdb380492529e04d4c0229b5e4d79572c5bd0aaa673/greenlet-3.5.6-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:975736b002ed080d124cf81a79cb7e05cb26d6b3f5c7a7b651c0fcce70353aa1", size = 621404, upload-time = "2026-09-14T14:35:59.027Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/c0/42/b1f8dbc89a53b9e77859fc1ad1627d106fc361daa3ea4bdf43a91ebb4338/greenlet-3.5.6-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:71890d5247020c25c21a6b65202782bfc281d4e6e244842419d30e3492bb6dcc", size = 432385, upload-time = "2026-09-14T15:28:37.369Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/a2/f5/33e5c9e48178b9259fd000f8f45caa4a65036f65d3d0c06a602f570f025d/greenlet-3.5.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0616b8f878098c5681fd8f0dc92d887551717402342a70f0abcbfea5f5ad8a44", size = 1584998, upload-time = "2026-09-14T15:10:06.653Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/ef/31/9b4e140bc24d0ad7927ebd651f5608b0acc2334d061748c3b6ad19085cfa/greenlet-3.5.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3dbb4596a6a4e5d47121a33ff20533a81e60f302d9e67b69909a8bc21a43f0a7", size = 1647568, upload-time = "2026-09-14T14:35:49.787Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/c3/71/d79f1791f824f8ff15c2978746640467ae932a2365e0201069f7f272395f/greenlet-3.5.6-cp312-cp312-win_amd64.whl", hash = "sha256:7ac4abb3877c43af320392c664774eef6fa2cc063c79a55fc02d844a3cbe7395", size = 324203, upload-time = "2026-09-14T14:22:54.504Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/63/af/42aca4d56e8cb321912203069d8d34734cb288222f10ad2ae102718cc577/greenlet-3.5.6-cp312-cp312-win_arm64.whl", hash = "sha256:301102a49120b095e72a7838792b41233975fc1c155daec6d98f81c00c9280e0", size = 308310, upload-time = "2026-09-14T14:24:03.008Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/f1/a1/e720a38852366c589e1a46cf570b886507ad2cf591050c203365638baab0/greenlet-3.5.6-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:f96f0e30b5a95c7631b12bfe214cbc90ec8fe8cfa36920596c10514a65743519", size = 294627, upload-time = "2026-09-14T14:24:40.102Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/eb/c3/58187858df41354a11e6a55b421e7af9059798abdab3a384cc51b8567c38/greenlet-3.5.6-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c75116c9de79949de23006e2d9b35ee82874c594fcf5c0311b439acaa14b8441", size = 614356, upload-time = "2026-09-14T15:12:03.399Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/ce/b9/3a7e67d5f05c9760b1ad411fa52264bd69cc08e22a2ebfb4018b90628ced/greenlet-3.5.6-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cad5782f93f7f738b62c6527b6f32a60694d924029f299a8b524758cfa53d815", size = 626756, upload-time = "2026-09-14T15:20:44.269Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/c6/7c/40400455f5b5a65bb83e94fde66d1be9e5ec518638113f8083ace746c309/greenlet-3.5.6-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a93ee7c6e8fd0f8a83525a51bd777be57ee17787e91d805bd8d6faf9dcada18e", size = 632632, upload-time = "2026-09-14T15:25:07.813Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/85/cb/ab0c123c514ed4e94c0dc9ee2e86362633e6b998cfc05de7fc9ac2eb9690/greenlet-3.5.6-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f98e8215e172f567ce80eeaed9107fb4d32b6c44f26983d9b8334658136a205a", size = 623779, upload-time = "2026-09-14T14:36:01.104Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/f9/67/1f35cff30a6c51c3f23b63d4afcc7313ab4f97490ba3676fa78178984b27/greenlet-3.5.6-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:7f731ebac68ea06d628658295cb2d217b10186329fcf9a3b6a149045059bf92e", size = 434933, upload-time = "2026-09-14T15:28:38.858Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/a5/26/fda8a5a06e7073333ccb038133c5893b9e0c4fe29d5992a17e83c241bc6e/greenlet-3.5.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df19e2d0b1620039af5102563fbd96e8938c7f5c3f5828528d641d9fc585525e", size = 1584930, upload-time = "2026-09-14T15:10:08.234Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/2f/37/50f8813163148d6234e08b23dcad6a9e37f01d148c8ec976e4c44ea2d918/greenlet-3.5.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:06c0e933290fba8ffe53ead4ae1b8044b0e9754b75cebf381aa2bc3e50d82fac", size = 1647590, upload-time = "2026-09-14T14:35:51.173Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/86/da/b7669b09586365654083a62bd0724cf06cb74bd5085a15cdd161271f992f/greenlet-3.5.6-cp313-cp313-win_amd64.whl", hash = "sha256:5b602b4201b965a8354d74e232364a66ff243dd142e350d035f46169bb36e13d", size = 324086, upload-time = "2026-09-14T14:23:48.428Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/e5/5d/c9663cfe84a2a9e0aa96f066f5b0594c227ea4c647511e087e2e11d4ac0a/greenlet-3.5.6-cp313-cp313-win_arm64.whl", hash = "sha256:876077e7ebb8c84ed068e2b23d4c62ebb010d60df84b9591af1be2f39010ffb2", size = 308211, upload-time = "2026-09-14T14:28:01.634Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/66/c0/d254544ae2b8bdd311aef000fafc02828c2771b17d994b3075620ea7cc6e/greenlet-3.5.6-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8cddea1b8339451c2fb3388e138347b6126744f33b611bdb55b7357361cfef46", size = 295221, upload-time = "2026-09-14T14:25:11.583Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/18/18/eb54be16b9cc3971e09ca5b73334e1b8c804a4630d9addaaf218a4fe300f/greenlet-3.5.6-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c59acfa8eb73a1e0d484392dc002bdf001fd4ce73394e0132df3d1ab6093d7cb", size = 660992, upload-time = "2026-09-14T15:12:04.876Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/8f/b4/e193efe65671dcf294bc51fcc59efb52d154adf8612c4ea016da0d2c486c/greenlet-3.5.6-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3b4a01c6da07ef9f80d4fe8933b994bc99747bcea3eab0330a9c34d3c12655b", size = 673428, upload-time = "2026-09-14T15:20:45.756Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/fd/21/631bb45fafde1dca782152377c0676d182ec924820064047f533a3627b28/greenlet-3.5.6-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dd0b83bed3405b586a3133629f1d1a5bc7bfd64822a3b7ab342bdc68e6dbc61b", size = 677688, upload-time = "2026-09-14T15:25:09.279Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/45/ac/28fa7a9e50f2859466214c4ac584d776db52c1604ad4dd158960a5af2a1f/greenlet-3.5.6-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9a09d59bef1db94f384b5bcc2d523694d338f3df6b757aeeaf7baca5d0c0be88", size = 670773, upload-time = "2026-09-14T14:36:02.577Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/40/30/2b0a73e68e1e18e30b601d0d183cfdfc2beca4de5a6843c630f0fc9fb90c/greenlet-3.5.6-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:fdacf26402389bdd89857ad3c045a26fe8f3314f9a8b28226f82f88463a65b77", size = 480475, upload-time = "2026-09-14T15:28:40.741Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/c3/cd/fb7d6cdd86ff3427c1494854f0e35437eba05142be91f530f6da75e09e19/greenlet-3.5.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8b7c73d1cef3d9ae963e9ff03f6222df43efbb9054ffd2f1969c935b7fc84c02", size = 1631900, upload-time = "2026-09-14T15:10:09.745Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/f6/40/143bdbb20a516628cb15074ae52ed17d850b450292609c7a6fccac6dbece/greenlet-3.5.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8b27df301f56e3b3d2298095c8f7d6b68f2521f6b1693e901fa039bdbae34424", size = 1693740, upload-time = "2026-09-14T14:35:52.959Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/c9/9e/019642432e6ae283301df1361227d47610709d2dc69a38f95edef266d713/greenlet-3.5.6-cp314-cp314-win_amd64.whl", hash = "sha256:f8f0bd690e1a41294ac87905e8121c81a3761ec2583c768f13467428606c8c7a", size = 327473, upload-time = "2026-09-14T14:28:12.948Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/e9/7f/8aafc7bf70c948786dba7221d0dc0838e5329bebc6d434ef2208b4f0e760/greenlet-3.5.6-cp314-cp314-win_arm64.whl", hash = "sha256:8cda13494d86a4f12429641117cb6ac4bbbc9c30a33f711f7d3a2e5fbe4b0b7e", size = 311095, upload-time = "2026-09-14T14:28:00.7Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/14/7e/7a205688a5b3074933b18a906608d46d106e9a79d776bdab5a4abf4b4feb/greenlet-3.5.6-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:97c5a53e8c1754df58e73f047a99e287d4da1bdfe64b0072fb25c87000897951", size = 305352, upload-time = "2026-09-14T14:21:31.962Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/78/cb/9c4a57a9d9dd0256e20b8f7f4f06554c2c92badebf0ab73ce344321b78b9/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fea4427d1ffdb3b523d7daa6712038428a4c16c450b9777bdd1221cfee0eab49", size = 672671, upload-time = "2026-09-14T15:12:06.347Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/97/52/c6729681ebbd298f4decd28746815acc8a0b0a0fde21d2df33776fd4d042/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73a29b5ba642e35433166a03a3e02935e7238c4b3467fbd77523b99edea23e5b", size = 679489, upload-time = "2026-09-14T15:20:47.291Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/71/76/3c11c21e0716b1f1dc7c1a4b3d690abb1d3b448c69a9d32049fecb64010a/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:61a61b4a95a4f97922c3a6f5606d3e360851584bd47e500a5161373c53810e3d", size = 681303, upload-time = "2026-09-14T15:25:11.088Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/58/c5/2b6c721ba8b8963da42d5a0f57f25b8aaeb1fe9bdd156875e57f3be648a2/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:460e70b033aba8ed47e2ac9b5d0d2157b05a34fbfa30a241400aef4118902cdc", size = 676608, upload-time = "2026-09-14T14:36:03.959Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/3f/26/3ae402202452cd5941bbbd483e5a74297e2397e7aa3182c2a5e3ab7d5666/greenlet-3.5.6-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:fe3170a69fe039b18ad18171e66faa9a75f6fe9d78f968fd9b54e09fbd714d81", size = 510112, upload-time = "2026-09-14T15:28:42.112Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/b2/04/0d018e0d05bcdde19a0fcb907834155f1fc853a9bedd3f3f5e6acadcae19/greenlet-3.5.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ca80a49b53ed1d22f7282da7255f7bb2fd1935fd0f623d8613fda38745f18961", size = 1641479, upload-time = "2026-09-14T15:10:11.216Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/59/bb/f02ef9073919158f6403fe3701d4ed4403d646720e7201dfc6e9d264bac3/greenlet-3.5.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:916f92f2a8db10508f739d0b5e00b83defe5d1115a997c54532a6d7cf8c95404", size = 1698758, upload-time = "2026-09-14T14:35:54.336Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/08/a5/1f48fe647473a2dcccfd1839b2ff2c78eb57009be776b4da071e901c9bff/greenlet-3.5.6-cp314-cp314t-win_amd64.whl", hash = "sha256:886bcf1870af74c32bc310fd00a6b803445e17e51b7d5a107c7b35c0f362cc16", size = 331574, upload-time = "2026-09-14T14:27:18.451Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/cd/72/3882855a75838faeb54a58aeef4fd77d20b2a86d4bad570c70d41b565dcf/greenlet-3.5.6-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:3ac3494c381dab876cad7d0b22f3a722f3e0c8deb3a65b9e7f35ad7f58b8fcb3", size = 295926, upload-time = "2026-09-14T14:27:21.16Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/10/1f/be4d957d8a9b90bcbe8db206548a42134d96222d43e5ed3fc4708fb6e24b/greenlet-3.5.6-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:602024dae6d77e161f4b89491b62ca1d4f19949d79d47b2db057e476d21179d6", size = 666910, upload-time = "2026-09-14T15:12:07.901Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/a1/af/60d62571a7d6de961e4ce7625d6c2faf359345659fc782d2cdf517c34577/greenlet-3.5.6-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f8e63209c3e1e828ee6a457529b4a6d8b05d050fe0ae03a7ae49e967c5d312e0", size = 677415, upload-time = "2026-09-14T15:20:48.817Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/f5/41/b3114c97c10e796010f00a30f51c81470072bca4b53e396ccca87484fcf7/greenlet-3.5.6-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9133d68624b1f2e89ec2f554d56aea8a5b0d7168cd9320200ba58d4d794845a4", size = 681337, upload-time = "2026-09-14T15:25:12.812Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/fb/16/ac9e547b611539aaed1870eb1d6ddc57abdd5924b3a99bb9b5f0b44176b8/greenlet-3.5.6-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccadce0130fd813ec86ebfe969a6c58b42acc1d0fe55a47525375b740e07b605", size = 675927, upload-time = "2026-09-14T14:36:05.34Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/48/1b/d41861c2fa00968e39e467a495ca8db9ce9b6310a5d9b57561b3d0dc48fa/greenlet-3.5.6-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:5adcbbfe78bdc242c71740a02e0991cc1b2f34d33c8bb15ca45eee8fd1140942", size = 487339, upload-time = "2026-09-14T15:28:43.497Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/c4/b1/b7ba08d6431121741f1d30be0d5d292e76873325179a63586cd9217b62f6/greenlet-3.5.6-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9297fb9c39b9a2c039dbcd306c410bd6906b95244dec3bba4318d36c718c164c", size = 1637236, upload-time = "2026-09-14T15:10:12.442Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/af/c5/3b1cbc68f0c082022fc8717f7fe4b8b13b8d583c52352be37f4e9f55bcd2/greenlet-3.5.6-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:b374e79ffa7511afc11773aef40a4ccea6191fba1c856ea2f9c56738dca69d7a", size = 1698526, upload-time = "2026-09-14T14:35:56.039Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/de/56/12941ed2711400451c89d544e10f831800a2770f19dd55eac8f0f7f2003b/greenlet-3.5.6-cp315-cp315-win_amd64.whl", hash = "sha256:7969bffa322c097bd46ae595ada6a931cefda613f18ba64587e9cff4cb320756", size = 327739, upload-time = "2026-09-14T14:23:55.768Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/c5/3b/576b9ed5ac929252e340cf60b4bcb6a8515350dc20797064b1922dc4ea75/greenlet-3.5.6-cp315-cp315-win_arm64.whl", hash = "sha256:8dba0129b93e7091dfefaf4cf7000172741bff7f47bf6326fcf17f32fbb54d6b", size = 311715, upload-time = "2026-09-14T14:28:25.154Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/16/c2/86cfc5555a98e12b86966ddbd24fd39af32f71f2f785c6595b7feb2db156/greenlet-3.5.6-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:de3de000d459402cda015068fd135aa50c0bf6f2477a80d4da1e646f123b4e78", size = 306244, upload-time = "2026-09-14T14:27:57.565Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/14/6d/83ffc9d05a75a80ab3a7595dbb1d9604e5d4fc2996d73a8ae2dbd1284900/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45663c01a4de48b9a64a2ee1509d92d1dfd3afb02b2ccfc9333029d11aef996a", size = 676578, upload-time = "2026-09-14T15:12:09.468Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/5d/d6/c2cf684810e5caded075970aaadea654ecb58b8382b9aecf1d231b936894/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3deccbb57a481e3a408fe61cdfd5c13e0678fc0a30fdd09597917ca87b4be877", size = 684203, upload-time = "2026-09-14T15:20:50.261Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/f2/d1/039c353d5593a97a89699e989324c9bc86af499e6c6152fe0180f5742204/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:63aff70fe5aac59c72215f42ec39fcb59ff46774fa966e717f8ecb6ee2273577", size = 686023, upload-time = "2026-09-14T15:25:14.528Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/62/19/00e1bee5d2af890dc8f400b54d0b0f9b489965f92bc12b407ff72cc6f469/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:311018b46472fb26ee85870847fb89eb64cc8aaddb617400789d87076f7cfeec", size = 681253, upload-time = "2026-09-14T14:36:06.742Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/8a/62/97ceb8e0b2ea96046cdf8e95b042715020ebb12d83ea0690db80a8f03d23/greenlet-3.5.6-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:520648db8fb92eef7b3e6013f5a6f901cdf0d6685f639c2f7a245879f865bef7", size = 517058, upload-time = "2026-09-14T15:28:44.924Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/89/58/c9275fd0ca195d1d3402931bcce8cfcc74726ff76efb1883d229e6e1a3d7/greenlet-3.5.6-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:7f924a5a9d5890649566f2f6682e0d8ad8ca23028bacffbbac36dbd7fd680176", size = 1645988, upload-time = "2026-09-14T15:10:13.758Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/e0/36/b35747582fa4f1a5453f8f3002405dbac788e450cec7674dc2d204b6ccb5/greenlet-3.5.6-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:de9923832f2d8c1a5ecd8d7260465a6ca5a86888a0d129e3bd5cf0406d2fc5bf", size = 1702383, upload-time = "2026-09-14T14:35:58.143Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/ed/69/6ec22ac9351e474d2a134d0ff9400dc80362d1c20f0721088ffffdfc205b/greenlet-3.5.6-cp315-cp315t-win_amd64.whl", hash = "sha256:2ab5f42ac6c238eb71770715e6e909ad9a1a92b6c681ccb64cd5a0f07edb953f", size = 331845, upload-time = "2026-09-14T14:27:41.723Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/30/cf/697c051fd534e223461fb8b523890e21a24eeca229cd50624cff6f02fabd/greenlet-3.5.6-cp315-cp315t-win_arm64.whl", hash = "sha256:f9fe868463ec7e1363733af77e38a5fda3e9b63940337048c945d69e0c80ff24", size = 315070, upload-time = "2026-09-14T14:22:21.476Z" },
]
[[package]]
name = "gunicorn"
version = "26.2.0"
@@ -1304,6 +1407,15 @@ wheels = [
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
]
[[package]]
name = "python-multipart"
version = "0.0.32"
source = { registry = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple" }
sdist = { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" }
wheels = [
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" },
]
[[package]]
name = "pywin32-ctypes"
version = "0.2.3"