Files
zhenxun_bot/zhenxun/utils/manager/zhenxun_repo_manager.py
T
8b16126e40 添加uv支持 (#2119)
* bugfix:修复内存泄露和信号量饥饿问题

* 修复图片渲染按高度截断问题

* 优化图片渲染速度

* 权限检查去掉无效引用代码

* 添加uv支持

* 🚨 auto fix by pre-commit hooks

* bugfix:修改gitignore换行

* bugfix:修复测试没有新生成uv.lock

* 修复导入错误

* bugfix:移除重复调用

* 🚨 auto fix by pre-commit hooks

* 清理残余poetry引用

* 更新uv安装方式

* 修复阿里云获取问题

* 增加资源下载提示

* 🚨 auto fix by pre-commit hooks

* 修改资源下载为流式

* 🚨 auto fix by pre-commit hooks

* 提高启动速度

* 移除bot.py支持

* 🚨 auto fix by pre-commit hooks

* 优化win脚本逻辑

* 🚨 auto fix by pre-commit hooks

* 清理残余无效逻辑

* 代码改进

* 🚨 auto fix by pre-commit hooks

* 增加数据库迁移存在性检查

* 🚨 auto fix by pre-commit hooks

* chore(test): 添加pytest超时控制和优雅关闭机制

- 在GitHub Actions工作流中添加作业级和步骤级超时限制,防止测试无限期挂起
- 添加pytest-timeout依赖并配置全局超时为120秒
- 在send_queue服务添加关闭钩子,确保worker任务正确取消
- 在priority_manager添加on_shutdown钩子,支持优先级生命周期的关闭阶段

* chore(lint): 禁用超长行的lint警告

* Modify restart logic for Windows platform

* 🚨 auto fix by pre-commit hooks

* bugfix:修复sys导入问题

* 清理无效结构

* bugfix:修复路径问题

* bugfix:修复shell语法传递给git导致资源获取失败问题

* 优化关闭显示

* bugfix:修复路径问题

* bugfix:增加路径安全

* bugfix:修复orm绕过问题

* 放宽numpy版本限制

* 修改重启方案

* bugfix:修复循环导入

* 优化逻辑

* Enhance disconnect function with error handling

Added error handling for disconnect function and imported ConfigurationError.

* Implement emergency restart mechanism

Added emergency restart mechanism using atexit to ensure process restart even on severe exceptions during shutdown.

* 🚨 auto fix by pre-commit hooks

* 重启行为归一化

* 修复测试检测问题

* bugfix:修复测试侧类型报错问题

* 引入launcher机制

* 移除重启测试

* 收紧缓存调用路径

* 类型注解收敛

* 优化浏览器回收行为

* 优化浏览器渲染

* bugfix:解决重复关闭浏览器问题

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: ManyManyTomato <93612024+ATTomatoo@users.noreply.github.com>
Co-authored-by: AkashiCoin <l1040186796@gmail.com>
2026-04-18 23:42:10 +08:00

591 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
真寻仓库管理器
负责真寻主仓库的更新、版本检查、文件处理等功能
"""
import os
from pathlib import Path
import shutil
from typing import ClassVar, Literal
import zipfile
import aiofiles
from zhenxun.configs.path_config import DATA_PATH, TEMP_PATH
from zhenxun.services.log import logger
from zhenxun.utils.github_utils import GithubUtils
from zhenxun.utils.http_utils import AsyncHttpx
from zhenxun.utils.manager.virtual_env_package_manager import VirtualEnvPackageManager
from zhenxun.utils.repo_utils import AliyunRepoManager, GithubRepoManager
from zhenxun.utils.repo_utils.models import RepoUpdateResult
from zhenxun.utils.repo_utils.utils import check_git
LOG_COMMAND = "ZhenxunRepoManager"
class ZhenxunUpdateException(Exception):
"""资源下载异常"""
pass
class ZhenxunRepoConfig:
"""真寻仓库配置"""
# Zhenxun Bot 相关配置
ZHENXUN_BOT_GIT = "https://github.com/zhenxun-org/zhenxun_bot.git"
ZHENXUN_BOT_GITHUB_URL = "https://github.com/HibiKier/zhenxun_bot/tree/main"
ZHENXUN_BOT_DOWNLOAD_FILE_STRING = "zhenxun_bot.zip"
ZHENXUN_BOT_DOWNLOAD_FILE = TEMP_PATH / ZHENXUN_BOT_DOWNLOAD_FILE_STRING
ZHENXUN_BOT_UNZIP_PATH = TEMP_PATH / "zhenxun_bot"
ZHENXUN_BOT_CODE_PATH = Path() / "zhenxun"
ZHENXUN_BOT_RELEASES_API_URL = (
"https://api.github.com/repos/HibiKier/zhenxun_bot/releases/latest"
)
ZHENXUN_BOT_BACKUP_PATH = Path() / "backup"
# 需要替换的文件夹
ZHENXUN_BOT_UPDATE_FOLDERS: ClassVar[list[str]] = [
"zhenxun/builtin_plugins",
"zhenxun/services",
"zhenxun/utils",
"zhenxun/models",
"zhenxun/configs",
"zhenxun/ui",
]
ZHENXUN_BOT_VERSION_FILE_STRING = "__version__"
ZHENXUN_BOT_VERSION_FILE = Path() / ZHENXUN_BOT_VERSION_FILE_STRING
# 备份杂项
BACKUP_FILES: ClassVar[list[str]] = [
"pyproject.toml",
"uv.lock",
"requirements.txt",
".env.dev",
".env.example",
]
# WEB UI 相关配置
WEBUI_GIT = "https://github.com/HibiKier/zhenxun_bot_webui.git"
WEBUI_DIST_GITHUB_URL = "https://github.com/HibiKier/zhenxun_bot_webui/tree/dist"
WEBUI_DOWNLOAD_FILE_STRING = "webui_assets.zip"
WEBUI_DOWNLOAD_FILE = TEMP_PATH / WEBUI_DOWNLOAD_FILE_STRING
WEBUI_UNZIP_PATH = TEMP_PATH / "web_ui"
WEBUI_PATH = DATA_PATH / "web_ui" / "public"
WEBUI_BACKUP_PATH = DATA_PATH / "web_ui" / "backup_public"
# 资源管理相关配置
RESOURCE_GIT = "https://github.com/zhenxun-org/zhenxun-bot-resources.git"
RESOURCE_GITHUB_URL = (
"https://github.com/zhenxun-org/zhenxun-bot-resources/tree/main"
)
RESOURCE_ZIP_FILE_STRING = "resources.zip"
RESOURCE_ZIP_FILE = TEMP_PATH / RESOURCE_ZIP_FILE_STRING
RESOURCE_UNZIP_PATH = TEMP_PATH / "resources"
RESOURCE_PATH = Path() / "resources"
REQUIREMENTS_FILE_STRING = "requirements.txt"
REQUIREMENTS_FILE = Path() / REQUIREMENTS_FILE_STRING
PYPROJECT_FILE_STRING = "pyproject.toml"
PYPROJECT_FILE = Path() / PYPROJECT_FILE_STRING
PYPROJECT_LOCK_FILE_STRING = "uv.lock"
PYPROJECT_LOCK_FILE = Path() / PYPROJECT_LOCK_FILE_STRING
class ZhenxunRepoManagerClass:
"""真寻仓库管理器"""
def __init__(self):
self.config = ZhenxunRepoConfig()
def __clear_folder(self, folder_path: Path):
"""
清空文件夹
参数:
folder_path: 文件夹路径
"""
if not folder_path.exists():
return
for filename in os.listdir(folder_path):
file_path = folder_path / filename
try:
if file_path.is_file():
os.unlink(file_path)
elif file_path.is_dir() and not filename.startswith("."):
shutil.rmtree(file_path)
except Exception as e:
logger.warning(f"无法删除 {file_path}", LOG_COMMAND, e=e)
def __copy_files(self, src_path: Path, dest_path: Path, incremental: bool = False):
"""
复制文件或文件夹
参数:
src_path: 源文件或文件夹路径
dest_path: 目标文件或文件夹路径
incremental: 是否增量复制
"""
if src_path.is_file():
shutil.copy(src_path, dest_path)
logger.debug(f"复制文件 {src_path} -> {dest_path}", LOG_COMMAND)
elif src_path.is_dir():
for filename in os.listdir(src_path):
file_path = src_path / filename
dest_file = dest_path / filename
dest_file.parent.mkdir(exist_ok=True, parents=True)
if file_path.is_file():
if dest_file.exists():
dest_file.unlink()
shutil.copy(file_path, dest_file)
logger.debug(f"复制文件 {file_path} -> {dest_file}", LOG_COMMAND)
elif file_path.is_dir():
if incremental:
self.__copy_files(file_path, dest_file, incremental=True)
else:
if dest_file.exists():
shutil.rmtree(dest_file, True)
shutil.copytree(file_path, dest_file)
logger.debug(
f"复制文件夹 {file_path} -> {dest_file}",
LOG_COMMAND,
)
# ==================== Zhenxun Bot 相关方法 ====================
async def zhenxun_get_version_from_repo(self) -> str:
"""从指定分支获取版本号
返回:
str: 版本号
"""
repo_info = GithubUtils.parse_github_url(self.config.ZHENXUN_BOT_GITHUB_URL)
version_url = await repo_info.get_raw_download_urls(
path=self.config.ZHENXUN_BOT_VERSION_FILE_STRING
)
try:
res = await AsyncHttpx.get(version_url)
if res.status_code == 200:
return res.text.strip()
except Exception as e:
logger.error(f"获取 {repo_info.branch} 分支版本失败", LOG_COMMAND, e=e)
return "未知版本"
async def zhenxun_write_version_file(self, version: str):
"""写入版本文件"""
async with aiofiles.open(
self.config.ZHENXUN_BOT_VERSION_FILE, "w", encoding="utf8"
) as f:
await f.write(f"__version__: {version}")
def __backup_zhenxun(self):
"""备份真寻文件"""
for filename in os.listdir(self.config.ZHENXUN_BOT_CODE_PATH):
file_path = self.config.ZHENXUN_BOT_CODE_PATH / filename
if file_path.exists():
self.__copy_files(
file_path,
self.config.ZHENXUN_BOT_BACKUP_PATH / filename,
True,
)
for filename in self.config.BACKUP_FILES:
file_path = Path() / filename
if file_path.exists():
self.__copy_files(
file_path,
self.config.ZHENXUN_BOT_BACKUP_PATH / filename,
)
async def zhenxun_get_latest_releases_data(self) -> dict:
"""获取真寻releases最新版本信息
返回:
dict: 最新版本数据
"""
try:
res = await AsyncHttpx.get(self.config.ZHENXUN_BOT_RELEASES_API_URL)
if res.status_code == 200:
return res.json()
except Exception as e:
logger.error("检查更新真寻获取版本失败", LOG_COMMAND, e=e)
return {}
async def zhenxun_download_zip(self, ver_type: Literal["main", "release"]) -> str:
"""下载真寻最新版文件
参数:
ver_type: 版本类型,main 为最新版,release 为最新release版
返回:
str: 版本号
"""
repo_info = GithubUtils.parse_github_url(self.config.ZHENXUN_BOT_GITHUB_URL)
if ver_type == "main":
download_url = await repo_info.get_archive_download_urls()
new_version = await self.zhenxun_get_version_from_repo()
else:
release_data = await self.zhenxun_get_latest_releases_data()
logger.debug(f"获取真寻RELEASES最新版本信息: {release_data}", LOG_COMMAND)
if not release_data:
raise ZhenxunUpdateException("获取真寻RELEASES最新版本失败...")
new_version = release_data.get("name", "")
download_url = await repo_info.get_release_source_download_urls_tgz(
new_version
)
if not download_url:
raise ZhenxunUpdateException("获取真寻最新版文件下载链接失败...")
if self.config.ZHENXUN_BOT_DOWNLOAD_FILE.exists():
self.config.ZHENXUN_BOT_DOWNLOAD_FILE.unlink()
if await AsyncHttpx.download_file(
download_url, self.config.ZHENXUN_BOT_DOWNLOAD_FILE, stream=True
):
logger.debug("下载真寻最新版文件完成...", LOG_COMMAND)
else:
raise ZhenxunUpdateException("下载真寻最新版文件失败...")
return new_version
async def zhenxun_unzip(self):
"""解压真寻最新版文件"""
if not self.config.ZHENXUN_BOT_DOWNLOAD_FILE.exists():
raise FileNotFoundError("真寻最新版文件不存在")
if self.config.ZHENXUN_BOT_UNZIP_PATH.exists():
shutil.rmtree(self.config.ZHENXUN_BOT_UNZIP_PATH)
tf = None
try:
tf = zipfile.ZipFile(self.config.ZHENXUN_BOT_DOWNLOAD_FILE)
tf.extractall(self.config.ZHENXUN_BOT_UNZIP_PATH)
logger.debug("解压Zhenxun Bot文件压缩包完成!", LOG_COMMAND)
self.__backup_zhenxun()
for filename in self.config.BACKUP_FILES:
self.__copy_files(
self.config.ZHENXUN_BOT_UNZIP_PATH / filename,
Path() / filename,
)
logger.debug("备份真寻更新文件完成!", LOG_COMMAND)
unzip_dir = next(self.config.ZHENXUN_BOT_UNZIP_PATH.iterdir())
for folder in self.config.ZHENXUN_BOT_UPDATE_FOLDERS:
self.__copy_files(unzip_dir / folder, Path() / folder)
logger.debug("移动真寻更新文件完成!", LOG_COMMAND)
if self.config.ZHENXUN_BOT_UNZIP_PATH.exists():
shutil.rmtree(self.config.ZHENXUN_BOT_UNZIP_PATH)
except Exception as e:
logger.error("解压真寻最新版文件失败...", LOG_COMMAND, e=e)
raise
finally:
if tf:
tf.close()
async def zhenxun_zip_update(self, ver_type: Literal["main", "release"]) -> str:
"""使用zip更新真寻
参数:
ver_type: 版本类型,main 为最新版,release 为最新release版
返回:
str: 版本号
"""
new_version = await self.zhenxun_download_zip(ver_type)
await self.zhenxun_unzip()
await self.zhenxun_write_version_file(new_version)
return new_version
async def zhenxun_git_update(
self, source: Literal["git", "ali"], branch: str = "main", force: bool = False
) -> RepoUpdateResult:
"""使用git或阿里云更新真寻
参数:
source: 更新源,git 为 git 更新,ali 为阿里云更新
branch: 分支名称
force: 是否强制更新
"""
if source == "git":
return await GithubRepoManager.update_via_git(
self.config.ZHENXUN_BOT_GIT,
Path(),
branch=branch,
force=force,
)
else:
return await AliyunRepoManager.update_via_git(
self.config.ZHENXUN_BOT_GIT,
Path(),
branch=branch,
force=force,
)
async def zhenxun_update(
self,
source: Literal["git", "ali"] = "ali",
branch: str = "main",
force: bool = False,
ver_type: Literal["main", "release"] = "main",
):
"""更新真寻
参数:
source: 更新源,git 为 git 更新,ali 为阿里云更新
branch: 分支名称
force: 是否强制更新
ver_type: 版本类型,main 为最新版,release 为最新release版
"""
if await check_git():
await self.zhenxun_git_update(source, branch, force)
logger.debug("使用git更新真寻!", LOG_COMMAND)
else:
await self.zhenxun_zip_update(ver_type)
logger.debug("使用zip更新真寻!", LOG_COMMAND)
async def install_requirements(self):
"""安装真寻依赖"""
await VirtualEnvPackageManager.install_requirement(
self.config.REQUIREMENTS_FILE
)
# ==================== 资源管理相关方法 ====================
def check_resources_exists(self) -> bool:
"""检查资源文件是否存在
返回:
bool: 是否存在
"""
if self.config.RESOURCE_PATH.exists():
font_path = self.config.RESOURCE_PATH / "font"
if font_path.exists() and os.listdir(font_path):
return True
return False
async def resources_download_zip(self):
"""下载资源文件"""
download_url = await GithubUtils.parse_github_url(
self.config.RESOURCE_GITHUB_URL
).get_archive_download_urls()
logger.info("开始下载资源压缩包...", LOG_COMMAND)
if await AsyncHttpx.download_file(
download_url,
self.config.RESOURCE_ZIP_FILE,
stream=True,
show_progress=True,
):
logger.info("下载资源压缩包成功!", LOG_COMMAND)
else:
raise ZhenxunUpdateException("下载资源压缩包失败...")
async def resources_unzip(self):
"""解压资源文件"""
if not self.config.RESOURCE_ZIP_FILE.exists():
raise FileNotFoundError("资源文件压缩包不存在")
if self.config.RESOURCE_UNZIP_PATH.exists():
shutil.rmtree(self.config.RESOURCE_UNZIP_PATH)
tf = None
try:
tf = zipfile.ZipFile(self.config.RESOURCE_ZIP_FILE)
tf.extractall(self.config.RESOURCE_UNZIP_PATH)
logger.debug("解压文件压缩包完成...", LOG_COMMAND)
unzip_dir = next(self.config.RESOURCE_UNZIP_PATH.iterdir())
self.__copy_files(unzip_dir, self.config.RESOURCE_PATH, True)
logger.debug("复制资源文件完成!", LOG_COMMAND)
shutil.rmtree(self.config.RESOURCE_UNZIP_PATH, ignore_errors=True)
except Exception as e:
logger.error("解压资源文件失败...", LOG_COMMAND, e=e)
raise
finally:
if tf:
tf.close()
async def resources_zip_update(self):
"""使用zip更新资源文件"""
await self.resources_download_zip()
await self.resources_unzip()
async def resources_git_update(
self, source: Literal["git", "ali"], branch: str = "main", force: bool = False
) -> RepoUpdateResult:
"""使用git或阿里云更新资源文件
参数:
source: 更新源,git 为 git 更新,ali 为阿里云更新
branch: 分支名称
force: 是否强制更新
"""
if source == "git":
return await GithubRepoManager.update_via_git(
self.config.RESOURCE_GIT,
self.config.RESOURCE_PATH,
branch=branch,
force=force,
)
else:
return await AliyunRepoManager.update_via_git(
self.config.RESOURCE_GIT,
self.config.RESOURCE_PATH,
branch=branch,
force=force,
)
async def resources_update(
self,
source: Literal["git", "ali"] = "ali",
branch: str = "main",
force: bool = False,
) -> RepoUpdateResult | None:
"""更新资源文件
参数:
source: 更新源,git 为 git 更新,ali 为阿里云更新
branch: 分支名称
force: 是否强制更新
返回:
RepoUpdateResult | None: git 更新时返回结果,zip 更新时返回 None
"""
critical_dir = self.config.RESOURCE_PATH / "themes" / "default"
if not critical_dir.exists() or not any(critical_dir.iterdir()):
logger.warning(
f"检测到关键资源目录 {critical_dir} 缺失或为空,将开启强制修复模式。",
LOG_COMMAND,
)
force = True
if await check_git():
result = await self.resources_git_update(source, branch, force)
if result.success:
logger.info("使用git更新资源文件完成!", LOG_COMMAND)
return result
else:
logger.warning(
f"使用git更新资源文件失败: {result.error_message},"
"尝试回退到zip下载...",
LOG_COMMAND,
)
# git 失败时回退 zip,确保资源文件一定能获取到
try:
await self.resources_zip_update()
logger.info("回退zip下载资源文件完成!", LOG_COMMAND)
result.success = True
result.error_message = ""
return result
except Exception as e:
logger.error("回退zip下载资源文件也失败", LOG_COMMAND, e=e)
return result
else:
await self.resources_zip_update()
logger.info("使用zip更新资源文件完成!", LOG_COMMAND)
return None
# ==================== Web UI 管理相关方法 ====================
def check_webui_exists(self) -> bool:
"""检查 Web UI 资源是否存在"""
return bool(
self.config.WEBUI_PATH.exists() and os.listdir(self.config.WEBUI_PATH)
)
async def webui_download_zip(self):
"""下载 WEBUI_ASSETS 资源"""
download_url = await GithubUtils.parse_github_url(
self.config.WEBUI_DIST_GITHUB_URL
).get_archive_download_urls()
logger.info("开始下载 WEBUI_ASSETS 资源...", LOG_COMMAND)
if await AsyncHttpx.download_file(
download_url, self.config.WEBUI_DOWNLOAD_FILE, follow_redirects=True
):
logger.info("下载 WEBUI_ASSETS 成功!", LOG_COMMAND)
else:
raise ZhenxunUpdateException("下载 WEBUI_ASSETS 失败", LOG_COMMAND)
def __backup_webui(self):
"""备份 WEBUI_ASSERT 资源"""
if self.config.WEBUI_PATH.exists():
if self.config.WEBUI_BACKUP_PATH.exists():
logger.debug(
f"删除旧的备份webui文件夹 {self.config.WEBUI_BACKUP_PATH}",
LOG_COMMAND,
)
shutil.rmtree(self.config.WEBUI_BACKUP_PATH)
shutil.copytree(self.config.WEBUI_PATH, self.config.WEBUI_BACKUP_PATH)
async def webui_unzip(self):
"""解压 WEBUI_ASSETS 资源
返回:
str: 更新结果
"""
if not self.config.WEBUI_DOWNLOAD_FILE.exists():
raise FileNotFoundError("webui文件压缩包不存在")
tf = None
try:
self.__backup_webui()
self.__clear_folder(self.config.WEBUI_PATH)
tf = zipfile.ZipFile(self.config.WEBUI_DOWNLOAD_FILE)
tf.extractall(self.config.WEBUI_UNZIP_PATH)
logger.debug("Web UI 解压文件压缩包完成...", LOG_COMMAND)
unzip_dir = next(self.config.WEBUI_UNZIP_PATH.iterdir())
self.__copy_files(unzip_dir, self.config.WEBUI_PATH)
logger.debug("Web UI 复制 WEBUI_ASSETS 成功!", LOG_COMMAND)
shutil.rmtree(self.config.WEBUI_UNZIP_PATH, ignore_errors=True)
except Exception as e:
if self.config.WEBUI_BACKUP_PATH.exists():
self.__copy_files(self.config.WEBUI_BACKUP_PATH, self.config.WEBUI_PATH)
logger.debug("恢复备份 WEBUI_ASSETS 成功!", LOG_COMMAND)
shutil.rmtree(self.config.WEBUI_BACKUP_PATH, ignore_errors=True)
logger.error("Web UI 更新失败", LOG_COMMAND, e=e)
raise
finally:
if tf:
tf.close()
async def webui_zip_update(self):
"""使用zip更新 Web UI"""
await self.webui_download_zip()
await self.webui_unzip()
async def webui_git_update(
self, source: Literal["git", "ali"], branch: str = "dist", force: bool = False
) -> RepoUpdateResult:
"""使用git或阿里云更新 Web UI
参数:
source: 更新源,git 为 git 更新,ali 为阿里云更新
branch: 分支名称
force: 是否强制更新
"""
if source == "git":
return await GithubRepoManager.update_via_git(
self.config.WEBUI_GIT,
self.config.WEBUI_PATH,
branch=branch,
force=force,
)
else:
return await AliyunRepoManager.update_via_git(
self.config.WEBUI_GIT,
self.config.WEBUI_PATH,
branch=branch,
force=force,
)
async def webui_update(
self,
source: Literal["git", "ali"] = "ali",
branch: str = "dist",
force: bool = False,
):
"""更新 Web UI
参数:
source: 更新源,git 为 git 更新,ali 为阿里云更新
"""
if await check_git():
await self.webui_git_update(source, branch, force)
logger.debug("使用git更新Web UI!", LOG_COMMAND)
else:
await self.webui_zip_update()
logger.debug("使用zip更新Web UI!", LOG_COMMAND)
ZhenxunRepoManager = ZhenxunRepoManagerClass()