From 97f778cc900e8a3a9460a0b521dc8de32db587a2 Mon Sep 17 00:00:00 2001 From: Wu Clan Date: Sun, 29 Jun 2025 17:41:12 +0800 Subject: [PATCH] Add CLI support for plugin install (#698) * Add CLI support for plugin install * Fix some usage errors * Update prompt information --- backend/__init__.py | 2 +- backend/app/admin/api/v1/sys/plugin.py | 8 +- backend/app/admin/service/plugin_service.py | 82 ++---------------- backend/cli.py | 63 +++++++++++--- backend/utils/file_ops.py | 94 ++++++++++++++++++++- 5 files changed, 154 insertions(+), 95 deletions(-) diff --git a/backend/__init__.py b/backend/__init__.py index 3c3740a8..83c65446 100644 --- a/backend/__init__.py +++ b/backend/__init__.py @@ -7,4 +7,4 @@ from backend.utils.console import console def get_version() -> str | None: - console.print(f'\n[cyan]{__version__}[/]') + console.print(f'[cyan]{__version__}[/]') diff --git a/backend/app/admin/api/v1/sys/plugin.py b/backend/app/admin/api/v1/sys/plugin.py index f0737297..afa21909 100644 --- a/backend/app/admin/api/v1/sys/plugin.py +++ b/backend/app/admin/api/v1/sys/plugin.py @@ -43,9 +43,11 @@ async def install_plugin( file: Annotated[UploadFile | None, File()] = None, repo_url: Annotated[str | None, Query(description='插件 git 仓库地址')] = None, ) -> ResponseModel: - await plugin_service.install(type=type, file=file, repo_url=repo_url) + plugin_name = await plugin_service.install(type=type, file=file, repo_url=repo_url) return response_base.success( - res=CustomResponse(code=200, msg='插件安装成功,请根据插件说明(README.md)进行相关配置并重启服务') + res=CustomResponse( + code=200, msg=f'插件 {plugin_name} 安装成功,请根据插件说明(README.md)进行相关配置并重启服务' + ) ) @@ -61,7 +63,7 @@ async def install_plugin( async def uninstall_plugin(plugin: Annotated[str, Path(description='插件名称')]) -> ResponseModel: await plugin_service.uninstall(plugin=plugin) return response_base.success( - res=CustomResponse(code=200, msg='插件卸载成功,请根据插件说明(README.md)移除相关配置并重启服务') + res=CustomResponse(code=200, msg=f'插件 {plugin} 卸载成功,请根据插件说明(README.md)移除相关配置并重启服务') ) diff --git a/backend/app/admin/service/plugin_service.py b/backend/app/admin/service/plugin_service.py index 99426ed0..cd295d64 100644 --- a/backend/app/admin/service/plugin_service.py +++ b/backend/app/admin/service/plugin_service.py @@ -3,23 +3,20 @@ import io import json import os -import re import shutil import zipfile from typing import Any -from dulwich import porcelain from fastapi import UploadFile from backend.common.enums import PluginType, StatusType from backend.common.exception import errors -from backend.common.log import log from backend.core.conf import settings from backend.core.path_conf import PLUGIN_DIR from backend.database.redis import redis_client -from backend.plugin.tools import install_requirements_async, uninstall_requirements_async -from backend.utils.re_verify import is_git_url +from backend.plugin.tools import uninstall_requirements_async +from backend.utils.file_ops import install_git_plugin, install_zip_plugin from backend.utils.timezone import timezone @@ -46,76 +43,7 @@ class PluginService: return await redis_client.get(f'{settings.PLUGIN_REDIS_PREFIX}:changed') @staticmethod - async def install_zip(*, file: UploadFile) -> None: - """ - 通过 zip 压缩包安装插件 - - :param file: 插件 zip 压缩包 - :return: - """ - contents = await file.read() - file_bytes = io.BytesIO(contents) - if not zipfile.is_zipfile(file_bytes): - raise errors.RequestError(msg='插件压缩包格式非法') - with zipfile.ZipFile(file_bytes) as zf: - # 校验压缩包 - plugin_namelist = zf.namelist() - zip_plugin_dir = plugin_namelist[0].split('/')[0] - if not plugin_namelist: - raise errors.RequestError(msg='插件压缩包内容非法') - if ( - len(plugin_namelist) <= 3 - or f'{zip_plugin_dir}/plugin.toml' not in plugin_namelist - or f'{zip_plugin_dir}/README.md' not in plugin_namelist - ): - raise errors.RequestError(msg='插件压缩包内缺少必要文件') - - # 插件是否可安装 - plugin_name = re.match(r'^([a-zA-Z0-9_]+)', file.filename.split('.')[0].strip()).group() - full_plugin_path = os.path.join(PLUGIN_DIR, plugin_name) - if os.path.exists(full_plugin_path): - raise errors.ConflictError(msg='此插件已安装') - else: - os.makedirs(full_plugin_path, exist_ok=True) - - # 解压(安装) - members = [] - for member in zf.infolist(): - if member.filename.startswith(zip_plugin_dir): - new_filename = member.filename.replace(zip_plugin_dir, '') - if new_filename: - member.filename = new_filename - members.append(member) - zf.extractall(full_plugin_path, members) - - await install_requirements_async(zip_plugin_dir) - await redis_client.set(f'{settings.PLUGIN_REDIS_PREFIX}:changed', 'ture') - - @staticmethod - async def install_git(*, repo_url: str): - """ - 通过 git 安装插件 - - :param repo_url: git 存储库的 URL - :return: - """ - match = is_git_url(repo_url) - if not match: - raise errors.RequestError(msg='Git 仓库地址格式非法') - repo_name = match.group('repo') - plugins = await redis_client.lrange(settings.PLUGIN_REDIS_PREFIX, 0, -1) - if repo_name in plugins: - raise errors.ConflictError(msg=f'{repo_name} 插件已安装') - try: - porcelain.clone(repo_url, os.path.join(PLUGIN_DIR, repo_name), checkout=True) - except Exception as e: - log.error(f'插件安装失败: {e}') - raise errors.ServerError(msg='插件安装失败,请稍后重试') from e - else: - await install_requirements_async(repo_name) - await redis_client.set(f'{settings.PLUGIN_REDIS_PREFIX}:changed', 'ture') - - async def install(self, *, type: PluginType, file: UploadFile | None = None, repo_url: str | None = None): + async def install(*, type: PluginType, file: UploadFile | None = None, repo_url: str | None = None) -> str: """ 安装插件 @@ -127,11 +55,11 @@ class PluginService: if type == PluginType.zip: if not file: raise errors.RequestError(msg='ZIP 压缩包不能为空') - await self.install_zip(file=file) + return await install_zip_plugin(file) elif type == PluginType.git: if not repo_url: raise errors.RequestError(msg='Git 仓库地址不能为空') - await self.install_git(repo_url=repo_url) + return await install_git_plugin(repo_url) @staticmethod async def uninstall(*, plugin: str): diff --git a/backend/cli.py b/backend/cli.py index 0f4f407f..9ad9cbbb 100644 --- a/backend/cli.py +++ b/backend/cli.py @@ -3,9 +3,9 @@ from dataclasses import dataclass from typing import Annotated +import cappa import uvicorn -from cappa import Arg, Subcommands, invoke from rich.panel import Panel from rich.progress import ( Progress, @@ -16,8 +16,11 @@ from rich.progress import ( from rich.text import Text from backend import console, get_version +from backend.common.exception.errors import BaseExceptionMixin from backend.core.conf import settings from backend.plugin.tools import get_plugins, install_requirements +from backend.utils._await import run_await +from backend.utils.file_ops import install_git_plugin, install_zip_plugin def run(host: str, port: int, reload: bool, workers: int | None) -> None: @@ -53,14 +56,35 @@ def run(host: str, port: int, reload: bool, workers: int | None) -> None: ) console.print(Panel(panel_content, title='fba 服务信息', border_style='purple', padding=(1, 2))) - uvicorn.run(app='backend.main:app', host=host, port=port, reload=reload, workers=workers) + uvicorn.run(app='backend.main:app', host=host, port=port, reload=not reload, workers=workers) +def install_plugin(path: str, repo_url: str) -> None: + if not path and not repo_url: + raise cappa.Exit('path 或 repo_url 必须指定其中一项', code=1) + if path and repo_url: + raise cappa.Exit('path 和 repo_url 不能同时指定', code=1) + + plugin_name = None + console.print(Text('开始安装插件...', style='bold cyan')) + + try: + if path: + plugin_name = run_await(install_zip_plugin)(file=path) + if repo_url: + plugin_name = run_await(install_git_plugin)(repo_url=repo_url) + except Exception as e: + raise cappa.Exit(e.msg if isinstance(e, BaseExceptionMixin) else str(e), code=1) + + console.print(Text(f'插件 {plugin_name} 安装成功', style='bold cyan')) + + +@cappa.command(help='运行服务') @dataclass class Run: host: Annotated[ str, - Arg( + cappa.Arg( long=True, default='127.0.0.1', help='提供服务的主机 IP 地址,对于本地开发,请使用 `127.0.0.1`。' @@ -69,28 +93,44 @@ class Run: ] port: Annotated[ int, - Arg(long=True, default=8000, help='提供服务的主机端口号'), + cappa.Arg(long=True, default=8000, help='提供服务的主机端口号'), ] - reload: Annotated[ + no_reload: Annotated[ bool, - Arg(long=True, default=True, help='启用在(代码)文件更改时自动重新加载服务器'), + cappa.Arg(long=True, default=False, help='禁用在(代码)文件更改时自动重新加载服务器'), ] workers: Annotated[ int | None, - Arg(long=True, default=None, help='使用多个工作进程。与 `--reload` 标志互斥'), + cappa.Arg(long=True, default=None, help='使用多个工作进程。与 `--reload` 标志互斥'), ] def __call__(self): - run(host=self.host, port=self.port, reload=self.reload, workers=self.workers) + run(host=self.host, port=self.port, reload=self.no_reload, workers=self.workers) + + +@cappa.command(help='新增插件') +@dataclass +class Add: + path: Annotated[ + str | None, + cappa.Arg(long=True, help='ZIP 插件的本地完整路径'), + ] + repo_url: Annotated[ + str | None, + cappa.Arg(long=True, help='Git 插件的仓库地址'), + ] + + def __call__(self): + install_plugin(path=self.path, repo_url=self.repo_url) @dataclass class FbaCli: version: Annotated[ bool, - Arg(short='-V', long=True, default=False, help='打印 fba 当前版本号'), + cappa.Arg(short='-V', long=True, default=False, help='打印当前版本号'), ] - subcmd: Subcommands[Run | None] = None + subcmd: cappa.Subcommands[Run | Add | None] = None def __call__(self): if self.version: @@ -98,4 +138,5 @@ class FbaCli: def main() -> None: - invoke(FbaCli) + output = cappa.Output(error_format='[red]Error[/]: {message}\n\n更多信息,尝试 "[cyan]--help[/]"') + cappa.invoke(FbaCli, output=output) diff --git a/backend/utils/file_ops.py b/backend/utils/file_ops.py index 7fd78dd3..f0a1de56 100644 --- a/backend/utils/file_ops.py +++ b/backend/utils/file_ops.py @@ -1,16 +1,23 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- +import io import os +import re +import zipfile import aiofiles +from dulwich import porcelain from fastapi import UploadFile from backend.common.enums import FileType from backend.common.exception import errors from backend.common.log import log from backend.core.conf import settings -from backend.core.path_conf import UPLOAD_DIR +from backend.core.path_conf import PLUGIN_DIR, UPLOAD_DIR +from backend.database.redis import redis_client +from backend.plugin.tools import install_requirements_async +from backend.utils.re_verify import is_git_url from backend.utils.timezone import timezone @@ -70,6 +77,87 @@ async def upload_file(file: UploadFile) -> str: except Exception as e: log.error(f'上传文件 {filename} 失败:{str(e)}') raise errors.RequestError(msg='上传文件失败') - finally: - await file.close() + await file.close() return filename + + +async def install_zip_plugin(file: UploadFile | str) -> str: + """ + 安装 ZIP 插件 + + :param file: FastAPI 上传文件对象或文件完整路径 + :return: + """ + if isinstance(file, str): + async with aiofiles.open(file, mode='rb') as fb: + contents = await fb.read() + else: + contents = await file.read() + file_bytes = io.BytesIO(contents) + if not zipfile.is_zipfile(file_bytes): + raise errors.RequestError(msg='插件压缩包格式非法') + with zipfile.ZipFile(file_bytes) as zf: + # 校验压缩包 + plugin_namelist = zf.namelist() + plugin_dir_name = plugin_namelist[0].split('/')[0] + if not plugin_namelist: + raise errors.RequestError(msg='插件压缩包内容非法') + if ( + len(plugin_namelist) <= 3 + or f'{plugin_dir_name}/plugin.toml' not in plugin_namelist + or f'{plugin_dir_name}/README.md' not in plugin_namelist + ): + raise errors.RequestError(msg='插件压缩包内缺少必要文件') + + # 插件是否可安装 + plugin_name = re.match( + r'^([a-zA-Z0-9_]+)', + file.split(os.sep)[-1].split('.')[0].strip() + if isinstance(file, str) + else file.filename.split('.')[0].strip(), + ).group() + full_plugin_path = os.path.join(PLUGIN_DIR, plugin_name) + if os.path.exists(full_plugin_path): + raise errors.ConflictError(msg='此插件已安装') + else: + os.makedirs(full_plugin_path, exist_ok=True) + + # 解压(安装) + members = [] + for member in zf.infolist(): + if member.filename.startswith(plugin_dir_name): + new_filename = member.filename.replace(plugin_dir_name, '') + if new_filename: + member.filename = new_filename + members.append(member) + zf.extractall(full_plugin_path, members) + + await install_requirements_async(plugin_dir_name) + await redis_client.set(f'{settings.PLUGIN_REDIS_PREFIX}:changed', 'ture') + + return plugin_name + + +async def install_git_plugin(repo_url: str) -> str: + """ + 安装 Git 插件 + + :param repo_url: + :return: + """ + match = is_git_url(repo_url) + if not match: + raise errors.RequestError(msg='Git 仓库地址格式非法') + repo_name = match.group('repo') + if os.path.exists(os.path.join(PLUGIN_DIR, repo_name)): + raise errors.ConflictError(msg=f'{repo_name} 插件已安装') + try: + porcelain.clone(repo_url, os.path.join(PLUGIN_DIR, repo_name), checkout=True) + except Exception as e: + log.error(f'插件安装失败: {e}') + raise errors.ServerError(msg='插件安装失败,请稍后重试') from e + + await install_requirements_async(repo_name) + await redis_client.set(f'{settings.PLUGIN_REDIS_PREFIX}:changed', 'ture') + + return repo_name