98 lines
3.9 KiB
Python
98 lines
3.9 KiB
Python
"""Configuration loading utilities."""
|
||
|
||
import json
|
||
from pathlib import Path
|
||
|
||
from nanobot.config.schema import Config
|
||
|
||
|
||
def get_config_path() -> Path:
|
||
"""Get the default configuration file path."""
|
||
# 统一约定配置文件位置:~/.nanobot/config.json
|
||
# 这样 CLI、Gateway、测试都能复用同一入口,不会出现路径分叉。
|
||
return Path.home() / ".nanobot" / "config.json"
|
||
|
||
|
||
def get_data_dir() -> Path:
|
||
"""Get the nanobot data directory."""
|
||
# 延迟导入(函数内 import)可以减少模块初始化时的依赖耦合。
|
||
# get_data_path() 内部会确保目录存在。
|
||
from nanobot.utils.helpers import get_data_path
|
||
return get_data_path()
|
||
|
||
|
||
def load_config(config_path: Path | None = None) -> Config:
|
||
"""
|
||
Load configuration from file or create default.
|
||
|
||
Args:
|
||
config_path: Optional path to config file. Uses default if not provided.
|
||
|
||
Returns:
|
||
Loaded configuration object.
|
||
"""
|
||
# 如果调用者没传路径,就走默认路径 ~/.nanobot/config.json
|
||
path = config_path or get_config_path()
|
||
|
||
# 只有文件存在才尝试读取;不存在时直接返回默认 Config。
|
||
if path.exists():
|
||
try:
|
||
# 1) 读取 JSON 原始配置
|
||
with open(path, encoding="utf-8") as f:
|
||
data = json.load(f)
|
||
# 2) 做向后兼容迁移(旧字段 -> 新字段)
|
||
data = _migrate_config(data)
|
||
# 3) 用 Pydantic 做强校验与类型转换
|
||
# 例如:camelCase/snake_case 映射、默认值补齐、字段类型检查。
|
||
return Config.model_validate(data)
|
||
except (json.JSONDecodeError, ValueError) as e:
|
||
# 容错策略:配置损坏时不让程序崩溃,而是退回默认配置继续运行。
|
||
print(f"Warning: Failed to load config from {path}: {e}")
|
||
print("Using default configuration.")
|
||
|
||
# 配置文件不存在,或读取失败 -> 返回 schema 里的默认配置对象。
|
||
return Config()
|
||
|
||
|
||
def save_config(config: Config, config_path: Path | None = None) -> None:
|
||
"""
|
||
Save configuration to file.
|
||
|
||
Args:
|
||
config: Configuration to save.
|
||
config_path: Optional path to save to. Uses default if not provided.
|
||
"""
|
||
# 目标路径:优先用调用方传入路径,否则走默认路径。
|
||
path = config_path or get_config_path()
|
||
# 先确保父目录存在,避免 open(..., "w") 因目录缺失而失败。
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
|
||
# model_dump(by_alias=True) 的关键点:
|
||
# - schema 中很多字段 Python 侧是 snake_case(如 api_key)
|
||
# - 配置文件对外希望保持 camelCase(如 apiKey)
|
||
# - by_alias=True 会把字段按 alias 输出,保证写回文件的键名与用户配置习惯一致
|
||
# (否则会写成 snake_case,和 README 示例不一致)。
|
||
data = config.model_dump(by_alias=True)
|
||
|
||
# ensure_ascii=False: 保留中文等非 ASCII 字符,不转成 \uXXXX
|
||
# indent=2: 让配置文件更易读、可手工编辑。
|
||
with open(path, "w", encoding="utf-8") as f:
|
||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||
|
||
|
||
def _migrate_config(data: dict) -> dict:
|
||
"""Migrate old config formats to current."""
|
||
# 这个函数专门做“历史配置兼容”:
|
||
# 旧版字段:tools.exec.restrictToWorkspace
|
||
# 新版字段:tools.restrictToWorkspace
|
||
#
|
||
# 迁移策略:
|
||
# - 仅当旧字段存在且新字段不存在时才迁移
|
||
# - 避免覆盖用户在新字段里已经明确设置的值
|
||
tools = data.get("tools", {})
|
||
exec_cfg = tools.get("exec", {})
|
||
if "restrictToWorkspace" in exec_cfg and "restrictToWorkspace" not in tools:
|
||
tools["restrictToWorkspace"] = exec_cfg.pop("restrictToWorkspace")
|
||
# 返回迁移后的原始 dict,后续再交给 Config.model_validate() 做结构化校验。
|
||
return data
|