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:
bot-ci committed 2026-11-02 12:58:20 +08:00
1 parent b4f0f45c4e
commit 8e7ead1669
21 files changed
+652 -182

No files matched your search

+95 -22
View File
@@ -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: