"""Load custom data source definitions from user data files.""" from __future__ import annotations import importlib import logging import re import shutil import subprocess from pathlib import Path import yaml from app.config import settings from app.data_providers.custom.config import CustomSourceConfig, load_config from app.data_providers.custom.provider import GenericHTTPProvider logger = logging.getLogger(__name__) _PROVIDERS: dict[str, GenericHTTPProvider] = {} _LOAD_ERRORS: list[dict] = [] # 内置插件状态: {name: {available, status, runtime, ...}} 供设置页独立分类展示。 # available=False 的插件不注册进 _PROVIDERS (不可切换), 但记录状态供 UI 显示安装提示。 _PLUGIN_STATUS: dict[str, dict] = {} _NAME_RE = re.compile(r"^[a-z0-9_]+$") def plugins_dir() -> Path: """内置可选插件目录 (app/plugins/, 与现有包结构一致, 开发态/容器态路径统一)。""" return Path(__file__).resolve().parents[2] / "plugins" def data_sources_dir() -> Path: return settings.data_dir / "data_sources" def load_all(path: Path | None = None) -> None: """Load all custom provider YAML files into process memory.""" global _PROVIDERS, _LOAD_ERRORS for provider in _PROVIDERS.values(): provider.close() _PROVIDERS = {} _LOAD_ERRORS = [] base = path or data_sources_dir() base.mkdir(parents=True, exist_ok=True) for file in sorted([*base.glob("*.yaml"), *base.glob("*.yml")]): try: config = load_config(file) provider = GenericHTTPProvider(config) errors = provider.validate() if errors: _LOAD_ERRORS.append({"path": str(file), "name": config.name, "errors": errors}) provider.close() continue _PROVIDERS[config.name] = provider except Exception as e: # noqa: BLE001 logger.warning("custom data source load failed %s: %s", file, e) _LOAD_ERRORS.append({"path": str(file), "errors": [str(e)]}) # 内置可选插件 (plugins/ 目录)。与用户 YAML 源独立, 缺依赖只记状态不报错。 _load_builtin_plugins() def list_sources() -> list[dict]: """只列出用户自定义 (YAML) 源。内置插件 (builtin=True) 由 list_plugins 独立呈现。""" return [ { "name": provider.name, "display_name": provider.config.display_name, "datasets": sorted(provider.config.datasets.keys()), "path": str(provider.config.path) if provider.config.path else None, } for provider in _PROVIDERS.values() if not getattr(provider, "builtin", False) ] def list_plugins() -> list[dict]: """返回所有内置插件的状态 (含已装/未装), 供设置页独立分类显示。""" return list(_PLUGIN_STATUS.values()) def plugin_manifest(name: str) -> dict | None: """读取指定插件的 plugin.yaml 清单。""" plugin_dir = plugins_dir() / (name or "") manifest_path = plugin_dir / "plugin.yaml" if not manifest_path.exists(): return None return yaml.safe_load(manifest_path.read_text(encoding="utf-8")) or {} def plugin_dir_of(name: str) -> Path: """返回插件目录路径。""" return plugins_dir() / (name or "") def install_plugin(name: str) -> tuple[bool, str]: """安装指定插件的依赖。根据 runtime 执行 npm install / pip install。 返回 (是否成功, 消息)。成功后调用方应 reload 重新扫描。 依赖未找到 (npm/pip 缺失) 或命令失败时返回 False。 """ manifest = plugin_manifest(name) if manifest is None: return False, f"插件 '{name}' 不存在或无 plugin.yaml" runtime = str(manifest.get("runtime", "none")).lower() pdir = plugin_dir_of(name) if not pdir.exists(): return False, f"插件目录不存在: {pdir}" try: if runtime == "node": npm = shutil.which("npm") if not npm: return False, "未找到 npm, 请先安装 Node.js (>=18)" # 在插件目录执行 npm install result = subprocess.run( [npm, "install", "--omit=dev", "--no-audit", "--no-fund"], cwd=str(pdir), capture_output=True, text=True, timeout=300, ) elif runtime == "python": # Python 型插件: 优先用 uv pip install (uv 管理的 venv 无 pip 模块), # 回退 python -m pip。都装进当前后端虚拟环境。 # uv 容错: 用户全局 uv.toml 配置错误时 exit 2, 回退 --no-config 重试。 # UV_HTTP_TIMEOUT=300: akshare 等含大包(如 mini-racer 14MB), 默认 30s 不够。 req = pdir / "requirements.txt" if not req.exists(): return False, "Python 型插件需要 requirements.txt" uv_bin = shutil.which("uv") if uv_bin: result = subprocess.run( [uv_bin, "pip", "install", "-r", str(req)], capture_output=True, text=True, timeout=300, env={**__import__("os").environ, "UV_HTTP_TIMEOUT": "300"}, ) # exit 2 通常是配置文件解析错误, 绕过配置重试 # --no-config 会丢镜像, 显式传国内镜像加速 (与用户 uv.toml 意图一致) if result.returncode == 2: result = subprocess.run( [uv_bin, "pip", "install", "--no-config", "--index-url", "https://pypi.tuna.tsinghua.edu.cn/simple", "-r", str(req)], capture_output=True, text=True, timeout=300, env={**__import__("os").environ, "UV_HTTP_TIMEOUT": "300"}, ) else: import sys result = subprocess.run( [sys.executable, "-m", "pip", "install", "-r", str(req)], capture_output=True, text=True, timeout=300, ) else: return False, f"runtime={runtime} 无需安装依赖" except subprocess.TimeoutExpired: return False, "安装超时 (5分钟), 请检查网络后重试" except Exception as e: # noqa: BLE001 return False, f"安装失败: {e}" if result.returncode != 0: # 取 stderr 的第一个 error: 行(真正的错误原因), 避免把 uv 的长字段列表返回给用户 raw = (result.stderr or result.stdout or "").strip() first_err = "" for line in raw.splitlines(): if line.strip().startswith(("error", "Error", "Caused by")): first_err = line.strip() break msg = first_err or raw[-200:] return False, f"安装失败 (exit {result.returncode}): {msg}" return True, "安装成功" def uninstall_plugin(name: str) -> tuple[bool, str]: """卸载指定插件的依赖。 node 型: 删除 node_modules 目录 (干净彻底, 下次需要重新 npm install)。 python 型: pip uninstall (包名从 requirements.txt 推断)。 """ import shutil as _shutil manifest = plugin_manifest(name) if manifest is None: return False, f"插件 '{name}' 不存在或无 plugin.yaml" runtime = str(manifest.get("runtime", "none")).lower() pdir = plugin_dir_of(name) if not pdir.exists(): return False, f"插件目录不存在: {pdir}" if runtime == "node": nm = pdir / "node_modules" if not nm.exists(): return True, "node_modules 不存在, 无需卸载" try: _shutil.rmtree(nm) return True, "已删除 node_modules" except Exception as e: # noqa: BLE001 return False, f"删除 node_modules 失败: {e}" if runtime == "python": req = pdir / "requirements.txt" if not req.exists(): return False, "Python 型插件缺少 requirements.txt, 无法自动卸载" # 读 requirements.txt 拿包名, 逐个 pip uninstall -y pkgs = [l.strip().split("==")[0].split(">=")[0].strip() for l in req.read_text().splitlines() if l.strip() and not l.startswith("#")] if not pkgs: return True, "requirements.txt 无有效包名" uv_bin = _shutil.which("uv") cmd = [uv_bin, "pip", "uninstall", *pkgs] if uv_bin else None if cmd is None: import sys cmd = [sys.executable, "-m", "pip", "uninstall", "-y", *pkgs] try: result = subprocess.run(cmd, capture_output=True, text=True, timeout=120) if result.returncode != 0: return False, f"卸载失败: {(result.stderr or '').strip()[-300:]}" return True, f"已卸载 {len(pkgs)} 个包" except Exception as e: # noqa: BLE001 return False, f"卸载失败: {e}" return False, f"runtime={runtime} 无需卸载" def is_builtin(name: str) -> bool: """判断 name 是否为内置插件 (不可被用户编辑/删除)。""" return (name or "").lower() in _PLUGIN_STATUS def names() -> set[str]: return set(_PROVIDERS) def errors() -> list[dict]: return list(_LOAD_ERRORS) def get_provider(name: str) -> GenericHTTPProvider: provider = _PROVIDERS.get((name or "").lower()) if provider is None: raise ValueError(f"Custom data source not found or invalid: {name}") return provider def is_custom_provider(name: str) -> bool: return (name or "").lower() in _PROVIDERS def provider_has_dataset(name: str, dataset: str) -> bool: """判断某个 custom 源是否配置了指定数据集。 用于主流程分流: 总开关选了 custom, 但某个数据集未启用时, 该数据集回退 TickFlow。 """ provider = _PROVIDERS.get((name or "").lower()) if provider is None: return False return dataset in provider.config.datasets def get_config_dict(name: str) -> dict | None: """读取一个已加载 custom 源的原始配置 dict(用于前端编辑回填)。内置插件不可编辑。""" if is_builtin(name): return None provider = _PROVIDERS.get((name or "").lower()) if provider is None: return None return _config_to_dict(provider.config) def _config_to_dict(config: CustomSourceConfig) -> dict: auth = config.auth out: dict = { "name": config.name, "display_name": config.display_name, "auth": { "type": auth.type, **({"token_env": auth.token_env} if auth.token_env else {}), **({"header": auth.header} if auth.type in {"bearer", "header"} and auth.header != "Authorization" else {}), **({"param": auth.param} if auth.type == "query" and auth.param != "token" else {}), }, "datasets": {}, } for ds_name, ds in config.datasets.items(): out["datasets"][ds_name] = { "url": ds.url, "method": ds.method, **({"batch": ds.batch} if ds.batch is not None else {}), **({"rpm": ds.rpm} if ds.rpm is not None else {}), "response_path": ds.response_path, "field_map": dict(ds.field_map), **({"transforms": dict(ds.transforms)} if ds.transforms else {}), "symbols_param": ds.symbols_param, "start_param": ds.start_param, "end_param": ds.end_param, **({"asset_type_param": ds.asset_type_param} if ds.asset_type_param else {}), **({"freq_param": ds.freq_param} if ds.freq_param else {}), } return out def save_config(name: str, config: dict) -> Path: """把一份配置 dict 写成 data/data_sources/{name}.yaml, 返回写入路径。""" if is_builtin(name): raise ValueError(f"'{name}' 是内置插件, 不可编辑") if not _NAME_RE.match(name or ""): raise ValueError(f"invalid data source name: {name!r} (only lowercase a-z 0-9 _ allowed)") base = data_sources_dir() base.mkdir(parents=True, exist_ok=True) path = (base / f"{name}.yaml").resolve() if not path.is_relative_to(base.resolve()): raise ValueError("invalid data source name: path escape detected") cleaned = _sanitize_for_yaml(config) path.write_text(yaml.safe_dump(cleaned, allow_unicode=True, sort_keys=False), encoding="utf-8") return path def delete_config(name: str) -> bool: """删除 data/data_sources/{name}.yaml。返回是否真的删除了。""" if is_builtin(name): raise ValueError(f"'{name}' 是内置插件, 不可删除") if not _NAME_RE.match(name or ""): raise ValueError(f"invalid data source name: {name!r}") base = data_sources_dir().resolve() path = (base / f"{name}.yaml").resolve() if not path.is_relative_to(base): raise ValueError("invalid data source name: path escape detected") if not path.exists(): return False path.unlink() return True def _sanitize_for_yaml(config: dict) -> dict: """剔除前端可能塞进来的空值/未启用数据集, 保证写入的 yaml 干净。""" out: dict = { "name": str(config.get("name", "")).lower(), "display_name": str(config.get("display_name") or config.get("name", "")), } auth_raw = config.get("auth") or {} auth_type = str(auth_raw.get("type", "none") or "none").lower() auth: dict = {"type": auth_type} if auth_type != "none" and auth_raw.get("token_env"): auth["token_env"] = str(auth_raw["token_env"]) if auth_type in {"bearer", "header"} and auth_raw.get("header"): auth["header"] = str(auth_raw["header"]) if auth_type == "query" and auth_raw.get("param"): auth["param"] = str(auth_raw["param"]) out["auth"] = auth datasets_out: dict = {} for ds_name, ds_cfg in (config.get("datasets") or {}).items(): if ds_name not in {"daily", "adj_factor", "realtime", "minute", "financial"}: continue if not isinstance(ds_cfg, dict): continue ds = _sanitize_dataset(ds_cfg) if ds: datasets_out[ds_name] = ds out["datasets"] = datasets_out return out def _sanitize_dataset(ds_cfg: dict) -> dict: out: dict = {} url = str(ds_cfg.get("url", "") or "").strip() if not url: return out out["url"] = url method = str(ds_cfg.get("method", "GET") or "GET").upper() out["method"] = method if ds_cfg.get("batch") is not None: try: out["batch"] = int(ds_cfg["batch"]) except (TypeError, ValueError): pass if ds_cfg.get("rpm") is not None: try: out["rpm"] = int(ds_cfg["rpm"]) except (TypeError, ValueError): pass out["response_path"] = str(ds_cfg.get("response_path", "") or "") field_map = { str(k): str(v) for k, v in (ds_cfg.get("field_map") or {}).items() if str(k).strip() and str(v).strip() } if field_map: out["field_map"] = field_map transforms = { str(k): str(v) for k, v in (ds_cfg.get("transforms") or {}).items() if str(k).strip() and str(v).strip() } if transforms: out["transforms"] = transforms if ds_cfg.get("symbols_param"): out["symbols_param"] = str(ds_cfg["symbols_param"]) if ds_cfg.get("start_param"): out["start_param"] = str(ds_cfg["start_param"]) if ds_cfg.get("end_param"): out["end_param"] = str(ds_cfg["end_param"]) if ds_cfg.get("asset_type_param"): out["asset_type_param"] = str(ds_cfg["asset_type_param"]) if ds_cfg.get("freq_param"): out["freq_param"] = str(ds_cfg["freq_param"]) return out # ================================================================ # 内置可选插件 (plugins/ 目录) 的发现与注册 # ================================================================ def _load_builtin_plugins() -> None: """扫描 plugins/ 目录下每个含 plugin.yaml 的子目录, 动态加载。 缺依赖时记录 "不可用" 状态, 不抛异常, 不影响主流程。 每次调用重建 _PLUGIN_STATUS, 并把可用的插件注册进 _PROVIDERS。 """ global _PLUGIN_STATUS _PLUGIN_STATUS = {} pdir = plugins_dir() if not pdir.exists(): return for plugin_dir in sorted(pdir.iterdir()): if not plugin_dir.is_dir(): continue manifest_path = plugin_dir / "plugin.yaml" if not manifest_path.exists(): continue try: manifest = yaml.safe_load(manifest_path.read_text(encoding="utf-8")) or {} _register_one_plugin(manifest) except Exception as e: # noqa: BLE001 logger.warning("插件 %s 清单解析失败: %s", plugin_dir.name, e) def _register_one_plugin(manifest: dict) -> None: """注册单个插件: 委托自检 → 可用则动态 import entry 注册进 _PROVIDERS。""" name = manifest.get("name") if not name or not _NAME_RE.match(name): logger.warning("插件清单缺少合法 name: %r", name) return runtime = str(manifest.get("runtime", "none")).lower() # 委托检测: 调用插件自己的 check 函数 (node 型/python 型各自实现) available, reason = _call_check(manifest.get("check")) _PLUGIN_STATUS[name] = { "name": name, "display_name": manifest.get("display_name", name), "datasets": list(manifest.get("datasets", []) or []), "runtime": runtime, "available": available, "status": reason, "description": manifest.get("description", ""), "install_hint": manifest.get("install_hint", ""), } if not available: return # 依赖没装: 不注册, 但状态已记录供 UI 显示 # 可用 → 动态加载 provider 类并实例化 try: provider_cls = _load_entry(manifest["entry"]) provider = provider_cls() if isinstance(provider_cls, type) else provider_cls provider.builtin = True # 标记为内置 (list_sources 过滤, 不可被用户编辑/删除) _PROVIDERS[name] = provider logger.info("内置插件 %s 已注册 (runtime=%s)", name, runtime) except Exception as e: # noqa: BLE001 # 声称可用但 import 失败 → 标记不可用, 避免启动崩溃 _PLUGIN_STATUS[name]["available"] = False _PLUGIN_STATUS[name]["status"] = f"加载失败: {e}" logger.warning("插件 %s provider 加载失败: %s", name, e) def _call_check(check_ref: str | None) -> tuple[bool, str]: """调用插件清单里指定的可用性检测函数, 返回 (是否可用, 原因)。 check_ref 格式 "module.path:func_name"。无 check 字段时视为可用。 """ if not check_ref: return True, "ok" try: func = _load_entry(check_ref) result = func() # 兼容两种返回: (bool, str) 或 bool if isinstance(result, tuple): return bool(result[0]), str(result[1]) return bool(result), "ok" if result else "不可用" except Exception as e: # noqa: BLE001 return False, str(e) def _load_entry(entry_ref: str): """动态加载 'module.path:attr' 形式的引用, 返回属性对象 (类或函数)。""" if ":" not in entry_ref: raise ValueError(f"entry 格式应为 'module.path:attr', 得到: {entry_ref!r}") module_path, attr = entry_ref.split(":", 1) module = importlib.import_module(module_path) return getattr(module, attr) # 模块导入时即扫描一次, 保证 names()/_allowed_data_providers() 在 startup 前可用。 _load_builtin_plugins()