281 lines
10 KiB
Python
281 lines
10 KiB
Python
"""
|
||
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.<name>、load_mode=external)+ 保留其余配置
|
||
5. 依赖迁移: plugins_deps.toml [<name>] 节 → 新项目 pyproject(按实际 import 过滤、
|
||
保留根 pyproject 的 extras 写法、共用节带入);[<name>] 节注释掉、其它节不动
|
||
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/<name>/ + config/workers/<name>/。"""
|
||
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("✅ 全部通过")
|