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

+36 -7
View File
@@ -58,28 +58,56 @@ def test_cookie_helpers():
def test_finish_updates_only_selected_account_and_rolls_back():
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "config.json"
# 主配置为 JSONC(含注释);auth 只写 secrets.json
config_path = Path(tmp) / "config.jsonc"
secrets_path = Path(tmp) / "secrets.json"
original = raw_config()
path.write_text(json.dumps(original, ensure_ascii=False), encoding="utf-8")
service = AuthService(path, account="write")
config_body = (
"// keep me\n"
+ json.dumps({"folders": [], "telegram": {"enabled": False}}, ensure_ascii=False)
)
config_path.write_text(config_body, encoding="utf-8")
secrets_path.write_text(json.dumps(original, ensure_ascii=False), encoding="utf-8")
service = AuthService(config_path, account="write")
assert service.secrets_path == secrets_path
service._get_json = lambda credentials, url, auth: { # type: ignore[method-assign]
"code": 0, "data": {"isLogin": True, "mid": 99, "uname": "writer"}
}
assert service.finish(service._load_raw(), NEW) == 0
saved = json.loads(path.read_text(encoding="utf-8"))
saved = json.loads(secrets_path.read_text(encoding="utf-8"))
assert saved["bilibili"]["read"] == original["bilibili"]["read"]
assert saved["bilibili"]["write"]["mid"] == 99
assert saved["bilibili"]["write"]["csrf"] == "newcsrf"
assert cookie_value(saved["bilibili"]["write"]["cookie"], "buvid3") == "WRITE-B3"
assert path.with_name("config.json.bak").exists()
assert secrets_path.with_name("secrets.json.bak").exists()
assert config_path.read_text(encoding="utf-8") == config_body
assert "// keep me" in config_path.read_text(encoding="utf-8")
before = path.read_text(encoding="utf-8")
before = secrets_path.read_text(encoding="utf-8")
service._get_json = lambda credentials, url, auth: { # type: ignore[method-assign]
"code": -101, "data": {"isLogin": False}
}
assert service.finish(service._load_raw(), NEW) == 4
assert secrets_path.read_text(encoding="utf-8") == before
assert config_path.read_text(encoding="utf-8") == config_body
def test_finish_seeds_secrets_from_legacy_config_without_touching_it():
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "config.json"
original = raw_config()
path.write_text(json.dumps(original, ensure_ascii=False), encoding="utf-8")
service = AuthService(path, account="write")
service._get_json = lambda credentials, url, auth: { # type: ignore[method-assign]
"code": 0, "data": {"isLogin": True, "mid": 99, "uname": "writer"}
}
before = path.read_text(encoding="utf-8")
assert service.finish(service._load_raw(), NEW) == 0
assert path.read_text(encoding="utf-8") == before
secrets = json.loads((Path(tmp) / "secrets.json").read_text(encoding="utf-8"))
assert secrets["bilibili"]["write"]["mid"] == 99
assert secrets["bilibili"]["read"] == original["bilibili"]["read"]
def test_qr_png():
@@ -207,12 +235,13 @@ def test_make_headers_drops_placeholder_and_non_latin1_cookies():
def main():
test_cookie_helpers()
test_finish_updates_only_selected_account_and_rolls_back()
test_finish_seeds_secrets_from_legacy_config_without_touching_it()
test_qr_png()
test_render_qr_ascii_roundtrip_and_quiet_zone()
test_run_prints_ascii_qr_without_network()
test_make_headers_matches_raw_request()
test_make_headers_drops_placeholder_and_non_latin1_cookies()
print("OK: 双账号 auth Cookie 合并、写回隔离、终端二维码与回滚测试全部通过")
print("OK: 双账号 auth Cookie 合并、secrets 写回隔离、终端二维码与回滚测试全部通过")
if __name__ == "__main__":
+83 -2
View File
@@ -11,7 +11,13 @@ from pathlib import Path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.stdout.reconfigure(encoding="utf-8")
from reaction_board.config import ConfigError, ConfigStore, load_config # noqa: E402
from reaction_board.config import ( # noqa: E402
ConfigError,
ConfigStore,
default_config_path,
load_config,
resolve_secrets_path,
)
def write(path: Path, data) -> None:
@@ -294,6 +300,78 @@ def test_schedule_recent_validation():
def test_jsonc_and_secrets_merge_and_hot_reload():
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
config_path = root / "config.jsonc"
secrets_path = root / "secrets.json"
config_path.write_text(
"""
// 业务配置
{
"folders": [
{"year": 2026, "media_id": 1, "title": "A"},
],
"track_fans": [],
"up_blacklist_seed": [],
"telegram": { "enabled": true },
"web": { "host": "127.0.0.1", "port": 8080 },
}
""",
encoding="utf-8",
)
secrets_path.write_text(json.dumps({
"bilibili": {
"read": {"mid": 11, "csrf": "rc", "cookie": "rk"},
"write": {"mid": 22, "csrf": "wc", "cookie": "wk"},
},
"telegram": {"bot_token": "tok", "chat_id": 9},
"web": {"auth_token": "secret-token"},
}), encoding="utf-8")
cfg = load_config(config_path, env={})
assert cfg.secrets_path == secrets_path
assert cfg.bilibili.read.cookie == "rk"
assert cfg.telegram.enabled is True
assert cfg.telegram.bot_token == "tok"
assert cfg.telegram.chat_id == 9
assert cfg.web.auth_token == "secret-token"
assert resolve_secrets_path(config_path) == secrets_path
# 主配置注释在热更新凭据后仍保留
original = config_path.read_text(encoding="utf-8")
store = ConfigStore(config_path, env={})
secrets = json.loads(secrets_path.read_text(encoding="utf-8"))
secrets["bilibili"]["read"]["cookie"] = "rk2"
secrets_path.write_text(json.dumps(secrets), encoding="utf-8")
bump_mtime(secrets_path)
assert store.get().bilibili.read.cookie == "rk2"
assert config_path.read_text(encoding="utf-8") == original
assert "// 业务配置" in original
def test_legacy_monolithic_json_still_loads():
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "config.json"
write(path, base_config())
cfg = load_config(path, env={})
assert cfg.bilibili.read.mid == 11
assert cfg.secrets_path == Path(tmp) / "secrets.json"
def test_default_config_path_prefers_jsonc():
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
json_path = root / "config.json"
jsonc_path = root / "config.jsonc"
json_path.write_text("{}", encoding="utf-8")
assert default_config_path(root) == json_path
jsonc_path.write_text("{}", encoding="utf-8")
assert default_config_path(root) == jsonc_path
def main():
test_dual_accounts_and_env_override()
test_flat_and_missing_account_rejected()
@@ -302,7 +380,10 @@ def main():
test_analytics_defaults_and_validation()
test_schedule_recent_validation()
test_web_track_defaults_and_validation()
print("OK: 双账号配置加载、拒绝 flat 格式、ENV 覆盖、热加载与 analytics/web.track 测试全部通过")
test_jsonc_and_secrets_merge_and_hot_reload()
test_legacy_monolithic_json_still_loads()
test_default_config_path_prefers_jsonc()
print("OK: 双账号配置加载、JSONC/secrets、拒绝 flat 格式、ENV 覆盖、热加载与 analytics/web.track 测试全部通过")
if __name__ == "__main__":
+46
View File
@@ -0,0 +1,46 @@
# -*- coding: utf-8 -*-
"""JSONC 剥离与解析测试: uv run python tests/test_jsonc.py"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.stdout.reconfigure(encoding="utf-8")
from reaction_board.jsonc import loads, strip_jsonc # noqa: E402
def test_comments_trailing_comma_and_strings():
text = '''
// head comment
{
"a": 1, // trail
"url": "https://ex.com//path", /* block */
"list": [1, 2, ],
"nested": { "x": true, },
}
'''
data = loads(text)
assert data == {
"a": 1,
"url": "https://ex.com//path",
"list": [1, 2],
"nested": {"x": True},
}
# 字符串内双斜杠不得被当成注释吃掉
assert "https://ex.com//path" in strip_jsonc(text)
def test_block_comment_and_bom():
text = '\ufeff{"k": /* c */ "v",}'
assert loads(text) == {"k": "v"}
def main():
test_comments_trailing_comma_and_strings()
test_block_comment_and_bom()
print("OK: JSONC 注释、尾逗号与字符串保护测试通过")
if __name__ == "__main__":
main()