882 lines
34 KiB
Python
882 lines
34 KiB
Python
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import io
|
|
import json
|
|
import subprocess
|
|
import sys
|
|
import tarfile
|
|
import types
|
|
import zipfile
|
|
from pathlib import Path
|
|
|
|
import imagefind.runtime as runtime_module
|
|
import pytest
|
|
from imagefind.config import Settings
|
|
from imagefind.runtime import AIDependencyManager, RuntimeToolManager
|
|
|
|
|
|
def _settings(tmp_path: Path, requirements: Path | None = None) -> Settings:
|
|
if requirements is not None:
|
|
requirements.mkdir(parents=True, exist_ok=True)
|
|
(requirements / "constraints-cp312.txt").write_text("# test constraints\n")
|
|
for component in ("visual", "ocr", "faces", "audio"):
|
|
(requirements / f"{component}.txt").touch(exist_ok=True)
|
|
settings = Settings(data_dir=tmp_path / "data", runtime_requirements_dir=requirements)
|
|
settings.prepare()
|
|
return settings
|
|
|
|
|
|
def test_ai_dependency_failure_preserves_previous_layer_and_redacts_proxy_password(tmp_path: Path, monkeypatch):
|
|
requirements = tmp_path / "requirements"
|
|
requirements.mkdir()
|
|
(requirements / "audio.txt").write_text("torch==test\n")
|
|
settings = _settings(tmp_path, requirements)
|
|
current = settings.ai_site_path
|
|
current.mkdir()
|
|
(current / "sentinel").write_text("previous")
|
|
(current / ".imagefind-runtime.json").write_text(
|
|
json.dumps({"lock_version": "old", "components": ["visual"]})
|
|
)
|
|
manager = AIDependencyManager(
|
|
settings,
|
|
lambda **_: {
|
|
"enabled": True,
|
|
"url": "http://proxy.invalid:8080",
|
|
"username": "imagefind",
|
|
"password": "top/secret?",
|
|
},
|
|
)
|
|
|
|
def failed_install(*_args, **_kwargs):
|
|
return subprocess.CompletedProcess([], 1, "", "proxy authentication failed: top%2Fsecret%3F")
|
|
|
|
monkeypatch.setattr(runtime_module.subprocess, "run", failed_install)
|
|
with pytest.raises(RuntimeError) as error:
|
|
manager.ensure("audio")
|
|
|
|
assert "top/secret?" not in str(error.value)
|
|
assert "top%2Fsecret%3F" not in str(error.value)
|
|
assert "***" in str(error.value)
|
|
assert (current / "sentinel").read_text() == "previous"
|
|
assert manager.status()["visual"]["state"] == "missing"
|
|
assert manager.status()["audio"]["state"] == "error"
|
|
assert "***" in manager.status()["audio"]["error"]
|
|
|
|
|
|
def test_ai_runtime_uses_configured_pip_sources_and_obeys_proxy_switch(tmp_path: Path, monkeypatch):
|
|
requirements = tmp_path / "requirements"
|
|
requirements.mkdir()
|
|
(requirements / "visual.txt").write_text("torch==test\n")
|
|
settings = _settings(tmp_path, requirements)
|
|
settings.pip_index_url = "https://pypi.tuna.example/simple"
|
|
settings.pytorch_index_url = "https://torch.example/cpu"
|
|
proxy = {
|
|
"enabled": False,
|
|
"url": "http://proxy.example:8080",
|
|
"username": "",
|
|
"password": "",
|
|
}
|
|
manager = AIDependencyManager(settings, lambda **_: proxy)
|
|
environments: list[dict[str, str]] = []
|
|
|
|
def successful_install(command, **kwargs):
|
|
environments.append(kwargs["env"])
|
|
return subprocess.CompletedProcess(command, 0, "", "")
|
|
|
|
monkeypatch.setenv("HTTPS_PROXY", "http://inherited-proxy.invalid:9999")
|
|
monkeypatch.setattr(runtime_module.subprocess, "run", successful_install)
|
|
monkeypatch.setattr(manager, "_validate", lambda *_args: None)
|
|
manager.ensure("visual")
|
|
|
|
assert environments[0]["PIP_INDEX_URL"] == "https://pypi.tuna.example/simple"
|
|
assert environments[0]["PIP_EXTRA_INDEX_URL"] == "https://torch.example/cpu"
|
|
assert "HTTPS_PROXY" not in environments[0]
|
|
assert environments[0]["PIP_DEFAULT_TIMEOUT"] == "60"
|
|
assert environments[0]["PIP_RETRIES"] == "3"
|
|
|
|
|
|
def test_ai_runtime_injects_enabled_proxy_without_logging_password(tmp_path: Path, monkeypatch):
|
|
requirements = tmp_path / "requirements"
|
|
requirements.mkdir()
|
|
(requirements / "audio.txt").write_text("torch==test\n")
|
|
settings = _settings(tmp_path, requirements)
|
|
manager = AIDependencyManager(
|
|
settings,
|
|
lambda **_: {
|
|
"enabled": True,
|
|
"url": "http://proxy.example:8080",
|
|
"username": "runtime-user",
|
|
"password": "runtime/password",
|
|
},
|
|
)
|
|
environments: list[dict[str, str]] = []
|
|
|
|
def successful_install(command, **kwargs):
|
|
environments.append(kwargs["env"])
|
|
return subprocess.CompletedProcess(command, 0, "", "")
|
|
|
|
monkeypatch.setattr(runtime_module.subprocess, "run", successful_install)
|
|
monkeypatch.setattr(manager, "_validate", lambda *_args: None)
|
|
manager.ensure("audio")
|
|
|
|
assert environments[0]["HTTPS_PROXY"] == (
|
|
"http://runtime-user:runtime%2Fpassword@proxy.example:8080"
|
|
)
|
|
|
|
|
|
def test_ai_runtime_stamp_binds_python_abi_and_lock_digests(tmp_path: Path, monkeypatch):
|
|
requirements = tmp_path / "requirements"
|
|
settings = _settings(tmp_path, requirements)
|
|
(requirements / "visual.txt").write_text("openvino==test\n")
|
|
manager = AIDependencyManager(settings)
|
|
|
|
monkeypatch.setattr(
|
|
runtime_module.subprocess,
|
|
"run",
|
|
lambda command, **_kwargs: subprocess.CompletedProcess(command, 0, "", ""),
|
|
)
|
|
monkeypatch.setattr(manager, "_validate", lambda *_args: None)
|
|
manager.ensure("visual")
|
|
|
|
stamp = json.loads(manager.stamp_path.read_text())
|
|
record = stamp["components"]["visual"]
|
|
assert stamp["schema"] == runtime_module.AI_RUNTIME_SCHEMA
|
|
assert record["python_abi"] == manager._python_abi()
|
|
assert len(record["requirements_sha256"]) == 64
|
|
assert len(record["constraints_sha256"]) == 64
|
|
assert manager.status()["visual"]["state"] == "ready"
|
|
|
|
(requirements / "constraints-cp312.txt").write_text("# comment-only release change\n")
|
|
assert manager.status()["visual"]["state"] == "ready"
|
|
|
|
(requirements / "constraints-cp312.txt").write_text("openvino==changed\n")
|
|
assert manager.status()["visual"]["state"] == "missing"
|
|
|
|
|
|
def test_schema_two_component_stamps_from_0319_all_remain_ready(tmp_path: Path, monkeypatch):
|
|
requirements = tmp_path / "requirements"
|
|
settings = _settings(tmp_path, requirements)
|
|
project_requirements = Path(__file__).resolve().parents[1] / "requirements" / "runtime-ai"
|
|
current_constraints = (project_requirements / "constraints-cp312.txt").read_text()
|
|
_, remainder = current_constraints.split("\n", 1)
|
|
(requirements / "constraints-cp312.txt").write_text(
|
|
"# ImageFind 0.3.19 AI runtime lock for fnOS Python 3.12.\n" + remainder
|
|
)
|
|
for component in runtime_module.AI_IMPORTS:
|
|
(requirements / f"{component}.txt").write_text(
|
|
(project_requirements / f"{component}.txt").read_text()
|
|
)
|
|
manager = AIDependencyManager(settings)
|
|
constraints_digest = runtime_module._sha256(requirements / "constraints-cp312.txt")
|
|
records = {}
|
|
for component in runtime_module.AI_IMPORTS:
|
|
requirement_digest = runtime_module._sha256(requirements / f"{component}.txt")
|
|
combined = hashlib.sha256()
|
|
combined.update(manager._python_abi().encode())
|
|
combined.update(requirement_digest.encode())
|
|
combined.update(constraints_digest.encode())
|
|
for value in runtime_module.AI_NO_DEPENDENCIES.get(component, ()):
|
|
combined.update(value.encode())
|
|
records[component] = {
|
|
"python_abi": manager._python_abi(),
|
|
"requirements_sha256": requirement_digest,
|
|
"constraints_sha256": constraints_digest,
|
|
"digest": combined.hexdigest(),
|
|
"runtime_version": "0.3.19",
|
|
}
|
|
manager.current.mkdir(parents=True, exist_ok=True)
|
|
manager.stamp_path.write_text(
|
|
json.dumps(
|
|
{
|
|
"schema": 2,
|
|
"runtime_version": "0.3.19",
|
|
"components": records,
|
|
}
|
|
)
|
|
)
|
|
monkeypatch.setattr(
|
|
runtime_module.subprocess,
|
|
"run",
|
|
lambda *_args, **_kwargs: pytest.fail("compatible runtime must not invoke pip"),
|
|
)
|
|
|
|
assert {name: value["state"] for name, value in manager.status().items()} == {
|
|
component: "ready" for component in runtime_module.AI_IMPORTS
|
|
}
|
|
for component in runtime_module.AI_IMPORTS:
|
|
manager.ensure(component)
|
|
|
|
|
|
def test_vad_constraint_upgrade_only_invalidates_audio_runtime(tmp_path: Path):
|
|
requirements = tmp_path / "requirements"
|
|
settings = _settings(tmp_path, requirements)
|
|
project_requirements = Path(__file__).resolve().parents[1] / "requirements" / "runtime-ai"
|
|
constraints = runtime_module._requirements_lines(project_requirements / "constraints-cp312.txt")
|
|
old_constraints = tuple(line for line in constraints if line != "webrtcvad-wheels==2.0.14")
|
|
(requirements / "constraints-cp312.txt").write_text("\n".join(constraints) + "\n")
|
|
for component in runtime_module.AI_IMPORTS:
|
|
(requirements / f"{component}.txt").write_text(
|
|
(project_requirements / f"{component}.txt").read_text()
|
|
)
|
|
manager = AIDependencyManager(settings)
|
|
prior_constraints_digest = hashlib.sha256(("\n".join(old_constraints) + "\n").encode()).hexdigest()
|
|
records = {}
|
|
for component in runtime_module.AI_IMPORTS:
|
|
component_lines = runtime_module._requirements_lines(requirements / f"{component}.txt")
|
|
if component == "audio":
|
|
component_lines = tuple(line for line in component_lines if line != "webrtcvad-wheels==2.0.14")
|
|
requirement_digest = hashlib.sha256(("\n".join(component_lines) + "\n").encode()).hexdigest()
|
|
combined = hashlib.sha256()
|
|
combined.update(manager._python_abi().encode())
|
|
combined.update(requirement_digest.encode())
|
|
combined.update(prior_constraints_digest.encode())
|
|
for value in runtime_module.AI_NO_DEPENDENCIES.get(component, ()):
|
|
combined.update(value.encode())
|
|
records[component] = {
|
|
"digest_format": runtime_module.AI_RUNTIME_DIGEST_FORMAT,
|
|
"python_abi": manager._python_abi(),
|
|
"requirements_sha256": requirement_digest,
|
|
"constraints_sha256": prior_constraints_digest,
|
|
"digest": combined.hexdigest(),
|
|
}
|
|
manager.current.mkdir(parents=True, exist_ok=True)
|
|
manager.stamp_path.write_text(json.dumps({"schema": 3, "components": records}))
|
|
|
|
states = {name: value["state"] for name, value in manager.status().items()}
|
|
|
|
assert states == {"visual": "ready", "ocr": "ready", "faces": "ready", "audio": "missing"}
|
|
|
|
|
|
def test_runtime_tools_prefer_system_binary(tmp_path: Path, monkeypatch):
|
|
settings = _settings(tmp_path)
|
|
manager = RuntimeToolManager(settings)
|
|
monkeypatch.setattr(runtime_module.shutil, "which", lambda name: "/system/bin/ffmpeg" if name == "ffmpeg" else None)
|
|
monkeypatch.setattr(manager, "_works", lambda path, name: path == "/system/bin/ffmpeg" and name == "ffmpeg")
|
|
monkeypatch.setattr(manager, "_install_release", lambda _name: pytest.fail("private fallback should not download"))
|
|
|
|
assert manager.ffmpeg() == "/system/bin/ffmpeg"
|
|
|
|
|
|
def test_bundled_ai_runtime_is_ready_and_never_invokes_pip(tmp_path: Path, monkeypatch):
|
|
settings = _settings(tmp_path)
|
|
settings.bundled_ai_runtime = True
|
|
manager = AIDependencyManager(settings)
|
|
imported: list[str] = []
|
|
monkeypatch.setattr(runtime_module.importlib, "import_module", lambda name: imported.append(name))
|
|
monkeypatch.setattr(runtime_module.subprocess, "run", lambda *_args, **_kwargs: pytest.fail("pip must not run"))
|
|
|
|
manager.ensure("audio")
|
|
|
|
assert imported == list(runtime_module.AI_IMPORTS["audio"])
|
|
assert manager.status()["audio"]["source"] == "bundled"
|
|
assert manager.environment_status()["components"] == list(runtime_module.AI_IMPORTS)
|
|
|
|
|
|
def test_runtime_tools_use_bundled_fallback_without_downloading(tmp_path: Path, monkeypatch):
|
|
settings = _settings(tmp_path)
|
|
settings.bundled_tools_dir = tmp_path / "app-bin"
|
|
settings.bundled_tools_dir.mkdir()
|
|
bundled = settings.bundled_tools_dir / "ffmpeg"
|
|
bundled.write_bytes(b"bundled")
|
|
manager = RuntimeToolManager(settings)
|
|
monkeypatch.setattr(runtime_module.shutil, "which", lambda _name: None)
|
|
monkeypatch.setattr(manager, "_works", lambda path, name: path == str(bundled) and name == "ffmpeg")
|
|
monkeypatch.setattr(manager, "_install_release", lambda _name: pytest.fail("fallback should not download"))
|
|
|
|
assert manager.ffmpeg() == str(bundled)
|
|
|
|
|
|
def test_ocr_runtime_uses_headless_opencv_without_desktop_dependency(tmp_path: Path, monkeypatch):
|
|
requirements = tmp_path / "requirements"
|
|
requirements.mkdir()
|
|
(requirements / "ocr.txt").write_text("opencv-python-headless==4.13.0.92\n")
|
|
settings = _settings(tmp_path, requirements)
|
|
manager = AIDependencyManager(settings)
|
|
commands: list[list[str]] = []
|
|
|
|
def successful_install(command, **_kwargs):
|
|
commands.append(command)
|
|
return subprocess.CompletedProcess(command, 0, "", "")
|
|
|
|
monkeypatch.setattr(runtime_module.subprocess, "run", successful_install)
|
|
monkeypatch.setattr(manager, "_validate", lambda *_args: None)
|
|
manager.ensure("ocr")
|
|
|
|
assert "--requirement" in commands[0]
|
|
assert "opencv-python-headless==4.13.0.92" in (requirements / "ocr.txt").read_text()
|
|
assert "opencv-python==" not in (requirements / "ocr.txt").read_text()
|
|
assert "--no-deps" in commands[1]
|
|
assert commands[1][-1] == "rapidocr-onnxruntime==1.4.4"
|
|
|
|
|
|
def test_installing_one_missing_component_preserves_and_reuses_compatible_runtime(
|
|
tmp_path: Path, monkeypatch
|
|
):
|
|
requirements = tmp_path / "requirements"
|
|
settings = _settings(tmp_path, requirements)
|
|
project_requirements = Path(__file__).resolve().parents[1] / "requirements" / "runtime-ai"
|
|
for component in runtime_module.AI_IMPORTS:
|
|
(requirements / f"{component}.txt").write_text(
|
|
(project_requirements / f"{component}.txt").read_text()
|
|
)
|
|
manager = AIDependencyManager(settings)
|
|
commands: list[list[str]] = []
|
|
|
|
def successful_install(command, **_kwargs):
|
|
commands.append(command)
|
|
return subprocess.CompletedProcess(command, 0, "", "")
|
|
|
|
monkeypatch.setattr(runtime_module.subprocess, "run", successful_install)
|
|
monkeypatch.setattr(manager, "_validate", lambda *_args: None)
|
|
manager.ensure("visual")
|
|
(manager.current / "preserved-package").write_text("keep")
|
|
|
|
assert manager.status()["visual"]["state"] == "ready"
|
|
assert manager.status()["audio"]["state"] == "missing"
|
|
assert manager.status()["ocr"]["state"] == "missing"
|
|
assert manager.status()["faces"]["state"] == "missing"
|
|
|
|
commands.clear()
|
|
manager.ensure("ocr")
|
|
|
|
assert len(commands) == 2
|
|
assert commands[0][-1] == str(requirements / "ocr.txt")
|
|
assert commands[1][-1] == "rapidocr-onnxruntime==1.4.4"
|
|
assert all(str(requirements / "visual.txt") not in command for command in commands)
|
|
assert (manager.current / "preserved-package").read_text() == "keep"
|
|
assert {name: value["state"] for name, value in manager.status().items()} == {
|
|
"visual": "ready",
|
|
"ocr": "ready",
|
|
"faces": "ready",
|
|
"audio": "missing",
|
|
}
|
|
|
|
monkeypatch.setattr(
|
|
runtime_module.subprocess,
|
|
"run",
|
|
lambda *_args, **_kwargs: pytest.fail("covered components must not invoke pip"),
|
|
)
|
|
manager.ensure("faces")
|
|
|
|
commands.clear()
|
|
monkeypatch.setattr(runtime_module.subprocess, "run", successful_install)
|
|
manager.ensure("audio")
|
|
assert len(commands) == 1
|
|
assert commands[0][-1] == str(requirements / "audio.txt")
|
|
|
|
|
|
def test_preserved_models_trigger_runtime_repair_and_accelerator_refresh(tmp_path: Path, monkeypatch):
|
|
from imagefind.models import ModelManager
|
|
|
|
requirements = tmp_path / "requirements"
|
|
requirements.mkdir()
|
|
settings = _settings(tmp_path, requirements)
|
|
|
|
class Accelerator:
|
|
def __init__(self):
|
|
self.refreshes = 0
|
|
|
|
def refresh(self):
|
|
self.refreshes += 1
|
|
|
|
accelerator = Accelerator()
|
|
embeddings = types.SimpleNamespace(accelerator=accelerator)
|
|
manager = ModelManager(
|
|
settings,
|
|
embeddings,
|
|
types.SimpleNamespace(),
|
|
types.SimpleNamespace(),
|
|
)
|
|
manager.component_versions = lambda: {
|
|
"visual": "visual-v1",
|
|
"ocr": "ocr-v1",
|
|
"faces": None,
|
|
"audio": None,
|
|
}
|
|
manager.runtime_dependencies.status = lambda: {
|
|
"visual": {"state": "missing"},
|
|
"ocr": {"state": "ready"},
|
|
"faces": {"state": "missing"},
|
|
"audio": {"state": "missing"},
|
|
}
|
|
|
|
assert accelerator.refreshes == 1
|
|
assert manager.missing_runtime_components() == ["visual"]
|
|
|
|
ensured = []
|
|
monkeypatch.setattr(
|
|
manager.runtime_dependencies,
|
|
"ensure",
|
|
lambda component, progress=None: ensured.append((component, progress)),
|
|
)
|
|
manager.ensure_runtime("visual")
|
|
|
|
assert ensured == [("visual", None)]
|
|
assert accelerator.refreshes == 2
|
|
|
|
|
|
def test_runtime_repair_resets_stale_component_fallback(tmp_path: Path, monkeypatch):
|
|
from imagefind.models import ModelManager
|
|
|
|
requirements = tmp_path / "requirements"
|
|
requirements.mkdir()
|
|
settings = _settings(tmp_path, requirements)
|
|
|
|
class Accelerator:
|
|
def __init__(self):
|
|
self.refreshes = 0
|
|
|
|
def refresh(self):
|
|
self.refreshes += 1
|
|
|
|
resets: list[str] = []
|
|
accelerator = Accelerator()
|
|
embeddings = types.SimpleNamespace(accelerator=accelerator)
|
|
ocr = types.SimpleNamespace(reset=lambda: resets.append("ocr"))
|
|
manager = ModelManager(settings, embeddings, ocr, types.SimpleNamespace())
|
|
monkeypatch.setattr(manager.runtime_dependencies, "ensure", lambda *_args, **_kwargs: None)
|
|
|
|
manager.ensure_runtime("ocr")
|
|
|
|
assert resets == ["ocr"]
|
|
assert accelerator.refreshes == 2
|
|
|
|
|
|
def test_unavailable_accelerator_makes_installed_component_repairable(tmp_path: Path):
|
|
from imagefind.models import ModelManager
|
|
|
|
settings = _settings(tmp_path)
|
|
accelerator = types.SimpleNamespace(
|
|
refresh=lambda: None,
|
|
status=lambda: {"components": {"visual": {"state": "unavailable"}}},
|
|
)
|
|
manager = ModelManager(
|
|
settings,
|
|
types.SimpleNamespace(accelerator=accelerator),
|
|
types.SimpleNamespace(),
|
|
types.SimpleNamespace(),
|
|
)
|
|
versions = {"visual": "visual-v1", "ocr": None, "faces": None, "audio": None}
|
|
runtime = {name: {"state": "ready"} for name in versions}
|
|
health = {name: {"state": "ready", "error": None} for name in versions}
|
|
|
|
assert manager.operational_components(versions, runtime, health)["visual"] is False
|
|
|
|
|
|
def test_visual_image_export_uses_clip_openvino_task_instead_of_generic_text_backend(
|
|
tmp_path: Path, monkeypatch
|
|
):
|
|
from imagefind.models import ModelManager
|
|
|
|
settings = _settings(tmp_path)
|
|
source = tmp_path / "clip-image"
|
|
source.mkdir()
|
|
(source / "modules.json").write_text(
|
|
'[{"idx":0,"path":"0_CLIPModel","type":"sentence_transformers.models.CLIPModel"}]'
|
|
)
|
|
transformer_source = source / "0_CLIPModel"
|
|
transformer_source.mkdir()
|
|
(transformer_source / "config.json").write_text("{}")
|
|
destination = tmp_path / "exported-image"
|
|
calls = {}
|
|
|
|
class ExportConfig:
|
|
pass
|
|
|
|
def main_export(**kwargs):
|
|
calls["export"] = kwargs
|
|
path = kwargs["output"]
|
|
path.mkdir(parents=True)
|
|
(path / "openvino_model.xml").write_text("<xml/>")
|
|
(path / "openvino_model.bin").write_bytes(b"model")
|
|
|
|
optimum = types.ModuleType("optimum")
|
|
optimum_exporters = types.ModuleType("optimum.exporters")
|
|
optimum_exporters_openvino = types.ModuleType("optimum.exporters.openvino")
|
|
optimum_exporters_openvino.main_export = main_export
|
|
optimum_intel = types.ModuleType("optimum.intel")
|
|
optimum_openvino = types.ModuleType("optimum.intel.openvino")
|
|
optimum_configuration = types.ModuleType("optimum.intel.openvino.configuration")
|
|
optimum_configuration.OVConfig = ExportConfig
|
|
for name, module in {
|
|
"optimum": optimum,
|
|
"optimum.exporters": optimum_exporters,
|
|
"optimum.exporters.openvino": optimum_exporters_openvino,
|
|
"optimum.intel": optimum_intel,
|
|
"optimum.intel.openvino": optimum_openvino,
|
|
"optimum.intel.openvino.configuration": optimum_configuration,
|
|
}.items():
|
|
monkeypatch.setitem(sys.modules, name, module)
|
|
|
|
class Accelerator:
|
|
@staticmethod
|
|
def refresh():
|
|
pass
|
|
|
|
@staticmethod
|
|
def ov_config(device):
|
|
assert device == "CPU"
|
|
return {"INFERENCE_NUM_THREADS": 2}
|
|
|
|
embeddings = types.SimpleNamespace(accelerator=Accelerator())
|
|
manager = ModelManager(settings, embeddings, types.SimpleNamespace(), types.SimpleNamespace())
|
|
manager._export_visual_image_openvino(source, destination)
|
|
|
|
assert calls["export"] == {
|
|
"model_name_or_path": str(transformer_source.resolve()),
|
|
"output": destination / "openvino",
|
|
"task": "zero-shot-image-classification",
|
|
"library_name": "transformers",
|
|
"local_files_only": True,
|
|
"ov_config": calls["export"]["ov_config"],
|
|
}
|
|
assert isinstance(calls["export"]["ov_config"], ExportConfig)
|
|
assert (destination / "modules.json").is_file()
|
|
assert (destination / "0_CLIPModel" / "config.json").is_file()
|
|
assert (destination / "openvino" / "openvino_model.xml").is_file()
|
|
|
|
|
|
def test_visual_text_export_uses_transformer_module_and_explicit_library(
|
|
tmp_path: Path, monkeypatch
|
|
):
|
|
from imagefind.models import ModelManager
|
|
|
|
settings = _settings(tmp_path)
|
|
source = tmp_path / "clip-text"
|
|
source.mkdir()
|
|
(source / "modules.json").write_text(
|
|
'[{"idx":0,"path":"0_Transformer","type":"sentence_transformers.models.Transformer"}]'
|
|
)
|
|
transformer_source = source / "0_Transformer"
|
|
transformer_source.mkdir()
|
|
(transformer_source / "config.json").write_text("{}")
|
|
destination = tmp_path / "exported-text"
|
|
calls = {}
|
|
|
|
class ExportConfig:
|
|
pass
|
|
|
|
def main_export(**kwargs):
|
|
calls["export"] = kwargs
|
|
path = kwargs["output"]
|
|
path.mkdir(parents=True)
|
|
(path / "openvino_model.xml").write_text("<xml/>")
|
|
(path / "openvino_model.bin").write_bytes(b"model")
|
|
|
|
optimum = types.ModuleType("optimum")
|
|
optimum_exporters = types.ModuleType("optimum.exporters")
|
|
optimum_exporters_openvino = types.ModuleType("optimum.exporters.openvino")
|
|
optimum_exporters_openvino.main_export = main_export
|
|
optimum_intel = types.ModuleType("optimum.intel")
|
|
optimum_openvino = types.ModuleType("optimum.intel.openvino")
|
|
optimum_configuration = types.ModuleType("optimum.intel.openvino.configuration")
|
|
optimum_configuration.OVConfig = ExportConfig
|
|
for name, module in {
|
|
"optimum": optimum,
|
|
"optimum.exporters": optimum_exporters,
|
|
"optimum.exporters.openvino": optimum_exporters_openvino,
|
|
"optimum.intel": optimum_intel,
|
|
"optimum.intel.openvino": optimum_openvino,
|
|
"optimum.intel.openvino.configuration": optimum_configuration,
|
|
}.items():
|
|
monkeypatch.setitem(sys.modules, name, module)
|
|
|
|
embeddings = types.SimpleNamespace(
|
|
accelerator=types.SimpleNamespace(refresh=lambda: None)
|
|
)
|
|
manager = ModelManager(settings, embeddings, types.SimpleNamespace(), types.SimpleNamespace())
|
|
manager._export_visual_text_openvino(source, destination)
|
|
|
|
assert calls["export"] == {
|
|
"model_name_or_path": str(transformer_source.resolve()),
|
|
"output": destination / "0_Transformer" / "openvino",
|
|
"task": "feature-extraction",
|
|
"library_name": "transformers",
|
|
"local_files_only": True,
|
|
"ov_config": calls["export"]["ov_config"],
|
|
}
|
|
assert isinstance(calls["export"]["ov_config"], ExportConfig)
|
|
assert (destination / "modules.json").is_file()
|
|
assert (destination / "0_Transformer" / "config.json").is_file()
|
|
assert (destination / "0_Transformer" / "openvino" / "openvino_model.xml").is_file()
|
|
|
|
|
|
def test_ai_and_model_sizes_are_cached_for_status_polling(tmp_path: Path, monkeypatch):
|
|
from imagefind.models import ModelManager
|
|
|
|
requirements = tmp_path / "requirements"
|
|
settings = _settings(tmp_path, requirements)
|
|
runtime = AIDependencyManager(settings)
|
|
runtime_calls = []
|
|
monkeypatch.setattr(
|
|
runtime_module,
|
|
"_directory_size",
|
|
lambda path: runtime_calls.append(path) or 123,
|
|
)
|
|
|
|
assert runtime._current_size() == 123
|
|
assert runtime._current_size() == 123
|
|
assert runtime_calls == [runtime.current]
|
|
|
|
embeddings = types.SimpleNamespace(
|
|
accelerator=types.SimpleNamespace(refresh=lambda: None)
|
|
)
|
|
manager = ModelManager(settings, embeddings, types.SimpleNamespace(), types.SimpleNamespace())
|
|
model_calls = []
|
|
monkeypatch.setattr(
|
|
manager,
|
|
"_directory_size",
|
|
lambda path: model_calls.append(path) or 456,
|
|
)
|
|
|
|
assert manager._component_sizes() == {
|
|
"visual": 456,
|
|
"ocr": 456,
|
|
"faces": 456,
|
|
"audio": 456,
|
|
}
|
|
assert manager._component_sizes()["visual"] == 456
|
|
assert len(model_calls) == 4
|
|
|
|
|
|
def test_preserved_model_files_queue_runtime_repair_after_upgrade(tmp_path: Path):
|
|
from imagefind.main import create_app
|
|
|
|
requirements = tmp_path / "requirements"
|
|
requirements.mkdir()
|
|
settings = _settings(tmp_path, requirements)
|
|
(settings.models_dir / "visual" / "image").mkdir(parents=True)
|
|
(settings.models_dir / "visual" / "text").mkdir(parents=True)
|
|
(settings.models_dir / "manifest.json").write_text(
|
|
json.dumps({"components": {"visual": {"version": "visual-v1"}}})
|
|
)
|
|
services = create_app(settings).state.services
|
|
|
|
assert services.models.missing_runtime_components() == ["visual"]
|
|
job_id = services.queue_missing_ai_runtime()
|
|
|
|
with services.db.read() as connection:
|
|
job = connection.execute(
|
|
"SELECT kind,payload_json,dedupe_key FROM jobs WHERE id=?", (job_id,)
|
|
).fetchone()
|
|
assert job["kind"] == "prepare_ai_runtime"
|
|
assert json.loads(job["payload_json"]) == {"components": ["visual"]}
|
|
assert job["dedupe_key"] == "prepare-ai-runtime:visual"
|
|
|
|
|
|
def test_private_ffmpeg_and_rclone_releases_coexist_and_failed_update_is_atomic(tmp_path: Path, monkeypatch):
|
|
settings = _settings(tmp_path)
|
|
manager = RuntimeToolManager(settings)
|
|
ffmpeg = b"ffmpeg-test"
|
|
ffprobe = b"ffprobe-test"
|
|
rclone = b"rclone-test"
|
|
ffmpeg_archive = tmp_path / "ffmpeg.tar.xz"
|
|
with tarfile.open(ffmpeg_archive, "w:xz") as archive:
|
|
for name, content in (("release/ffmpeg", ffmpeg), ("release/ffprobe", ffprobe)):
|
|
member = tarfile.TarInfo(name)
|
|
member.size = len(content)
|
|
archive.addfile(member, io.BytesIO(content))
|
|
rclone_archive = tmp_path / "rclone.zip"
|
|
with zipfile.ZipFile(rclone_archive, "w") as archive:
|
|
archive.writestr("release/rclone", rclone)
|
|
releases = {
|
|
"ffmpeg": {
|
|
"version": "test",
|
|
"url": str(ffmpeg_archive),
|
|
"files": {
|
|
"ffmpeg": hashlib.sha256(ffmpeg).hexdigest(),
|
|
"ffprobe": hashlib.sha256(ffprobe).hexdigest(),
|
|
},
|
|
},
|
|
"rclone": {
|
|
"version": "test",
|
|
"url": str(rclone_archive),
|
|
"files": {"rclone": hashlib.sha256(rclone).hexdigest()},
|
|
},
|
|
}
|
|
monkeypatch.setattr(runtime_module, "TOOL_RELEASES", releases)
|
|
|
|
def local_download(url: str, destination: Path):
|
|
destination.write_bytes(Path(url).read_bytes())
|
|
|
|
monkeypatch.setattr(manager, "_download", local_download)
|
|
manager._install_release("ffmpeg")
|
|
manager._install_release("rclone")
|
|
assert (manager.root / "ffmpeg").read_bytes() == ffmpeg
|
|
assert (manager.root / "ffprobe").read_bytes() == ffprobe
|
|
assert (manager.root / "rclone").read_bytes() == rclone
|
|
|
|
releases["rclone"]["files"]["rclone"] = "0" * 64
|
|
with pytest.raises(RuntimeError, match="SHA-256"):
|
|
manager._install_release("rclone")
|
|
assert (manager.root / "ffmpeg").read_bytes() == ffmpeg
|
|
assert (manager.root / "rclone").read_bytes() == rclone
|
|
|
|
|
|
def test_audio_export_declares_transformers_library_and_fp16_configuration(tmp_path: Path, monkeypatch):
|
|
calls: dict[str, object] = {}
|
|
|
|
class ExportConfig:
|
|
def __init__(self, *, dtype):
|
|
self.dtype = dtype
|
|
|
|
class Processor:
|
|
@classmethod
|
|
def from_pretrained(cls, source, *, local_files_only):
|
|
calls["processor_source"] = source
|
|
calls["processor_local"] = local_files_only
|
|
return cls()
|
|
|
|
def save_pretrained(self, destination):
|
|
calls["processor_destination"] = destination
|
|
_write_minimal_audio_processor_files(destination)
|
|
|
|
def main_export(**kwargs):
|
|
calls["export"] = kwargs
|
|
_write_minimal_audio_export(kwargs["output"])
|
|
|
|
modules = {
|
|
"optimum": types.ModuleType("optimum"),
|
|
"optimum.exporters": types.ModuleType("optimum.exporters"),
|
|
"optimum.exporters.openvino": types.ModuleType("optimum.exporters.openvino"),
|
|
"optimum.intel": types.ModuleType("optimum.intel"),
|
|
"optimum.intel.openvino": types.ModuleType("optimum.intel.openvino"),
|
|
"optimum.intel.openvino.configuration": types.ModuleType("optimum.intel.openvino.configuration"),
|
|
"transformers": types.ModuleType("transformers"),
|
|
}
|
|
modules["optimum.exporters.openvino"].main_export = main_export
|
|
modules["optimum.intel.openvino.configuration"].OVConfig = ExportConfig
|
|
modules["transformers"].AutoProcessor = Processor
|
|
for name, module in modules.items():
|
|
monkeypatch.setitem(sys.modules, name, module)
|
|
|
|
from imagefind.models import ModelManager
|
|
|
|
source = tmp_path / "source"
|
|
destination = tmp_path / "output"
|
|
ModelManager._export_audio_model(source, destination)
|
|
|
|
export = calls["export"]
|
|
assert export["library_name"] == "transformers"
|
|
assert export["task"] == "automatic-speech-recognition-with-past"
|
|
assert export["local_files_only"] is True
|
|
assert export["ov_config"].dtype == "fp16"
|
|
assert calls["processor_destination"] == destination
|
|
|
|
|
|
def _write_minimal_audio_export(root: Path, *, include_cache: bool = False, marker: str = "main") -> None:
|
|
root.mkdir(parents=True, exist_ok=True)
|
|
for name in (
|
|
"openvino_encoder_model.xml",
|
|
"openvino_decoder_model.xml",
|
|
):
|
|
(root / name).write_text(f"<{marker}/>")
|
|
for name in (
|
|
"openvino_encoder_model.bin",
|
|
"openvino_decoder_model.bin",
|
|
):
|
|
(root / name).write_bytes(marker.encode())
|
|
if include_cache:
|
|
(root / "openvino_decoder_with_past_model.xml").write_text(f"<{marker}-cache/>")
|
|
(root / "openvino_decoder_with_past_model.bin").write_bytes(marker.encode())
|
|
|
|
|
|
def _write_minimal_audio_processor_files(root: Path) -> None:
|
|
root.mkdir(parents=True, exist_ok=True)
|
|
for name in ("config.json", "preprocessor_config.json", "tokenizer_config.json", "tokenizer.json"):
|
|
(root / name).write_text("{}", encoding="utf-8")
|
|
|
|
|
|
def _install_audio_export_modules(monkeypatch, main_export, *, fallback_model=None):
|
|
class ExportConfig:
|
|
def __init__(self, *, dtype):
|
|
self.dtype = dtype
|
|
|
|
class Processor:
|
|
@classmethod
|
|
def from_pretrained(cls, source, *, local_files_only):
|
|
assert local_files_only is True
|
|
return cls()
|
|
|
|
def save_pretrained(self, destination):
|
|
_write_minimal_audio_processor_files(destination)
|
|
|
|
modules = {
|
|
"optimum": types.ModuleType("optimum"),
|
|
"optimum.exporters": types.ModuleType("optimum.exporters"),
|
|
"optimum.exporters.openvino": types.ModuleType("optimum.exporters.openvino"),
|
|
"optimum.intel": types.ModuleType("optimum.intel"),
|
|
"optimum.intel.openvino": types.ModuleType("optimum.intel.openvino"),
|
|
"optimum.intel.openvino.configuration": types.ModuleType("optimum.intel.openvino.configuration"),
|
|
"transformers": types.ModuleType("transformers"),
|
|
}
|
|
modules["optimum.exporters.openvino"].main_export = main_export
|
|
modules["optimum.intel.openvino.configuration"].OVConfig = ExportConfig
|
|
if fallback_model is not None:
|
|
modules["optimum.intel.openvino"].OVModelForSpeechSeq2Seq = fallback_model
|
|
modules["transformers"].AutoProcessor = Processor
|
|
for name, module in modules.items():
|
|
monkeypatch.setitem(sys.modules, name, module)
|
|
|
|
|
|
def test_audio_export_keeps_main_export_when_optional_cache_export_fails(
|
|
tmp_path: Path, monkeypatch
|
|
):
|
|
def main_export(**kwargs):
|
|
_write_minimal_audio_export(kwargs["output"], marker="main")
|
|
|
|
class FailingFallback:
|
|
@classmethod
|
|
def from_pretrained(cls, *_args, **_kwargs):
|
|
raise ValueError("cache export unavailable")
|
|
|
|
_install_audio_export_modules(monkeypatch, main_export, fallback_model=FailingFallback)
|
|
|
|
from imagefind.models import ModelManager
|
|
|
|
destination = tmp_path / "audio"
|
|
ModelManager._export_audio_model(tmp_path / "source", destination)
|
|
|
|
assert (destination / "openvino_encoder_model.xml").read_text() == "<main/>"
|
|
assert not (destination / "openvino_decoder_with_past_model.xml").exists()
|
|
assert (destination / "tokenizer_config.json").is_file()
|
|
|
|
|
|
def test_audio_export_replaces_main_export_only_when_cache_export_is_complete(
|
|
tmp_path: Path, monkeypatch
|
|
):
|
|
def main_export(**kwargs):
|
|
_write_minimal_audio_export(kwargs["output"], marker="main")
|
|
|
|
class SuccessfulFallback:
|
|
@classmethod
|
|
def from_pretrained(cls, *_args, **_kwargs):
|
|
return cls()
|
|
|
|
def save_pretrained(self, destination):
|
|
_write_minimal_audio_export(destination, include_cache=True, marker="cache")
|
|
|
|
_install_audio_export_modules(monkeypatch, main_export, fallback_model=SuccessfulFallback)
|
|
|
|
from imagefind.models import ModelManager
|
|
|
|
destination = tmp_path / "audio"
|
|
ModelManager._export_audio_model(tmp_path / "source", destination)
|
|
|
|
assert (destination / "openvino_encoder_model.xml").read_text() == "<cache/>"
|
|
assert (destination / "openvino_decoder_with_past_model.xml").read_text() == "<cache-cache/>"
|
|
assert (destination / "tokenizer_config.json").is_file()
|
|
|
|
|
|
def test_audio_export_main_export_failure_reports_root_cause(tmp_path: Path, monkeypatch):
|
|
def main_export(**_kwargs):
|
|
raise ValueError("library name could not be inferred")
|
|
|
|
_install_audio_export_modules(monkeypatch, main_export)
|
|
|
|
from imagefind.models import ModelManager
|
|
|
|
with pytest.raises(RuntimeError, match="音频模型转换阶段失败:ValueError.*library name"):
|
|
ModelManager._export_audio_model(tmp_path / "source", tmp_path / "audio")
|