From 88d5caf642b185f8b26dff3a1b6a132da2e7f4de Mon Sep 17 00:00:00 2001 From: pixelpaws Date: Sat, 27 Sep 2025 22:22:15 +0800 Subject: [PATCH] feat(settings): migrate settings to user config dir --- README.md | 13 ++-- py/config.py | 6 +- py/routes/update_routes.py | 4 +- py/services/settings_manager.py | 6 +- py/utils/settings_paths.py | 84 +++++++++++++++++++++++++ pyproject.toml | 3 +- requirements.txt | 1 + standalone.py | 12 ++-- tests/services/test_settings_manager.py | 33 +++++++++- 9 files changed, 146 insertions(+), 16 deletions(-) create mode 100644 py/utils/settings_paths.py diff --git a/README.md b/README.md index 87ba168f..a74b3d74 100644 --- a/README.md +++ b/README.md @@ -140,8 +140,11 @@ Enhance your Civitai browsing experience with our companion browser extension! S ### Option 2: **Portable Standalone Edition** (No ComfyUI required) 1. Download the [Portable Package](https://github.com/willmiao/ComfyUI-Lora-Manager/releases/download/v0.9.2/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 -3. Edit `settings.json` to include your correct model folder paths and CivitAI API key +2. Copy the provided `settings.json.example` file to your LoRA Manager settings folder and rename it to `settings.json`: + - **Windows:** `%APPDATA%/ComfyUI-LoRA-Manager/settings.json` + - **macOS:** `~/Library/Application Support/ComfyUI-LoRA-Manager/settings.json` + - **Linux:** `${XDG_CONFIG_HOME:-~/.config}/ComfyUI-LoRA-Manager/settings.json` +3. Edit the new `settings.json` to include your correct model folder paths and CivitAI API key 4. Run run.bat - To change the startup port, edit `run.bat` and modify the parameter (e.g. `--port 9001`) @@ -209,7 +212,7 @@ You can combine multiple patterns to create detailed, organized filenames for yo You can now run LoRA Manager independently from ComfyUI: 1. **For ComfyUI users**: - - Launch ComfyUI with LoRA Manager at least once to initialize the necessary path information in the `settings.json` file. + - Launch ComfyUI with LoRA Manager at least once to initialize the necessary path information in the `settings.json` file located in your user settings folder (see paths above). - Make sure dependencies are installed: `pip install -r requirements.txt` - From your ComfyUI root directory, run: ```bash @@ -222,7 +225,7 @@ You can now run LoRA Manager independently from ComfyUI: ``` 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 the LoRA Manager settings folder (`%APPDATA%/ComfyUI-LoRA-Manager/`, `~/Library/Application Support/ComfyUI-LoRA-Manager/`, or `${XDG_CONFIG_HOME:-~/.config}/ComfyUI-LoRA-Manager/`) and rename it to `settings.json` - Edit `settings.json` to include your correct model folder paths and CivitAI API key - Install required dependencies: `pip install -r requirements.txt` - Run standalone mode: @@ -231,6 +234,8 @@ You can now run LoRA Manager independently from ComfyUI: ``` - Access the interface through your browser at: `http://localhost:8188/loras` + > **Note:** Existing installations automatically migrate the legacy `settings.json` from the plugin folder to the user settings directory the first time you launch this version. + This standalone mode provides a lightweight option for managing your model and recipe collection without needing to run the full ComfyUI environment, making it useful even for users who primarily use other stable diffusion interfaces. ## Testing & Coverage diff --git a/py/config.py b/py/config.py index a15d0141..ccf081d7 100644 --- a/py/config.py +++ b/py/config.py @@ -6,6 +6,8 @@ import logging import json import urllib.parse +from py.utils.settings_paths import ensure_settings_file + # Use an environment variable to control standalone mode standalone_mode = os.environ.get("HF_HUB_DISABLE_TELEMETRY", "0") == "0" @@ -40,12 +42,12 @@ class Config: try: # Check if we're running in ComfyUI mode (not standalone) # Load existing settings - settings_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'settings.json') + settings_path = ensure_settings_file(logger) settings = {} if os.path.exists(settings_path): with open(settings_path, 'r', encoding='utf-8') as f: settings = json.load(f) - + # Update settings with paths settings['folder_paths'] = { 'loras': self.loras_roots, diff --git a/py/routes/update_routes.py b/py/routes/update_routes.py index 11d77ed7..443b686a 100644 --- a/py/routes/update_routes.py +++ b/py/routes/update_routes.py @@ -8,6 +8,8 @@ import tempfile import asyncio from aiohttp import web, ClientError from typing import Dict, List + +from py.utils.settings_paths import ensure_settings_file from ..services.downloader import get_downloader logger = logging.getLogger(__name__) @@ -121,7 +123,7 @@ class UpdateRoutes: current_dir = os.path.dirname(os.path.abspath(__file__)) plugin_root = os.path.dirname(os.path.dirname(current_dir)) - settings_path = os.path.join(plugin_root, 'settings.json') + settings_path = ensure_settings_file(logger) settings_backup = None if os.path.exists(settings_path): with open(settings_path, 'r', encoding='utf-8') as f: diff --git a/py/services/settings_manager.py b/py/services/settings_manager.py index 795c6da0..41a4ed50 100644 --- a/py/services/settings_manager.py +++ b/py/services/settings_manager.py @@ -1,8 +1,10 @@ -import os import json +import os import logging from typing import Any, Dict +from py.utils.settings_paths import ensure_settings_file + logger = logging.getLogger(__name__) @@ -36,7 +38,7 @@ DEFAULT_SETTINGS: Dict[str, Any] = { class SettingsManager: def __init__(self): - self.settings_file = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), 'settings.json') + self.settings_file = ensure_settings_file(logger) self.settings = self._load_settings() self._migrate_setting_keys() self._ensure_default_settings() diff --git a/py/utils/settings_paths.py b/py/utils/settings_paths.py new file mode 100644 index 00000000..0a05b3da --- /dev/null +++ b/py/utils/settings_paths.py @@ -0,0 +1,84 @@ +"""Utilities for locating and migrating the LoRA Manager settings file.""" + +from __future__ import annotations + +import logging +import os +import shutil +from typing import Optional + +from platformdirs import user_config_dir + + +APP_NAME = "ComfyUI-LoRA-Manager" +_LOGGER = logging.getLogger(__name__) + + +def get_project_root() -> str: + """Return the root directory of the project repository.""" + + return os.path.dirname(os.path.dirname(os.path.dirname(__file__))) + + +def get_legacy_settings_path() -> str: + """Return the legacy location of ``settings.json`` within the project tree.""" + + return os.path.join(get_project_root(), "settings.json") + + +def get_settings_dir(create: bool = True) -> str: + """Return the user configuration directory for the application. + + Args: + create: Whether to create the directory if it does not already exist. + + Returns: + The absolute path to the user configuration directory. + """ + + config_dir = user_config_dir(APP_NAME, appauthor=False) + if create: + os.makedirs(config_dir, exist_ok=True) + return config_dir + + +def get_settings_file_path(create_dir: bool = True) -> str: + """Return the path to ``settings.json`` in the user configuration directory.""" + + return os.path.join(get_settings_dir(create=create_dir), "settings.json") + + +def ensure_settings_file(logger: Optional[logging.Logger] = None) -> str: + """Ensure the settings file resides in the user configuration directory. + + If a legacy ``settings.json`` is detected in the project root it is migrated to + the platform-specific user configuration folder. The caller receives the path + to the settings file irrespective of whether a migration was needed. + + Args: + logger: Optional logger used for migration messages. Falls back to a + module level logger when omitted. + + Returns: + The absolute path to ``settings.json`` in the user configuration folder. + """ + + logger = logger or _LOGGER + target_path = get_settings_file_path(create_dir=True) + legacy_path = get_legacy_settings_path() + + if os.path.exists(legacy_path) and not os.path.exists(target_path): + try: + os.makedirs(os.path.dirname(target_path), exist_ok=True) + shutil.move(legacy_path, target_path) + logger.info("Migrated settings.json to %s", target_path) + except Exception as exc: # pragma: no cover - defensive fallback path + logger.warning("Failed to move legacy settings.json: %s", exc) + try: + shutil.copy2(legacy_path, target_path) + logger.info("Copied legacy settings.json to %s", target_path) + except Exception as copy_exc: # pragma: no cover - defensive fallback path + logger.error("Could not migrate settings.json: %s", copy_exc) + + return target_path + diff --git a/pyproject.toml b/pyproject.toml index 8ac3aebd..0c16679c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,8 @@ dependencies = [ "toml", "natsort", "GitPython", - "aiosqlite" + "aiosqlite", + "platformdirs" ] [project.urls] diff --git a/requirements.txt b/requirements.txt index 4051dc74..0e6eb5b1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,3 +10,4 @@ natsort GitPython aiosqlite beautifulsoup4 +platformdirs diff --git a/standalone.py b/standalone.py index 95c45ca7..70464008 100644 --- a/standalone.py +++ b/standalone.py @@ -3,6 +3,7 @@ import os import sys import json from py.middleware.cache_middleware import cache_control +from py.utils.settings_paths import ensure_settings_file, get_settings_dir # Set environment variable to indicate standalone mode os.environ["COMFYUI_LORA_MANAGER_STANDALONE"] = "1" @@ -32,7 +33,7 @@ class MockFolderPaths: @staticmethod def get_folder_paths(folder_name): # Load paths from settings.json - settings_path = os.path.join(os.path.dirname(__file__), 'settings.json') + settings_path = ensure_settings_file() try: if os.path.exists(settings_path): with open(settings_path, 'r', encoding='utf-8') as f: @@ -159,7 +160,7 @@ class StandaloneServer: self.app.router.add_get('/', self.handle_status) # Add static route for example images if the path exists in settings - settings_path = os.path.join(os.path.dirname(__file__), 'settings.json') + settings_path = ensure_settings_file(logger) if os.path.exists(settings_path): with open(settings_path, 'r', encoding='utf-8') as f: settings = json.load(f) @@ -219,16 +220,19 @@ from py.lora_manager import LoraManager def validate_settings(): """Validate that settings.json exists and has required configuration""" - settings_path = os.path.join(os.path.dirname(__file__), 'settings.json') + settings_path = ensure_settings_file(logger) if not os.path.exists(settings_path): logger.error("=" * 80) logger.error("CONFIGURATION ERROR: settings.json file not found!") logger.error("") + logger.error("Expected location: %s", settings_path) + logger.error("") logger.error("To run in standalone mode, you need to create a settings.json file.") logger.error("Please follow these steps:") logger.error("") logger.error("1. Copy the provided settings.json.example file to create a new file") - logger.error(" named settings.json in the comfyui-lora-manager folder") + logger.error(" named settings.json inside the LoRA Manager settings folder:") + logger.error(" %s", get_settings_dir()) logger.error("") logger.error("2. Edit settings.json to include your correct model folder paths") logger.error(" and CivitAI API key") diff --git a/tests/services/test_settings_manager.py b/tests/services/test_settings_manager.py index 7e547680..1951c7c1 100644 --- a/tests/services/test_settings_manager.py +++ b/tests/services/test_settings_manager.py @@ -3,21 +3,32 @@ import json import pytest from py.services.settings_manager import SettingsManager +from py.utils import settings_paths @pytest.fixture def manager(tmp_path, monkeypatch): monkeypatch.setattr(SettingsManager, "_save_settings", lambda self: None) + fake_settings_path = tmp_path / "settings.json" + monkeypatch.setattr( + "py.services.settings_manager.ensure_settings_file", + lambda logger=None: str(fake_settings_path), + ) mgr = SettingsManager() - mgr.settings_file = str(tmp_path / "settings.json") + mgr.settings_file = str(fake_settings_path) return mgr def test_environment_variable_overrides_settings(tmp_path, monkeypatch): monkeypatch.setattr(SettingsManager, "_save_settings", lambda self: None) monkeypatch.setenv("CIVITAI_API_KEY", "secret") + fake_settings_path = tmp_path / "settings.json" + monkeypatch.setattr( + "py.services.settings_manager.ensure_settings_file", + lambda logger=None: str(fake_settings_path), + ) mgr = SettingsManager() - mgr.settings_file = str(tmp_path / "settings.json") + mgr.settings_file = str(fake_settings_path) assert mgr.get("civitai_api_key") == "secret" @@ -59,3 +70,21 @@ def test_delete_setting(manager): manager.set("example", 1) manager.delete("example") assert manager.get("example") is None + + +def test_migrates_legacy_settings_file(tmp_path, monkeypatch): + legacy_root = tmp_path / "legacy" + legacy_root.mkdir() + legacy_file = legacy_root / "settings.json" + legacy_file.write_text("{\"value\": 1}", encoding="utf-8") + + target_dir = tmp_path / "config" + + monkeypatch.setattr(settings_paths, "get_project_root", lambda: str(legacy_root)) + monkeypatch.setattr(settings_paths, "user_config_dir", lambda *_, **__: str(target_dir)) + + migrated_path = settings_paths.ensure_settings_file() + + assert migrated_path == str(target_dir / "settings.json") + assert (target_dir / "settings.json").exists() + assert not legacy_file.exists()