Files
PlantAI-rag-new/bioflow_service/tests/test_extensions.py
adminandClaude Fable 5 4866b0408a feat(bioflow): extension lifecycle backend (P0) - spec whitelist, state machine, AI build, intent layers
服务端生命周期部分(契约 §1/§2/§4/§5/§6;Dev-A):
- extension_spec.py: spec_v2 白名单 12 占位符 + {output_dir} 必含 + 白名单外拒绝
- extensions.py: 生命周期状态机(契约 §2 枚举) + {extensions}/{ext_id} 文件存储
  + ExtensionExecutor 接口占位(Dev-B 实现 extension_exec.py, inject_executor 注入)
  + extension_* 事件帧经 EventBus 发布(events.py 注册, 不改 job_* 语义)
- extension_llm.py: build/repair AI 转换(DOCGEN_LLM_* 优先, BIOFLOW_EXTENSION_* 别名)
  LLM 不可用/坏输出一律回退规则版(_detect_requirements/_command_to_template 移植), 绝不 500
- routers/extension.py: 契约 §6 端点全集; trial/run 执行器未接入返回 503
- intent.py: 契约 §5 两层判定纯函数(A 创建词库 × B 已发布扩展打分 + 歧义消解 + 漏判安全)
  chat/stream 接线留给 Dev-C 主分支整合
- config/schemas/main: upload_ext_allow 加 .zip/.py/.r/.sh; extensions_dir +
  extension_max_repair_rounds(20) + LLM 键注释; 模型; router 注册
- tests: 5 个测试文件 71 用例; 全仓 286 用例通过

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-14 13:48:37 +08:00

