feat: switch config to JSONC + split secrets.json
Load business config as JSONC (comments + trailing commas) via an in-repo stripper then stdlib json — no new deps. Default path prefers config.jsonc, falls back to legacy config.json. Auth write-back targets only sibling secrets.json so JSONC comments are never wiped; secrets overlay merges on load. Examples, compose, Docker, docs, favsort and tools all share the same loader. Phase A only — no DB settings migration.
This commit was merged in pull request #4.
This commit is contained in:
1 parent
b4f0f45c4e
commit
8e7ead1669
21 files changed
+652
-182
No files matched your search
+95
-22
@@ -8,7 +8,6 @@ B 站凭据必须采用 ``bilibili.read`` / ``bilibili.write`` 双账号格式
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
@@ -16,12 +15,15 @@ from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping
|
||||
|
||||
from . import jsonc
|
||||
from .client_ip import (
|
||||
DEFAULT_CLIENT_IP_HEADERS,
|
||||
DEFAULT_TRUSTED_PROXIES,
|
||||
parse_networks,
|
||||
)
|
||||
|
||||
DEFAULT_SECRETS_NAME = "secrets.json"
|
||||
|
||||
DEFAULT_CLASSIFY: dict[str, Any] = {
|
||||
"require_title": "咻咻满",
|
||||
"self_mid": 37754047,
|
||||
@@ -213,6 +215,7 @@ class AnalyticsConfig:
|
||||
@dataclass(frozen=True)
|
||||
class AppConfig:
|
||||
path: Path
|
||||
secrets_path: Path
|
||||
bilibili: BilibiliConfig
|
||||
telegram: TelegramConfig
|
||||
web: WebConfig
|
||||
@@ -352,7 +355,7 @@ def _validate_folders(value: Any) -> tuple[FolderConfig, ...]:
|
||||
return tuple(folders)
|
||||
|
||||
|
||||
def _build_config(data: Mapping[str, Any], path: Path) -> AppConfig:
|
||||
def _build_config(data: Mapping[str, Any], path: Path, *, secrets_path: Path) -> AppConfig:
|
||||
b = data["bilibili"]
|
||||
if not isinstance(b, Mapping):
|
||||
raise ConfigError("bilibili 必须是对象")
|
||||
@@ -593,6 +596,7 @@ def _build_config(data: Mapping[str, Any], path: Path) -> AppConfig:
|
||||
|
||||
return AppConfig(
|
||||
path=path,
|
||||
secrets_path=secrets_path,
|
||||
bilibili=bilibili,
|
||||
telegram=telegram,
|
||||
web=web,
|
||||
@@ -610,33 +614,87 @@ def _build_config(data: Mapping[str, Any], path: Path) -> AppConfig:
|
||||
)
|
||||
|
||||
|
||||
def resolve_secrets_path(
|
||||
config_path: Path,
|
||||
raw: Mapping[str, Any] | None = None,
|
||||
) -> Path:
|
||||
"""解析凭据文件路径。
|
||||
|
||||
优先读配置里的 ``secrets_path``(相对配置文件目录或绝对路径);
|
||||
否则默认同目录 ``secrets.json``。
|
||||
"""
|
||||
base = Path(config_path).resolve().parent
|
||||
if raw and "secrets_path" in raw and raw["secrets_path"] not in (None, ""):
|
||||
value = raw["secrets_path"]
|
||||
if not isinstance(value, str):
|
||||
raise ConfigError("secrets_path 必须是字符串")
|
||||
path = Path(value)
|
||||
return path if path.is_absolute() else (base / path)
|
||||
return base / DEFAULT_SECRETS_NAME
|
||||
|
||||
|
||||
def default_config_path(root: str | os.PathLike[str] | None = None) -> Path:
|
||||
"""默认配置:同目录下优先 ``config.jsonc``,否则 ``config.json``。
|
||||
|
||||
两者都不存在时返回 ``config.jsonc``(作为建议创建路径)。
|
||||
"""
|
||||
base = Path(root) if root is not None else Path(__file__).resolve().parent.parent
|
||||
jsonc_path = base / "config.jsonc"
|
||||
json_path = base / "config.json"
|
||||
if jsonc_path.exists():
|
||||
return jsonc_path
|
||||
if json_path.exists():
|
||||
return json_path
|
||||
return jsonc_path
|
||||
|
||||
|
||||
def _read_mapping_file(path: Path, *, kind: str) -> dict[str, Any]:
|
||||
if not path.exists():
|
||||
raise ConfigError(f"缺少{kind}: {path}")
|
||||
try:
|
||||
raw = jsonc.load(path)
|
||||
except ValueError as exc:
|
||||
raise ConfigError(f"{kind}不是合法 JSON/JSONC: {path}: {exc}") from exc
|
||||
except OSError as exc:
|
||||
raise ConfigError(f"无法读取{kind}: {path}: {exc}") from exc
|
||||
if not isinstance(raw, Mapping):
|
||||
raise ConfigError(f"{kind}顶层必须是 JSON 对象: {path}")
|
||||
return dict(raw)
|
||||
|
||||
|
||||
def load_config(
|
||||
path: str | os.PathLike[str],
|
||||
*,
|
||||
env: Mapping[str, str] | None = None,
|
||||
require_auth: bool = False,
|
||||
) -> AppConfig:
|
||||
"""读取并校验配置。``env`` 默认为 ``os.environ``。"""
|
||||
"""读取并校验配置。``env`` 默认为 ``os.environ``。
|
||||
|
||||
主文件支持 ``.jsonc`` / ``.json``(注释与尾逗号)。敏感凭据优先从同目录
|
||||
``secrets.json``(或 ``secrets_path`` 指定文件)合并;``auth`` 只回写该凭据
|
||||
文件,避免冲掉 JSONC 注释。旧版把 ``bilibili`` 写在主配置里的用法仍可用。
|
||||
"""
|
||||
config_path = Path(path)
|
||||
if not config_path.exists():
|
||||
raise ConfigError(f"缺少配置文件: {config_path}")
|
||||
try:
|
||||
with open(config_path, encoding="utf-8") as fh:
|
||||
raw = json.load(fh)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ConfigError(f"配置文件不是合法 JSON: {config_path}: {exc}") from exc
|
||||
except OSError as exc:
|
||||
raise ConfigError(f"无法读取配置文件: {config_path}: {exc}") from exc
|
||||
if not isinstance(raw, Mapping):
|
||||
raise ConfigError("配置文件顶层必须是 JSON 对象")
|
||||
raw = _read_mapping_file(config_path, kind="配置文件")
|
||||
secrets_path = resolve_secrets_path(config_path, raw)
|
||||
if secrets_path.exists():
|
||||
secrets_raw = _read_mapping_file(secrets_path, kind="凭据文件")
|
||||
# 凭据覆盖主配置中的同名段(深度合并)
|
||||
raw = _deep_merge(raw, secrets_raw)
|
||||
|
||||
raw_bilibili = raw.get("bilibili")
|
||||
if not isinstance(raw_bilibili, Mapping):
|
||||
raise ConfigError("缺少 bilibili.read / bilibili.write 双账号配置")
|
||||
raise ConfigError(
|
||||
"缺少 bilibili.read / bilibili.write 双账号配置"
|
||||
f"(可写在主配置或 {secrets_path.name})"
|
||||
)
|
||||
for account in ("read", "write"):
|
||||
if account not in raw_bilibili:
|
||||
raise ConfigError(f"缺少 bilibili.{account} 账号配置")
|
||||
|
||||
data = _deep_merge(DEFAULT_CONFIG, raw)
|
||||
# secrets_path 不是业务字段
|
||||
data.pop("secrets_path", None)
|
||||
|
||||
env_map = os.environ if env is None else env
|
||||
for env_name, (target, caster) in ENV_OVERRIDES.items():
|
||||
@@ -650,16 +708,17 @@ def load_config(
|
||||
raise ConfigError(f"环境变量 {env_name} 必须是整数") from exc
|
||||
_set_path(data, target, value)
|
||||
|
||||
cfg = _build_config(data, config_path)
|
||||
cfg = _build_config(data, config_path, secrets_path=secrets_path)
|
||||
if require_auth and (not cfg.bilibili.read.cookie or not cfg.bilibili.read.csrf):
|
||||
raise ConfigError(
|
||||
"config.json 中的 bilibili.read.cookie / csrf 不能为空(可先运行 python app.py auth)"
|
||||
"bilibili.read.cookie / csrf 不能为空"
|
||||
f"(写入 {secrets_path.name},或运行 python app.py auth)"
|
||||
)
|
||||
return cfg
|
||||
|
||||
|
||||
class ConfigStore:
|
||||
"""按文件 mtime 热加载配置;坏配置不会覆盖上一份可用配置。"""
|
||||
"""按主配置与凭据文件 mtime 热加载;坏配置不会覆盖上一份可用配置。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -677,11 +736,25 @@ class ConfigStore:
|
||||
self.last_error: str | None = None
|
||||
self.revision = 0
|
||||
|
||||
def _watched_paths(self) -> tuple[Path, ...]:
|
||||
paths = [self.path]
|
||||
secrets = None
|
||||
if getattr(self, "_config", None) is not None:
|
||||
secrets = self._config.secrets_path
|
||||
if secrets is None:
|
||||
secrets = resolve_secrets_path(self.path)
|
||||
if secrets not in paths:
|
||||
paths.append(secrets)
|
||||
return tuple(paths)
|
||||
|
||||
def _read_mtime_ns(self) -> int | None:
|
||||
try:
|
||||
return self.path.stat().st_mtime_ns
|
||||
except OSError:
|
||||
return None
|
||||
times: list[int] = []
|
||||
for candidate in self._watched_paths():
|
||||
try:
|
||||
times.append(candidate.stat().st_mtime_ns)
|
||||
except OSError:
|
||||
continue
|
||||
return max(times) if times else None
|
||||
|
||||
def get(self) -> AppConfig:
|
||||
with self._lock:
|
||||
|
||||
Reference in new issue
Block a user