""" migrate_plugin(内置插件 → 进程插件 一键迁移)测试 覆盖: 1. 前置拦截: 自定义 FastAPI 路由(APIRouter 实例化 / 顶层 router 绑定 / star-import 再导出)拒绝迁移;--force 可跳过 2. 前置拦截: 网关包导入(import app / from chongming...)拒绝迁移 3. 放行: `from . import router`(子模块,非 APIRouter)不误伤(ip_sound_column 写法) 4. 迁移产物: 项目骨架(pyproject/start.sh/config.toml/README/zenoh 配置)+ 代码/配置 迁移 + [workers] 改写(module=workers.、load_mode=external)+ 保留其余配置 5. 依赖迁移: plugins_deps.toml [] 节 → 新项目 pyproject(按实际 import 过滤、 保留根 pyproject 的 extras 写法、共用节带入);[] 节注释掉、其它节不动 6. --dry-run 不落盘;目标已存在 / 插件不存在时拒绝 运行: uv run python test/test_migrate_plugin.py """ import contextlib import io 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")) os.environ.pop("CHONGMING_ROOT", None) # 根目录定位不应受外部环境变量影响 from chongming_cli import migrate_plugin as mp # noqa: E402 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) DEPS_TOML = """\ imports = ["httpx"] [foo] imports = [ "volcenginesdkarkruntime", "chongming_cache", "orjson", "totally_missing_pkg", ] [bar] imports = ["openai"] """ PLUGIN_INIT = '''"""fixture 插件""" import orjson import httpx from chongming_cache import Cache # noqa: F401 from chongming_worker.plugin import Plugin from volcenginesdkarkruntime import AsyncArk # noqa: F401 from totally_missing_pkg import thing # noqa: F401 from . import router # noqa: F401 触发 @rpc_method 注册 {extra} class FooPlugin(Plugin): type = "foo" db_name = None async def on_init(self): pass plugin = FooPlugin() create_workers = plugin.create_workers cleanup = plugin.cleanup ''' CONFIG_TOML = """\ # foo Worker 插件配置 [workers] type = "foo" module = "plugins.foo" factory = "create_workers" cleanup = "cleanup" count = 2 [worker] name = "foo" version = "0.1.0" description = "fixture 插件" config_version = "1.0" defaults = { method = "POST", timeout = 30.0 } [worker.registration] router_prefix = "/foo" [workers.config] data_dir = "./data" api_key = "PLACEHOLDER" """ def make_root(tmp: Path) -> Path: """最小仓库现场:根 pyproject(含 extras 依赖)/ config/zenoh_config.json5 副本 / plugins_deps.toml。""" tmp.mkdir(parents=True, exist_ok=True) (tmp / "pyproject.toml").write_text( "[project]\nname = 'fixture'\ndependencies = [\"volcengine-python-sdk[ark]\"]\n", encoding="utf-8", ) (tmp / "config").mkdir(parents=True, exist_ok=True) (tmp / "config" / "zenoh_config.json5").write_text("{}\n", encoding="utf-8") (tmp / "plugins_deps.toml").write_text(DEPS_TOML, encoding="utf-8") return tmp def make_plugin(tmp: Path, name: str = "foo", init_extra: str = "", files: dict | None = None): """造内置插件现场:src/plugins// + config/workers//。""" code = tmp / "src" / "plugins" / name (code / "router").mkdir(parents=True, exist_ok=True) (code / "models").mkdir() (code / "models" / "__init__.py").write_text("", encoding="utf-8") (code / "__init__.py").write_text(PLUGIN_INIT.format(extra=init_extra), encoding="utf-8") (code / "router" / "__init__.py").write_text("from . import hello # noqa: F401\n", encoding="utf-8") (code / "router" / "hello.py").write_text("async def hello():\n return {}\n", encoding="utf-8") conf = tmp / "config" / "workers" / name conf.mkdir(parents=True, exist_ok=True) (conf / "config.toml").write_text(CONFIG_TOML, encoding="utf-8") (conf / "hello.toml").write_text("[POST]\nsummary = \"hi\"\n", encoding="utf-8") for rel, text in (files or {}).items(): path = code / rel path.parent.mkdir(parents=True, exist_ok=True) path.write_text(text, encoding="utf-8") return tmp def run(tmp: Path, *args, name: str = "foo") -> tuple[int, str]: """chdir 到现场执行迁移,捕获 stdout。""" buf = io.StringIO() cwd = Path.cwd() os.chdir(tmp) try: with contextlib.redirect_stdout(buf): rc = mp.migrate_plugin_package(name, **kwargs_for(args)) finally: os.chdir(cwd) return rc, buf.getvalue() def kwargs_for(args): kwargs = {} for a in args: kwargs[a] = True return kwargs ROUTER_FILES = { "routes.py": ( "from fastapi import APIRouter\n\n" "router = APIRouter(prefix=\"/foo\")\n" ), } tmp_root = Path(tempfile.mkdtemp(prefix="migrate_plugin_test_")) # ========== 1. 自定义路由拦截(aibox 写法) ========== case = make_root(tmp_root / "case_router") make_plugin(case, init_extra="from .routes import router # noqa: F401", files=ROUTER_FILES) rc, out = run(case) check("路由挂载(from .routes import router + APIRouter)拒绝迁移", rc == 1, f"rc={rc}") check("拒绝时给出路由提示", "APIRouter" in out and "拒绝迁移" in out) check("拒绝时不动任何文件", (case / "src" / "plugins" / "foo").is_dir() and not (case / "workers").exists()) # ========== 2. star-import 再导出的 APIRouter 同样拦截 ========== case = make_root(tmp_root / "case_star") make_plugin( case, init_extra="from .router import * # noqa: F401,F403", files={}, # router/ 由 make_plugin 建好,下面覆盖 __init__.py 为 APIRouter 定义 ) (case / "src" / "plugins" / "foo" / "router" / "__init__.py").write_text( "from fastapi import APIRouter\n\nrouter = APIRouter()\n", encoding="utf-8" ) rc, out = run(case) check("star-import 链上的 APIRouter 拒绝迁移", rc == 1 and "APIRouter" in out, f"rc={rc}") # ========== 3. --force 跳过拦截 ========== rc, out = run(case, "force") check("--force 跳过后可迁移", rc == 0 and (case / "workers" / "foo" / "pyproject.toml").is_file(), f"rc={rc}") check("--force 输出回显跳过", "跳过上述拦截" in out) check( "配置缺 module/load_mode 时自动补全", 'module = "workers.foo"' in (case / "workers" / "foo" / "config" / "workers" / "foo" / "config.toml").read_text(), ) # ========== 4. 网关包导入拦截 ========== case = make_root(tmp_root / "case_gw") make_plugin(case, init_extra="from app.core.logger import get_logger # noqa: F401") rc, out = run(case) check("网关包导入拒绝迁移", rc == 1 and "网关" in out, f"rc={rc}") # ========== 5. dry-run 不落盘 ========== case = make_root(tmp_root / "case_dry") make_plugin(case) before = (case / "plugins_deps.toml").read_text(encoding="utf-8") rc, out = run(case, "dry_run") check("--dry-run 返回成功且不落盘", rc == 0 and not (case / "workers").exists()) check("--dry-run 不改 plugins_deps.toml", (case / "plugins_deps.toml").read_text(encoding="utf-8") == before) check("--dry-run 打印演练提示", "未改动任何文件" in out) # ========== 6. 目标已存在 / 插件不存在 ========== case = make_root(tmp_root / "case_exists") make_plugin(case) (case / "workers" / "foo").mkdir(parents=True) rc, out = run(case) check("目标已存在拒绝迁移", rc == 1 and "目标已存在" in out) case = make_root(tmp_root / "case_missing") rc, out = run(case) check("插件不存在拒绝迁移", rc == 1 and "未找到内置插件" in out) # ========== 7. 完整迁移(happy path) ========== case = make_root(tmp_root / "case_full") make_plugin(case) rc, out = run(case) project = case / "workers" / "foo" check("完整迁移成功", rc == 0 and project.is_dir(), f"rc={rc}") code_dst = project / "workers" / "foo" conf_dst = project / "config" / "workers" / "foo" check( "代码迁移(含 router/ 与 models/)", (code_dst / "__init__.py").is_file() and (code_dst / "router" / "hello.py").is_file() and (code_dst / "models" / "__init__.py").is_file(), ) check("路由配置迁移", (conf_dst / "hello.toml").is_file()) conf_text = (conf_dst / "config.toml").read_text(encoding="utf-8") check( "[workers] 改写: module=workers.foo / load_mode=external / 其余保留", 'module = "workers.foo"' in conf_text and 'load_mode = "external"' in conf_text and 'count = 2' in conf_text and 'api_key = "PLACEHOLDER"' in conf_text, ) check("项目骨架生成", all((project / f).is_file() for f in ("config.toml", "pyproject.toml", "README.md", "start.sh"))) check("zenoh 配置复制", (project / "config" / "zenoh_config.json5").is_file()) check("start.sh 可执行且入口正确", (project / "start.sh").stat().st_mode & 0o111 and "chongming-worker --type foo" in (project / "start.sh").read_text()) check("README 标注迁移来源", "migrate_plugin foo" in (project / "README.md").read_text()) check("源目录已删除", not (case / "src" / "plugins" / "foo").exists() and not (case / "config" / "workers" / "foo").exists()) pyproject = tomllib.loads((project / "pyproject.toml").read_text(encoding="utf-8")) deps = pyproject["project"]["dependencies"] check("新项目依赖: chongming-worker + 迁移带入", "chongming-worker" in deps and "httpx" in deps and "orjson" in deps and "chongming-cache" in deps, str(deps)) check("extras 写法沿用根 pyproject", "volcengine-python-sdk[ark]" in deps, str(deps)) check("未解析/未使用的依赖不进新项目", not any("totally_missing_pkg" in d for d in deps), str(deps)) check("未解析依赖有告警", "totally_missing_pkg" in out) deps_data = tomllib.loads((case / "plugins_deps.toml").read_text(encoding="utf-8")) check( "plugins_deps.toml: [foo] 节注释掉、共用节与其它节不动", "foo" not in deps_data and deps_data.get("imports") == ["httpx"] and deps_data.get("bar", {}).get("imports") == ["openai"], str(deps_data), ) # ========== 8. ip_sound_column 写法(from . import router 子模块)不误伤 ========== case = make_root(tmp_root / "case_submod") make_plugin(case, init_extra="from . import router # noqa: F401 触发 @rpc_method 注册") rc, out = run(case, "dry_run") check("from . import router(子模块)不误伤", rc == 0, f"rc={rc}") shutil.rmtree(tmp_root, ignore_errors=True) print("\n" + ("=" * 40)) if failures: print(f"❌ {len(failures)} 项未通过: {failures}") sys.exit(1) print("✅ 全部通过")