feat: store packaged user data under data directory

This commit is contained in:
chengma
2026-07-07 14:12:32 +08:00
parent caa04a9aa7
commit 148c4a73eb
33 changed files with 602 additions and 196 deletions
+231 -14
View File
@@ -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"])