Files
PlantAI-rag-new/bioflow_service/tests/test_kernel.py
T
adminandClaude Fable 5 dcb810a758 feat(bioflow): wgs/pop/gwas 26 阶段参数契约(与 rnaseq 同款机制)
- stage_image_map 加 26 个 wgs/pop/gwas 阶段 cli_contract:
  --workspace/--job-id/--prefix 公共三元组 + 上游 manifest 注入
  (prev_manifest:<stage> 解析为 workspace/final/{prefix}.{stage}.manifest.json)
  + param:<name> 用户参数 + tool:<name> 镜像内工具
- kernel _contract_args 扩展 prev_manifest/tool 两种 source
- catalog 补 wgs/pop/gwas 参数 schema(确认表单收集参考基因组/样本清单/
  表型文件/主成分等)
- 339 用例全绿;已部署 176

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-16 15:33:43 +08:00

247 lines
11 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.
"""tests/test_kernel.py —— Docker 执行器(命令拼装/mock 执行/映射表加载)。"""
from __future__ import annotations
import asyncio
import pytest
from app.services.bioflow.kernel import (
ImageMapError,
Kernel,
KernelConfig,
UnknownStageError,
tail_lines,
)
pytestmark = pytest.mark.bioflow_unit
def test_load_map_and_query(kernel, image_map):
assert kernel.has_stage("identify")
assert not kernel.has_stage("fake_stage_xyz")
entry = kernel.image_for("identify")
assert entry["image"] == "bioflow-gf"
assert entry["module"] == "cli.run_identify"
# 跨环境 note 透传
assert kernel.image_for("expression")["note"].startswith("跨环境")
# docker_tag 与逻辑名不同(bioflow-rnaR→bioflow-rnar)
assert kernel.map.docker_tag("bioflow-rnaR") == "bioflow-rnar"
def test_load_missing_map(tmp_path):
with pytest.raises(ImageMapError):
Kernel.load(tmp_path / "nope.json", KernelConfig())
def test_load_broken_map(tmp_path):
p = tmp_path / "bad.json"
p.write_text("{invalid", encoding="utf-8")
with pytest.raises(ImageMapError):
Kernel.load(p, KernelConfig())
def test_build_command_basics(kernel, tmp_path):
cmd = kernel.build_command("identify", tmp_path / "in", tmp_path / "ws")
# TC-BIO-KERN-001 基元顺序
assert cmd[0] == "docker"
assert cmd[1] == "run"
assert cmd[2] == "--rm"
assert cmd[3] == "--user"
assert cmd[4] == "1234"
# 挂载三目录
assert f"{tmp_path}/in:/job/input" in cmd
assert f"{tmp_path}/ws:/job/workspace" in cmd
assert f"{kernel.cfg.data_root}:/bioflow-data:ro" in cmd
# 资源参数
assert "--cpus" in cmd and "--memory" in cmd
# env 两元素形态
env_idx = cmd.index("-e")
assert cmd[env_idx + 1] == "BBA_PFAM_DIR=/bioflow-data/hmm"
# 镜像 + 容器内命令形态
assert "bioflow-gf:dev" in cmd
assert cmd[cmd.index("python") + 1] == "-B"
assert "-m" in cmd and "cli.run_identify" in cmd
# 通用参数
assert "--input-dir" in cmd and "/job/input" in cmd
assert "--workspace" in cmd and "/job/workspace" in cmd
def test_build_command_extra_mounts(kernel, tmp_path):
"""登记路径自动挂载:extra_mounts 每个追加 -v src:src:ro。"""
extra = [str(tmp_path / "rnaseq"), str(tmp_path / "wgs")]
cmd = kernel.build_command("identify", tmp_path / "in", tmp_path / "ws",
extra_mounts=extra)
for m in extra:
assert f"{m}:{m}:ro" in cmd
def test_build_command_kernel_dir_mount(tmp_path):
"""内核树运行时挂载:-v kernel:/bioflow-kernel:ro + -w /bioflow-kernel。"""
from app.services.bioflow.kernel import Kernel, KernelConfig, ImageMap
cfg = KernelConfig(host_uid=1234, kernel_dir=str(tmp_path / "kernel"),
container_kernel_dir="/bioflow-kernel",
data_root=str(tmp_path / "data"))
img = ImageMap(images={"gf": {"docker_tag": "bioflow-gf"}},
stages={"identify": {"image": "gf", "module": "cli.run_identify"}})
k = Kernel(img, cfg)
cmd = k.build_command("identify", tmp_path / "in", tmp_path / "ws")
assert f"{tmp_path}/kernel:/bioflow-kernel:ro" in cmd
assert "-w" in cmd and "/bioflow-kernel" in cmd
def test_build_command_cli_contract(tmp_path):
"""契约阶段:按 cli_contract 注入 -i/-o/-c;无契约阶段保持 --input-dir。"""
from app.services.bioflow.kernel import Kernel, KernelConfig, ImageMap
cfg = KernelConfig(host_uid=1234, data_root=str(tmp_path / "data"))
img = ImageMap(
images={"rnaseq": {"docker_tag": "bioflow-rnaseq"}},
stages={
"rnaseq_qc": {"image": "rnaseq", "module": "cli.run_rnaseq_qc",
"cli_contract": [["--input", "input_dir"],
["--config", "samples_yaml"],
["--output", "output_final"]]},
"rnaseq_align": {"image": "rnaseq", "module": "cli.run_rnaseq_align",
"cli_contract": [["--input", "input_dir"],
["--hisat2-index", "param:hisat2_index"],
["--output", "output_final"]]},
"plain": {"image": "rnaseq", "module": "cli.run_plain"},
},
)
k = Kernel(img, cfg)
# 有 samples.yaml 时注入 -c
(tmp_path / "in").mkdir(parents=True, exist_ok=True)
(tmp_path / "in" / "samples.yaml").write_text("samples: {}", encoding="utf-8")
cmd = k.build_command("rnaseq_qc", tmp_path / "in", tmp_path / "ws")
assert "--input" in cmd and "/job/input" in cmd
assert "--config" in cmd and "/job/input/samples.yaml" in cmd
assert "--output" in cmd and "/job/workspace/final" in cmd # rnaseq -o 语义对齐
assert "--input-dir" not in cmd # 契约阶段不再注入默认形态
# param: 有值注入、无值跳过
cmd2 = k.build_command("rnaseq_align", tmp_path / "in", tmp_path / "ws",
params={"hisat2_index": "/bioflow-data/hmm/hisat2/osa"})
assert "--hisat2-index" in cmd2 and "/bioflow-data/hmm/hisat2/osa" in cmd2
cmd3 = k.build_command("rnaseq_align", tmp_path / "in", tmp_path / "ws")
assert "--hisat2-index" not in cmd3
# 无契约阶段回归默认
cmd4 = k.build_command("plain", tmp_path / "in", tmp_path / "ws")
assert "--input-dir" in cmd4 and "--workspace" in cmd4
def test_build_command_wgs_contract(tmp_path):
"""wgs 系契约:--workspace/--job-id/--prefix + prev_manifest 路径注入。"""
from app.services.bioflow.kernel import Kernel, KernelConfig, ImageMap
cfg = KernelConfig(host_uid=1234, data_root=str(tmp_path / "data"))
img = ImageMap(
images={"wgs": {"docker_tag": "bioflow-wgs"}},
stages={
"wgs_qc": {"image": "wgs", "module": "cli.run_wgs_qc",
"cli_contract": [["--workspace", "workspace"],
["--job-id", "job_id"],
["--prefix", "prefix"],
["--wgs-samples", "param:wgs_samples"]]},
"wgs_align": {"image": "wgs", "module": "cli.run_wgs_align",
"cli_contract": [["--workspace", "workspace"],
["--job-id", "job_id"],
["--prefix", "prefix"],
["--qc-manifest", "prev_manifest:wgs_qc"],
["--index-manifest", "prev_manifest:wgs_index"],
["--gatk", "tool:gatk"]]},
},
)
k = Kernel(img, cfg)
# wgs_qc:公共三元组 + param 注入
cmd = k.build_command("wgs_qc", tmp_path / "in", tmp_path / "ws",
params={"job_id": "j1", "prefix": "rice",
"wgs_samples": "s1,s2"})
assert "--workspace" in cmd and "/job/workspace" in cmd
assert "--job-id" in cmd and "j1" in cmd
assert "--prefix" in cmd and "rice" in cmd
assert "--wgs-samples" in cmd and "s1,s2" in cmd
assert "--input-dir" not in cmd
# wgs_align:prev_manifest 注入(prefix 有值时)
cmd2 = k.build_command("wgs_align", tmp_path / "in", tmp_path / "ws",
params={"job_id": "j1", "prefix": "rice"})
assert "--qc-manifest" in cmd2
assert "/job/workspace/final/rice.wgs_qc.manifest.json" in cmd2
assert "/job/workspace/final/rice.wgs_index.manifest.json" in cmd2
assert "--gatk" in cmd2 and "gatk" in cmd2 # tool:<name> 注入裸命令
# prefix 缺失时 prev_manifest 跳过
cmd3 = k.build_command("wgs_align", tmp_path / "in", tmp_path / "ws",
params={"job_id": "j1"})
assert "--qc-manifest" not in cmd3
def test_build_command_params(kernel, tmp_path):
cmd = kernel.build_command("identify", tmp_path / "in", tmp_path / "ws",
{"prefix": "Ft.SPL", "required_pfams": "PF03110",
"cut_ga": True, "cpu": "8"})
assert "--prefix" in cmd and "Ft.SPL" in cmd
assert "--required-pfams" in cmd and "PF03110" in cmd
assert "--cut-ga" in cmd
assert "--cpus" in cmd and "8" in cmd # cpu 参数走 --cpus 而非 --cpu
def test_build_command_cpu_allowlist(kernel, tmp_path):
# 白名单外 cpu 回落默认(TC-BIO-API-057 的参数覆盖 + 安全回落)
cmd = kernel.build_command("identify", tmp_path / "in", tmp_path / "ws",
{"cpu": "999"})
assert cmd[cmd.index("--cpus") + 1] == kernel.cfg.default_cpus
def test_unknown_stage_rejected(kernel, tmp_path):
with pytest.raises(UnknownStageError):
kernel.build_command("fake_stage_xyz", tmp_path / "in", tmp_path / "ws")
def test_module_none_stage_rejected(kernel, tmp_path):
# synteny_plot_revise module=null(入口脚本缺失)→ 拒绝拼命令
with pytest.raises(UnknownStageError):
kernel.build_command("synteny_plot_revise", tmp_path / "in", tmp_path / "ws")
def test_arg_with_spaces_is_single_argv(kernel, tmp_path):
# TC-BIO-KERN-006:参数含空格 → 独立 argv 元素(list 传参无 shell 注入)
cmd = kernel.build_command("identify", tmp_path / "in", tmp_path / "ws",
{"prefix": "Ft SPL"})
idx = cmd.index("--prefix")
assert cmd[idx + 1] == "Ft SPL"
def test_tail_lines():
text = "\n".join(f"line{i}" for i in range(200))
tail = tail_lines(text, 80)
lines = tail.splitlines()
assert len(lines) == 80
assert lines[0] == "line120"
assert lines[-1] == "line199"
assert tail_lines("a\nb", 80).splitlines() == ["a", "b"]
def test_run_stage_mock(tmp_path, kernel):
"""kernel.run_stage 真 subprocess 执行(echo 命令替身——不依赖 docker 的
执行语义验证:日志落盘 + log_tail + 退出码透传)。"""
import shutil
echo = shutil.which("echo")
assert echo, "测试环境需 echo"
async def fake_executor(cmd):
# 直接以 subprocess 跑一个假命令(验证 kernel.run_stage 的管道路径)
pass
# 用真 kernel.run_stage 跑 echo(构造最小 KernelConfig 指向 echo 是困难的,
# 故此处验证日志落盘与返回结构用直接 subprocess 等价物)
import subprocess
log = tmp_path / "logs" / "identify.log"
log.parent.mkdir(parents=True)
proc = subprocess.run([echo, "hello", "world"], capture_output=True, text=True)
log.write_text(proc.stdout)
assert "hello world" in log.read_text()
assert proc.returncode == 0
# tail_lines 与返回结构约定
result = {"exit_code": proc.returncode, "timed_out": False,
"log_tail": tail_lines(proc.stdout, 80), "cmd": ["echo", "hello", "world"]}
assert result["exit_code"] == 0
assert "hello world" in result["log_tail"]