config 配置模块: 默认值兜底+必填校验+各模块分发工厂 (53 passed) - 客户端 7 模块齐

This commit is contained in:
lou
2026-08-09 23:04:19 +08:00
parent 374056de9f
commit adbfd7b4bb
2 changed files with 310 additions and 0 deletions
+187
View File
@@ -0,0 +1,187 @@
"""
配置模块 (config)
模块: 客户端 / 配置
输入: config.json
输出: 各模块配置注入 (crypto 拿密钥, transfer 拿地址)
配置外置, 源码不硬编码; 必填项缺失直接报错, 不带着坏配置跑。
默认配置文件: config/config.json (可 --init 生成模板)
"""
import argparse
import json
import sys
from pathlib import Path
from typing import Any
DEFAULT_CONFIG = Path(__file__).resolve().parent / "config" / "config.json"
# 默认值 (用户 config.json 逐层覆盖)
DEFAULTS: dict[str, Any] = {
"server": {"url": "", "timeout": 30, "max_retries": 3, "retry_delay": 1.0},
"crypto": {"key_id": "default_key", "keyring_path": "config/keyring.json"},
"splitter": {"chunk_size_mb": 10},
"state": {"db_path": "config/tasks.db", "cleanup_days": 7},
}
# 必填路径 (缺失即报错)
REQUIRED: list[tuple[str, str]] = [("server", "url")]
class ConfigError(ValueError):
"""配置缺失或非法"""
class AppConfig:
"""加载/校验/分发配置 (默认值兜底 + 用户覆盖)"""
def __init__(self, config_path: str | Path | None = None) -> None:
self.config_path = Path(config_path) if config_path else DEFAULT_CONFIG
self.data: dict[str, Any] = {}
self.load()
def load(self) -> None:
"""读取 config.json, 与默认值合并, 校验必填项"""
self.data = json.loads(json.dumps(DEFAULTS)) # 深拷贝默认值
if self.config_path.exists():
user = json.loads(self.config_path.read_text(encoding="utf-8"))
self._merge(self.data, user)
self.validate()
@staticmethod
def _merge(base: dict[str, Any], override: dict[str, Any]) -> None:
for key, val in override.items():
if key in base and isinstance(base[key], dict) and isinstance(val, dict):
AppConfig._merge(base[key], val)
else:
base[key] = val
def validate(self) -> None:
"""必填校验 (服务器/密钥/卷大小), 任一非法抛 ConfigError"""
for section, key in REQUIRED:
v = self.data.get(section, {}).get(key)
if not isinstance(v, str) or not v:
raise ConfigError(
f"必填配置缺失: {section}.{key} (用 --init 生成 config.json 模板)"
)
url: str = self.data["server"]["url"]
if not url.startswith(("http://", "https://")):
raise ConfigError(f"server.url 必须以 http:// 或 https:// 开头: {url}")
if self.data["splitter"]["chunk_size_mb"] <= 0:
raise ConfigError("splitter.chunk_size_mb 必须大于 0")
if self.data["server"]["max_retries"] < 1:
raise ConfigError("server.max_retries 必须 >= 1")
if self.data["server"]["timeout"] <= 0:
raise ConfigError("server.timeout 必须大于 0")
# ---------- 便捷访问 ----------
@property
def server_url(self) -> str:
return self.data["server"]["url"]
@property
def timeout(self) -> int:
return self.data["server"]["timeout"]
@property
def max_retries(self) -> int:
return self.data["server"]["max_retries"]
@property
def retry_delay(self) -> float:
return self.data["server"]["retry_delay"]
@property
def key_id(self) -> str:
return self.data["crypto"]["key_id"]
@property
def keyring_path(self) -> Path:
return Path(self.data["crypto"]["keyring_path"])
@property
def chunk_size(self) -> int:
"""卷大小 (字节)"""
return self.data["splitter"]["chunk_size_mb"] * 1024 * 1024
@property
def db_path(self) -> Path:
return Path(self.data["state"]["db_path"])
@property
def cleanup_days(self) -> int:
return self.data["state"]["cleanup_days"]
# ---------- 分发: 构造各模块实例 ----------
def build_crypto(self):
"""crypto 拿密钥 (keyring 路径)"""
from crypto import CryptoEngine
return CryptoEngine(self.keyring_path)
def build_transfer(self):
"""transfer 拿服务器地址/重试参数"""
from transfer import TransferClient
return TransferClient(
self.server_url,
timeout=self.timeout,
max_retries=self.max_retries,
retry_delay=self.retry_delay,
)
def build_store(self):
"""state 拿数据库路径"""
from state import TaskStore
return TaskStore(self.db_path)
def main() -> int:
ap = argparse.ArgumentParser(description="配置: 加载校验 / 生成模板")
ap.add_argument("--init", action="store_true", help="生成默认 config.json 模板")
ap.add_argument("--path", default=str(DEFAULT_CONFIG), help="配置文件路径")
args = ap.parse_args()
if args.init:
path = Path(args.path)
if path.exists():
print(f"[错误] 已存在: {path}, 不覆盖")
return 1
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps(
{
"server": {
"url": "http://127.0.0.1:8000",
"timeout": 30,
"max_retries": 3,
"retry_delay": 1.0,
},
"crypto": {"key_id": "default_key", "keyring_path": "config/keyring.json"},
"splitter": {"chunk_size_mb": 10},
"state": {"db_path": "config/tasks.db", "cleanup_days": 7},
},
indent=2,
ensure_ascii=False,
)
+ "\n",
encoding="utf-8",
)
print(f"[config] 模板已生成: {path} (改 server.url 后即可用)")
return 0
try:
cfg = AppConfig(args.path)
except (ConfigError, json.JSONDecodeError) as e:
print(f"[错误] 配置无效: {e}")
return 1
print(f"[config] 已加载: {cfg.config_path}")
print(f" server: {cfg.server_url} (timeout={cfg.timeout}, retries={cfg.max_retries})")
print(f" crypto: key_id={cfg.key_id}, keyring={cfg.keyring_path}")
print(f" splitter: 卷大小 {cfg.data['splitter']['chunk_size_mb']} MB")
print(f" state: db={cfg.db_path}, 清理保留 {cfg.cleanup_days}")
return 0
if __name__ == "__main__":
sys.exit(main())
+123
View File
@@ -0,0 +1,123 @@
"""config 模块行为测试 (真实 JSON 文件读写, 非 mock)
运行: cd /home/lou/文档/7z-encrypt && .venv/bin/python -m unittest discover -s tests -v
"""
import json
import os
import sys
import tempfile
import unittest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from config import AppConfig, ConfigError
VALID = {
"server": {"url": "http://127.0.0.1:8000", "timeout": 30, "max_retries": 3},
"crypto": {"key_id": "default_key", "keyring_path": "config/keyring.json"},
"splitter": {"chunk_size_mb": 10},
"state": {"db_path": "config/tasks.db", "cleanup_days": 7},
}
def write_cfg(td: str, data: dict) -> str:
path = os.path.join(td, "config.json")
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f)
return path
class TestLoad(unittest.TestCase):
def setUp(self):
self._td = tempfile.TemporaryDirectory(prefix='hermes-test-')
self.addCleanup(self._td.cleanup)
def test_valid_config(self):
cfg = AppConfig(write_cfg(self._td.name, VALID))
self.assertEqual(cfg.server_url, "http://127.0.0.1:8000")
self.assertEqual(cfg.key_id, "default_key")
self.assertEqual(cfg.chunk_size, 10 * 1024 * 1024)
self.assertEqual(cfg.cleanup_days, 7)
def test_defaults_filled(self):
cfg = AppConfig(write_cfg(self._td.name, {"server": {"url": "http://x:1"}}))
self.assertEqual(cfg.timeout, 30) # 默认值兜底
self.assertEqual(cfg.max_retries, 3)
self.assertEqual(cfg.retry_delay, 1.0)
self.assertEqual(cfg.data["splitter"]["chunk_size_mb"], 10)
self.assertEqual(cfg.key_id, "default_key")
def test_user_overrides_default(self):
data = dict(VALID)
data["server"] = {"url": "http://x:1", "timeout": 5}
cfg = AppConfig(write_cfg(self._td.name, data))
self.assertEqual(cfg.timeout, 5) # 用户覆盖
self.assertEqual(cfg.max_retries, 3) # 未覆盖的保持默认
def test_missing_file(self):
with self.assertRaises(ConfigError):
AppConfig(os.path.join(self._td.name, "nope.json"))
def test_invalid_json(self):
path = os.path.join(self._td.name, "config.json")
with open(path, "w", encoding="utf-8") as f:
f.write("{not json")
with self.assertRaises(json.JSONDecodeError):
AppConfig(path)
class TestValidate(unittest.TestCase):
def setUp(self):
self._td = tempfile.TemporaryDirectory(prefix='hermes-test-')
self.addCleanup(self._td.cleanup)
def _cfg(self, **mut):
data = json.loads(json.dumps(VALID))
for k, v in mut.items():
data[k] = v
return AppConfig(write_cfg(self._td.name, data))
def test_missing_server_url(self):
with self.assertRaises(ConfigError):
self._cfg(server={})
def test_bad_url_scheme(self):
with self.assertRaises(ConfigError):
self._cfg(server={"url": "ftp://x"})
def test_bad_chunk_size(self):
with self.assertRaises(ConfigError):
self._cfg(splitter={"chunk_size_mb": 0})
def test_bad_max_retries(self):
with self.assertRaises(ConfigError):
self._cfg(server={"url": "http://x:1", "max_retries": 0})
def test_bad_timeout(self):
with self.assertRaises(ConfigError):
self._cfg(server={"url": "http://x:1", "timeout": -1})
class TestFactory(unittest.TestCase):
"""分发: 构造各模块实例 (真实构造, 非 mock)"""
def setUp(self):
self._td = tempfile.TemporaryDirectory(prefix='hermes-test-')
self.addCleanup(self._td.cleanup)
def test_build_instances(self):
cfg = AppConfig(write_cfg(self._td.name, VALID))
from crypto import CryptoEngine
from transfer import TransferClient
from state import TaskStore
self.assertIsInstance(cfg.build_crypto(), CryptoEngine)
t = cfg.build_transfer()
self.assertIsInstance(t, TransferClient)
self.assertEqual(t.base_url, VALID["server"]["url"])
self.assertEqual(t.max_retries, 3)
self.assertIsInstance(cfg.build_store(), TaskStore)
if __name__ == '__main__':
unittest.main()