Files
chongming-core/test/test_migrate_plugin.py
T

281 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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("✅ 全部通过")