mirror of
https://github.com/baketnk/frame-yap.git
synced 2026-10-06 05:00:04 +02:00
feat(models): add pinned backend manifests and offline verification
This commit is contained in:
1 parent
07c03ea27e
commit
5cbbda3ec2
8 files changed
+589
-53
No files matched your search
@@ -0,0 +1,70 @@
|
||||
"""Manifest schema tests: adding a second backend requires no C++ worker changes."""
|
||||
import copy
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "python"))
|
||||
from frameyap.model_files import DEFAULT_MANIFEST_DIR, ManifestError, load_backends
|
||||
|
||||
|
||||
class BackendTests(unittest.TestCase):
|
||||
def test_redux_manifest_preserves_pinned_artifacts_and_launcher_contract(self):
|
||||
redux = load_backends()["redux"]
|
||||
self.assertEqual(redux.revision, "fad622f25f303105c20d70e201bcc477c88b620c")
|
||||
self.assertEqual({f.path: (f.size, f.sha256) for f in redux.files}, {
|
||||
"model.safetensors": (177774490, "78ec25733ee0d0c1586d1346fc86db9d0c2e436e3a8ab1d32a82d1bb8f848d21"),
|
||||
"config.json": (12988, "503c653b2e3bb788adbcb04f5abdee532d958686564081baeed133ff10143f6e"),
|
||||
"ternary.json": (57970, "1221c6d3ce901ffe09c089da758a8db8b76189f80cff41c5afc244fc61e2051d"),
|
||||
"tokenizer.json": (1159960, "bd321b096832a3f270bd3b2a88823957920f1a5c5ada71114a26ea729d0cbe91"),
|
||||
"README.md": (8533, "a8b327f983a8b8ff262ff7bead3a791fbed9350632002af8db85ab5cd84cdaa5"),
|
||||
})
|
||||
self.assertEqual(redux.launcher["protocol"], "frameyap-worker-v1")
|
||||
self.assertEqual(redux.launcher["arguments"], ["--model", "{model_dir}", "--threads", "{threads}", "--clip-dir", "{clip_dir}"])
|
||||
self.assertIn("CC-BY-4.0", redux.license_id)
|
||||
|
||||
def test_second_backend_and_strict_schema(self):
|
||||
fixture = json.loads((DEFAULT_MANIFEST_DIR / "redux.json").read_text())
|
||||
with tempfile.TemporaryDirectory() as path:
|
||||
root = Path(path)
|
||||
def save(value, name="other.json"):
|
||||
(root / name).write_text(json.dumps(value))
|
||||
fixture["id"] = "other"
|
||||
fixture["display_name"] = "Independent offline test backend"
|
||||
fixture["launcher"] = {"type": "executable", "path": "bin/other-worker",
|
||||
"arguments": ["--model", "{model_dir}", "--clip-dir", "{clip_dir}"],
|
||||
"protocol": "frameyap-worker-v1"}
|
||||
fixture["model"]["files"] = [{"path": "nested/model.bin", "size": 3, "sha256": "a" * 64}]
|
||||
save(fixture)
|
||||
self.assertEqual(load_backends(root)["other"].files[0].path, "nested/model.bin")
|
||||
(root / "redux.json").write_bytes((DEFAULT_MANIFEST_DIR / "redux.json").read_bytes())
|
||||
self.assertEqual(list(load_backends(root)), ["other", "redux"])
|
||||
for edit in (
|
||||
lambda f: f.update(schema=2),
|
||||
lambda f: f.update(unexpected="x"),
|
||||
lambda f: f["model"]["files"][0].update(path="../secret"),
|
||||
lambda f: f["model"]["files"][0].update(size=True),
|
||||
lambda f: f["model"]["files"][0].update(sha256="A" * 64),
|
||||
lambda f: f["launcher"]["arguments"].append("{unknown}"),
|
||||
lambda f: f["launcher"].update(path="/bin/sh"),
|
||||
lambda f: f["launcher"].update(protocol="not-the-wire-protocol"),
|
||||
):
|
||||
invalid = copy.deepcopy(fixture)
|
||||
edit(invalid)
|
||||
save(invalid)
|
||||
with self.subTest(invalid=invalid), self.assertRaises(ManifestError):
|
||||
load_backends(root)
|
||||
save(fixture)
|
||||
(root / "other.json").write_text('{"id":"other","id":"other"}')
|
||||
with self.assertRaises(ManifestError):
|
||||
load_backends(root)
|
||||
(root / "other.json").unlink()
|
||||
(root / "other.json").symlink_to(DEFAULT_MANIFEST_DIR / "redux.json")
|
||||
with self.assertRaises(ManifestError):
|
||||
load_backends(root)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Offline hash/status and CLI tests; fixtures are bytes, not models."""
|
||||
import contextlib
|
||||
from dataclasses import replace
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import io
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "python"))
|
||||
from frameyap.model_files import ModelFile, check_file, check_model, load_backends
|
||||
|
||||
CLI = ROOT / "scripts" / "model-status.py"
|
||||
|
||||
|
||||
class ModelFileTests(unittest.TestCase):
|
||||
def fixture(self, root):
|
||||
manifests = root / "manifests"
|
||||
manifests.mkdir()
|
||||
backend = json.loads((ROOT / "assets/backends/redux.json").read_text())
|
||||
backend["model"]["files"] = [{"path": "nested/weights.bin", "size": 7,
|
||||
"sha256": hashlib.sha256(b"fixture").hexdigest()}]
|
||||
(manifests / "redux.json").write_text(json.dumps(backend))
|
||||
return manifests, load_backends(manifests)["redux"]
|
||||
|
||||
def cli(self, manifests, *arguments):
|
||||
return subprocess.run([sys.executable, str(CLI), "--manifest-dir", str(manifests),
|
||||
"--json", *map(str, arguments)], capture_output=True, text=True, timeout=5)
|
||||
|
||||
def test_status_and_cli_from_same_verifier(self):
|
||||
with tempfile.TemporaryDirectory() as path:
|
||||
root = Path(path)
|
||||
manifests, backend = self.fixture(root)
|
||||
store = root / "models"
|
||||
model = store / "redux"
|
||||
result = self.cli(manifests, "--list-models", "--model-dir", store)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
listed = json.loads(result.stdout)
|
||||
self.assertEqual(list(listed), ["models", "schema"])
|
||||
self.assertEqual(listed["models"][0]["state"], "not_installed")
|
||||
self.assertEqual(listed["models"][0]["reason"], "directory_missing")
|
||||
self.assertEqual(listed["models"][0]["total_bytes"], 7)
|
||||
self.assertEqual(self.cli(manifests, "--check-model", "redux", "--model-dir", model).returncode, 1)
|
||||
(model / "nested").mkdir(parents=True)
|
||||
weight = model / "nested/weights.bin"
|
||||
weight.write_bytes(b"fixture")
|
||||
checked = self.cli(manifests, "--check-model", "redux", "--model-dir", model)
|
||||
self.assertEqual(checked.returncode, 0, checked.stderr)
|
||||
self.assertEqual(json.loads(checked.stdout), {"schema": 1, "model": check_model(backend, model)})
|
||||
self.assertEqual(check_file(model, backend.files[0]), (None, None))
|
||||
weight.write_bytes(b"altered")
|
||||
self.assertEqual(check_model(backend, model)["reason"], "hash_mismatch")
|
||||
self.assertEqual(json.loads(self.cli(manifests, "--check-model", "redux", "--model-dir", model).stdout)["model"]["state"], "invalid")
|
||||
weight.write_bytes(b"short")
|
||||
self.assertEqual(check_model(backend, model)["reason"], "size_mismatch")
|
||||
weight.unlink()
|
||||
self.assertEqual(check_model(backend, model)["state"], "not_installed")
|
||||
outside = root / "outside"
|
||||
outside.write_bytes(b"fixture")
|
||||
weight.symlink_to(outside)
|
||||
self.assertEqual(check_model(backend, model)["reason"], "unsafe_file")
|
||||
weight.unlink()
|
||||
weight.symlink_to(root / "absent")
|
||||
self.assertEqual(check_model(backend, model)["reason"], "unsafe_file")
|
||||
weight.unlink()
|
||||
(model / "nested").rmdir()
|
||||
(model / "nested").symlink_to(root)
|
||||
self.assertEqual(check_model(backend, model)["reason"], "unsafe_file")
|
||||
|
||||
def test_second_backend_status_without_runtime_changes(self):
|
||||
with tempfile.TemporaryDirectory() as path:
|
||||
root = Path(path)
|
||||
manifests, _ = self.fixture(root)
|
||||
independent = json.loads((manifests / "redux.json").read_text())
|
||||
independent["id"] = "independent"
|
||||
independent["display_name"] = "Independent backend"
|
||||
independent["launcher"] = {"type": "executable", "path": "bin/independent-worker",
|
||||
"arguments": ["--model", "{model_dir}", "--clip-dir", "{clip_dir}"],
|
||||
"protocol": "frameyap-worker-v1"}
|
||||
independent["model"]["files"] = [{"path": "alternate.bin", "size": 3,
|
||||
"sha256": hashlib.sha256(b"abc").hexdigest()}]
|
||||
(manifests / "independent.json").write_text(json.dumps(independent))
|
||||
model_dir = root / "store/independent"
|
||||
model_dir.mkdir(parents=True)
|
||||
(model_dir / "alternate.bin").write_bytes(b"abc")
|
||||
listed = self.cli(manifests, "--list-models", "--model-dir", root / "store")
|
||||
self.assertEqual(listed.returncode, 0, listed.stderr)
|
||||
models = json.loads(listed.stdout)["models"]
|
||||
self.assertEqual([(model["id"], model["state"]) for model in models],
|
||||
[("independent", "installed_verified"), ("redux", "not_installed")])
|
||||
self.assertEqual(self.cli(manifests, "--check-model", "independent", "--model-dir", model_dir).returncode, 0)
|
||||
|
||||
def test_explicit_fetch_tool_reuses_shared_verifier_offline(self):
|
||||
spec = importlib.util.spec_from_file_location("fetch_model", ROOT / "scripts/fetch-model.py")
|
||||
fetch = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(fetch)
|
||||
with tempfile.TemporaryDirectory() as path:
|
||||
root = Path(path)
|
||||
_, backend = self.fixture(root)
|
||||
backend = replace(backend, files=(ModelFile("weights.bin", 7, hashlib.sha256(b"fixture").hexdigest()),))
|
||||
model = root / "local-model"
|
||||
# The Redux-only tool uses this small fixture manifest here; all network
|
||||
# calls are mocked, never real downloads.
|
||||
with patch.object(fetch, "load_backends", return_value={"redux": backend}), \
|
||||
patch.object(fetch.urllib.request, "urlopen", return_value=io.BytesIO(b"fixture")) as urlopen, \
|
||||
patch.object(sys, "argv", ["fetch-model.py", "--destination", str(model)]):
|
||||
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
|
||||
fetch.main()
|
||||
urlopen.assert_called_once()
|
||||
self.assertEqual(check_model(backend, model)["state"], "installed_verified")
|
||||
fetch.main()
|
||||
urlopen.assert_called_once()
|
||||
(model / "weights.bin").write_bytes(b"changed")
|
||||
with self.assertRaises(SystemExit):
|
||||
fetch.main()
|
||||
urlopen.assert_called_once()
|
||||
|
||||
def test_missing_manifest_unknown_id_and_no_runtime_imports(self):
|
||||
with tempfile.TemporaryDirectory() as path:
|
||||
manifests, _ = self.fixture(Path(path))
|
||||
result = self.cli(manifests, "--check-model", "unknown", "--model-dir", path)
|
||||
self.assertEqual(result.returncode, 2)
|
||||
self.assertEqual(result.stdout, "")
|
||||
result = self.cli(Path(path) / "absent", "--list-models")
|
||||
self.assertEqual(result.returncode, 2)
|
||||
result = self.cli(manifests, "--list-models")
|
||||
self.assertEqual(result.returncode, 0)
|
||||
self.assertEqual(json.loads(result.stdout)["models"][0]["state"], "unknown")
|
||||
self.assertEqual(json.loads(result.stdout)["models"][0]["reason"], "model_dir_unspecified")
|
||||
# This subprocess never imports inference packages even if not installed.
|
||||
result = subprocess.run([sys.executable, "-c", "import sys; import frameyap.model_files; "
|
||||
"assert not {'moondream', 'torch', 'numpy'} & set(sys.modules)"],
|
||||
env={**os.environ, "PYTHONPATH": str(ROOT / "python")},
|
||||
capture_output=True, timeout=5)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -238,14 +238,18 @@ sys.exit(worker.main(sys.argv[1:]))
|
||||
|
||||
def test_pinned_model_hashes_and_symlinks(self):
|
||||
import hashlib
|
||||
from frameyap.model_files import Backend, ModelFile
|
||||
with tempfile.TemporaryDirectory() as path:
|
||||
root = Path(path)
|
||||
(root / "model.safetensors").write_bytes(b"fixture")
|
||||
digest = hashlib.sha256(b"fixture").hexdigest()
|
||||
with patch.object(worker, "FILES", {"model.safetensors": (7, digest)}):
|
||||
backend = Backend("redux", "Redux", {}, "https://example.test", "pinned",
|
||||
(ModelFile("model.safetensors", 7, digest),), "source",
|
||||
"CC-BY-4.0", "license", "cpu", "none")
|
||||
with patch.object(worker, "load_backends", return_value={"redux": backend}):
|
||||
self.assertEqual(worker.local_model(path), path)
|
||||
(root / "model.safetensors").write_bytes(b"changed")
|
||||
with self.assertRaisesRegex(ValueError, "pinned revision"):
|
||||
with self.assertRaisesRegex(ValueError, "hash_mismatch"):
|
||||
worker.local_model(path)
|
||||
(root / "model.safetensors").unlink()
|
||||
(root / "other").write_bytes(b"fixture")
|
||||
|
||||
Reference in new issue
Block a user