feat: normalize tags to lowercase for Windows compatibility, see #637

Convert all tags to lowercase in tag processing logic to prevent case sensitivity issues on Windows filesystems. This ensures consistent tag matching and prevents duplicate tags with different cases from being created.

Changes include:
- TagUpdateService now converts tags to lowercase before comparison
- Utils function converts model tags to lowercase before priority resolution
- Test cases updated to reflect lowercase tag expectations
This commit is contained in:
Will Miao
2025-11-04 12:54:09 +08:00
parent 6c03aa1430
commit 3d6bb432c4
5 changed files with 154 additions and 5 deletions

View File

@@ -33,7 +33,8 @@ class TagUpdateService:
tags_added: List[str] = []
for tag in new_tags:
if isinstance(tag, str) and tag.strip():
normalized = tag.strip()
# Convert all tags to lowercase to avoid case sensitivity issues on Windows
normalized = tag.strip().lower()
if normalized.lower() not in existing_lower:
existing_tags.append(normalized)
existing_lower.append(normalized.lower())

View File

@@ -205,7 +205,9 @@ def calculate_relative_path_for_model(model_data: Dict, model_type: str = 'lora'
base_model_mappings = settings_manager.get('base_model_path_mappings', {})
mapped_base_model = base_model_mappings.get(base_model, base_model)
first_tag = settings_manager.resolve_priority_tag_for_model(model_tags, model_type)
# Convert all tags to lowercase to avoid case sensitivity issues on Windows
lowercase_tags = [tag.lower() for tag in model_tags if isinstance(tag, str)]
first_tag = settings_manager.resolve_priority_tag_for_model(lowercase_tags, model_type)
if not first_tag:
first_tag = 'no tags' # Default if no tags available

View File

@@ -240,7 +240,7 @@ def test_download_coordinator_emits_progress() -> None:
def test_tag_update_service_adds_unique_tags(tmp_path: Path) -> None:
metadata_path = tmp_path / "model.metadata.json"
metadata_path.write_text(json.dumps({"tags": ["Existing"]}))
metadata_path.write_text(json.dumps({"tags": ["existing"]}))
async def loader(path: str) -> Dict[str, Any]:
return json.loads(Path(path).read_text())
@@ -258,12 +258,12 @@ def test_tag_update_service_adds_unique_tags(tmp_path: Path) -> None:
tags = asyncio.run(
service.add_tags(
file_path=str(tmp_path / "model.safetensors"),
new_tags=["New", "existing"],
new_tags=["new", "existing"],
metadata_loader=loader,
update_cache=update_cache,
)
)
assert tags == ["Existing", "New"]
assert tags == ["existing", "new"]
assert manager.saved
assert cache_updates

View File

@@ -0,0 +1,96 @@
"""Tests for tag case sensitivity handling to prevent issues on Windows."""
import asyncio
import json
from pathlib import Path
from typing import Any, Dict
import pytest
from py.services.tag_update_service import TagUpdateService
class RecordingMetadataManager:
def __init__(self) -> None:
self.saved: list[tuple[str, Dict[str, Any]]] = []
async def save_metadata(self, path: str, metadata: Dict[str, Any]) -> bool:
self.saved.append((path, json.loads(json.dumps(metadata))))
return True
class DummyProvider:
async def __call__(self, path: str) -> Dict[str, Any]:
return {"tags": []}
@pytest.mark.asyncio
async def test_tag_update_service_handles_case_insensitive_tags(tmp_path: Path) -> None:
"""Test that tag update service treats tags case-insensitively."""
metadata_path = tmp_path / "model.metadata.json"
metadata_path.write_text(json.dumps({"tags": ["test"]}))
async def loader(path: str) -> Dict[str, Any]:
return json.loads(Path(path).read_text())
manager = RecordingMetadataManager()
service = TagUpdateService(metadata_manager=manager)
cache_updates: list[Dict[str, Any]] = []
async def update_cache(original: str, new: str, metadata: Dict[str, Any]) -> bool:
cache_updates.append(metadata)
return True
# Try to add "Test" (different case) - should not be added since "test" already exists
tags = await service.add_tags(
file_path=str(tmp_path / "model.safetensors"),
new_tags=["Test"],
metadata_loader=loader,
update_cache=update_cache,
)
# Should still only have "test" (lowercase) in the tags
assert tags == ["test"]
assert len(manager.saved) == 1
saved_metadata = manager.saved[0][1]
assert saved_metadata["tags"] == ["test"]
@pytest.mark.asyncio
async def test_tag_update_service_adds_new_tags_in_lowercase(tmp_path: Path) -> None:
"""Test that new tags are stored in lowercase."""
metadata_path = tmp_path / "model.metadata.json"
metadata_path.write_text(json.dumps({"tags": ["existing"]}))
async def loader(path: str) -> Dict[str, Any]:
return json.loads(Path(path).read_text())
manager = RecordingMetadataManager()
service = TagUpdateService(metadata_manager=manager)
cache_updates: list[Dict[str, Any]] = []
async def update_cache(original: str, new: str, metadata: Dict[str, Any]) -> bool:
cache_updates.append(metadata)
return True
# Add new tags with mixed case
tags = await service.add_tags(
file_path=str(tmp_path / "model.safetensors"),
new_tags=["NewTag", "ANOTHER_TAG"],
metadata_loader=loader,
update_cache=update_cache,
)
# New tags should be stored in lowercase
assert "existing" in tags
assert "newtag" in tags
assert "another_tag" in tags
assert len(manager.saved) == 1
saved_metadata = manager.saved[0][1]
assert "newtag" in saved_metadata["tags"]
assert "another_tag" in saved_metadata["tags"]
# Ensure all tags are lowercase
for tag in saved_metadata["tags"]:
assert tag == tag.lower()

View File

@@ -0,0 +1,50 @@
"""Tests for utils module case sensitivity handling."""
import pytest
from unittest.mock import Mock
from py.utils.utils import calculate_relative_path_for_model
from py.services.settings_manager import SettingsManager
def test_calculate_relative_path_handles_case_insensitive_tags():
"""Test that calculate_relative_path_for_model handles case insensitive tags correctly."""
# Create a mock settings manager
mock_settings = Mock(spec=SettingsManager)
mock_settings.get.return_value = {} # base_model_path_mappings
mock_settings.resolve_priority_tag_for_model.return_value = "test"
mock_settings.get_download_path_template.return_value = "{base_model}/{first_tag}"
# Mock the settings manager function
import py.utils.utils as utils_module
original_get_settings_manager = utils_module.get_settings_manager
utils_module.get_settings_manager = Mock(return_value=mock_settings)
try:
# Test model data with mixed case tags
model_data = {
"base_model": "SDXL",
"tags": ["Test", "ANOTHER_TAG"], # Mixed case tags
"model_name": "Test Model"
}
model_type = "lora"
# Call the function
result = calculate_relative_path_for_model(model_data, model_type)
# Verify that resolve_priority_tag_for_model was called with lowercase tags
called_args = mock_settings.resolve_priority_tag_for_model.call_args[0]
lowercase_tags = called_args[0]
# Check that tags are converted to lowercase
assert all(tag == tag.lower() for tag in lowercase_tags)
assert "test" in lowercase_tags
assert "another_tag" in lowercase_tags
# Verify the result format
assert result == "SDXL/test"
finally:
# Restore original function
utils_module.get_settings_manager = original_get_settings_manager