feat: store packaged user data under data directory
This commit is contained in:
+231
-14
@@ -1,24 +1,64 @@
|
||||
"""Application-level configuration for cmshopee.
|
||||
|
||||
This module owns `config.json` plus `config/ai_models.json`. `config.json`
|
||||
stores local app settings and AI role/generation parameters. AI provider
|
||||
definitions and local plaintext API keys live in ignored `config/ai_models.json`.
|
||||
This module owns `data/config.json` plus `data/config/ai_models.json`.
|
||||
`config.json` stores local app settings and AI role/generation parameters.
|
||||
AI provider definitions and local plaintext API keys live in ignored
|
||||
`data/config/ai_models.json`.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
|
||||
CONFIG_PATH = "config.json"
|
||||
AI_MODELS_PATH = os.path.join("config", "ai_models.json")
|
||||
CMHUB_CONFIG_PATH = os.path.join("config", "cmhub.json")
|
||||
DATA_DIR_NAME = "data"
|
||||
|
||||
|
||||
def app_base_dir() -> str:
|
||||
"""Return the application directory used as the parent of local data."""
|
||||
|
||||
if getattr(sys, "frozen", False):
|
||||
return os.path.dirname(os.path.abspath(sys.executable))
|
||||
return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
|
||||
def default_data_dir(base_dir=None) -> str:
|
||||
"""Return the default local user data directory."""
|
||||
|
||||
return os.path.abspath(os.path.join(base_dir or app_base_dir(), DATA_DIR_NAME))
|
||||
|
||||
|
||||
CONFIG_PATH = os.path.join(default_data_dir(), "config.json")
|
||||
AI_MODELS_PATH = os.path.join(default_data_dir(), "config", "ai_models.json")
|
||||
CMHUB_CONFIG_PATH = os.path.join(default_data_dir(), "config", "cmhub.json")
|
||||
CATEGORIES = {"text", "image"}
|
||||
API_TYPES = {"chat", "images_edits", "auto"}
|
||||
AI_BACKENDS = {"direct", "cmhub"}
|
||||
RUNTIME_CONFIG_KEYS = {
|
||||
"config_path",
|
||||
"ai_models_path",
|
||||
"cmhub_config_path",
|
||||
"data_dir",
|
||||
}
|
||||
LEGACY_USER_DATA_PATHS = (
|
||||
"config.json",
|
||||
"config",
|
||||
"cmshopee.db",
|
||||
"cmshopee.db-wal",
|
||||
"cmshopee.db-shm",
|
||||
"db.sqlite",
|
||||
"chrome_user_data_dir",
|
||||
"images",
|
||||
"logs",
|
||||
"prompts",
|
||||
"title_prompt.txt",
|
||||
)
|
||||
|
||||
DEFAULT_CONFIG = {
|
||||
"chrome_path": r"C:\Program Files\Google\Chrome\Application\chrome.exe",
|
||||
@@ -104,6 +144,180 @@ class ConfigError(RuntimeError):
|
||||
"""Raised when app configuration is missing or malformed."""
|
||||
|
||||
|
||||
class DataMigrationConflictError(ConfigError):
|
||||
"""Raised when old-layout data cannot be moved into data/ safely."""
|
||||
|
||||
|
||||
class DataDirectoryWriteError(ConfigError):
|
||||
"""Raised when the local data directory is not writable."""
|
||||
|
||||
|
||||
def _data_dir_for_config_path(path) -> str:
|
||||
config_path = os.path.abspath(path or CONFIG_PATH)
|
||||
if config_path == os.path.abspath(CONFIG_PATH):
|
||||
return default_data_dir()
|
||||
return os.path.dirname(config_path)
|
||||
|
||||
|
||||
def data_dir(config=None) -> str:
|
||||
"""Return the absolute local user data directory."""
|
||||
|
||||
if isinstance(config, dict):
|
||||
if config.get("data_dir"):
|
||||
return os.path.abspath(str(config["data_dir"]))
|
||||
if config.get("config_path"):
|
||||
return _data_dir_for_config_path(config["config_path"])
|
||||
return default_data_dir()
|
||||
|
||||
|
||||
def _strip_runtime_config_keys(config):
|
||||
if not isinstance(config, dict):
|
||||
return config
|
||||
return {
|
||||
key: copy.deepcopy(value)
|
||||
for key, value in config.items()
|
||||
if key not in RUNTIME_CONFIG_KEYS
|
||||
}
|
||||
|
||||
|
||||
def _runtime_paths(config_path, data_dir_path=None):
|
||||
root = os.path.abspath(data_dir_path or _data_dir_for_config_path(config_path))
|
||||
return {
|
||||
"config_path": os.path.abspath(config_path or os.path.join(root, "config.json")),
|
||||
"data_dir": root,
|
||||
"ai_models_path": os.path.join(root, "config", "ai_models.json"),
|
||||
"cmhub_config_path": os.path.join(root, "config", "cmhub.json"),
|
||||
}
|
||||
|
||||
|
||||
def _with_runtime_paths(config, path):
|
||||
result = copy.deepcopy(config)
|
||||
result.update(_runtime_paths(path))
|
||||
return result
|
||||
|
||||
|
||||
def _data_parent_dir(config=None) -> str:
|
||||
return os.path.dirname(data_dir(config))
|
||||
|
||||
|
||||
def resolve_data_path(path, config=None) -> str:
|
||||
"""Resolve a user-data path under data_dir unless it is already absolute."""
|
||||
|
||||
text = str(path or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
if os.path.isabs(text):
|
||||
return os.path.abspath(text)
|
||||
normalized = os.path.normpath(text)
|
||||
first_part = normalized.split(os.sep, 1)[0]
|
||||
if first_part == DATA_DIR_NAME:
|
||||
return os.path.abspath(os.path.join(_data_parent_dir(config), normalized))
|
||||
return os.path.abspath(os.path.join(data_dir(config), normalized))
|
||||
|
||||
|
||||
def data_path(*parts, config=None) -> str:
|
||||
return os.path.abspath(os.path.join(data_dir(config), *[str(part) for part in parts]))
|
||||
|
||||
|
||||
def ai_models_config_path(config=None) -> str:
|
||||
if isinstance(config, dict) and config.get("ai_models_path"):
|
||||
return os.path.abspath(str(config["ai_models_path"]))
|
||||
return data_path("config", "ai_models.json", config=config)
|
||||
|
||||
|
||||
def cmhub_config_file_path(config=None) -> str:
|
||||
if isinstance(config, dict) and config.get("cmhub_config_path"):
|
||||
return os.path.abspath(str(config["cmhub_config_path"]))
|
||||
return data_path("config", "cmhub.json", config=config)
|
||||
|
||||
|
||||
def title_prompt_path(config=None) -> str:
|
||||
return data_path("title_prompt.txt", config=config)
|
||||
|
||||
|
||||
def cover_prompts_dir(config=None) -> str:
|
||||
return data_path("prompts", "cover", config=config)
|
||||
|
||||
|
||||
def diagnostic_log_dir(config=None) -> str:
|
||||
return data_path("logs", config=config)
|
||||
|
||||
|
||||
def _is_within(path, parent) -> bool:
|
||||
try:
|
||||
return os.path.commonpath([os.path.abspath(path), os.path.abspath(parent)]) == os.path.abspath(parent)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def migrate_legacy_user_data(base_dir=None, data_dir_path=None):
|
||||
"""Move old exe-top-level local data into data_dir without overwriting."""
|
||||
|
||||
base = os.path.abspath(base_dir or app_base_dir())
|
||||
target_root = os.path.abspath(data_dir_path or default_data_dir(base))
|
||||
if base == target_root:
|
||||
return []
|
||||
os.makedirs(target_root, exist_ok=True)
|
||||
|
||||
moves = []
|
||||
seen_sources = set()
|
||||
for relative in LEGACY_USER_DATA_PATHS:
|
||||
source = os.path.abspath(os.path.join(base, relative))
|
||||
if source in seen_sources or not os.path.exists(source):
|
||||
continue
|
||||
seen_sources.add(source)
|
||||
if _is_within(source, target_root):
|
||||
continue
|
||||
if not _is_within(source, base):
|
||||
raise ConfigError(f"旧数据路径不在程序目录内: {source}")
|
||||
target = os.path.abspath(os.path.join(target_root, relative))
|
||||
if not _is_within(target, target_root):
|
||||
raise ConfigError(f"迁移目标路径不在 data 目录内: {target}")
|
||||
moves.append((relative, source, target))
|
||||
|
||||
conflicts = [relative for relative, _source, target in moves if os.path.exists(target)]
|
||||
if conflicts:
|
||||
raise DataMigrationConflictError(
|
||||
"检测到旧布局数据和 data 目录内数据同时存在,无法自动迁移。"
|
||||
"请先手动合并或备份后再启动。冲突项: " + "、".join(conflicts)
|
||||
)
|
||||
|
||||
moved = []
|
||||
for relative, source, target in moves:
|
||||
os.makedirs(os.path.dirname(target), exist_ok=True)
|
||||
shutil.move(source, target)
|
||||
moved.append(relative)
|
||||
return moved
|
||||
|
||||
|
||||
def ensure_writable_data_dir(path=None) -> str:
|
||||
root = os.path.abspath(path or default_data_dir())
|
||||
try:
|
||||
os.makedirs(root, exist_ok=True)
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w",
|
||||
encoding="utf-8",
|
||||
prefix=".cmshopee_write_test_",
|
||||
dir=root,
|
||||
delete=False,
|
||||
) as fh:
|
||||
marker = fh.name
|
||||
fh.write("ok")
|
||||
os.remove(marker)
|
||||
except Exception as exc:
|
||||
raise DataDirectoryWriteError(
|
||||
f"数据目录不可写:{root}。请把程序放到可写目录,勿放 Program Files。"
|
||||
) from exc
|
||||
return root
|
||||
|
||||
|
||||
def prepare_data_dir(base_dir=None, data_dir_path=None, migrate=True) -> str:
|
||||
root = os.path.abspath(data_dir_path or default_data_dir(base_dir))
|
||||
if migrate:
|
||||
migrate_legacy_user_data(base_dir=base_dir, data_dir_path=root)
|
||||
return ensure_writable_data_dir(root)
|
||||
|
||||
|
||||
def default_config() -> dict:
|
||||
"""Return a new copy of the default config."""
|
||||
|
||||
@@ -241,7 +455,7 @@ def _normalize_cmhub_config(config):
|
||||
if config is None:
|
||||
config = {}
|
||||
if not isinstance(config, dict):
|
||||
raise ConfigError("config/cmhub.json 必须是对象")
|
||||
raise ConfigError("cmhub 配置必须是对象")
|
||||
return {"api_key": str(config.get("api_key", "") or "")}
|
||||
|
||||
|
||||
@@ -279,7 +493,7 @@ def get_cmhub_api_key(path=CMHUB_CONFIG_PATH, masked=False) -> str:
|
||||
def save_config(config, path=CONFIG_PATH) -> dict:
|
||||
"""Persist config to JSON and return the normalized config."""
|
||||
|
||||
normalized = _deep_merge(DEFAULT_CONFIG, config)
|
||||
normalized = _deep_merge(DEFAULT_CONFIG, _strip_runtime_config_keys(config))
|
||||
_normalize_config_values(normalized)
|
||||
_assert_no_secrets(normalized)
|
||||
directory = os.path.dirname(os.path.abspath(path))
|
||||
@@ -288,7 +502,7 @@ def save_config(config, path=CONFIG_PATH) -> dict:
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
json.dump(normalized, fh, ensure_ascii=False, indent=2)
|
||||
fh.write("\n")
|
||||
return normalized
|
||||
return _with_runtime_paths(normalized, path)
|
||||
|
||||
|
||||
def load_config(path=CONFIG_PATH) -> dict:
|
||||
@@ -304,7 +518,7 @@ def load_config(path=CONFIG_PATH) -> dict:
|
||||
normalized = _deep_merge(DEFAULT_CONFIG, loaded)
|
||||
_normalize_config_values(normalized)
|
||||
_assert_no_secrets(normalized)
|
||||
return normalized
|
||||
return _with_runtime_paths(normalized, path)
|
||||
|
||||
|
||||
def update_config(updates, path=CONFIG_PATH) -> dict:
|
||||
@@ -323,15 +537,18 @@ def chrome_path(config=None) -> str:
|
||||
|
||||
|
||||
def user_data_root(config=None) -> str:
|
||||
return _config_or_load(config).get("user_data_root", "chrome_user_data_dir")
|
||||
cfg = _config_or_load(config)
|
||||
return resolve_data_path(cfg.get("user_data_root", "chrome_user_data_dir"), cfg)
|
||||
|
||||
|
||||
def image_dir(config=None) -> str:
|
||||
return _config_or_load(config).get("image_dir", "images")
|
||||
cfg = _config_or_load(config)
|
||||
return resolve_data_path(cfg.get("image_dir", "images"), cfg)
|
||||
|
||||
|
||||
def db_path(config=None) -> str:
|
||||
return _config_or_load(config).get("db_path", "cmshopee.db")
|
||||
cfg = _config_or_load(config)
|
||||
return resolve_data_path(cfg.get("db_path", "cmshopee.db"), cfg)
|
||||
|
||||
|
||||
def default_debug_port(config=None) -> int:
|
||||
@@ -446,7 +663,7 @@ def _normalize_ai_model(model):
|
||||
def _normalize_ai_models_config(config):
|
||||
models = config.get("models") if isinstance(config, dict) else None
|
||||
if not isinstance(models, list):
|
||||
raise ConfigError("config/ai_models.json 必须包含 models 列表")
|
||||
raise ConfigError("AI 模型清单必须包含 models 列表")
|
||||
normalized = {"models": [_normalize_ai_model(model) for model in models]}
|
||||
_assert_unique_model_names(normalized["models"])
|
||||
_assert_required_categories(normalized["models"])
|
||||
|
||||
Reference in New Issue
Block a user