feat(plugin): enhance fallback strategies

This commit is contained in:
InfinityPacer
2024-09-14 17:23:44 +08:00
parent e0e4b31933
commit 343f51ce79
2 changed files with 192 additions and 66 deletions
+170 -66
View File
@@ -1,6 +1,5 @@
import json import json
import shutil import shutil
import subprocess
import traceback import traceback
from pathlib import Path from pathlib import Path
from typing import Dict, Tuple, Optional, List, Any from typing import Dict, Tuple, Optional, List, Any
@@ -119,7 +118,15 @@ class PluginHelper(metaclass=Singleton):
def install(self, pid: str, repo_url: str) -> Tuple[bool, str]: def install(self, pid: str, repo_url: str) -> Tuple[bool, str]:
""" """
安装插件 安装插件,包括依赖安装和文件下载,相关资源支持自动降级策略
1. 从 GitHub 获取文件列表(包括 requirements.txt
2. 删除旧的插件目录
3. 下载并预安装 requirements.txt 中的依赖(如果存在)
4. 下载并安装插件的其他文件
5. 再次尝试安装依赖(确保安装完整)
:param pid: 插件 ID
:param repo_url: 插件仓库地址
:return: (是否成功, 错误信息)
""" """
if SystemUtils.is_frozen(): if SystemUtils.is_frozen():
return False, "可执行文件模式下,只能安装本地插件" return False, "可执行文件模式下,只能安装本地插件"
@@ -128,30 +135,41 @@ class PluginHelper(metaclass=Singleton):
if not pid or not repo_url: if not pid or not repo_url:
return False, "参数错误" return False, "参数错误"
# 从GitHubrepo_url获取用户和项目名 # 从 GitHubrepo_url 获取用户和项目名
user, repo = self.get_repo_info(repo_url) user, repo = self.get_repo_info(repo_url)
if not user or not repo: if not user or not repo:
return False, "不支持的插件仓库地址格式" return False, "不支持的插件仓库地址格式"
user_repo = f"{user}/{repo}" user_repo = f"{user}/{repo}"
# 获取插件文件列表 # 1. 获取插件文件列表(包括 requirements.txt
file_list, msg = self.__get_file_list(pid.lower(), user_repo) file_list, msg = self.__get_file_list(pid.lower(), user_repo)
if not file_list: if not file_list:
return False, msg return False, msg
# 删除旧的插件目录 # 2. 删除旧的插件目录
self.__remove_old_plugin(pid.lower()) self.__remove_old_plugin(pid.lower())
# 下载所有插件文件 # 3. 查找并安装 requirements.txt 中的依赖,确保插件环境的依赖尽可能完整。依赖安装可能失败且不影响插件安装,目前只记录日志
download_success, download_msg = self.__download_files(pid.lower(), file_list, user_repo) requirements_file_info = next((f for f in file_list if f.get("name") == "requirements.txt"), None)
if requirements_file_info:
logger.info(f"发现 requirements.txt,开始下载并安装依赖")
download_success, download_msg = self.__download_and_install_requirements(requirements_file_info,
pid.lower(), user_repo)
if not download_success:
logger.error(f"依赖预安装失败:{download_msg}")
# 4. 下载插件的其他文件
download_success, download_msg = self.__download_files(pid.lower(), file_list, user_repo,
skip_requirements=True)
if not download_success: if not download_success:
return False, download_msg return False, download_msg
# 插件目录下如有requirements.txt则安装依赖 # 5. 插件文件安装成功后,再次尝试安装依赖,避免因为遗漏依赖导致的插件运行问题,目前依旧只记录日志
logger.info(f"插件文件安装完成,尝试再次安装依赖:{pid}")
success, message = self.__install_dependencies_if_required(pid.lower()) success, message = self.__install_dependencies_if_required(pid.lower())
if not success: if not success:
return False, message logger.error(f"依赖安装失败(安装后):{message}")
# 插件安装成功后,统计安装信息 # 插件安装成功后,统计安装信息
self.install_reg(pid) self.install_reg(pid)
@@ -160,6 +178,9 @@ class PluginHelper(metaclass=Singleton):
def __get_file_list(self, pid: str, user_repo: str) -> Tuple[Optional[list], Optional[str]]: def __get_file_list(self, pid: str, user_repo: str) -> Tuple[Optional[list], Optional[str]]:
""" """
获取插件的文件列表 获取插件的文件列表
:param pid: 插件 ID
:param user_repo: GitHub 仓库的 user/repo 路径
:return: (文件列表, 错误信息)
""" """
file_api = f"https://api.github.com/repos/{user_repo}/contents/plugins/{pid}" file_api = f"https://api.github.com/repos/{user_repo}/contents/plugins/{pid}"
res = self.__request_with_fallback(file_api, res = self.__request_with_fallback(file_api,
@@ -182,9 +203,15 @@ class PluginHelper(metaclass=Singleton):
logger.error(f"插件数据解析失败:{res.text}{e}") logger.error(f"插件数据解析失败:{res.text}{e}")
return None, "插件数据解析失败" return None, "插件数据解析失败"
def __download_files(self, pid: str, file_list: List[dict], user_repo: str) -> Tuple[bool, str]: def __download_files(self, pid: str, file_list: List[dict], user_repo: str, skip_requirements: bool = False) \
-> Tuple[bool, str]:
""" """
下载插件文件 下载插件文件
:param pid: 插件 ID
:param file_list: 要下载的文件列表,包含文件的元数据(包括下载链接)
:param user_repo: GitHub 仓库的 user/repo 路径
:param skip_requirements: 是否跳过 requirements.txt 文件的下载
:return: (是否成功, 错误信息)
""" """
if not file_list: if not file_list:
return False, "文件列表为空" return False, "文件列表为空"
@@ -196,6 +223,10 @@ class PluginHelper(metaclass=Singleton):
current_pid, current_file_list = stack.pop() current_pid, current_file_list = stack.pop()
for item in current_file_list: for item in current_file_list:
# 跳过 requirements.txt 的下载
if skip_requirements and item.get("name") == "requirements.txt":
continue
if item.get("download_url"): if item.get("download_url"):
logger.debug(f"正在下载文件:{item.get('path')}") logger.debug(f"正在下载文件:{item.get('path')}")
res = self.__request_with_fallback(item.get('download_url'), res = self.__request_with_fallback(item.get('download_url'),
@@ -203,8 +234,7 @@ class PluginHelper(metaclass=Singleton):
if not res: if not res:
return False, f"文件 {item.get('path')} 下载失败!" return False, f"文件 {item.get('path')} 下载失败!"
elif res.status_code != 200: elif res.status_code != 200:
return False, f"下载文件 {item.get('path')} 失败:{res.status_code} - " \ return False, f"下载文件 {item.get('path')} 失败:{res.status_code}"
f"{'超出速率限制,请配置GITHUB_TOKEN环境变量或稍后重试' if res.status_code == 403 else res.reason}"
# 创建插件文件夹并写入文件 # 创建插件文件夹并写入文件
file_path = Path(settings.ROOT_PATH) / "app" / item.get("path") file_path = Path(settings.ROOT_PATH) / "app" / item.get("path")
@@ -213,7 +243,7 @@ class PluginHelper(metaclass=Singleton):
f.write(res.text) f.write(res.text)
logger.debug(f"文件 {item.get('path')} 下载成功,保存路径:{file_path}") logger.debug(f"文件 {item.get('path')} 下载成功,保存路径:{file_path}")
else: else:
# 将子目录加入栈中以便处理 # 如果是子目录,则将子目录内容加入栈中继续处理
sub_list, msg = self.__get_file_list(f"{current_pid}/{item.get('name')}", user_repo) sub_list, msg = self.__get_file_list(f"{current_pid}/{item.get('name')}", user_repo)
if not sub_list: if not sub_list:
return False, msg return False, msg
@@ -221,102 +251,176 @@ class PluginHelper(metaclass=Singleton):
return True, "" return True, ""
def __download_and_install_requirements(self, requirements_file_info: dict, pid: str, user_repo: str) \
-> Tuple[bool, str]:
"""
下载并安装 requirements.txt 文件中的依赖
:param requirements_file_info: requirements.txt 文件的元数据信息
:param pid: 插件 ID
:param user_repo: GitHub 仓库的 user/repo 路径
:return: (是否成功, 错误信息)
"""
# 下载 requirements.txt
res = self.__request_with_fallback(requirements_file_info.get("download_url"),
headers=settings.REPO_GITHUB_HEADERS(repo=user_repo))
if not res:
return False, "requirements.txt 文件下载失败"
elif res.status_code != 200:
return False, f"下载 requirements.txt 文件失败:{res.status_code}"
requirements_txt = res.text
if requirements_txt.strip():
# 保存并安装依赖
requirements_file_path = Path(settings.ROOT_PATH) / "app" / "plugins" / pid / "requirements.txt"
requirements_file_path.parent.mkdir(parents=True, exist_ok=True)
with open(requirements_file_path, "w", encoding="utf-8") as f:
f.write(requirements_txt)
success, message = self.__pip_uninstall_and_install_with_fallback(requirements_file_path)
return success, message
return True, "" # 如果 requirements.txt 为空,视作成功
def __install_dependencies_if_required(self, pid: str) -> Tuple[bool, str]: def __install_dependencies_if_required(self, pid: str) -> Tuple[bool, str]:
""" """
安装插件依赖(如果有requirements.txt 安装插件依赖
:param pid: 插件 ID
:return: (是否成功, 错误信息)
""" """
plugin_dir = Path(settings.ROOT_PATH) / "app" / "plugins" / pid plugin_dir = Path(settings.ROOT_PATH) / "app" / "plugins" / pid
requirements_file = plugin_dir / "requirements.txt" requirements_file = plugin_dir / "requirements.txt"
if requirements_file.exists(): if requirements_file.exists():
return self.__pip_install_with_fallback(requirements_file) return self.__pip_uninstall_and_install_with_fallback(requirements_file)
return True, "" return True, ""
@staticmethod @staticmethod
def __remove_old_plugin(pid: str): def __remove_old_plugin(pid: str):
""" """
删除旧插件 删除旧插件
:param pid: 插件 ID
""" """
plugin_dir = Path(settings.ROOT_PATH) / "app" / "plugins" / pid plugin_dir = Path(settings.ROOT_PATH) / "app" / "plugins" / pid
if plugin_dir.exists(): if plugin_dir.exists():
shutil.rmtree(plugin_dir, ignore_errors=True) shutil.rmtree(plugin_dir, ignore_errors=True)
@staticmethod @staticmethod
def __pip_install_with_fallback(requirements_file: Path) -> Tuple[bool, str]: def __pip_uninstall_and_install_with_fallback(requirements_file: Path) -> Tuple[bool, str]:
""" """
使用自动降级策略 PIP 安装依赖,优先级依次为镜像站、代理、直连 先卸载 requirements.txt 中的依赖,再按照自动降级策略重新安装,不使用 PIP 缓存
:param requirements_file: 依赖的 requirements.txt 文件路径 :param requirements_file: 依赖的 requirements.txt 文件路径
:return: 依赖安装成功返回 (True, ""),失败返回 (False, 错误信息) :return: (是否成功, 错误信息)
""" """
# 构建三种不同策略下的 PIP 命令 # 读取 requirements.txt 文件中的依赖列表
pip_commands = [ try:
["pip", "install", "-r", str(requirements_file), "-i", settings.PIP_PROXY] if settings.PIP_PROXY else None, with open(requirements_file, "r", encoding="utf-8") as f:
# 使用镜像站 dependencies = [line.strip() for line in f if line.strip() and not line.startswith("#")]
["pip", "install", "-r", str(requirements_file), "--proxy", except Exception as e:
settings.PROXY_HOST] if settings.PROXY_HOST else None, # 使用代理 return False, f"无法读取 requirements.txt 文件:{str(e)}"
["pip", "install", "-r", str(requirements_file)] # 直连
]
# 过滤掉 None 的命令 # 1. 先卸载所有依赖包
pip_commands = [cmd for cmd in pip_commands if cmd is not None] for dep in dependencies:
pip_uninstall_command = ["pip", "uninstall", "-y", dep]
logger.info(f"尝试卸载依赖:{dep}")
success, message = SystemUtils.execute_with_subprocess(pip_uninstall_command)
if success:
logger.debug(f"依赖 {dep} 卸载成功,输出:{message}")
else:
error_message = f"卸载依赖 {dep} 失败,错误信息:{message}"
logger.error(error_message)
for pip_command in pip_commands: # 2. 重新安装所有依赖,使用自动降级策略
try: strategies = []
logger.info(f"尝试使用PIP安装依赖,命令:{' '.join(pip_command)}")
# 使用 subprocess.run 捕获标准输出和标准错误 # 添加策略到列表中
result = subprocess.run(pip_command, check=True, text=True, if settings.PIP_PROXY:
stdout=subprocess.PIPE, stderr=subprocess.PIPE) strategies.append(("镜像站",
logger.info(f"依赖安装成功,输出:{result.stdout}") ["pip", "install", "-r", str(requirements_file),
return True, result.stdout "-i", settings.PIP_PROXY, "--no-cache-dir"]))
except subprocess.CalledProcessError as e: if settings.PROXY_HOST:
error_message = f"命令:{' '.join(pip_command)},执行失败,错误信息:{e.stderr.strip()}" strategies.append(("代理",
logger.error(error_message) ["pip", "install", "-r", str(requirements_file),
return False, error_message "--proxy", settings.PROXY_HOST, "--no-cache-dir"]))
except Exception as e: strategies.append(("直连", ["pip", "install", "-r", str(requirements_file), "--no-cache-dir"]))
error_message = f"未知错误,命令:{' '.join(pip_command)},错误:{str(e)}"
logger.error(error_message) # 遍历策略进行安装
return False, error_message for strategy_name, pip_command in strategies:
logger.info(f"PIP 尝试使用策略 {strategy_name} 安装依赖,命令:{' '.join(pip_command)}")
success, message = SystemUtils.execute_with_subprocess(pip_command)
if success:
logger.info(f"PIP 策略 {strategy_name} 安装依赖成功,输出:{message}")
return True, message
else:
logger.error(f"PIP 策略 {strategy_name} 安装依赖失败,错误信息:{message}")
return False, "所有依赖安装方式均失败,请检查网络连接或 PIP 配置" return False, "所有依赖安装方式均失败,请检查网络连接或 PIP 配置"
@staticmethod
def __pip_install_with_fallback(requirements_file: Path) -> Tuple[bool, str]:
"""
使用自动降级策略,PIP 安装依赖,优先级依次为镜像站、代理、直连
:param requirements_file: 依赖的 requirements.txt 文件路径
:return: (是否成功, 错误信息)
"""
strategies = []
# 添加策略到列表中
if settings.PIP_PROXY:
strategies.append(("镜像站", ["pip", "install", "-r", str(requirements_file), "-i", settings.PIP_PROXY]))
if settings.PROXY_HOST:
strategies.append(
("代理", ["pip", "install", "-r", str(requirements_file), "--proxy", settings.PROXY_HOST]))
strategies.append(("直连", ["pip", "install", "-r", str(requirements_file)]))
# 遍历策略进行安装
for strategy_name, pip_command in strategies:
logger.info(f"PIP 尝试使用策略 {strategy_name} 安装依赖,命令:{' '.join(pip_command)}")
success, message = SystemUtils.execute_with_subprocess(pip_command)
if success:
logger.debug(f"PIP 策略 {strategy_name} 安装依赖成功,输出:{message}")
return True, message
else:
logger.error(f"PIP 策略 {strategy_name} 安装依赖失败,错误信息:{message}")
return False, "所有PIP依赖安装方式均失败,请检查网络连接或 PIP 配置"
@staticmethod @staticmethod
def __request_with_fallback(url: str, def __request_with_fallback(url: str,
headers: Optional[dict] = None, headers: Optional[dict] = None,
timeout: int = 60, timeout: int = 60,
is_api: bool = False) -> Optional[Any]: is_api: bool = False) -> Optional[Any]:
""" """
使用自动降级策略请求资源,优先级依次为镜像站、代理、直连 使用自动降级策略请求资源,优先级依次为镜像站、代理、直连
:param url: 目标URL :param url: 目标URL
:param headers: 请求头信息 :param headers: 请求头信息
:param timeout: 请求超时时间 :param timeout: 请求超时时间
:param is_api: 是否为GitHub API请求,API请求不走镜像站 :param is_api: 是否为GitHub API请求,API请求不走镜像站
:return: 请求成功则返回Response,失败返回None :return: 请求成功则返回 Response,失败返回 None
""" """
# 镜像站一般不支持API请求,因此API请求直接跳过镜像站 strategies = []
# 1. 尝试使用镜像站,镜像站一般不支持API请求,因此API请求直接跳过镜像站
if not is_api and settings.GITHUB_PROXY: if not is_api and settings.GITHUB_PROXY:
proxy_url = f"{UrlUtils.standardize_base_url(settings.GITHUB_PROXY)}{url}" proxy_url = f"{UrlUtils.standardize_base_url(settings.GITHUB_PROXY)}{url}"
try: strategies.append(("镜像站", proxy_url, {"headers": headers, "timeout": timeout}))
res = RequestUtils(headers=headers, timeout=timeout).get_res(url=proxy_url,
raise_exception=True)
return res
except Exception as e:
logger.error(f"使用镜像站 {settings.GITHUB_PROXY} 访问 {url} 失败: {str(e)}")
# 使用代理 # 2. 尝试使用代理
if settings.PROXY_HOST: if settings.PROXY_HOST:
proxies = {"http": settings.PROXY_HOST, "https": settings.PROXY_HOST} strategies.append(("代理", url, {"headers": headers, "proxies": settings.PROXY, "timeout": timeout}))
# 3. 最后尝试直连
strategies.append(("直连", url, {"headers": headers, "timeout": timeout}))
# 遍历策略并尝试请求
for strategy_name, target_url, request_params in strategies:
logger.info(f"GitHub 尝试使用策略 {strategy_name} 访问 {target_url}")
try: try:
res = RequestUtils(headers=headers, proxies=proxies, timeout=timeout).get_res(url=url, res = RequestUtils(**request_params).get_res(url=target_url, raise_exception=True)
raise_exception=True) logger.info(f"GitHub 策略 {strategy_name} 访问成功,URL: {target_url}")
return res return res
except Exception as e: except Exception as e:
logger.error(f"使用代理 {settings.PROXY_HOST} 访问 {url} 失败: {str(e)}") logger.error(f"GitHub 策略 {strategy_name} 访问失败,URL: {target_url},错误信息:{str(e)}")
# 最后尝试直连
try:
res = RequestUtils(headers=headers, timeout=timeout).get_res(url=url,
raise_exception=True)
return res
except Exception as e:
logger.error(f"直连访问 {url} 失败: {str(e)}")
logger.error(f"所有GitHub策略访问 {url} 均失败")
return None return None
+22
View File
@@ -3,6 +3,7 @@ import os
import platform import platform
import re import re
import shutil import shutil
import subprocess
import sys import sys
from pathlib import Path from pathlib import Path
from typing import List, Union, Tuple, Optional from typing import List, Union, Tuple, Optional
@@ -27,6 +28,27 @@ class SystemUtils:
print(str(err)) print(str(err))
return "" return ""
@staticmethod
def execute_with_subprocess(pip_command: list) -> Tuple[bool, str]:
"""
执行命令并捕获标准输出和错误输出,记录日志。
:param pip_command: 要执行的命令,以列表形式提供
:return: (命令是否成功, 输出信息或错误信息)
"""
try:
# 使用 subprocess.run 捕获标准输出和标准错误
result = subprocess.run(pip_command, check=True, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
# 合并 stdout 和 stderr
output = result.stdout + result.stderr
return True, output
except subprocess.CalledProcessError as e:
error_message = f"命令:{' '.join(pip_command)},执行失败,错误信息:{e.stderr.strip()}"
return False, error_message
except Exception as e:
error_message = f"未知错误,命令:{' '.join(pip_command)},错误:{str(e)}"
return False, error_message
@staticmethod @staticmethod
def is_docker() -> bool: def is_docker() -> bool:
""" """