405 lines
15 KiB
Python
Raw Permalink 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.
"""tests/test_extensions.py —— 扩展生命周期状态机与存储(契约 §1/§2)。
覆盖:创建布局(extension.json/raw_code/raw_test_data/converted_code)/
状态机合法流转 / 非法流转 409 语义 / build 规则版回退(无 LLM key)/
trial 执行(fake executor,成/败)/ validate(无金标+有金标)/
repair(轮次上限/LLM 不可用规则回退)/
confirm(nl_triggers 编辑/parameters 补齐)/ publish / run(仅 published、
run_result 落盘、输出登记)/ 列表 published 优先 / 删除守卫。
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from app.services.bioflow import extension_spec as spec
from app.services.bioflow import extensions as ext
pytestmark = pytest.mark.bioflow_unit
_CODE = "#!/usr/bin/env python\nimport sys\nprint('hello')\n"
@pytest.fixture()
def store(tmp_path, monkeypatch):
monkeypatch.delenv("DOCGEN_LLM_API_KEY", raising=False)
monkeypatch.delenv("BIOFLOW_EXTENSION_API_KEY", raising=False)
return ext.ExtensionStore(base_dir=str(tmp_path / "data" / "extensions"))
def _create(store, *, name="测序质量统计", command="python quality_stats.py --fastq input.fastq --out results/",
code=None, data=None, golden=None):
return store.create_extension(
display_name=name,
command=command,
runtime_image="test4py311", # 旧 conda 环境名 → 映射 bioflow-gf
code_files=code or [("quality_stats.py", _CODE.encode())],
data_files=data or [],
golden=golden,
)
def _fake_executor(exit_code=0, log="run ok\n", outputs=("result.txt",)):
"""sync 可调用 executor:写输出 + 返回 ExtensionRunResult(兼容 _execute 三类形态)。"""
class FakeExec:
def __init__(self):
self.calls = []
def execute(self, spec_in):
self.calls.append(spec_in)
out_dir = Path(spec_in.output_dir)
out_dir.mkdir(parents=True, exist_ok=True)
for name in outputs:
(out_dir / name).write_text("data", encoding="utf-8")
if spec_in.log_path:
Path(spec_in.log_path).parent.mkdir(parents=True, exist_ok=True)
Path(spec_in.log_path).write_text(log, encoding="utf-8")
return ext.ExtensionRunResult(
run_id="", exit_code=exit_code, log_tail=log,
outputs=list(outputs),
error="" if exit_code == 0 else "命令失败",
)
return FakeExec()
# ---- 创建与布局(契约 §1)----
async def test_create_layout(tmp_path):
s = ext.ExtensionStore(base_dir=str(tmp_path / "ext"))
e = _create(s)
assert e["status"] == "draft"
assert e["ext_id"].startswith("ext_")
assert e["runtime_image"] == "bioflow-gf" # test4py311 → 映射
assert e["schema_version"] == spec.SPEC_VERSION
root = s.ext_dir(e["ext_id"])
assert (root / "extension.json").is_file()
assert (root / "raw_code" / "quality_stats.py").read_text(encoding="utf-8") == _CODE
assert (root / "converted_code" / "quality_stats.py").is_file()
assert (root / "raw_test_data").is_dir()
assert (root / "validation").is_dir()
s.delete(e["ext_id"])
def test_create_requires_command_and_code(tmp_path):
s = ext.ExtensionStore(base_dir=str(tmp_path / "ext_req"))
with pytest.raises(ext.ExtensionError, match="display_name"):
s.create_extension(display_name=" ", command="python a.py", code_files=[("a.py", b"x")])
with pytest.raises(ext.ExtensionError, match="command"):
s.create_extension(display_name="n", command="", code_files=[("a.py", b"x")])
with pytest.raises(ext.ExtensionError, match="代码包"):
s.create_extension(display_name="n", command="python a.py", code_files=[])
def test_create_zip_path_traversal_sanitized(store):
e = _create(store, code=[("../../evil.py", b"x"), ("ok.py", b"y")])
root = store.ext_dir(e["ext_id"])
assert (root / "raw_code" / "evil.py").is_file()
assert not (root.parent / "evil.py").exists()
def test_load_missing_404(store):
with pytest.raises(ext.ExtensionNotFoundError):
store.load_extension("ext_nope")
# ---- 状态机 ----
async def test_status_transitions_valid(store):
e = _create(store)
assert (await store.build_extension(e["ext_id"]))["status"] == "built"
async def test_invalid_transition(store):
e = _create(store)
with pytest.raises(ext.ExtensionStatusError):
await store.trial_run(e["ext_id"]) # draft → trial 非法
await store.build_extension(e["ext_id"])
with pytest.raises(ext.ExtensionStatusError):
store.publish(e["ext_id"]) # built → publish 非法(须 validate/confirm)
async def test_build_failed_converges(store):
e = _create(store)
# 命令无 .py 文件可引用 + 无 LLM → 规则版仍能给出模板(不会 build_failed);
# 构造一个规则版也会失败的场景:空原始命令被创建层拦截。这里直接验证
# 规则版回退路径本身可用(无 LLM key 时 build 成功且 source=rules)。
out = await store.build_extension(e["ext_id"])
assert out["status"] == "built"
assert out["last_build"]["source"] == "rules"
assert "{output_dir}" in out["command_template"]
spec.validate_command_template(out["command_template"])
async def test_llm_invalid_output_falls_back_rules(store, monkeypatch):
"""LLM 返回白名单外占位符 → 视为转换失败 → 规则版回退(build 仍成功)。"""
e = _create(store)
monkeypatch.setattr("app.services.bioflow.extension_llm.llm_config",
lambda: {"base_url": "http://x", "api_key": "k", "model": "m", "timeout": 1.0})
async def fake_complete_json(system, user, **kw):
return {"command_template": "python {code_dir}/a.py --out {not_allowed}",
"requirements": {}, "nl_triggers": ["t"]}
monkeypatch.setattr("app.services.bioflow.llm.complete_json", fake_complete_json)
out = await store.build_extension(e["ext_id"])
assert out["status"] == "built"
assert out["last_build"]["source"] == "rules"
spec.validate_command_template(out["command_template"])
# ---- trial ----
async def test_trial_pass(store):
e = _create(store, data=[("input.fastq", b">s1\nACGT\n")])
await store.build_extension(e["ext_id"])
store.executor = _fake_executor(0, "ok\n", ("result.txt",))
out = await store.trial_run(e["ext_id"])
assert out["status"] == "trial_passed"
assert out["trial"]["ok"] is True
assert out["trial"]["outputs"] == ["result.txt"]
vdir = store.validation_dir(e["ext_id"])
assert (vdir / "trial_1.log").is_file()
assert (vdir / "trial_1.outputs" / "result.txt").is_file()
async def test_trial_fail(store):
e = _create(store)
await store.build_extension(e["ext_id"])
store.executor = _fake_executor(2, "boom\n", ())
out = await store.trial_run(e["ext_id"])
assert out["status"] == "trial_failed"
assert out["trial"]["ok"] is False
assert "命令失败" in out["trial"]["error"]
async def test_trial_retry_increments(store):
e = _create(store)
await store.build_extension(e["ext_id"])
store.executor = _fake_executor(1, "fail\n", ())
await store.trial_run(e["ext_id"])
store.executor = _fake_executor(0, "ok\n", ("r.txt",))
out = await store.trial_run(e["ext_id"])
assert out["trial"]["n"] == 2
assert out["status"] == "trial_passed"
async def test_trial_without_executor_503_semantics(store):
e = _create(store)
await store.build_extension(e["ext_id"])
with pytest.raises(ext.ExtensionRunError):
await store.trial_run(e["ext_id"]) # 占位执行器 → 明确错误(路由层 503)
# ---- validate ----
async def test_validate_without_golden(store):
e = _create(store)
await store.build_extension(e["ext_id"])
store.executor = _fake_executor(0, "ok\n")
await store.trial_run(e["ext_id"])
out = store.validate(e["ext_id"])
assert out["status"] == "validated"
assert out["validation"]["compared"] is False
async def test_validate_golden_pass(store):
e = _create(store, data=[("sample.fastq", b">s\nACGT\n")],
golden={"test_input": "sample.fastq", "expected_outputs": ["result.txt"]})
await store.build_extension(e["ext_id"])
store.executor = _fake_executor(0, "ok\n", ("result.txt",))
await store.trial_run(e["ext_id"])
out = store.validate(e["ext_id"])
assert out["status"] == "validated"
assert out["validation"]["compared"] is True
assert out["validation"]["ok"] is True
async def test_validate_golden_missing_output(store):
e = _create(store, golden={"test_input": "sample.fastq", "expected_outputs": ["gold.tsv"]})
await store.build_extension(e["ext_id"])
store.executor = _fake_executor(0, "ok\n", ("other.txt",))
await store.trial_run(e["ext_id"])
out = store.validate(e["ext_id"])
assert out["status"] == "trial_failed"
assert out["validation"]["missing_outputs"] == ["gold.tsv"]
# ---- repair ----
async def test_repair_limit(store):
e = _create(store)
await store.build_extension(e["ext_id"])
store.executor = _fake_executor(1, "No module named 'pandas'\n", ())
await store.trial_run(e["ext_id"])
store.max_repair_rounds = 1
out = await store.repair(e["ext_id"]) # 第 1 轮成功(规则版并入 pandas)
assert out["repair_count"] == 1
assert "pandas" in out["requirements"]["pip"]
store.executor = _fake_executor(1, "still fail\n", ())
await store.trial_run(e["ext_id"])
with pytest.raises(ext.ExtensionStatusError, match="上限"):
await store.repair(e["ext_id"])
async def test_repair_requires_failure(store):
e = _create(store)
await store.build_extension(e["ext_id"])
with pytest.raises(ext.ExtensionStatusError):
await store.repair(e["ext_id"]) # built(未失败)不可修复
# ---- confirm / publish ----
async def test_full_flow_to_publish(store):
e = _create(store)
ext_id = e["ext_id"]
await store.build_extension(ext_id)
store.executor = _fake_executor(0, "ok\n")
await store.trial_run(ext_id)
store.validate(ext_id)
out = store.confirm(ext_id, nl_triggers=["质量统计", "fastq 统计"])
assert out["status"] == "confirming"
assert out["nl_triggers"] == ["质量统计", "fastq 统计"]
pub = store.publish(ext_id)
assert pub["status"] == "published"
# 已发布不可再发布流转(幂等返回)
assert store.publish(ext_id)["status"] == "published"
async def test_confirm_parameters_reconcile(store):
e = _create(store, command="python quality_stats.py --fastq input.fastq --cpu {param_cpu} --out results/")
await store.build_extension(e["ext_id"])
store.executor = _fake_executor(0)
await store.trial_run(e["ext_id"])
store.validate(e["ext_id"])
# 用户编辑后的 parameters 丢掉 {param_cpu} → 自动补齐
out = store.confirm(e["ext_id"], parameters=[])
names = {p["name"] for p in out["parameters"]}
assert "cpu" in names
# ---- run ----
async def test_run_requires_published(store):
e = _create(store)
await store.build_extension(e["ext_id"])
with pytest.raises(ext.ExtensionStatusError, match="仅已发布"):
await store.run(e["ext_id"], params={}, files=[])
async def test_run_full(store):
e = _create(store)
ext_id = e["ext_id"]
await store.build_extension(ext_id)
store.executor = _fake_executor(0, "run ok\n", ("out.tsv",))
await store.trial_run(ext_id)
store.validate(ext_id)
store.confirm(ext_id)
store.publish(ext_id)
# 输入文件登记(须在白名单根内:data_root 或扩展目录)
data_root = Path(store.base_dir).parent
input_file = data_root / "input.fastq"
input_file.write_text(">s\nACGT\n", encoding="utf-8")
result = await store.run(ext_id, params={"cpu": "4"}, files=[str(input_file)])
assert result["status"] == "done"
assert result["exit_code"] == 0
assert "out.tsv" in result["outputs"]
run_dir = store.runs_dir(ext_id) / result["run_id"]
assert (run_dir / "run_result.json").is_file()
assert (run_dir / "inputs" / "input.fastq").is_file()
assert (run_dir / "outputs" / "out.tsv").is_file()
runs = store.list_runs(ext_id)
assert len(runs) == 1
assert runs[0]["run_id"] == result["run_id"]
async def test_run_rejects_outside_file(store):
e = _create(store)
ext_id = e["ext_id"]
await store.build_extension(ext_id)
store.executor = _fake_executor(0)
await store.trial_run(ext_id)
store.validate(ext_id)
store.publish(ext_id)
with pytest.raises(ext.ExtensionRunError, match="白名单"):
await store.run(ext_id, params={}, files=["/etc/passwd"])
async def test_run_failed_status(store):
e = _create(store)
ext_id = e["ext_id"]
await store.build_extension(ext_id)
store.executor = _fake_executor(3, "output contract failed\n", ())
await store.trial_run(ext_id) # 空输出 → 失败(假执行器 exit=3)
assert store.load_extension(ext_id)["status"] == "trial_failed"
# ---- 列表 / 删除 ----
async def test_list_published_first(store):
a = _create(store, name="扩展 A")
await store.build_extension(a["ext_id"])
b = _create(store, name="扩展 B")
await store.build_extension(b["ext_id"])
store.executor = _fake_executor(0)
await store.trial_run(b["ext_id"])
store.validate(b["ext_id"])
store.publish(b["ext_id"])
items = store.list_extensions()
assert items[0]["ext_id"] == b["ext_id"] # published 优先
assert items[1]["ext_id"] == a["ext_id"]
def test_delete_guard_trial_running(store):
e = _create(store)
# 手工置 trial_running(不可删除守卫;无需 build——直接构造状态)
store.save_extension({**store.load_extension(e["ext_id"]), "status": "trial_running"})
with pytest.raises(ext.ExtensionStatusError, match="试运行"):
store.delete(e["ext_id"])
store.save_extension({**store.load_extension(e["ext_id"]), "status": "draft"})
store.delete(e["ext_id"])
assert store.load_extension(e["ext_id"], required=False) is None
# ---- 并发写合并 ----
async def test_apply_and_write_preserves_fields(store):
e = _create(store)
ext_id = e["ext_id"]
# 两次锁内读-改-写(模拟交错协程写不同字段):后写不丢先写字段
store._apply_and_write(ext_id, lambda ext: ext.__setitem__(
"command_template", "python {code_dir}/a.py --out {output_dir}"))
store._apply_and_write(ext_id, lambda ext: ext.__setitem__("nl_triggers", ["触发词"]))
merged = store.load_extension(ext_id)
assert merged["command_template"] == "python {code_dir}/a.py --out {output_dir}"
assert merged["nl_triggers"] == ["触发词"]
assert merged["original_command"] # 其余字段保留
# ---- 事件总线发布(契约 §4;run 完成后可回放)----
async def test_run_publishes_events(store, tmp_path):
from app.services.bioflow.events import EventBus
bus = EventBus(tmp_path / "events")
e = _create(store)
ext_id = e["ext_id"]
await store.build_extension(ext_id)
store.executor = _fake_executor(0, "run ok\n", ("o.txt",))
await store.trial_run(ext_id)
store.validate(ext_id)
store.publish(ext_id)
store._bus_override = bus
result = await store.run(ext_id, params={}, files=[])
replay = bus.replay(ext_id)
types = [ev["type"] for ev in replay]
assert "extension_run_started" in types
assert "extension_run_done" in types
assert result["status"] == "done"