""" Test the model installer """ import gc import platform import shutil import threading import time import uuid from pathlib import Path from typing import Any, Dict import pytest from pydantic_core import Url from invokeai.app.services.config import InvokeAIAppConfig from invokeai.app.services.events.events_base import EventServiceBase from invokeai.app.services.events.events_common import ( ModelInstallCompleteEvent, ModelInstallDownloadProgressEvent, ModelInstallDownloadsCompleteEvent, ModelInstallDownloadStartedEvent, ModelInstallErrorEvent, ModelInstallStartedEvent, ) from invokeai.app.services.model_install import ( HFModelSource, ModelInstallService, ModelInstallServiceBase, ) from invokeai.app.services.model_install.model_install_common import ( InstallStatus, InvalidModelConfigException, LocalModelSource, ModelInstallJob, URLModelSource, ) from invokeai.app.services.model_records import ModelRecordChanges, UnknownModelException from invokeai.backend.model_manager.configs.external_api import ExternalApiModelConfig from invokeai.backend.model_manager.taxonomy import ( BaseModelType, ModelFormat, ModelRepoVariant, ModelSourceType, ModelType, ) from tests.backend.model_manager.model_manager_fixtures import * # noqa F403 from tests.test_nodes import TestEventService OS = platform.uname().system def test_registration(mm2_installer: ModelInstallServiceBase, embedding_file: Path) -> None: store = mm2_installer.record_store matches = store.search_by_attr(model_name="test_embedding") assert len(matches) == 0 key = mm2_installer.register_path(embedding_file) # Not raising here is sufficient - key should be UUIDv4 uuid.UUID(key, version=4) def test_registration_meta(mm2_installer: ModelInstallServiceBase, embedding_file: Path) -> None: store = mm2_installer.record_store key = mm2_installer.register_path(embedding_file) model_record = store.get_model(key) assert model_record is not None assert model_record.name == "test_embedding" assert model_record.type == ModelType.TextualInversion assert Path(model_record.path) == embedding_file assert Path(model_record.path).exists() assert model_record.base == BaseModelType("sd-1") assert model_record.description is None assert model_record.source is not None assert Path(model_record.source) == embedding_file def test_registration_meta_override_fail(mm2_installer: ModelInstallServiceBase, embedding_file: Path) -> None: with pytest.raises(InvalidModelConfigException): mm2_installer.register_path(embedding_file, ModelRecordChanges(name="banana_sushi", type=ModelType("lora"))) def test_registration_meta_override_succeed(mm2_installer: ModelInstallServiceBase, embedding_file: Path) -> None: store = mm2_installer.record_store key = mm2_installer.register_path( embedding_file, ModelRecordChanges(name="banana_sushi", source="fake/repo_id", key="xyzzy") ) model_record = store.get_model(key) assert model_record.name == "banana_sushi" assert model_record.source == "fake/repo_id" assert model_record.key == "xyzzy" def test_install( mm2_installer: ModelInstallServiceBase, embedding_file: Path, mm2_app_config: InvokeAIAppConfig ) -> None: store = mm2_installer.record_store key = mm2_installer.install_path(embedding_file) model_record = store.get_model(key) assert model_record.path.endswith(f"{key}/test_embedding.safetensors") assert (mm2_app_config.models_path / model_record.path).exists() assert model_record.source == embedding_file.as_posix() def test_rename( mm2_installer: ModelInstallServiceBase, embedding_file: Path, mm2_app_config: InvokeAIAppConfig ) -> None: store = mm2_installer.record_store key = mm2_installer.install_path(embedding_file) model_record = store.get_model(key) assert model_record.path.endswith(f"{key}/test_embedding.safetensors") new_model_record = store.update_model( key, ModelRecordChanges(name="new model name", base=BaseModelType.StableDiffusion2), allow_class_change=True, ) # Renaming the model record shouldn't rename the file assert new_model_record.name == "new model name" assert model_record.path.endswith(f"{key}/test_embedding.safetensors") @pytest.mark.parametrize( "fixture_name,size,key,destination", [ ("embedding_file", 15440, "foo", "foo/test_embedding.safetensors"), ("diffusers_dir", 8241 if OS == "Windows" else 7907, "bar", "bar"), # EOL chars ], ) def test_background_install( mm2_installer: ModelInstallServiceBase, fixture_name: str, key: str, size: int, destination: str, mm2_app_config: InvokeAIAppConfig, request: pytest.FixtureRequest, ) -> None: """Note: may want to break this down into several smaller unit tests.""" path: Path = request.getfixturevalue(fixture_name) description = "Test of metadata assignment" source = LocalModelSource(path=path, inplace=False) job = mm2_installer.import_model(source, config=ModelRecordChanges(key=key, description=description)) assert job is not None assert isinstance(job, ModelInstallJob) # See if job is registered properly assert job in mm2_installer.get_job_by_source(source) # test that the job object tracked installation correctly jobs = mm2_installer.wait_for_installs() assert len(jobs) > 0 my_job = [x for x in jobs if x.source == source] assert len(my_job) == 1 assert job == my_job[0] assert job.status == InstallStatus.COMPLETED assert job.total_bytes == size # test that the expected events were issued bus: TestEventService = mm2_installer.event_bus assert bus assert hasattr(bus, "events") assert len(bus.events) == 2 assert isinstance(bus.events[0], ModelInstallStartedEvent) assert isinstance(bus.events[1], ModelInstallCompleteEvent) assert Path(bus.events[0].source.path) == source assert Path(bus.events[1].source.path) == source key = bus.events[1].key assert key is not None # see if the thing actually got installed at the expected location model_record = mm2_installer.record_store.get_model(key) assert model_record is not None assert model_record.path.endswith(destination) assert (mm2_app_config.models_path / model_record.path).exists() # see if metadata was properly passed through assert model_record.description == description # see if job filtering works assert mm2_installer.get_job_by_source(source)[0] == job # see if prune works properly mm2_installer.prune_jobs() assert not mm2_installer.get_job_by_source(source) def test_not_inplace_install( mm2_installer: ModelInstallServiceBase, embedding_file: Path, mm2_app_config: InvokeAIAppConfig ) -> None: # An non in-place install will/should call `register_path()` internally source = LocalModelSource(path=embedding_file, inplace=False) job = mm2_installer.import_model(source) mm2_installer.wait_for_installs() assert job is not None assert job.config_out is not None # Non in-place install should _move_ the model from the original location to the models path # The model config's path should be different from the original file assert Path(job.config_out.path) != embedding_file # Original file should _not_ exist after install assert not embedding_file.exists() assert (mm2_app_config.models_path / job.config_out.path).exists() def test_inplace_install( mm2_installer: ModelInstallServiceBase, embedding_file: Path, mm2_app_config: InvokeAIAppConfig ) -> None: # An in-place install will/should call `install_path()` internally source = LocalModelSource(path=embedding_file, inplace=True) job = mm2_installer.import_model(source) mm2_installer.wait_for_installs() assert job is not None assert job.config_out is not None # In-place install should not touch the model file, just register it # The model config's path should be the same as the original file assert Path(job.config_out.path) == embedding_file # Model file should still exist after install assert embedding_file.exists() assert Path(job.config_out.path).exists() def test_external_install(mm2_installer: ModelInstallServiceBase) -> None: config = ModelRecordChanges(name="ChatGPT Image", description="External model", key="chatgpt_image") job = mm2_installer.heuristic_import("external://openai/gpt-image-1", config=config) mm2_installer.wait_for_installs() assert job.status == InstallStatus.COMPLETED assert job.config_out is not None assert isinstance(job.config_out, ExternalApiModelConfig) assert job.config_out.provider_id == "openai" assert job.config_out.provider_model_id == "gpt-image-1" assert job.config_out.base == BaseModelType.External assert job.config_out.type == ModelType.ExternalImageGenerator assert job.config_out.source_type == ModelSourceType.External def test_external_install_is_idempotent(mm2_installer: ModelInstallServiceBase) -> None: first_job = mm2_installer.heuristic_import( "external://openai/gpt-image-1", config=ModelRecordChanges(name="Initial name"), ) mm2_installer.wait_for_installs() second_job = mm2_installer.heuristic_import( "external://openai/gpt-image-1", config=ModelRecordChanges(name="Updated name"), ) mm2_installer.wait_for_installs() assert first_job.status == InstallStatus.COMPLETED assert second_job.status == InstallStatus.COMPLETED assert first_job.config_out is not None assert second_job.config_out is not None assert first_job.config_out.key == second_job.config_out.key external_models = mm2_installer.record_store.search_by_attr( base_model=BaseModelType.External, model_type=ModelType.ExternalImageGenerator, ) assert len(external_models) == 1 assert isinstance(external_models[0], ExternalApiModelConfig) assert external_models[0].name == "Updated name" def test_delete_install( mm2_installer: ModelInstallServiceBase, embedding_file: Path, mm2_app_config: InvokeAIAppConfig ) -> None: store = mm2_installer.record_store key = mm2_installer.install_path(embedding_file) # non in-place install model_record = store.get_model(key) assert (mm2_app_config.models_path / model_record.path).exists() assert not embedding_file.exists() # ensure file handles are released on Windows gc.collect() mm2_installer.delete(key) # after deletion, installed copy should not exist assert not (mm2_app_config.models_path / model_record.path).exists() with pytest.raises(UnknownModelException): store.get_model(key) def test_delete_register( mm2_installer: ModelInstallServiceBase, embedding_file: Path, mm2_app_config: InvokeAIAppConfig ) -> None: store = mm2_installer.record_store key = mm2_installer.register_path(embedding_file) # in-place install model_record = store.get_model(key) assert Path(model_record.path).exists() assert embedding_file.exists() mm2_installer.delete(key) assert Path(model_record.path).exists() with pytest.raises(UnknownModelException): store.get_model(key) @pytest.mark.timeout(timeout=10, method="thread") def test_simple_download(mm2_installer: ModelInstallServiceBase, mm2_app_config: InvokeAIAppConfig) -> None: source = URLModelSource(url=Url("https://www.test.foo/download/test_embedding.safetensors")) bus: TestEventService = mm2_installer.event_bus store = mm2_installer.record_store assert store is not None assert bus is not None assert hasattr(bus, "events") # the dummy event service has this job = mm2_installer.import_model(source) assert job.source == source job_list = mm2_installer.wait_for_installs(timeout=10) assert len(job_list) == 1 assert job.complete assert job.config_out key = job.config_out.key model_record = store.get_model(key) assert (mm2_app_config.models_path / model_record.path).exists() assert len(bus.events) == 5 assert isinstance(bus.events[0], ModelInstallDownloadStartedEvent) # download starts assert isinstance(bus.events[1], ModelInstallDownloadProgressEvent) # download progresses assert isinstance(bus.events[2], ModelInstallDownloadsCompleteEvent) # download completed assert isinstance(bus.events[3], ModelInstallStartedEvent) # install started assert isinstance(bus.events[4], ModelInstallCompleteEvent) # install completed def test_import_waits_for_startup_restore( mm2_app_config: InvokeAIAppConfig, mm2_record_store, mm2_download_queue, mm2_session, embedding_file: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: installer = ModelInstallService( app_config=mm2_app_config, record_store=mm2_record_store, download_queue=mm2_download_queue, event_bus=TestEventService(), session=mm2_session, ) restore_started = threading.Event() release_restore = threading.Event() imported = threading.Event() def _blocked_restore() -> None: restore_started.set() assert release_restore.wait(timeout=5) monkeypatch.setattr(installer, "_restore_incomplete_installs", _blocked_restore) try: installer.start() assert restore_started.wait(timeout=5) import_thread = threading.Thread( target=lambda: ( installer.import_model(LocalModelSource(path=embedding_file)), imported.set(), ) ) import_thread.start() time.sleep(0.1) assert not imported.is_set() release_restore.set() import_thread.join(timeout=5) assert imported.is_set() installer.wait_for_installs(timeout=5) finally: release_restore.set() installer.stop() def test_huggingface_blob_url_uses_resolve_download_url(mm2_installer: ModelInstallServiceBase) -> None: source = URLModelSource( url=Url("https://huggingface.co/h94/IP-Adapter/blob/main/sdxl_models/ip-adapter.safetensors") ) assert isinstance(mm2_installer, ModelInstallService) files, metadata = mm2_installer._remote_files_from_source(source) assert metadata is None assert len(files) == 1 assert str(files[0].url) == "https://huggingface.co/h94/IP-Adapter/resolve/main/sdxl_models/ip-adapter.safetensors" @pytest.mark.timeout(timeout=10, method="thread") def test_huggingface_install(mm2_installer: ModelInstallServiceBase, mm2_app_config: InvokeAIAppConfig) -> None: source = URLModelSource(url=Url("https://huggingface.co/stabilityai/sdxl-turbo")) bus: TestEventService = mm2_installer.event_bus store = mm2_installer.record_store assert isinstance(bus, EventServiceBase) assert store is not None job = mm2_installer.import_model(source) job_list = mm2_installer.wait_for_installs(timeout=10) assert len(job_list) == 1 assert job.complete assert job.config_out key = job.config_out.key model_record = store.get_model(key) assert (mm2_app_config.models_path / model_record.path).exists() assert model_record.type == ModelType.Main assert model_record.format == ModelFormat.Diffusers assert any(isinstance(x, ModelInstallStartedEvent) for x in bus.events) assert any(isinstance(x, ModelInstallDownloadProgressEvent) for x in bus.events) assert any(isinstance(x, ModelInstallCompleteEvent) for x in bus.events) assert len(bus.events) >= 3 @pytest.mark.timeout(timeout=10, method="thread") def test_huggingface_repo_id(mm2_installer: ModelInstallServiceBase, mm2_app_config: InvokeAIAppConfig) -> None: source = HFModelSource(repo_id="stabilityai/sdxl-turbo", variant=ModelRepoVariant.Default) bus = mm2_installer.event_bus store = mm2_installer.record_store assert isinstance(bus, EventServiceBase) assert store is not None job = mm2_installer.import_model(source) job_list = mm2_installer.wait_for_installs(timeout=10) assert len(job_list) == 1 assert job.complete assert job.config_out key = job.config_out.key model_record = store.get_model(key) assert (mm2_app_config.models_path / model_record.path).exists() assert model_record.type == ModelType.Main assert model_record.format == ModelFormat.Diffusers assert hasattr(bus, "events") # the dummyeventservice has this assert len(bus.events) >= 3 event_types = [type(x) for x in bus.events] assert all( x in event_types for x in [ ModelInstallDownloadProgressEvent, ModelInstallDownloadsCompleteEvent, ModelInstallStartedEvent, ModelInstallCompleteEvent, ] ) completed_events = [x for x in bus.events if isinstance(x, ModelInstallCompleteEvent)] downloading_events = [x for x in bus.events if isinstance(x, ModelInstallDownloadProgressEvent)] assert completed_events[0].total_bytes == downloading_events[-1].bytes assert job.total_bytes == completed_events[0].total_bytes print(downloading_events[-1]) print(job.download_parts) assert job.total_bytes == sum(x["total_bytes"] for x in downloading_events[-1].parts) def test_restore_paused_hf_install_preserves_access_token( mm2_installer: ModelInstallServiceBase, mm2_app_config: InvokeAIAppConfig, mm2_download_queue, mm2_session, monkeypatch: pytest.MonkeyPatch, ) -> None: assert isinstance(mm2_installer, ModelInstallService) access_token = "hf_test_access_token" tmpdir = mm2_app_config.models_path / f"tmpinstall_resume_token_{uuid.uuid4().hex}" tmpdir.mkdir(parents=True, exist_ok=True) try: paused_job = ModelInstallJob( id=99999, source=HFModelSource( repo_id="stabilityai/sdxl-turbo", variant=ModelRepoVariant.Default, access_token=access_token, ), config_in=ModelRecordChanges(), local_path=tmpdir, ) paused_job._install_tmpdir = tmpdir paused_job.status = InstallStatus.PAUSED mm2_installer._write_install_marker(paused_job, status=InstallStatus.PAUSED) marker = mm2_installer._read_install_marker(tmpdir) assert marker is not None assert marker["access_token"] == access_token restored_installer = ModelInstallService( app_config=mm2_app_config, record_store=mm2_installer.record_store, download_queue=mm2_download_queue, session=mm2_session, ) restored_installer._restore_incomplete_installs() restored_jobs = restored_installer.list_jobs() assert len(restored_jobs) == 1 restored_job = restored_jobs[0] assert restored_job.paused assert isinstance(restored_job.source, HFModelSource) assert restored_job.source.access_token == access_token captured: dict[str, str | None] = {} def _capture_resume(job: ModelInstallJob) -> None: assert isinstance(job.source, HFModelSource) captured["access_token"] = job.source.access_token monkeypatch.setattr(restored_installer, "_resume_remote_download", _capture_resume) restored_installer.resume_job(restored_job) assert captured["access_token"] == access_token finally: shutil.rmtree(tmpdir, ignore_errors=True) def test_404_download(mm2_installer: ModelInstallServiceBase, mm2_app_config: InvokeAIAppConfig) -> None: source = URLModelSource(url=Url("https://test.com/missing_model.safetensors")) job = mm2_installer.import_model(source) mm2_installer.wait_for_installs(timeout=10) assert job.status == InstallStatus.ERROR assert job.errored assert job.error_type == "HTTPError" assert job.error assert "NOT FOUND" in job.error assert job.error_traceback is not None assert job.error_traceback.startswith("Traceback") bus = mm2_installer.event_bus assert bus is not None assert hasattr(bus, "events") # the dummyeventservice has this event_types = [type(x) for x in bus.events] assert ModelInstallErrorEvent in event_types def test_other_error_during_install( monkeypatch: pytest.MonkeyPatch, mm2_installer: ModelInstallServiceBase, mm2_app_config: InvokeAIAppConfig ) -> None: def raise_runtime_error(*args, **kwargs): raise RuntimeError("Test error") monkeypatch.setattr( "invokeai.app.services.model_install.model_install_default.ModelInstallService._register_or_install", raise_runtime_error, ) source = LocalModelSource(path=Path("tests/data/embedding/test_embedding.safetensors")) job = mm2_installer.import_model(source) mm2_installer.wait_for_installs(timeout=10) assert job.status == InstallStatus.ERROR assert job.errored assert job.error_type == "RuntimeError" assert job.error == "Test error" @pytest.mark.parametrize( "model_params", [ # SDXL, Lora { "repo_id": "InvokeAI-test/textual_inversion_tests::learned_embeds-steps-1000.safetensors", "name": "test_lora", "type": "embedding", }, # SDXL, Lora - incorrect type { "repo_id": "InvokeAI-test/textual_inversion_tests::learned_embeds-steps-1000.safetensors", "name": "test_lora", "type": "lora", }, ], ) @pytest.mark.timeout(timeout=10, method="thread") def test_heuristic_import_with_type(mm2_installer: ModelInstallServiceBase, model_params: Dict[str, str]): """Test whether or not type is respected on configs when passed to heuristic import.""" assert "name" in model_params and "type" in model_params config1: Dict[str, Any] = { "name": f"{model_params['name']}_1", "type": model_params["type"], "hash": "placeholder1", } config2: Dict[str, Any] = { "name": f"{model_params['name']}_2", "type": ModelType(model_params["type"]), "hash": "placeholder2", } assert "repo_id" in model_params install_job1 = mm2_installer.heuristic_import(source=model_params["repo_id"], config=config1) mm2_installer.wait_for_job(install_job1, timeout=10) if model_params["type"] != "embedding": assert install_job1.errored assert install_job1.error_type == "InvalidModelConfigException" return assert install_job1.complete assert install_job1.config_out if model_params["type"] == "embedding" else not install_job1.config_out install_job2 = mm2_installer.heuristic_import(source=model_params["repo_id"], config=config2) mm2_installer.wait_for_job(install_job2, timeout=10) assert install_job2.complete assert install_job2.config_out if model_params["type"] == "embedding" else not install_job2.config_out