fix(settings): restrict minimal persistence keys

This commit is contained in:
pixelpaws
2025-10-26 19:53:43 +08:00
parent b5ee4a6408
commit 38e766484e
4 changed files with 172 additions and 28 deletions

View File

@@ -149,8 +149,8 @@ Enhance your Civitai browsing experience with our companion browser extension! S
### Option 2: **Portable Standalone Edition** (No ComfyUI required) ### Option 2: **Portable Standalone Edition** (No ComfyUI required)
1. Download the [Portable Package](https://github.com/willmiao/ComfyUI-Lora-Manager/releases/download/v0.9.8/lora_manager_portable.7z) 1. Download the [Portable Package](https://github.com/willmiao/ComfyUI-Lora-Manager/releases/download/v0.9.8/lora_manager_portable.7z)
2. Copy the provided `settings.json.example` file to create a new file named `settings.json` in `comfyui-lora-manager` folder 2. Copy the provided `settings.json.example` file to create a new file named `settings.json` in `comfyui-lora-manager` folder. Only adjust the API key, optional language, and folder paths—the library registry is generated automatically at runtime.
3. Edit the new `settings.json` to include your correct model folder paths and CivitAI API key 3. Edit the new `settings.json` to include your correct model folder paths and CivitAI API key (or keep the placeholders until you are ready to configure them)
4. Run run.bat 4. Run run.bat
- To change the startup port, edit `run.bat` and modify the parameter (e.g. `--port 9001`) - To change the startup port, edit `run.bat` and modify the parameter (e.g. `--port 9001`)
@@ -231,8 +231,8 @@ You can now run LoRA Manager independently from ComfyUI:
``` ```
2. **For non-ComfyUI users**: 2. **For non-ComfyUI users**:
- Copy the provided `settings.json.example` file to create a new file named `settings.json` - Copy the provided `settings.json.example` file to create a new file named `settings.json`. Update the API key, optional language, and folder paths only—the library registry is created automatically when LoRA Manager starts.
- Edit `settings.json` to include your correct model folder paths and CivitAI API key - Edit `settings.json` to include your correct model folder paths and CivitAI API key (you can leave the defaults until ready to configure them)
- Install required dependencies: `pip install -r requirements.txt` - Install required dependencies: `pip install -r requirements.txt`
- Run standalone mode: - Run standalone mode:
```bash ```bash

View File

@@ -3,6 +3,7 @@ import copy
import json import json
import os import os
import logging import logging
from pathlib import Path
from datetime import datetime, timezone from datetime import datetime, timezone
from threading import Lock from threading import Lock
from typing import Any, Awaitable, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple from typing import Any, Awaitable, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple
@@ -19,6 +20,12 @@ from ..utils.tag_priorities import (
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
CORE_USER_SETTING_KEYS: Tuple[str, ...] = (
"civitai_api_key",
"folder_paths",
)
DEFAULT_SETTINGS: Dict[str, Any] = { DEFAULT_SETTINGS: Dict[str, Any] = {
"civitai_api_key": "", "civitai_api_key": "",
"language": "en", "language": "en",
@@ -35,6 +42,7 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
"default_embedding_root": "", "default_embedding_root": "",
"base_model_path_mappings": {}, "base_model_path_mappings": {},
"download_path_templates": {}, "download_path_templates": {},
"folder_paths": {},
"example_images_path": "", "example_images_path": "",
"optimize_example_images": True, "optimize_example_images": True,
"auto_download_example_images": False, "auto_download_example_images": False,
@@ -57,6 +65,7 @@ class SettingsManager:
self._startup_messages: List[Dict[str, Any]] = [] self._startup_messages: List[Dict[str, Any]] = []
self._needs_initial_save = False self._needs_initial_save = False
self._bootstrap_reason: Optional[str] = None self._bootstrap_reason: Optional[str] = None
self._seed_template: Optional[Dict[str, Any]] = None
self.settings = self._load_settings() self.settings = self._load_settings()
self._migrate_setting_keys() self._migrate_setting_keys()
self._ensure_default_settings() self._ensure_default_settings()
@@ -114,31 +123,99 @@ class SettingsManager:
if not os.path.exists(self.settings_file): if not os.path.exists(self.settings_file):
self._needs_initial_save = True self._needs_initial_save = True
self._bootstrap_reason = "missing" self._bootstrap_reason = "missing"
seeded = self._load_settings_template()
if seeded is not None:
defaults = self._get_default_settings()
merged = self._merge_template_with_defaults(defaults, seeded)
return merged
return self._get_default_settings() return self._get_default_settings()
def _load_settings_template(self) -> Optional[Dict[str, Any]]:
"""Load the bundled template when no user settings are found."""
template_path = Path(__file__).resolve().parents[2] / "settings.json.example"
try:
with template_path.open("r", encoding="utf-8") as handle:
data = json.load(handle)
except FileNotFoundError:
logger.debug("settings.json.example not found at %s", template_path)
return None
except json.JSONDecodeError as exc:
logger.warning("Failed to parse settings.json.example: %s", exc)
return None
if not isinstance(data, dict):
logger.debug("settings.json.example is not a JSON object; ignoring template")
return None
self._seed_template = copy.deepcopy(data)
return copy.deepcopy(data)
def _merge_template_with_defaults(
self, defaults: Dict[str, Any], template: Mapping[str, Any]
) -> Dict[str, Any]:
"""Merge template values into the in-memory defaults."""
merged = copy.deepcopy(defaults)
for key, value in template.items():
if key == "folder_paths" and isinstance(value, Mapping):
merged[key] = self._normalize_folder_paths(value)
else:
merged[key] = copy.deepcopy(value)
merged.setdefault("language", "en")
merged.setdefault("folder_paths", {})
library_name = merged.get("active_library") or "default"
merged["libraries"] = {
library_name: self._build_library_payload(
folder_paths=merged.get("folder_paths", {}),
default_lora_root=merged.get("default_lora_root"),
default_checkpoint_root=merged.get("default_checkpoint_root"),
default_embedding_root=merged.get("default_embedding_root"),
)
}
merged["active_library"] = library_name
return merged
def _ensure_default_settings(self) -> None: def _ensure_default_settings(self) -> None:
"""Ensure all default settings keys exist""" """Ensure all default settings keys exist"""
updated = False defaults = self._get_default_settings()
normalized_priority = self._normalize_priority_tag_config( updated_existing = False
self.settings.get("priority_tags") inserted_defaults = False
)
if normalized_priority != self.settings.get("priority_tags"): if "priority_tags" in self.settings:
self.settings["priority_tags"] = normalized_priority normalized_priority = self._normalize_priority_tag_config(
updated = True self.settings.get("priority_tags")
for key, value in self._get_default_settings().items(): )
if normalized_priority != self.settings.get("priority_tags"):
self.settings["priority_tags"] = normalized_priority
updated_existing = True
else:
self.settings["priority_tags"] = copy.deepcopy(
defaults.get("priority_tags", DEFAULT_PRIORITY_TAG_CONFIG)
)
inserted_defaults = True
for key, value in defaults.items():
if key == "priority_tags":
continue
if key not in self.settings: if key not in self.settings:
if isinstance(value, dict): if isinstance(value, dict):
self.settings[key] = value.copy() self.settings[key] = copy.deepcopy(value)
else: else:
self.settings[key] = value self.settings[key] = value
updated = True inserted_defaults = True
if updated:
if updated_existing or (
inserted_defaults and self._bootstrap_reason in {"invalid", "unreadable"}
):
self._save_settings() self._save_settings()
def _migrate_to_library_registry(self) -> None: def _migrate_to_library_registry(self) -> None:
"""Ensure settings include the multi-library registry structure.""" """Ensure settings include the multi-library registry structure."""
libraries = self.settings.get("libraries") libraries = self.settings.get("libraries")
active_name = self.settings.get("active_library") active_name = self.settings.get("active_library")
initial_bootstrap = self._bootstrap_reason == "missing"
if not isinstance(libraries, dict) or not libraries: if not isinstance(libraries, dict) or not libraries:
library_name = active_name or "default" library_name = active_name or "default"
@@ -152,7 +229,8 @@ class SettingsManager:
self.settings["libraries"] = libraries self.settings["libraries"] = libraries
self.settings["active_library"] = library_name self.settings["active_library"] = library_name
self._sync_active_library_to_root(save=False) self._sync_active_library_to_root(save=False)
self._save_settings() if not initial_bootstrap:
self._save_settings()
return return
sanitized_libraries: Dict[str, Dict[str, Any]] = {} sanitized_libraries: Dict[str, Dict[str, Any]] = {}
@@ -177,12 +255,15 @@ class SettingsManager:
self.settings["libraries"] = sanitized_libraries self.settings["libraries"] = sanitized_libraries
if not active_name or active_name not in sanitized_libraries: if not active_name or active_name not in sanitized_libraries:
changed = True
if sanitized_libraries: if sanitized_libraries:
self.settings["active_library"] = next(iter(sanitized_libraries.keys())) self.settings["active_library"] = next(iter(sanitized_libraries.keys()))
else: else:
self.settings["active_library"] = "default" self.settings["active_library"] = "default"
self._sync_active_library_to_root(save=changed) self._sync_active_library_to_root(save=changed and not initial_bootstrap)
if changed and initial_bootstrap:
self._needs_initial_save = True
def _sync_active_library_to_root(self, *, save: bool = False) -> None: def _sync_active_library_to_root(self, *, save: bool = False) -> None:
"""Update top-level folder path settings to mirror the active library.""" """Update top-level folder path settings to mirror the active library."""
@@ -427,7 +508,10 @@ class SettingsManager:
default_checkpoint_root=self.settings.get('default_checkpoint_root'), default_checkpoint_root=self.settings.get('default_checkpoint_root'),
default_embedding_root=self.settings.get('default_embedding_root'), default_embedding_root=self.settings.get('default_embedding_root'),
) )
self._save_settings() if self._bootstrap_reason == "missing":
self._needs_initial_save = True
else:
self._save_settings()
def _check_environment_variables(self) -> None: def _check_environment_variables(self) -> None:
"""Check for environment variables and update settings if needed""" """Check for environment variables and update settings if needed"""
@@ -524,11 +608,21 @@ class SettingsManager:
def _get_default_settings(self) -> Dict[str, Any]: def _get_default_settings(self) -> Dict[str, Any]:
"""Return default settings""" """Return default settings"""
defaults = DEFAULT_SETTINGS.copy() defaults = copy.deepcopy(DEFAULT_SETTINGS)
# Ensure nested dicts are independent copies
defaults['base_model_path_mappings'] = {} defaults['base_model_path_mappings'] = {}
defaults['download_path_templates'] = {} defaults['download_path_templates'] = {}
defaults['priority_tags'] = DEFAULT_PRIORITY_TAG_CONFIG.copy() defaults['priority_tags'] = DEFAULT_PRIORITY_TAG_CONFIG.copy()
defaults.setdefault('folder_paths', {})
library_name = defaults.get("active_library") or "default"
default_library = self._build_library_payload(
folder_paths=defaults.get("folder_paths", {}),
default_lora_root=defaults.get("default_lora_root"),
default_checkpoint_root=defaults.get("default_checkpoint_root"),
default_embedding_root=defaults.get("default_embedding_root"),
)
defaults['libraries'] = {library_name: default_library}
defaults['active_library'] = library_name
return defaults return defaults
def _normalize_priority_tag_config(self, value: Any) -> Dict[str, str]: def _normalize_priority_tag_config(self, value: Any) -> Dict[str, str]:
@@ -685,10 +779,32 @@ class SettingsManager:
def _save_settings(self) -> None: def _save_settings(self) -> None:
"""Save settings to file""" """Save settings to file"""
try: try:
payload = self._serialize_settings_for_disk()
with open(self.settings_file, 'w', encoding='utf-8') as f: with open(self.settings_file, 'w', encoding='utf-8') as f:
json.dump(self.settings, f, indent=2) json.dump(payload, f, indent=2)
except Exception as e: except Exception as e:
logger.error(f"Error saving settings: {e}") logger.error(f"Error saving settings: {e}")
else:
if self._bootstrap_reason == "missing":
self._bootstrap_reason = None
self._seed_template = None
def _serialize_settings_for_disk(self) -> Dict[str, Any]:
"""Return the settings payload that should be persisted to disk."""
if self._bootstrap_reason == "missing":
minimal: Dict[str, Any] = {}
for key in CORE_USER_SETTING_KEYS:
if key in self.settings:
minimal[key] = copy.deepcopy(self.settings[key])
if self._seed_template:
for key, value in self._seed_template.items():
minimal.setdefault(key, copy.deepcopy(value))
return minimal
return copy.deepcopy(self.settings)
def get_libraries(self) -> Dict[str, Dict[str, Any]]: def get_libraries(self) -> Dict[str, Dict[str, Any]]:
"""Return a copy of the registered libraries.""" """Return a copy of the registered libraries."""

View File

@@ -1,17 +1,16 @@
{ {
"_note": "LoRA Manager builds the detailed library registry automatically at runtime.",
"language": "en",
"civitai_api_key": "your_civitai_api_key_here", "civitai_api_key": "your_civitai_api_key_here",
"folder_paths": { "folder_paths": {
"loras": [ "loras": [
"C:/path/to/your/loras_folder", "C:/path/to/your/loras_folder"
"C:/path/to/another/loras_folder"
], ],
"checkpoints": [ "checkpoints": [
"C:/path/to/your/checkpoints_folder", "C:/path/to/your/checkpoints_folder"
"C:/path/to/another/checkpoints_folder"
], ],
"embeddings": [ "embeddings": [
"C:/path/to/your/embeddings_folder", "C:/path/to/your/embeddings_folder"
"C:/path/to/another/embeddings_folder"
] ]
} }
} }

View File

@@ -24,6 +24,35 @@ def manager(tmp_path, monkeypatch):
return mgr return mgr
def test_initial_save_persists_minimal_template(tmp_path, monkeypatch):
settings_path = tmp_path / "settings.json"
monkeypatch.setattr(
"py.services.settings_manager.ensure_settings_file",
lambda logger=None: str(settings_path),
)
template = {
"_note": "template note",
"language": "fr",
"folder_paths": {"loras": ["/loras"]},
}
def fake_template_loader(self):
self._seed_template = copy.deepcopy(template)
return copy.deepcopy(template)
monkeypatch.setattr(SettingsManager, "_load_settings_template", fake_template_loader)
manager = SettingsManager()
persisted = json.loads(settings_path.read_text(encoding="utf-8"))
assert persisted["_note"] == "template note"
assert "libraries" not in persisted
assert persisted["folder_paths"]["loras"] == ["/loras"]
assert manager.get_libraries()["default"]["folder_paths"]["loras"] == ["/loras"]
def test_environment_variable_overrides_settings(tmp_path, monkeypatch): def test_environment_variable_overrides_settings(tmp_path, monkeypatch):
monkeypatch.setattr(SettingsManager, "_save_settings", lambda self: None) monkeypatch.setattr(SettingsManager, "_save_settings", lambda self: None)
monkeypatch.setenv("CIVITAI_API_KEY", "secret") monkeypatch.setenv("CIVITAI_API_KEY", "secret")