mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-21 03:01:27 -03:00
feat(settings): filename templates for download and bulk rename (#1071)
Add per-model-type filename templates ({model_name}, {version_name},
{base_model}, {author}, {first_tag}, {hash_short}, {original_name}) so
downloaded files get informative names instead of e.g. V1.safetensors.
Empty template keeps the current filename (opt-in, off by default).
- apply template automatically after downloads; rename conflicts keep
the original name and never fail the download
- record original_file_name in metadata on rename for traceability
- bulk apply via GET|POST /api/lm/{prefix}/apply-filename-template with
WebSocket progress, sharing the auto-organize lock
- settings UI lives in the new Organization tab with validation, live
preview, and per-type 'apply to library' actions
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
"""Tests for the post-download filename template rename phase."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from py.services.download_manager import DownloadManager
|
||||
from py.services.service_registry import ServiceRegistry
|
||||
from py.services.settings_manager import get_settings_manager
|
||||
|
||||
|
||||
class DummyScanner:
|
||||
def __init__(self, root: Path):
|
||||
self._root = root
|
||||
self.model_type = "lora"
|
||||
self.updates = []
|
||||
|
||||
def get_model_roots(self):
|
||||
return [str(self._root)]
|
||||
|
||||
async def update_single_model_cache(self, original_path, new_path, metadata):
|
||||
self.updates.append((original_path, new_path, metadata))
|
||||
return True
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def download_manager() -> DownloadManager:
|
||||
return DownloadManager()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def no_recipe_scanner(monkeypatch: pytest.MonkeyPatch):
|
||||
async def _no_scanner():
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(ServiceRegistry, "get_recipe_scanner", _no_scanner)
|
||||
|
||||
|
||||
def _set_template(template: str, model_type: str = "lora") -> None:
|
||||
manager = get_settings_manager()
|
||||
templates = dict(manager.settings.get("download_filename_templates") or {})
|
||||
templates[model_type] = template
|
||||
manager.settings["download_filename_templates"] = templates
|
||||
|
||||
|
||||
def _write_model(root: Path, stem: str, model_name: str, sha256: str) -> Path:
|
||||
model_path = root / f"{stem}.safetensors"
|
||||
model_path.write_bytes(b"model")
|
||||
metadata_path = root / f"{stem}.metadata.json"
|
||||
metadata_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"file_name": stem,
|
||||
"file_path": model_path.as_posix(),
|
||||
"model_name": model_name,
|
||||
"sha256": sha256,
|
||||
"civitai": {"id": 1},
|
||||
}
|
||||
)
|
||||
)
|
||||
return model_path
|
||||
|
||||
|
||||
async def test_download_rename_applies_filename_template(
|
||||
tmp_path: Path, download_manager: DownloadManager
|
||||
):
|
||||
_set_template("{model_name}-{hash_short}")
|
||||
model_path = _write_model(tmp_path, "V1", "My Model", "abcdef0123456789")
|
||||
download_manager._active_downloads["dl1"] = {"file_path": model_path.as_posix()}
|
||||
|
||||
downloaded_metadata = [
|
||||
{
|
||||
"file_path": model_path.as_posix(),
|
||||
"file_name": "V1",
|
||||
"model_name": "My Model",
|
||||
"sha256": "abcdef0123456789",
|
||||
"civitai": {"id": 1},
|
||||
}
|
||||
]
|
||||
|
||||
await download_manager._apply_download_filename_template(
|
||||
scanner=DummyScanner(tmp_path),
|
||||
model_type="lora",
|
||||
downloaded_metadata=downloaded_metadata,
|
||||
download_id="dl1",
|
||||
)
|
||||
|
||||
new_path = tmp_path / "My Model-abcdef0123.safetensors"
|
||||
assert new_path.exists()
|
||||
assert not model_path.exists()
|
||||
|
||||
new_metadata = json.loads(
|
||||
(tmp_path / "My Model-abcdef0123.metadata.json").read_text()
|
||||
)
|
||||
assert new_metadata["original_file_name"] == "V1"
|
||||
|
||||
assert (
|
||||
download_manager._active_downloads["dl1"]["file_path"]
|
||||
== new_path.as_posix()
|
||||
)
|
||||
|
||||
|
||||
async def test_download_rename_keeps_original_on_conflict(
|
||||
tmp_path: Path, download_manager: DownloadManager
|
||||
):
|
||||
_set_template("{model_name}-{hash_short}")
|
||||
model_path = _write_model(tmp_path, "V1", "My Model", "abcdef0123456789")
|
||||
# Conflicting target already exists.
|
||||
(tmp_path / "My Model-abcdef0123.safetensors").write_bytes(b"other")
|
||||
|
||||
downloaded_metadata = [
|
||||
{
|
||||
"file_path": model_path.as_posix(),
|
||||
"file_name": "V1",
|
||||
"model_name": "My Model",
|
||||
"sha256": "abcdef0123456789",
|
||||
"civitai": {"id": 1},
|
||||
}
|
||||
]
|
||||
|
||||
# Must not raise: a rename conflict never fails the download.
|
||||
await download_manager._apply_download_filename_template(
|
||||
scanner=DummyScanner(tmp_path),
|
||||
model_type="lora",
|
||||
downloaded_metadata=downloaded_metadata,
|
||||
download_id=None,
|
||||
)
|
||||
|
||||
assert model_path.exists()
|
||||
|
||||
|
||||
async def test_download_rename_noop_without_template(
|
||||
tmp_path: Path, download_manager: DownloadManager
|
||||
):
|
||||
_set_template("")
|
||||
model_path = _write_model(tmp_path, "V1", "My Model", "abcdef0123456789")
|
||||
|
||||
await download_manager._apply_download_filename_template(
|
||||
scanner=DummyScanner(tmp_path),
|
||||
model_type="lora",
|
||||
downloaded_metadata=[{"file_path": model_path.as_posix()}],
|
||||
download_id=None,
|
||||
)
|
||||
|
||||
assert model_path.exists()
|
||||
assert (tmp_path / "V1.metadata.json").exists()
|
||||
@@ -424,6 +424,52 @@ async def test_rename_model_preserves_extension(tmp_path: Path):
|
||||
assert payload["file_name"] == new_name
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rename_model_records_original_file_name(tmp_path: Path):
|
||||
old_name = "V1"
|
||||
new_name = "flux-my-model-v3"
|
||||
|
||||
model_path = tmp_path / f"{old_name}.safetensors"
|
||||
model_path.write_bytes(b"model")
|
||||
|
||||
metadata_path = tmp_path / f"{old_name}.metadata.json"
|
||||
metadata_payload = {
|
||||
"file_name": old_name,
|
||||
"file_path": model_path.as_posix(),
|
||||
}
|
||||
metadata_path.write_text(json.dumps(metadata_payload))
|
||||
|
||||
async def metadata_loader(path: str):
|
||||
with open(path, "r", encoding="utf-8") as handle:
|
||||
return json.load(handle)
|
||||
|
||||
service = ModelLifecycleService(
|
||||
scanner=DummyScanner(),
|
||||
metadata_manager=PassthroughMetadataManager(),
|
||||
metadata_loader=metadata_loader,
|
||||
)
|
||||
|
||||
await service.rename_model(
|
||||
file_path=model_path.as_posix(),
|
||||
new_file_name=new_name,
|
||||
)
|
||||
|
||||
saved_metadata = json.loads((tmp_path / f"{new_name}.metadata.json").read_text())
|
||||
assert saved_metadata["original_file_name"] == old_name
|
||||
|
||||
# A second rename keeps the very first recorded name.
|
||||
second_name = "flux-my-model-v4"
|
||||
await service.rename_model(
|
||||
file_path=(tmp_path / f"{new_name}.safetensors").as_posix(),
|
||||
new_file_name=second_name,
|
||||
)
|
||||
|
||||
saved_metadata = json.loads(
|
||||
(tmp_path / f"{second_name}.metadata.json").read_text()
|
||||
)
|
||||
assert saved_metadata["original_file_name"] == old_name
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rename_model_with_dotted_basename(tmp_path: Path):
|
||||
old_name = "model.v1"
|
||||
|
||||
@@ -19,6 +19,7 @@ from py.services.use_cases import (
|
||||
DownloadModelEarlyAccessError,
|
||||
DownloadModelUseCase,
|
||||
DownloadModelValidationError,
|
||||
FilenameTemplateUseCase,
|
||||
ImportExampleImagesUseCase,
|
||||
ImportExampleImagesValidationError,
|
||||
)
|
||||
@@ -33,7 +34,7 @@ from py.utils.example_images_processor import (
|
||||
ExampleImagesValidationError,
|
||||
)
|
||||
from py.utils.metadata_manager import MetadataManager
|
||||
from tests.conftest import MockModelService, MockScanner
|
||||
from tests.conftest import MockCache, MockModelService, MockScanner
|
||||
|
||||
|
||||
class StubLockProvider:
|
||||
@@ -502,4 +503,179 @@ async def test_import_example_images_use_case_propagates_generic_error() -> None
|
||||
request = DummyJsonRequest({"model_hash": "abc", "file_paths": ["/tmp/file"]})
|
||||
|
||||
with pytest.raises(ExampleImagesImportError):
|
||||
await use_case.execute(request) # pyright: ignore[reportArgumentType]
|
||||
await use_case.execute(request) # pyright: ignore[reportArgumentType]
|
||||
|
||||
|
||||
class StubLifecycleService:
|
||||
def __init__(self, scanner: Optional[MockScanner] = None) -> None:
|
||||
self.renames: List[Dict[str, str]] = []
|
||||
self.error: Optional[Exception] = None
|
||||
self.cancel_on_rename = False
|
||||
self._scanner = scanner
|
||||
|
||||
async def rename_model(self, *, file_path: str, new_file_name: str) -> Dict[str, Any]:
|
||||
if self.error is not None:
|
||||
raise self.error
|
||||
self.renames.append({"file_path": file_path, "new_file_name": new_file_name})
|
||||
if self.cancel_on_rename and self._scanner is not None:
|
||||
self._scanner.cancel_task()
|
||||
return {"success": True, "new_file_path": file_path}
|
||||
|
||||
|
||||
def _filename_template_model(
|
||||
file_path: str,
|
||||
model_name: str,
|
||||
sha256: str = "abcdef0123456789",
|
||||
) -> Dict[str, Any]:
|
||||
return {
|
||||
"file_path": file_path,
|
||||
"file_name": file_path.rsplit("/", 1)[-1].rsplit(".", 1)[0],
|
||||
"model_name": model_name,
|
||||
"sha256": sha256,
|
||||
"civitai": {"id": 1},
|
||||
}
|
||||
|
||||
|
||||
def _set_filename_template(template: str, model_type: str = "lora") -> None:
|
||||
from py.services.settings_manager import get_settings_manager
|
||||
|
||||
manager = get_settings_manager()
|
||||
templates = dict(manager.settings.get("download_filename_templates") or {})
|
||||
templates[model_type] = template
|
||||
manager.settings["download_filename_templates"] = templates
|
||||
|
||||
|
||||
def _make_filename_template_use_case(
|
||||
scanner: MockScanner,
|
||||
lifecycle: StubLifecycleService,
|
||||
lock_provider: Optional[StubLockProvider] = None,
|
||||
) -> FilenameTemplateUseCase:
|
||||
return FilenameTemplateUseCase(
|
||||
scanner=scanner,
|
||||
lifecycle_service=lifecycle, # pyright: ignore[reportArgumentType]
|
||||
lock_provider=lock_provider or StubLockProvider(),
|
||||
model_type="lora",
|
||||
)
|
||||
|
||||
|
||||
async def test_filename_template_use_case_renames_models() -> None:
|
||||
_set_filename_template("{model_name}-{hash_short}")
|
||||
scanner = MockScanner(cache=MockCache([
|
||||
_filename_template_model("/library/alpha.safetensors", "Alpha"),
|
||||
_filename_template_model("/library/beta.safetensors", "Beta"),
|
||||
]))
|
||||
lifecycle = StubLifecycleService()
|
||||
progress = ProgressCollector()
|
||||
use_case = _make_filename_template_use_case(scanner, lifecycle)
|
||||
|
||||
result = await use_case.execute(progress_callback=progress)
|
||||
|
||||
assert result.status == "success"
|
||||
assert result.operation_type == "filename_template"
|
||||
assert result.total == 2
|
||||
assert result.success_count == 2
|
||||
assert result.failure_count == 0
|
||||
assert lifecycle.renames == [
|
||||
{"file_path": "/library/alpha.safetensors", "new_file_name": "Alpha-abcdef0123"},
|
||||
{"file_path": "/library/beta.safetensors", "new_file_name": "Beta-abcdef0123"},
|
||||
]
|
||||
statuses = [event["status"] for event in progress.events]
|
||||
assert statuses[0] == "started"
|
||||
assert statuses[-1] == "completed"
|
||||
assert all(event["type"] == "filename_template_progress" for event in progress.events)
|
||||
|
||||
|
||||
async def test_filename_template_use_case_skips_unchanged_names() -> None:
|
||||
_set_filename_template("{model_name}-{hash_short}")
|
||||
scanner = MockScanner(cache=MockCache([
|
||||
_filename_template_model("/library/Alpha-abcdef0123.safetensors", "Alpha"),
|
||||
]))
|
||||
lifecycle = StubLifecycleService()
|
||||
use_case = _make_filename_template_use_case(scanner, lifecycle)
|
||||
|
||||
result = await use_case.execute(progress_callback=None)
|
||||
|
||||
assert result.success_count == 0
|
||||
assert result.skipped_count == 1
|
||||
assert lifecycle.renames == []
|
||||
|
||||
|
||||
async def test_filename_template_use_case_skips_all_when_template_empty() -> None:
|
||||
_set_filename_template("")
|
||||
scanner = MockScanner(cache=MockCache([
|
||||
_filename_template_model("/library/alpha.safetensors", "Alpha"),
|
||||
]))
|
||||
lifecycle = StubLifecycleService()
|
||||
use_case = _make_filename_template_use_case(scanner, lifecycle)
|
||||
|
||||
result = await use_case.execute(progress_callback=None)
|
||||
|
||||
assert result.skipped_count == 1
|
||||
assert lifecycle.renames == []
|
||||
|
||||
|
||||
async def test_filename_template_use_case_counts_conflicts_as_failures() -> None:
|
||||
_set_filename_template("{model_name}")
|
||||
scanner = MockScanner(cache=MockCache([
|
||||
_filename_template_model("/library/alpha.safetensors", "Alpha"),
|
||||
_filename_template_model("/library/beta.safetensors", "Beta"),
|
||||
]))
|
||||
lifecycle = StubLifecycleService()
|
||||
lifecycle.error = ValueError("A file with this name already exists")
|
||||
use_case = _make_filename_template_use_case(scanner, lifecycle)
|
||||
|
||||
result = await use_case.execute(progress_callback=None)
|
||||
|
||||
assert result.status == "success"
|
||||
assert result.failure_count == 2
|
||||
assert result.success_count == 0
|
||||
assert len(result.results) == 2
|
||||
|
||||
|
||||
async def test_filename_template_use_case_honours_cancellation() -> None:
|
||||
_set_filename_template("{model_name}-{hash_short}")
|
||||
scanner = MockScanner(cache=MockCache([
|
||||
_filename_template_model("/library/alpha.safetensors", "Alpha"),
|
||||
_filename_template_model("/library/beta.safetensors", "Beta"),
|
||||
]))
|
||||
lifecycle = StubLifecycleService(scanner=scanner)
|
||||
lifecycle.cancel_on_rename = True
|
||||
progress = ProgressCollector()
|
||||
use_case = _make_filename_template_use_case(scanner, lifecycle)
|
||||
|
||||
result = await use_case.execute(progress_callback=progress)
|
||||
|
||||
assert result.status == "cancelled"
|
||||
assert len(lifecycle.renames) == 1
|
||||
assert progress.events[-1]["status"] == "cancelled"
|
||||
|
||||
|
||||
async def test_filename_template_use_case_filters_file_paths() -> None:
|
||||
_set_filename_template("{model_name}-{hash_short}")
|
||||
scanner = MockScanner(cache=MockCache([
|
||||
_filename_template_model("/library/alpha.safetensors", "Alpha"),
|
||||
_filename_template_model("/library/beta.safetensors", "Beta"),
|
||||
]))
|
||||
lifecycle = StubLifecycleService()
|
||||
use_case = _make_filename_template_use_case(scanner, lifecycle)
|
||||
|
||||
result = await use_case.execute(
|
||||
file_paths=["/library/beta.safetensors"], progress_callback=None
|
||||
)
|
||||
|
||||
assert result.total == 1
|
||||
assert lifecycle.renames == [
|
||||
{"file_path": "/library/beta.safetensors", "new_file_name": "Beta-abcdef0123"}
|
||||
]
|
||||
|
||||
|
||||
async def test_filename_template_use_case_rejects_when_lock_held() -> None:
|
||||
_set_filename_template("{model_name}")
|
||||
scanner = MockScanner(cache=MockCache())
|
||||
lifecycle = StubLifecycleService()
|
||||
lock_provider = StubLockProvider()
|
||||
lock_provider.running = True
|
||||
use_case = _make_filename_template_use_case(scanner, lifecycle, lock_provider)
|
||||
|
||||
with pytest.raises(AutoOrganizeInProgressError):
|
||||
await use_case.execute(progress_callback=None)
|
||||
Reference in New Issue
Block a user