""" 插件专属依赖清单(plugins_deps.toml)测试 "插件专属依赖跟插件走、不混进网关构建配置"这条约定的守卫: 1. 仓库清单可解析:doubao 专属(openai / volcengine / bytedance)与共用(httpx) 都能被 build 的读取/合并逻辑读出来 2. 收集清单 = config.toml [pyinstaller_build_import] + plugins_deps.toml, 去重且网关本体依赖在前;文件缺失时不报错、不追加 3. 拆分不回潮:插件专属依赖不再出现在 config.toml 里(防止又手工加回网关清单) 4. 配置写错(imports 非字符串列表 / 顶层多余键 / 插件节缺 imports)直接抛错, 不静默漏收依赖(漏了要到运行期加载插件才 ImportError) 5. venv 里 chongming-cli 版本低于本地源码时告警:打包用的是 venv 里的 CLI, 不先发布升级,plugins_deps.toml 不会生效 运行: uv run python test/test_plugin_deps.py """ import importlib.metadata import os import shutil import sys import tempfile import tomllib from pathlib import Path ROOT = Path(__file__).parent.parent sys.path.insert(0, str(ROOT / "utils" / "cli" / "src")) DOUBAO_DEPS = { "openai", "volcenginesdkarkruntime", "bytedance", "bytedance.volcengine_aicc_sdk", "bytedance.volcengine_aicc_sdk.secure_http_client", } # build 的根目录定位支持 CHONGMING_ROOT 环境变量,测试结论不应受它影响 os.environ.pop("CHONGMING_ROOT", None) 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) try: from chongming_cli import build as b # noqa: E402 HAVE_CLI = True except ImportError as exc: # utils/cli 子仓库未就位(干净克隆)时只跑清单一致性检查 HAVE_CLI = False print(f"[SKIP] chongming_cli 源码不可用({exc}):仅跑清单一致性检查") cwd = Path.cwd() def load_repo_deps() -> dict: """读仓库的 plugins_deps.toml(不依赖 chongming_cli,一致性检查也可用)。""" with open(ROOT / "plugins_deps.toml", "rb") as f: return tomllib.load(f) def flat_modules(data: dict) -> set[str]: """plugins_deps.toml 里的全部模块(共用 imports + 各插件节)。""" mods = set(data.get("imports", [])) for value in data.values(): if isinstance(value, dict): mods |= set(value.get("imports", [])) return mods # ========== 1. 仓库清单可解析(build 的读取逻辑直测) ========== if HAVE_CLI: os.chdir(ROOT) try: modules, summary = b._load_plugin_imports() finally: os.chdir(cwd) check( "仓库 plugins_deps.toml 可解析且含 doubao 专属依赖", DOUBAO_DEPS <= set(modules), f"缺失: {sorted(DOUBAO_DEPS - set(modules))}", ) check("共用节(httpx)并入清单", "httpx" in modules) check("摘要含 doubao 节名", "doubao" in summary, summary) # ========== 2. 合并:网关本体在前、去重保序 ========== fake_config = { "pyinstaller_build_import": { "base": ["fastapi", "openai"], # openai 故意与插件清单重复,验证去重 "linux": ["uvloop"], "windows": ["win_only"], } } os.chdir(ROOT) try: merged, _ = b._collect_build_imports(fake_config) finally: os.chdir(cwd) check( "合并后网关本体依赖在前且 openai 只出现一次", merged[0] == "fastapi" and merged[1] == "openai" and merged.count("openai") == 1, str(merged[:6]), ) check( "插件专属依赖追加在网关清单之后", "httpx" in merged and merged.index("httpx") > merged.index("openai"), ) # ========== 3. 拆分不回潮:config.toml 里不应再有插件专属依赖 ========== repo_modules = flat_modules(load_repo_deps()) with open(ROOT / "config.toml", "rb") as f: cfg = tomllib.load(f) bcfg = cfg["pyinstaller_build_import"] gateway_imports = set(bcfg["base"]) | set(bcfg["linux"]) | set(bcfg["windows"]) check( "doubao 专属依赖已从 config.toml 移出(只留 plugins_deps.toml)", not (DOUBAO_DEPS & gateway_imports), f"回潮: {sorted(DOUBAO_DEPS & gateway_imports)}", ) check( "两份清单不重叠(插件专属 vs 网关本体)", not (repo_modules & gateway_imports), f"重叠: {sorted(repo_modules & gateway_imports)}", ) # ========== 4. 文件缺失 / 写错的行为 ========== tmp = Path(tempfile.mkdtemp(prefix="plugin_deps_test_")) (tmp / "pyproject.toml").write_text("[project]\nname = 'x'\n", encoding="utf-8") if HAVE_CLI: (tmp / "plugins_deps.toml").unlink(missing_ok=True) cfg_only = {"pyinstaller_build_import": {"base": ["fastapi"], "linux": [], "windows": []}} os.chdir(tmp) try: mods, summary = b._load_plugin_imports() merged, _ = b._collect_build_imports(cfg_only) finally: os.chdir(cwd) check( "无 plugins_deps.toml:不报错、不追加", mods == [] and summary == "" and merged == ["fastapi"], f"{mods} / {merged}", ) def expect_error(name: str, text: str) -> None: (tmp / "plugins_deps.toml").write_text(text, encoding="utf-8") os.chdir(tmp) try: try: b._load_plugin_imports() check(name, False, "未抛错") except ValueError as exc: check(name, True, str(exc)) finally: os.chdir(cwd) expect_error("imports 非列表 → 报错", 'imports = "openai"\n') expect_error("imports 含非字符串 → 报错", "[doubao]\nimports = [1, 2]\n") expect_error("插件节缺 imports(如写成 import)→ 报错", "[doubao]\nimport = ['openai']\n") expect_error("顶层多余标量键 → 报错", "foo = 1\n") # 合法多节:共用 + 逐插件(去重发生在合并阶段,读取阶段保持原样) (tmp / "plugins_deps.toml").write_text( 'imports = ["httpx"]\n\n[alpha]\nimports = ["pkg_a"]\n\n' '[beta]\nimports = ["pkg_b", "pkg_a"]\n', encoding="utf-8", ) os.chdir(tmp) try: mods, summary = b._load_plugin_imports() finally: os.chdir(cwd) check( "多节解析:共用 + 逐插件均并入,摘要含各节", mods == ["httpx", "pkg_a", "pkg_b", "pkg_a"] and "alpha:1" in summary and "beta:2" in summary, f"{mods} / {summary}", ) shutil.rmtree(tmp, ignore_errors=True) # ========== 5. venv 里的 CLI 版本(打包装箱用的是它,不是本文件旁边的源码) ========== local_ver = tomllib.loads((ROOT / "utils" / "cli" / "pyproject.toml").read_text())[ "project" ]["version"] def as_tuple(v: str): return tuple(int(x) for x in v.split(".")) try: installed_ver = importlib.metadata.version("chongming-cli") except importlib.metadata.PackageNotFoundError: installed_ver = None if installed_ver is None: print("[WARN] venv 未安装 chongming-cli:先 uv sync 或发布后再构建") else: try: outdated = as_tuple(installed_ver) < as_tuple(local_ver) except ValueError: # 非常规版本号(如 rc/后缀),不猜,直接提示 outdated = True if outdated: print( f"[WARN] venv 里 chongming-cli {installed_ver} < 源码 {local_ver}:" "plugins_deps.toml 需新版 build 才生效 —— 发布 chongming-cli 后执行 " "uv lock --upgrade-package chongming-cli && uv sync" ) else: check(f"venv chongming-cli {installed_ver} ≥ 源码 {local_ver}", True) print("\n" + ("=" * 40)) if failures: print(f"❌ {len(failures)} 项未通过: {failures}") sys.exit(1) print("✅ 全部通过")