feat(config): isolate config and database via HAOCODE_CONFIG_FILE override

core/config_paths.py resolves a single override so tests and multiple instances can run against isolated temp config/db without touching user data.
This commit is contained in:
2026-09-17 16:40:02 +08:00
parent a7412824e0
commit ce56c77023
3 changed files with 410 additions and 0 deletions
+77
View File
@@ -0,0 +1,77 @@
# -*- coding: utf-8 -*-
"""core/config_paths.py —— 统一的配置文件路径解析与容错加载(P0-01 新增)
所有配置读取入口必须经过本模块,不得在别处直接定位 data/config.json
1. 环境变量 HAOCODE_CONFIG_FILE 优先级最高(自动化测试用它指向临时文件);
2. 未设置环境变量时才回落到项目内 data/config.json(锚定 core/ 目录,不受 CWD 影响)。
行为约定(REPAIR_BACKLOG P0-01 硬约束):
· 路径解析与加载过程只在出错时输出可见警告(只含路径与错误类型,不含配置内容);
· 缺失 / 损坏 / 非 JSON 对象 → 返回安全空 dict 继续,不抛异常、不阻断
不需要该配置的源码路径;
· import 本模块时不打印、不复制、不迁移任何配置内容。
"""
import json
import os
ENV_KEY = "HAOCODE_CONFIG_FILE"
_DEFAULT_CONFIG_PATH = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "..", "data", "config.json")
def config_path() -> str:
"""返回当前生效的配置文件路径(环境变量优先,调用时解析)。"""
p = os.environ.get(ENV_KEY, "").strip()
return p if p else _DEFAULT_CONFIG_PATH
def load_config() -> dict:
"""容错加载配置。缺失/损坏/非对象 → 可见警告 + 安全空 dict(绝不抛异常)。"""
p = config_path()
if not os.path.exists(p):
print(f"[config] 配置文件缺失: {p} → 使用安全默认值(providers 为空)继续启动")
return {}
try:
with open(p, "r", encoding="utf-8") as f:
data = json.load(f)
except Exception as e:
print(f"[config] 配置文件读取/解析失败: {p}{type(e).__name__}"
f" → 使用安全默认值继续启动")
return {}
if not isinstance(data, dict):
print(f"[config] 配置文件内容不是 JSON 对象: {p}"
f" → 使用安全默认值继续启动")
return {}
return data
# ----------------------------------------------------------------------
# P1-01:渲染窗口配置解析(render_window_mode / render_window_size
# 规则(与 ui/web/render_window.js 的 JS 侧守卫保持一致):
# size:只接受非布尔整数 10..200,缺失/布尔/字符串/小数/零/负数/越界 → 静默回落 40;
# mode:只接受 "auto"/"manual",否则回落 "auto"。
# ----------------------------------------------------------------------
DEFAULT_RENDER_WINDOW_SIZE = 40
ALLOWED_RENDER_WINDOW_SIZES = (10, 40, 200)
def render_window_settings(cfg: dict | None = None) -> dict:
"""解析渲染窗口配置。cfg 缺省时读取当前生效配置。永不抛异常。"""
if cfg is None:
cfg = load_config()
if not isinstance(cfg, dict):
cfg = {}
raw_size = cfg.get("render_window_size", None)
if isinstance(raw_size, bool) or not isinstance(raw_size, int) \
or raw_size < 10 or raw_size > 200:
size = DEFAULT_RENDER_WINDOW_SIZE
else:
size = raw_size
raw_mode = cfg.get("render_window_mode", None)
mode = raw_mode if raw_mode in ("auto", "manual") else "auto"
return {"mode": mode, "size": size}
+74
View File
@@ -0,0 +1,74 @@
# -*- coding: utf-8 -*-
"""tests/_test_env.py —— 自动化测试的统一临时环境(P0-01 新增)
铁律(VERIFICATION.md §凭据与运行数据隔离):
1. 每个测试进程独立临时目录 + 最小临时配置;
2. 在 import 任何可能间接加载 MainWindow 的模块之前设置 HAOCODE_CONFIG_FILE
3. 在 import MainWindow 之前把 core.db_manager._DEFAULT_DB 指向临时数据库;
4. 写入、迁移、附件和截图产物只落到临时目录。
用法(放在测试文件顶部、任何 UI import 之前):
from tests._test_env import isolate
isolate("bashpanel", config={"providers": {}, "mode_switch": True})
说明:
· 临时目录按 进程 pid 隔离,测试进程互不干扰;
· 默认临时配置为最小可启动结构(空 providers + 测试 provider);
· 本模块不 import 任何 PyQt 模块,可在无 GUI 环境中安全调用。
"""
import json
import os
import sys
import tempfile
_TESTS_DIR = os.path.dirname(os.path.abspath(__file__))
_ROOT = os.path.dirname(_TESTS_DIR)
if _ROOT not in sys.path:
sys.path.insert(0, _ROOT)
def default_config() -> dict:
"""最小可启动临时配置(不含任何真实凭据)。"""
return {
"providers": {
"testprov": {
"api_key": "test-key-not-real",
"base_url": "http://127.0.0.1:9/v1",
"models": ["test-model"],
"model_contexts": {"test-model": 100000},
}
},
"default_provider": "testprov",
"default_model": "test-model",
}
def isolate(tag: str = "t", config: dict | None = None) -> dict:
"""创建临时配置 + 临时数据库并完成重定向。必须在 import MainWindow 之前调用。
返回 {"base": 临时目录, "config": 临时配置路径, "db": 临时数据库路径}。
"""
base = os.path.join(tempfile.gettempdir(),
f"haocode_test_{tag}_{os.getpid()}")
os.makedirs(base, exist_ok=True)
cfg_path = os.path.join(base, "config.json")
with open(cfg_path, "w", encoding="utf-8") as f:
json.dump(config if config is not None else default_config(),
f, ensure_ascii=False, indent=2)
db_path = os.path.join(base, "chat_history.db")
for p in (db_path, db_path + "-wal", db_path + "-shm"):
if os.path.exists(p):
os.remove(p)
os.environ["HAOCODE_CONFIG_FILE"] = cfg_path
# P1-03QtWebEngine 独立 profile 重定向到临时目录(源码运行才在 data/webengine/
os.environ.setdefault("HAOCODE_WEBENGINE_PROFILE_DIR", os.path.join(base, "webengine_profile"))
import core.db_manager as _dbm
_dbm._DEFAULT_DB = db_path
return {"base": base, "config": cfg_path, "db": db_path}
+259
View File
@@ -0,0 +1,259 @@
# -*- coding: utf-8 -*-
"""tests/test_config_isolation.py —— P0-01 配置路径与测试隔离回归
运行: python tests/test_config_isolation.py
(仓库惯例:无 pytest 依赖,独立可跑;GUI 部分自动走 offscreen
完成证据(REPAIR_BACKLOG.md P0-01):
1. 打开路径拦截器记录到的配置打开路径全部位于临时目录,真实配置路径从未被打开;
该断言只用路径字符串比较,不读取、不散列真实配置文件;
2. 临时配置读写用例通过,临时数据库之外没有数据库写入;
3. 缺失配置与损坏配置各有一个回归用例:日志含明确警告、返回安全默认值、无异常;
4. 静态扫描:源码中不存在绕过统一路径解析(core/config_paths)的运行时配置读取。
"""
import ast
import contextlib
import io
import json
import os
import sqlite3
import sys
_TESTS_DIR = os.path.dirname(os.path.abspath(__file__))
_ROOT = os.path.dirname(_TESTS_DIR)
sys.path.insert(0, _ROOT)
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
os.environ.setdefault("HAOCODE_RENDER", "software")
os.environ.setdefault("QTWEBENGINE_CHROMIUM_FLAGS", "--disable-gpu")
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
from tests._test_env import isolate, default_config # noqa: E402
_TMP = isolate("cfgiso") # 必须在 import MainWindow 之前
_REAL_CONFIG = os.path.join(_ROOT, "data", "config.json") # 仅作路径字符串,永不打开
ok = True
def check(name, cond, extra=""):
global ok
print((" PASS " if cond else " FAIL ") + name +
("" if cond else f" {extra}"), flush=True)
if not cond:
ok = False
# ======================================================================
# 拦截器:记录 builtins.open 与 sqlite3.connect 触碰的路径(不读取真实配置内容)
# ======================================================================
class OpenInterceptor:
def __init__(self):
self.ops = [] # (path, mode)
self._real_open = None
def __enter__(self):
self._real_open = __builtins__["open"] if isinstance(
__builtins__, dict) else __builtins__.open
import builtins
builtins.open = self._open
return self
def _open(self, file, mode="r", *args, **kwargs):
try:
p = os.fspath(file)
except TypeError:
p = None
if p is not None:
self.ops.append((os.path.abspath(str(p)), str(mode)))
return self._real_open(file, mode, *args, **kwargs)
def __exit__(self, *exc):
import builtins
builtins.open = self._real_open
return False
# ---- 断言辅助 ----
def config_ops(self):
# 含原子写的临时文件 config.json.tmp
return [(p, m) for p, m in self.ops
if os.path.basename(p).startswith("config.json")]
def real_config_opened(self):
return [op for op in self.ops
if os.path.normcase(op[0]) == os.path.normcase(_REAL_CONFIG)]
class SqliteInterceptor:
def __init__(self):
self.paths = []
self._real_connect = None
def __enter__(self):
self._real_connect = sqlite3.connect
sqlite3.connect = self._connect
return self
def _connect(self, database, *args, **kwargs):
self.paths.append(str(database))
return self._real_connect(database, *args, **kwargs)
def __exit__(self, *exc):
sqlite3.connect = self._real_connect
return False
# ======================================================================
# 1) 静态扫描:不得存在绕过统一路径解析的配置读取
# (源码中不允许出现 "config.json" 字符串字面量,core/config_paths.py 除外;
# 文档字符串与注释不计)
# ======================================================================
def _docstring_values(tree) -> set:
vals = set()
def _first_expr_body(body):
if body and isinstance(body[0], ast.Expr) and \
isinstance(body[0].value, ast.Constant) and \
isinstance(body[0].value.value, str):
vals.add(body[0].value.value)
_first_expr_body(tree.body)
for node in ast.walk(tree):
if isinstance(node, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)):
_first_expr_body(node.body)
return vals
def static_scan():
allowed = os.path.normcase(os.path.join(_ROOT, "core", "config_paths.py"))
bad = []
scan_dirs = [os.path.join(_ROOT, d) for d in ("core", "ui", "tools")]
scan_files = [os.path.join(_ROOT, "main.py")]
targets = list(scan_files)
for d in scan_dirs:
for dirpath, _dirnames, filenames in os.walk(d):
if "__pycache__" in dirpath:
continue
for fn in filenames:
if fn.endswith(".py"):
targets.append(os.path.join(dirpath, fn))
for path in targets:
with open(path, "r", encoding="utf-8") as f:
src = f.read()
tree = ast.parse(src, filename=path)
docstrings = _docstring_values(tree)
for node in ast.walk(tree):
if isinstance(node, ast.Constant) and isinstance(node.value, str) \
and "config.json" in node.value \
and node.value not in docstrings:
if os.path.normcase(path) != allowed:
bad.append(f"{os.path.relpath(path, _ROOT)}:{node.lineno}")
return bad
bad_literal = static_scan()
check("静态:源码无绕过统一解析的 config.json 字面量(仅 core/config_paths.py 允许)",
not bad_literal, f"发现: {bad_literal}")
# ======================================================================
# 2) 缺失配置 / 损坏配置:可见警告 + 安全默认值 + 无异常
# ======================================================================
from core.config_paths import config_path, load_config # noqa: E402
import core.llm_engine as le # noqa: E402
saved_env = os.environ.get("HAOCODE_CONFIG_FILE")
_missing = os.path.join(_TMP["base"], "no_such_config.json")
os.environ["HAOCODE_CONFIG_FILE"] = _missing
_buf = io.StringIO()
with contextlib.redirect_stdout(_buf):
_res = load_config()
_log = _buf.getvalue()
check("缺失配置:返回安全空 dict", _res == {})
check("缺失配置:日志含明确警告", "缺失" in _log and "config" in _log, _log)
_corrupt = os.path.join(_TMP["base"], "corrupt.json")
with open(_corrupt, "w", encoding="utf-8") as f:
f.write("{ this is not valid json")
os.environ["HAOCODE_CONFIG_FILE"] = _corrupt
_buf = io.StringIO()
with contextlib.redirect_stdout(_buf):
_res = load_config()
_log = _buf.getvalue()
check("损坏配置:返回安全空 dict", _res == {})
check("损坏配置:日志含明确警告", "解析失败" in _log or "读取/解析失败" in _log, _log)
_notdict = os.path.join(_TMP["base"], "notdict.json")
with open(_notdict, "w", encoding="utf-8") as f:
f.write("[1, 2, 3]")
os.environ["HAOCODE_CONFIG_FILE"] = _notdict
_buf = io.StringIO()
with contextlib.redirect_stdout(_buf):
_res = load_config()
_log = _buf.getvalue()
check("非对象配置:返回安全空 dict 且有警告", _res == {} and "不是 JSON 对象" in _log, _log)
os.environ["HAOCODE_CONFIG_FILE"] = saved_env # 恢复临时配置
check("环境变量恢复:config_path 回到临时目录",
os.path.normcase(config_path()) == os.path.normcase(_TMP["config"]))
# ======================================================================
# 3) 临时配置读取(llm_engine 统一入口)
# ======================================================================
_cfg_now = le._load_config()
check("llm_engine._load_config 读取临时配置(含测试 provider)",
"testprov" in _cfg_now.get("providers", {}))
# ======================================================================
# 4) MainWindow 全链路:构造主窗口期间,配置打开/数据库连接全部落在临时目录
# ======================================================================
from PyQt6.QtWidgets import QApplication # noqa: E402
from ui.views.main_window import MainWindow # noqa: E402
import ui.views.bash_panel as bp # noqa: E402
app = QApplication.instance() or QApplication(sys.argv)
oi = OpenInterceptor()
si = SqliteInterceptor()
with oi, si:
win = MainWindow()
# 宽度记录写回(config.json 写路径)
bp.save_panel_width(340)
win.close()
win.deleteLater()
app.processEvents()
check("MainWindow 构造期间真实配置从未被打开",
not oi.real_config_opened(), f"打开记录: {oi.real_config_opened()}")
_cfg_ops = oi.config_ops()
check("MainWindow 链路至少发生一次配置读取(路径被验证而非空转)",
len(_cfg_ops) >= 1, f"记录: {_cfg_ops}")
_check_base = os.path.normcase(os.path.abspath(_TMP["base"]))
_outside = [op for op in _cfg_ops
if not os.path.normcase(op[0]).startswith(_check_base)]
check("全部 config.json 打开路径均位于临时目录", not _outside, f"越界: {_outside}")
_write_ops = [op for op in _cfg_ops if "w" in op[1]]
check("配置写路径(bash_panel 宽度)落在临时目录",
bool(_write_ops) and not [op for op in _write_ops
if not os.path.normcase(op[0]).startswith(_check_base)],
f"写记录: {_write_ops}")
check("save_panel_width 写回可被读回", bp.load_panel_width() == 340)
_db_outside = [p for p in si.paths
if not (os.path.normcase(os.path.abspath(p)).startswith(_check_base)
or p in (":memory:", ""))]
check("临时数据库之外没有数据库连接/写入", not _db_outside, f"越界: {_db_outside}")
check("MainWindow.config_data 来自临时配置(providers 无真实凭据)",
set(win.config_data.get("providers", {}).keys()) == {"testprov"})
# ======================================================================
# 汇总
# 注:QtWebEngine 在 offscreen 下的 C++ 静态析构可能在正常 sys.exit 后段错误,
# 测试进程用 os._exit 直接退出(退出码已在上方断言中确定),不改变测试结果。
# ======================================================================
print(f"\n===== test_config_isolation: {'ALL PASS' if ok else 'HAS FAILURES'} =====", flush=True)
os._exit(0 if ok else 1)