Files

389 lines
16 KiB
Python
Raw Permalink Normal View History

"""SDK 自测:清单校验 / 打包往返 / 装载策略 / 本地测试台(无需 worker)。
有设备的用例标 `@pytest.mark.device`(无 sophon.sail 时自动跳过):
uv run pytest # 静态用例
uv run pytest -m device # 设备上跑推理
"""
from __future__ import annotations
import json
import subprocess
import sys
import tempfile
from pathlib import Path
import pytest
from algpkg_sdk import (AlgoBuilder, AlgoError, AlgoPackage, AlgoPackageError, Manifest,
ManifestError, install_sdk_alias, sha256_file)
from algpkg_sdk import testkit
from algpkg_sdk.cli import main as cli_main
EXAMPLES = Path(__file__).resolve().parents[1] / "examples"
def _manifest(**overrides) -> dict:
payload = {
"manifest_format": 1, "id": "t.demo", "name": "测试包", "version": "1.0.0",
"api_version": 1, "kind": "detector", "entry": "Algorithm",
"code_package": "alg_t_demo",
"models": [{"name": "default", "file": "models/t.bmodel", "precision": "fp16",
"resolution": [640, 640], "batch": 1, "default": True}],
"params_schema": [{"key": "conf", "type": "float", "default": 0.25,
"min": 0.01, "max": 0.99, "label": "置信度"}],
"defaults": {"conf": 0.25},
}
payload.update(overrides)
return payload
# ---------------------------------------------------------------------- 清单
def test_manifest_required_fields() -> None:
Manifest.parse(_manifest())
for missing in ("id", "version", "models"):
bad = _manifest()
bad.pop(missing)
with pytest.raises(ManifestError):
Manifest.parse(bad)
def test_manifest_rejects_bad_values() -> None:
with pytest.raises(ManifestError): # id 形态
Manifest.parse(_manifest(id="Bad Id!"))
with pytest.raises(ManifestError): # 模型路径越界
Manifest.parse(_manifest(models=[{"name": "d", "file": "../x.bmodel"}]))
with pytest.raises(ManifestError): # api_version 过高
Manifest.parse(_manifest(api_version=99))
with pytest.raises(ManifestError): # params_schema 重复键
Manifest.parse(_manifest(params_schema=[
{"key": "conf", "type": "float"}, {"key": "conf", "type": "float"}]))
def test_manifest_default_model_pick() -> None:
manifest = Manifest.parse(_manifest(models=[
{"name": "fp16", "file": "models/a.bmodel"},
{"name": "int8", "file": "models/b.bmodel", "default": True}]))
assert manifest.default_model().name == "int8"
assert manifest.model("fp16").file == "models/a.bmodel"
with pytest.raises(ManifestError):
manifest.model("nope")
def test_manifest_check_params_semantics() -> None:
manifest = Manifest.parse(_manifest())
merged = manifest.check_params({"conf": 0.5})
assert merged["conf"] == 0.5
assert manifest.check_params({})["conf"] == 0.25 # 补默认
with pytest.raises(ManifestError): # 越界拒绝(不静默截断)
manifest.check_params({"conf": 2.0})
with pytest.raises(ManifestError): # 类型不符
manifest.check_params({"conf": "abc"})
def test_manifest_legacy_and_entry_helpers() -> None:
manifest = Manifest.parse(_manifest(entry="sub:Alg", legacy={"algorithm": "yolo"}))
assert manifest.entry_module == "alg_t_demo.sub"
assert manifest.entry_class == "Alg"
assert manifest.legacy["algorithm"] == "yolo"
# ---------------------------------------------------------------------- 打包往返
def _make_source(root: Path, *, model_bytes: bytes = b"FAKE-BMODEL" * 512) -> Path:
src = root / "t.demo"
(src / "alg_t_demo").mkdir(parents=True)
(src / "alg_t_demo" / "__init__.py").write_text(
"from video_algpkg_api import Algorithm as Base, AlgoResult\n"
"class Algorithm(Base):\n"
" def load(self):\n"
" self._data = self.ctx.model_bytes()\n"
" self.ctx.logger.info('loaded %s bytes', len(self._data))\n"
" def infer(self, frame):\n"
" return AlgoResult(boxes=None, value=len(self._data))\n",
encoding="utf-8",
)
(src / "models").mkdir()
(src / "models" / "t.bmodel").write_bytes(model_bytes)
(src / "assets").mkdir()
(src / "assets" / "labels.txt").write_text("person\ncar\n", encoding="utf-8")
(src / "manifest.json").write_text(json.dumps(_manifest()), encoding="utf-8")
return src
def test_build_roundtrip_and_bytes_fidelity() -> None:
install_sdk_alias()
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
src = _make_source(root)
out = root / "t.demo-1.0.0.mbank"
summary = AlgoBuilder(src, verbose=False).build(out)
assert out.is_file() and summary["algo_id"] == "t.demo"
assert summary["sha256"] == sha256_file(out)
pkg = AlgoPackage(out)
pkg.open()
manifest = pkg.require_manifest()
assert manifest.version == "1.0.0"
data, meta = pkg.read_model("default")
assert data == (src / "models" / "t.bmodel").read_bytes() # 包内模型与源文件逐字节相同
assert meta["precision"] == "fp16"
assert pkg.read_asset("assets/labels.txt").decode().splitlines() == ["person", "car"]
files = {item["path"] for item in pkg.list_files()}
assert {"manifest.json", "assets/labels.txt", "models/t.bmodel"} <= files
pkg.close()
def test_build_creates_version_snapshot_and_signature() -> None:
from sqlite_vfs.core import SQLiteVFS
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
src = _make_source(root)
out = root / "signed.mbank"
AlgoBuilder(src, verbose=False).build(out, sign_key="KEY-1", sign_key_id="ci")
vfs = SQLiteVFS(str(out), readonly=True, check_same_thread=False)
try:
assert [s["name"] for s in vfs.list_snapshots()] == ["1.0.0"]
report = vfs.verify_signature("KEY-1")
assert report["ok"] and report["key_id"] == "ci"
assert not vfs.verify_signature("KEY-WRONG")["ok"]
finally:
vfs.close()
def test_build_reports_missing_model_and_code() -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
src = _make_source(root)
(src / "models" / "t.bmodel").unlink()
with pytest.raises(Exception) as exc:
AlgoBuilder(src, verbose=False).build(root / "x.mbank")
assert "模型" in str(exc.value)
src2 = _make_source(root / "other")
import shutil
shutil.rmtree(src2 / "alg_t_demo")
with pytest.raises(Exception) as exc2:
AlgoBuilder(src2, verbose=False).build(root / "y.mbank")
assert "代码目录" in str(exc2.value)
def test_encrypt_removes_plain_source() -> None:
from sqlite_vfs.core import SQLiteVFS
key = "uA9mYq0T2wGZ0dX1vJ8kL3pR5sN7bC4eF6hK2mP0qT8="
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
src = _make_source(root)
out = root / "enc.mbank"
AlgoBuilder(src, key=key, verbose=False).build(out)
vfs = SQLiteVFS(str(out), readonly=True, check_same_thread=False)
try:
files = [e["path"] for e in vfs.all_entries() if not e["is_directory"]]
finally:
vfs.close()
assert not any(f.endswith(".py") for f in files) # 明文源码不得留在包里
# 不带 key 打开结构没问题;装载(import)会因缺密钥失败
pkg = AlgoPackage(out)
pkg.open()
with pytest.raises(AlgoPackageError) as exc:
pkg.mount()
assert "密钥" in str(exc.value)
pkg.close()
def test_bad_file_rejected() -> None:
with tempfile.TemporaryDirectory() as tmp:
bad = Path(tmp) / "bad.mbank"
bad.write_bytes(b"not a database at all")
with pytest.raises(AlgoPackageError):
AlgoPackage(bad).open()
# ---------------------------------------------------------------------- 多模型包
def _make_multi_model_source(root: Path) -> Path:
"""一个包带两份模型(检测 + 二次判定):代码按名字取第二份(多模型包的标准写法)。"""
src = root / "t.multi"
(src / "alg_t_multi").mkdir(parents=True)
(src / "alg_t_multi" / "__init__.py").write_text(
"from video_algpkg_api import Algorithm as Base, AlgoResult\n"
"class Algorithm(Base):\n"
" def load(self):\n"
" self._det = self.ctx.model_bytes() # 选中模型(默认检测器)\n"
" self._cls = self.ctx.model_bytes('fp_filter') # 按名字取第二份\n"
" def infer(self, frame):\n"
" return AlgoResult(boxes=None, value=len(self._det) + len(self._cls))\n",
encoding="utf-8",
)
(src / "models").mkdir()
(src / "models" / "det.bmodel").write_bytes(b"DET" * 256)
(src / "models" / "fp.bmodel").write_bytes(b"FPFILTER" * 128)
(src / "manifest.json").write_text(json.dumps(_manifest(
id="t.multi", name="多模型包", code_package="alg_t_multi",
models=[{"name": "detector", "file": "models/det.bmodel", "default": True},
{"name": "fp_filter", "file": "models/fp.bmodel", "precision": "int8"}])),
encoding="utf-8")
return src
def test_multi_model_package_context_access() -> None:
"""`ctx.model_bytes(name)` 按 manifest.models[] 取包内任意模型;不给名 = 选中模型(旧写法兼容)。"""
install_sdk_alias()
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
src = _make_multi_model_source(root)
out = root / "multi.mbank"
AlgoBuilder(src, verbose=False).build(out)
pkg = AlgoPackage(out)
pkg.open()
pkg.mount()
try:
instance = pkg.new_instance("detector", {})
ctx = instance.ctx
det = (src / "models" / "det.bmodel").read_bytes()
fp = (src / "models" / "fp.bmodel").read_bytes()
assert ctx.model_name == "detector"
assert ctx.model_bytes() == det # 选中模型(向后兼容)
assert ctx.model_bytes("fp_filter") == fp # 按 name 取第二份
assert ctx.model_bytes("models/fp.bmodel") == fp # 按 file 路径也认
assert [m["name"] for m in ctx.models] == ["detector", "fp_filter"]
assert ctx.model_spec("fp_filter")["precision"] == "int8"
assert ctx.model_spec()["name"] == "detector"
with pytest.raises(AlgoError) as exc:
ctx.model_bytes("nope")
assert "fp_filter" in str(exc.value) # 报错带可选清单
assert instance.infer(None).value == len(det) + len(fp) # 包内代码真读到两份不同字节
finally:
pkg.close()
# ---------------------------------------------------------------------- 契约名(video_algpkg_api)
def test_contract_module_importable_without_prior_registration() -> None:
"""`video_algpkg_api` 是随 SDK 分发的**真实模块**,不只是运行时注册的别名。
新解释器里**先** import 它也要成功(本地开发/IDE/类型检查的常见姿势),
且与 `algpkg_sdk.api` 是同一批对象(重导出,非副本)——否则 worker 侧
`isinstance(instance, Algorithm)` 会出现两个基类("本地过、设备挂")。
"""
script = (
"import video_algpkg_api as contract\n"
"assert contract.__file__.endswith('video_algpkg_api/__init__.py'), contract.__file__\n"
"import algpkg_sdk.api as api\n"
"for name in contract.__all__:\n"
" assert getattr(contract, name) is getattr(api, name), name\n"
"print('contract-ok')\n"
)
with tempfile.TemporaryDirectory() as tmp:
proc = subprocess.run([sys.executable, "-c", script], capture_output=True,
text=True, timeout=30, cwd=tmp)
assert proc.returncode == 0, proc.stderr
assert "contract-ok" in proc.stdout
def test_contract_alias_still_registered_to_api_module() -> None:
"""worker 侧注册语义不变:导入 `algpkg_sdk` 后,`video_algpkg_api` 解析到 api 模块本身。
(`install_sdk_alias` 用 setdefault:设备上 worker 先注册,真实模块被惰性遮蔽,
两条路径拿到的是同一批对象。)
"""
import video_algpkg_api as resolved
from algpkg_sdk import api
assert resolved is api
# ---------------------------------------------------------------------- CLI
def test_cli_init_build_inspect_check(tmp_path: Path) -> None:
src = tmp_path / "cli.demo"
assert cli_main(["init", str(src), "--id", "cli.demo", "--name", "CLI 示例"]) == 0
assert (src / "manifest.json").is_file()
code_pkg = next((src.glob("alg_cli_demo")), None)
assert code_pkg is not None and (code_pkg / "__init__.py").is_file()
model = tmp_path / "fake.bmodel"
model.write_bytes(b"FAKE" * 1024)
out = tmp_path / "cli.demo-1.0.0.mbank"
assert cli_main(["build", str(src), "--out", str(out), "--model", f"default={model}",
"--quiet"]) == 0
assert cli_main(["inspect", str(out), "--json"]) == 0
assert cli_main(["verify", str(out), "--json"]) == 0
assert cli_main(["list", str(tmp_path)]) == 0
def test_cli_check_reports_static_results(tmp_path: Path) -> None:
"""check 在无设备时也要给出"清单/结构 OK、试加载跳过"的诚实结论。"""
src = tmp_path / "chk.demo"
cli_main(["init", str(src), "--id", "chk.demo", "--name", "检查示例"])
model = tmp_path / "fake.bmodel"
model.write_bytes(b"FAKE" * 1024)
out = tmp_path / "chk.mbank"
cli_main(["build", str(src), "--out", str(out), "--model", f"default={model}", "--quiet"])
report = testkit.check(out)
names = {item["name"]: item for item in report["checks"]}
assert names["manifest 合法"]["ok"]
assert names["模型文件在包内"]["ok"]
if not testkit.has_device():
assert not names["代码挂载(试加载)"]["ok"] and "跳过" in names["代码挂载(试加载)"]["detail"]
def test_module_entry_point_help() -> None:
proc = subprocess.run([sys.executable, "-m", "algpkg_sdk.cli", "--help"],
capture_output=True, text=True, timeout=30)
assert proc.returncode == 0
assert "check" in proc.stdout and "run" in proc.stdout and "compare" in proc.stdout
# ---------------------------------------------------------------------- 示例包(真实结构)
def test_example_person_count_manifest() -> None:
example = EXAMPLES / "person-count"
manifest = Manifest.parse(json.loads((example / "manifest.json").read_text()))
assert manifest.id == "demo.person-count" and manifest.kind == "counter"
assert manifest.output["type"] == "counter"
assert (example / manifest.code_package).is_dir()
# 模型文件由打包时 --model 指定(示例目录不带 bmodel),这里只校验声明形态
assert manifest.default_model().file.startswith("models/")
# ---------------------------------------------------------------------- 设备用例
@pytest.mark.device
def test_check_deep_on_device() -> None:
"""设备:入口类可实例化(模型真加载)。
默认用**自包含**示例包(examples/person-count 打出来的包);内置包(依赖 worker 内部模块)
属"特权包",需额外传 `extra_path=[worker 工程目录]`。
"""
import os
pkg_path = os.environ.get("ALGPKG_DEVICE_PACKAGE")
if not pkg_path or not Path(pkg_path).is_file():
pytest.skip("未提供 ALGPKG_DEVICE_PACKAGE(指向含真实模型的 .mbank)")
extra = [p for p in (os.environ.get("ALGPKG_EXTRA_PATH") or "").split(":") if p]
report = testkit.check(pkg_path, deep=True, extra_path=extra)
assert report["ok"], report["checks"]
@pytest.mark.device
def test_run_on_device() -> None:
"""设备:跑一段视频,拿到逐帧检出与单帧耗时。"""
import os
pkg_path = os.environ.get("ALGPKG_DEVICE_PACKAGE")
clip = os.environ.get("ALGPKG_DEVICE_CLIP")
if not pkg_path or not clip:
pytest.skip("未提供 ALGPKG_DEVICE_PACKAGE / ALGPKG_DEVICE_CLIP")
extra = [p for p in (os.environ.get("ALGPKG_EXTRA_PATH") or "").split(":") if p]
report = testkit.run(pkg_path, source=clip, frames=3, warmup=1, extra_path=extra)
summary = report.summary()
assert summary["frames"] >= 1
assert summary["infer_ms"]["avg"] > 0