from __future__ import annotations import json import logging import os import re from pathlib import Path from typing import Any, Dict, List, Optional try: import yaml YAML_AVAILABLE = True except ImportError: YAML_AVAILABLE = False logger = logging.getLogger(__name__) ROOT = Path(__file__).resolve().parent CONFIG_PATH = ROOT / "config.yaml" LOCAL_CONFIG_PATH = ROOT / "config.local.yaml" # 嵌套 YAML 路径 → 旧版扁平静态 key 映射表 # 确保从 config.yaml 迁移后,旧代码仍能按 flat key 取值 _NESTED_TO_FLAT_MAP = { # output ("output", "base_dir"): "OUTPUT_BASE_DIR", # device ("device", "is_emulator"): "IS_EMULATOR", ("device", "physical_ip"): "PHYSICAL_DEVICE_IP", ("device", "physical_adb_port"): "PHYSICAL_ADB_PORT", ("device", "physical_serial"): "PHYSICAL_DEVICE_SERIAL", ("device", "airtest_adb_host"): "AIRTEST_ADB_HOST", # app ("app", "keep_app"): "KEEP_APP", # policy ("policy", "name"): "POLICY", ("policy", "enable_guiagent"): "ENABLE_GUIAGENT", ("policy", "cv_mode"): "CV_MODE", # execution ("execution", "timeout"): "TIMEOUT", ("execution", "block_timeout"): "BLOCK_TIMEOUT", # model ("model", "current_name"): "CURRENT_MODEL_NAME", # logging ("logging", "level"): "LOG_LEVEL", # redis ("redis", "host"): "CONTROL_REDIS_HOST", ("redis", "port"): "CONTROL_REDIS_PORT", ("redis", "db"): "CONTROL_REDIS_DB", ("redis", "channel_namespace"): "CONTROL_CHANNEL_NAMESPACE", # shared_paths ("shared_paths", "traffic_data"): "TRAFFIC_DATA_SHARE", ("shared_paths", "traversal_log"): "TRAVERSAL_LOG_SHARE", ("shared_paths", "clean_backup"): "CLEAN_BACKUP_PATH", ("shared_paths", "local_apk"): "LOCAL_APK_PATH", ("shared_paths", "apkpure_apk"): "APKPURE_APK_PATH", ("shared_paths", "app_block_app_list"): "APP_BLOCK_APP_LIST", ("shared_paths", "app_block_url_lib"): "APP_BLOCK_URL_LIB", # git ("git", "repo_url"): "GIT_REPO_URL", # notifications ("notifications", "wechat"): "WECHAT_TOKENS", ("notifications", "wecom"): "WECOM_TOKENS", # mumu ("mumu", "manager_path"): "MUMU_MANAGER_PATH", ("mumu", "local_import_dir"): "LOCAL_IMPORT_DIR", ("mumu", "default_vm_index"): "DEFAULT_VM_INDEX", ("mumu", "max_vm_index"): "MAX_VM_INDEX", # network ("network", "bridge_card"): "NET_BRIDGE_CARD", # llm_api ("llm_api", "key_pool_url"): "KEY_POOL_URL", } def _normalize_config(config: Dict[str, Any]) -> Dict[str, Any]: """将嵌套 YAML 配置归一化为兼容旧版 flat key 格式。 如果配置已包含旧版 flat key(如 CONTROL_REDIS_HOST), 说明是旧格式 JSON,不做转换直接返回。 否则按照 _NESTED_TO_FLAT_MAP 将嵌套路径值注入到顶层。 """ # 已包含 flat key 说明是旧格式,跳过 if "CONTROL_REDIS_HOST" in config: return config normalized = dict(config) for nested_keys, flat_key in _NESTED_TO_FLAT_MAP.items(): if flat_key in normalized: continue value = config for key in nested_keys: if isinstance(value, dict): value = value.get(key) else: value = None break if value is not None: normalized[flat_key] = value return normalized def _expand_env_vars(value: Any) -> Any: """递归展开配置中的环境变量引用,格式:${ENV_VAR_NAME}""" if isinstance(value, str): # 匹配 ${VAR_NAME} 格式 pattern = re.compile(r'\$\{([^}]+)\}') def replacer(match): var_name = match.group(1) env_value = os.environ.get(var_name) if env_value is None: # 环境变量不存在时保持原样,便于调试 return match.group(0) return env_value return pattern.sub(replacer, value) elif isinstance(value, dict): return {k: _expand_env_vars(v) for k, v in value.items()} elif isinstance(value, list): return [_expand_env_vars(item) for item in value] return value def _load_config_dict(path: Path, *, required: bool) -> Dict[str, Any]: """加载配置文件(支持 JSON 和 YAML 格式) Args: path: 配置文件路径 required: 是否必须存在,不存在时是否抛出异常 Returns: 配置字典 Raises: FileNotFoundError: 当 required=True 且文件不存在时 ImportError: 加载 YAML 文件但 PyYAML 未安装时 ValueError: 配置文件格式不正确时 """ if not path.exists(): if required: raise FileNotFoundError( f"Configuration file not found: {path}\n" f"Hint: Make sure the config file exists at the expected location." ) return {} with path.open("r", encoding="utf-8") as handle: if path.suffix in {".yaml", ".yml"}: if not YAML_AVAILABLE: raise ImportError( f"PyYAML is required to load {path.name}.\n" f"Install it with: pip install pyyaml" ) payload = yaml.safe_load(handle) elif path.suffix == ".json": payload = json.load(handle) else: raise ValueError(f"Unsupported config format: {path.suffix} (supported: .json, .yaml, .yml)") if not isinstance(payload, dict): raise ValueError(f"Config payload must be a JSON/YAML object (dict), got {type(payload).__name__}: {path}") # 展开环境变量引用 return _expand_env_vars(payload) def _merge_dict(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]: merged = dict(base) for key, value in override.items(): current = merged.get(key) if isinstance(current, dict) and isinstance(value, dict): merged[key] = _merge_dict(current, value) continue merged[key] = value return merged def _load_local_config() -> Dict[str, Any]: """加载本地配置文件(config.local.yaml) Returns: 本地配置字典,如果文件不存在则返回空字典 """ if LOCAL_CONFIG_PATH.exists(): logger.debug(f"Loading local config from: {LOCAL_CONFIG_PATH}") return _load_config_dict(LOCAL_CONFIG_PATH, required=False) logger.debug("No local config file found (optional)") return {} def load_layered_config() -> Dict[str, Any]: """加载分层配置(主配置 + 本地覆盖配置) 配置加载顺序: 1. config.yaml 作为基础配置 2. config.local.yaml 覆盖基础配置(如果存在) 3. 归一化为兼容旧版 flat key 格式 Returns: 合并并归一化后的配置字典 Raises: FileNotFoundError: config.yaml 不存在时 """ config = _load_config_dict(CONFIG_PATH, required=True) local_config = _load_local_config() config = _merge_dict(config, local_config) return _normalize_config(config) def load_config(yaml_path: Optional[str] = None) -> Dict[str, Any]: """加载配置文件 Args: yaml_path: 自定义配置文件路径,None 时使用默认分层配置 Returns: 归一化后的配置字典(同时包含嵌套结构和旧版 flat key) Raises: FileNotFoundError: 配置文件不存在时 """ if yaml_path: return _normalize_config(_load_config_dict(Path(yaml_path).resolve(), required=True)) return load_layered_config()